diff options
Diffstat (limited to 'components')
129 files changed, 21181 insertions, 0 deletions
diff --git a/components/ProjectSelector.tsx b/components/ProjectSelector.tsx new file mode 100644 index 00000000..50d5b9d5 --- /dev/null +++ b/components/ProjectSelector.tsx @@ -0,0 +1,124 @@ +"use client" + +import * as React from "react" +import { Check, ChevronsUpDown } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover" +import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@/components/ui/command" +import { cn } from "@/lib/utils" +import { getProjects, type Project } from "@/lib/rfqs/service" + +interface ProjectSelectorProps { + selectedProjectId?: number | null; + onProjectSelect: (project: Project) => void; + placeholder?: string; +} + +export function ProjectSelector({ + selectedProjectId, + onProjectSelect, + placeholder = "프로젝트 선택..." +}: ProjectSelectorProps) { + const [open, setOpen] = React.useState(false) + const [searchTerm, setSearchTerm] = React.useState("") + const [projects, setProjects] = React.useState<Project[]>([]) + const [isLoading, setIsLoading] = React.useState(false) + const [selectedProject, setSelectedProject] = React.useState<Project | null>(null) + + // 모든 프로젝트 데이터 로드 (한 번만) + React.useEffect(() => { + async function loadAllProjects() { + setIsLoading(true); + try { + const allProjects = await getProjects(); + setProjects(allProjects); + + // 초기 선택된 프로젝트가 있으면 설정 + if (selectedProjectId) { + const selected = allProjects.find(p => p.id === selectedProjectId); + if (selected) { + setSelectedProject(selected); + } + } + } catch (error) { + console.error("프로젝트 목록 로드 오류:", error); + } finally { + setIsLoading(false); + } + } + + loadAllProjects(); + }, [selectedProjectId]); + + // 클라이언트 측에서 검색어로 필터링 + const filteredProjects = React.useMemo(() => { + if (!searchTerm.trim()) return projects; + + const lowerSearch = searchTerm.toLowerCase(); + return projects.filter( + project => + project.projectCode.toLowerCase().includes(lowerSearch) || + project.projectName.toLowerCase().includes(lowerSearch) + ); + }, [projects, searchTerm]); + + // 프로젝트 선택 처리 + const handleSelectProject = (project: Project) => { + setSelectedProject(project); + onProjectSelect(project); + setOpen(false); + }; + + return ( + <Popover open={open} onOpenChange={setOpen}> + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + aria-expanded={open} + className="w-full justify-between" + > + {selectedProject + ? `${selectedProject.projectCode} - ${selectedProject.projectName}` + : placeholder} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-[400px] p-0"> + <Command> + <CommandInput + placeholder="프로젝트 코드/이름 검색..." + onValueChange={setSearchTerm} + /> + <CommandList className="max-h-[300px]"> + <CommandEmpty>검색 결과가 없습니다</CommandEmpty> + {isLoading ? ( + <div className="py-6 text-center text-sm">로딩 중...</div> + ) : ( + <CommandGroup> + {filteredProjects.map((project) => ( + <CommandItem + key={project.id} + value={`${project.projectCode} ${project.projectName}`} + onSelect={() => handleSelectProject(project)} + > + <Check + className={cn( + "mr-2 h-4 w-4", + selectedProject?.id === project.id + ? "opacity-100" + : "opacity-0" + )} + /> + <span className="font-medium">{project.projectCode}</span> + <span className="ml-2 text-gray-500 truncate">- {project.projectName}</span> + </CommandItem> + ))} + </CommandGroup> + )} + </CommandList> + </Command> + </PopoverContent> + </Popover> + ); +}
\ No newline at end of file diff --git a/components/client-data-table/data-table-column-simple-header.tsx b/components/client-data-table/data-table-column-simple-header.tsx new file mode 100644 index 00000000..0f3997c6 --- /dev/null +++ b/components/client-data-table/data-table-column-simple-header.tsx @@ -0,0 +1,61 @@ +"use client" + +import * as React from "react" +import { cn } from "@/lib/utils" +import { type Column } from "@tanstack/react-table" +import { ArrowDown, ArrowUp, ChevronsUpDown } from "lucide-react" + +interface DataTableColumnHeaderSimpleProps<TData, TValue> + extends React.HTMLAttributes<HTMLDivElement> { + column: Column<TData, TValue> + title: string +} + +export function ClientDataTableColumnHeaderSimple<TData, TValue>({ + column, + title, + className, +}: DataTableColumnHeaderSimpleProps<TData, TValue>) { + // 정렬 불가능 시 → 제목만 보여주기 + if (!column.getCanSort()) { + return <div className={cn(className)}>{title}</div> + } + + // 정렬 상태: "asc" | "desc" | false + const sorted = column.getIsSorted() + + // 아이콘 결정 + let icon = <ChevronsUpDown className="ml-1 size-4" aria-hidden="true" /> + if (sorted === "asc") { + icon = <ArrowUp className="ml-1 size-4" aria-hidden="true" /> + } else if (sorted === "desc") { + icon = <ArrowDown className="ml-1 size-4" aria-hidden="true" /> + } + + // 클릭 핸들러: 무정렬 → asc → desc → 무정렬 + function handleClick() { + if (!sorted) { + // 현재 무정렬 → asc + column.toggleSorting(false) + } else if (sorted === "asc") { + // asc → desc + column.toggleSorting(true) + } else { + // desc → 무정렬 + column.toggleSorting(false) + } + } + + return ( + <div + onClick={handleClick} + className={cn( + "flex cursor-pointer select-none items-center gap-1", + className + )} + > + <span>{title}</span> + {icon} + </div> + ) +}
\ No newline at end of file diff --git a/components/client-data-table/data-table-filter-list.tsx b/components/client-data-table/data-table-filter-list.tsx new file mode 100644 index 00000000..f06d837e --- /dev/null +++ b/components/client-data-table/data-table-filter-list.tsx @@ -0,0 +1,662 @@ +"use client" + +import * as React from "react" +import type { + DataTableAdvancedFilterField, + Filter, + FilterOperator, + JoinOperator, +} from "@/types/table" +import { type Table } from "@tanstack/react-table" +import { + CalendarIcon, + Check, + ChevronsUpDown, + GripVertical, + ListFilter, + Trash2, +} from "lucide-react" +import { customAlphabet } from "nanoid" + +import { getDefaultFilterOperator, getFilterOperators } from "@/lib/data-table" +import { cn, formatDate } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Calendar } from "@/components/ui/calendar" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command" +import { + FacetedFilter, + FacetedFilterContent, + FacetedFilterEmpty, + FacetedFilterGroup, + FacetedFilterInput, + FacetedFilterItem, + FacetedFilterList, + FacetedFilterTrigger, +} from "@/components/ui/faceted-filter" +import { Input } from "@/components/ui/input" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Sortable, + SortableDragHandle, + SortableItem, +} from "@/components/ui/sortable" + + +interface DataTableAdvancedFilterProps<TData> { + table: Table<TData> + filterFields: DataTableAdvancedFilterField<TData>[] + debounceMs?: number +} + +export function ClientDataTableAdvancedFilter<TData>({ + table, + filterFields, + debounceMs = 300, +}: DataTableAdvancedFilterProps<TData>) { + const popoverId = React.useId() + + + // 1) local filter state + const [filters, setFilters] = React.useState<Filter<TData>[]>([]) + + // 2) local joinOperator (and/or) + const [joinOperator, setJoinOperator] = React.useState<JoinOperator>("and") + + // 3) Sync to table + React.useEffect(() => { + const newColumnFilters = filters.map((f) => { + // If it's numeric, transform f.value from string → number + if (f.type === "number") { + return { + id: String(f.id), + value: { + operator: f.operator, + inputValue: parseFloat(String(f.value)), + } + } + } + else { + // For text, date, boolean, etc., it's fine to keep value as a string or whatever + return { + id: String(f.id), + value: f.value, + } + } + }) + + table.setColumnFilters(newColumnFilters) + }, [filters, joinOperator, table]) + + + function addFilter() { + if (!filterFields.length) return + const firstField = filterFields[0] + + setFilters((prev) => [ + ...prev, + { + id: firstField.id, + type: firstField.type, + operator: getDefaultFilterOperator(firstField.type), + value: "", + rowId: customAlphabet( + "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", + 6 + )(), + }, + ]) + } + + function updateFilter(rowId: string, patch: Partial<Filter<TData>>) { + setFilters((prev) => + prev.map((f) => (f.rowId === rowId ? { ...f, ...patch } : f)) + ) + } + + function removeFilter(rowId: string) { + setFilters((prev) => prev.filter((f) => f.rowId !== rowId)) + } + + function moveFilter(activeIndex: number, overIndex: number) { + setFilters((prev) => { + const arr = [...prev] + const [removed] = arr.splice(activeIndex, 1) + if (!removed) return prev + arr.splice(overIndex, 0, removed) + return arr + }) + } + + /** + * Render the input UI for each filter type (text, select, date, etc.) + */ + function renderFilterInput(filter: Filter<TData>) { + + const fieldDef = filterFields.find((f) => f.id === filter.id) + if (!fieldDef) return null + + // For "isEmpty"/"isNotEmpty", no input needed + if (filter.operator === "isEmpty" || filter.operator === "isNotEmpty") { + return ( + <div + id={filter.id} + role="status" + aria-live="polite" + aria-label={`${fieldDef.label} filter is ${filter.operator === "isEmpty" ? "empty" : "not empty"}`} + className="h-8 w-full rounded border border-dashed" + /> + ) + } + + switch (filter.type) { + case "text": + case "number": + return ( + <Input + id={fieldDef.id} + type={filter.type} + aria-label={`${fieldDef.label} filter value`} + aria-describedby={`${fieldDef.id}-description`} + placeholder={fieldDef.placeholder ?? "Enter..."} + className="h-8 w-full focus:outline-none !important focus:ring-offset-4 !important" + defaultValue={ + typeof filter.value === "string" ? filter.value : undefined + } + onChange={(event) => + updateFilter( + filter.rowId, + { value: event.target.value }, + ) + } + /> + ) + case "select": + return ( + <FacetedFilter> + <FacetedFilterTrigger asChild> + <Button + // id={inputId} + variant="outline" + size="sm" + aria-label={`${fieldDef.label} filter value`} + // aria-controls={`${inputId}-listbox`} + className="h-8 w-full justify-start gap-2 rounded px-1.5 text-left text-muted-foreground hover:text-muted-foreground" + > + {filter.value && typeof filter.value === "string" ? ( + <Badge + variant="secondary" + className="rounded-sm px-1 font-normal" + > + {fieldDef?.options?.find( + (option) => option.value === filter.value + )?.label || filter.value} + </Badge> + ) : ( + <> + {fieldDef.placeholder ?? "Select an option..."} + <ChevronsUpDown className="size-4" aria-hidden="true" /> + </> + )} + </Button> + </FacetedFilterTrigger> + <FacetedFilterContent className="w-[12.5rem]"> + <FacetedFilterInput placeholder={fieldDef.label} /> + <FacetedFilterList> + <FacetedFilterEmpty>{"No options"}</FacetedFilterEmpty> + <FacetedFilterGroup> + {fieldDef.options?.map((opt) => ( + <FacetedFilterItem + key={opt.value} + value={String(opt.value)} + selected={filter.value === opt.value} + onSelect={(val) => { + updateFilter(filter.rowId, { value: val }) + }} + > + {opt.label} + </FacetedFilterItem> + ))} + </FacetedFilterGroup> + </FacetedFilterList> + </FacetedFilterContent> + </FacetedFilter> + ) + + case "multi-select": + const selectedValues = new Set( + Array.isArray(filter.value) ? filter.value : [] + ) + + return ( + <FacetedFilter> + <FacetedFilterTrigger asChild> + <Button + id={filter.id} + variant="outline" + size="sm" + aria-label={`${filter.id} filter values`} + aria-controls={`${filter.id}-listbox`} + className="h-8 w-full justify-between gap-2 rounded px-1.5 text-left text-muted-foreground hover:text-muted-foreground" + > + <> + {selectedValues.size === 0 && ( + <> + {"Select options"} + <ChevronsUpDown className="size-4" aria-hidden="true" /> + </> + )} + </> + {selectedValues?.size > 0 && ( + <div className="flex items-center"> + <Badge + variant="secondary" + className="rounded-sm px-1 font-normal lg:hidden" + > + {selectedValues.size} + </Badge> + <div className="hidden min-w-0 gap-1 lg:flex"> + {selectedValues.size > 2 ? ( + <Badge + variant="secondary" + className="rounded-sm px-1 font-normal" + > + {selectedValues.size} selected + </Badge> + ) : ( + fieldDef?.options + ?.filter((option) => selectedValues.has(String(option.value))) + .map((option) => ( + <Badge + variant="secondary" + key={option.value} + className="truncate rounded-sm px-1 font-normal" + > + {option.label} + </Badge> + )) + )} + </div> + </div> + )} + </Button> + </FacetedFilterTrigger> + <FacetedFilterContent + id={`${filter.id}-listbox`} + className="w-[12.5rem] origin-[var(--radix-popover-content-transform-origin)]" + > + <FacetedFilterInput + aria-label={`Search ${fieldDef?.label} options`} + placeholder={fieldDef?.label ?? "Search options..."} + /> + <FacetedFilterList> + <FacetedFilterEmpty>No options found.</FacetedFilterEmpty> + <FacetedFilterGroup> + {fieldDef?.options?.map((option) => ( + <FacetedFilterItem + key={option.value} + value={String(option.value)} + selected={selectedValues.has(String(option.value))} + onSelect={(value) => { + const currentValue = Array.isArray(filter.value) + ? filter.value + : [] + const newValue = currentValue.includes(value) + ? currentValue.filter((v) => v !== value) + : [...currentValue, value] + updateFilter( + filter.rowId, + { value: newValue }, + ) + }} + > + {option.icon && ( + <option.icon + className="mr-2 size-4 text-muted-foreground" + aria-hidden="true" + /> + )} + <span>{option.label}</span> + {option.count && ( + <span className="ml-auto flex size-4 items-center justify-center font-mono text-xs"> + {option.count} + </span> + )} + </FacetedFilterItem> + ))} + </FacetedFilterGroup> + </FacetedFilterList> + </FacetedFilterContent> + </FacetedFilter> + ) + + case "date": + const dateValue = Array.isArray(filter.value) + ? filter.value.filter(Boolean) + : [filter.value, filter.value].filter(Boolean) + + const displayValue = + filter.operator === "isBetween" && dateValue.length === 2 + ? `${formatDate(dateValue[0] ?? new Date())} - ${formatDate( + dateValue[1] ?? new Date() + )}` + : dateValue[0] + ? formatDate(dateValue[0]) + : "Pick a date" + + return ( + <Popover> + <PopoverTrigger asChild> + <Button + id={fieldDef.id} + variant="outline" + size="sm" + aria-label={`${fieldDef.label} date filter`} + aria-controls={`${fieldDef.id}-calendar`} + className={cn( + "h-8 w-full justify-start gap-2 rounded text-left font-normal", + !filter.value && "text-muted-foreground" + )} + > + <CalendarIcon + className="size-3.5 shrink-0" + aria-hidden="true" + /> + <span className="truncate">{displayValue}</span> + </Button> + </PopoverTrigger> + <PopoverContent + id={`${fieldDef.id}-calendar`} + align="start" + className="w-auto p-0" + > + {filter.operator === "isBetween" ? ( + <Calendar + id={`${fieldDef.id}-calendar`} + mode="range" + aria-label={`Select ${fieldDef.label} date range`} + selected={ + dateValue.length === 2 + ? { + from: new Date(dateValue[0] ?? ""), + to: new Date(dateValue[1] ?? ""), + } + : { + from: new Date(), + to: new Date(), + } + } + onSelect={(date) => { + updateFilter( + filter.rowId, + { + value: date + ? [ + date.from?.toISOString() ?? "", + date.to?.toISOString() ?? "", + ] + : [], + }, + ) + }} + initialFocus + numberOfMonths={1} + /> + ) : ( + <Calendar + id={`${fieldDef.id}-calendar`} + mode="single" + aria-label={`Select ${fieldDef.label} date`} + selected={dateValue[0] ? new Date(dateValue[0]) : undefined} + onSelect={(date) => { + updateFilter( + filter.rowId, + { value: date?.toISOString() ?? "" }, + ) + + setTimeout(() => { + document.getElementById(fieldDef.id)?.click() + }, 0) + }} + initialFocus + /> + )} + </PopoverContent> + </Popover> + ) + case "boolean": { + if (Array.isArray(filter.value)) return null + + return ( + <Select + value={filter.value} + onValueChange={(value) => + updateFilter(filter.rowId, { value }) + } + > + <SelectTrigger + id={fieldDef.id} + aria-label={`${fieldDef.label} boolean filter`} + aria-controls={`${fieldDef.id}-listbox`} + className="h-8 w-full rounded bg-transparent" + > + <SelectValue placeholder={filter.value ? "True" : "False"} /> + </SelectTrigger> + <SelectContent id={`${fieldDef.id}-listbox`}> + <SelectItem value="true">True</SelectItem> + <SelectItem value="false">False</SelectItem> + </SelectContent> + </Select> + ) + } + default: + return null + } + } + + + const filterCount = filters.length + + return ( + <Sortable + value={filters.map((f) => ({ id: f.rowId }))} + onMove={({ activeIndex, overIndex }) => moveFilter(activeIndex, overIndex)} + > + <Popover> + <PopoverTrigger asChild> + <Button variant="outline" size="sm" className="gap-2"> + <ListFilter className="h-4 w-4" /> + {"Filters"} + {filterCount > 0 && ( + <Badge variant="secondary" className="ml-1"> + {filterCount} + </Badge> + )} + </Button> + </PopoverTrigger> + + <PopoverContent + id={popoverId} + align="start" + className="flex w-[36rem] max-w-[calc(100vw-3rem)] flex-col gap-3 p-4" + > + {filterCount > 0 ? ( + <h4 className="font-medium"> + {"Filters"} + </h4> + ) : ( + <div> + <h4 className="font-medium"> + {"No filters applied"} + </h4> + <p className="text-sm text-muted-foreground"> + {"Add filters below"} + </p> + </div> + )} + + {/* Filter list */} + <div className="flex max-h-60 flex-col gap-2 overflow-y-auto pr-1"> + {filters.map((filter, index) => ( + <SortableItem key={filter.rowId} value={filter.rowId} asChild> + <div className="flex items-center gap-2"> + <div className="w-24 text-center"> + {index === 0 ? ( + <span className="text-sm text-muted-foreground"> + {"Where"} + </span> + ) : index === 1 ? ( + <Select + value={(joinOperator)} + onValueChange={(val) => setJoinOperator(val as JoinOperator)} + > + <SelectTrigger className="h-8 w-24 lowercase text-sm"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="and">{"AND"}</SelectItem> + <SelectItem value="or">{"OR"}</SelectItem> + </SelectContent> + </Select> + ) : ( + <span className="text-sm text-muted-foreground uppercase"> + {joinOperator === "and" + ? "AND" + : "OR"} + </span> + )} + </div> + + {/* Field (column) selection */} + <Popover modal> + <PopoverTrigger asChild> + <Button variant="outline" size="sm" className="h-8 w-32 justify-between"> + { + filterFields.find((f) => f.id === filter.id)?.label ?? + "Search fields..." + } + <ChevronsUpDown className="h-4 w-4 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-40 p-0"> + <Command> + <CommandInput placeholder={"Select field"} /> + <CommandList> + <CommandEmpty>{"No fields found."}</CommandEmpty> + <CommandGroup> + {filterFields.map((ff) => ( + <CommandItem + key={ff.id as string} + value={String(ff.id)} + onSelect={(val) => { + const newField = filterFields.find((x) => x.id === val) + if (!newField) return + updateFilter(filter.rowId, { + id: newField.id, + type: newField.type, + operator: getDefaultFilterOperator(newField.type), + value: "", + }) + }} + > + {ff.label} + <Check + className={cn( + "ml-auto h-4 w-4", + ff.id === filter.id ? "opacity-100" : "opacity-0" + )} + /> + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + + {/* Operator selection */} + <Select + value={filter.operator} + onValueChange={(val: FilterOperator) => { + updateFilter(filter.rowId, { + operator: val, + value: + val === "isEmpty" || val === "isNotEmpty" ? "" : filter.value, + }) + }} + > + <SelectTrigger className="h-8 w-28 text-sm"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {getFilterOperators(filter.type).map((op) => ( + <SelectItem key={op.value} value={op.value}> + {op.label} + </SelectItem> + ))} + </SelectContent> + </Select> + + {/* The actual filter input */} + <div className="flex-1">{renderFilterInput(filter)}</div> + + {/* Remove button */} + <Button + variant="outline" + size="icon" + onClick={() => removeFilter(filter.rowId)} + className="h-8 w-8" + > + <Trash2 className="h-4 w-4" /> + </Button> + + {/* Drag handle */} + <SortableDragHandle variant="outline" size="icon" className="h-8 w-8"> + <GripVertical className="h-4 w-4" /> + </SortableDragHandle> + </div> + </SortableItem> + ))} + </div> + + {/* Footer: Add filter / Reset */} + <div className="flex items-center gap-2"> + <Button size="sm" onClick={addFilter}> + {"Add filter"} + </Button> + {filterCount > 0 && ( + <Button + size="sm" + variant="outline" + onClick={() => { + setFilters([]) + setJoinOperator("and") + }} + > + {"Reset"} + </Button> + )} + </div> + </PopoverContent> + </Popover> + </Sortable> + ) +}
\ No newline at end of file diff --git a/components/client-data-table/data-table-group-list.tsx b/components/client-data-table/data-table-group-list.tsx new file mode 100644 index 00000000..519b7327 --- /dev/null +++ b/components/client-data-table/data-table-group-list.tsx @@ -0,0 +1,279 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Layers, Check, ChevronsUpDown, GripVertical, XCircle } from "lucide-react" + +import { toSentenceCase, cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover" +import { + Command, + CommandList, + CommandGroup, + CommandItem, + CommandInput, + CommandEmpty, +} from "@/components/ui/command" +import { + Sortable, + SortableItem, + SortableDragHandle, +} from "@/components/ui/sortable" + +interface DataTableGroupListLocalProps<TData> { + /** TanStack Table 인스턴스 (grouping을 사용할 수 있어야 함) */ + table: Table<TData> +} + +export function ClientDataTableGroupList<TData>({ + table, +}: DataTableGroupListLocalProps<TData>) { + // ----------------------------- + // 1) Local grouping state + // ----------------------------- + const [grouping, setGrouping] = React.useState<string[]>( + (table.initialState.grouping as string[]) ?? [] + ) + + // Keep the table grouping in sync + React.useEffect(() => { + table.setGrouping(grouping) + }, [grouping, table]) + + // Avoid duplicates (just in case) + const uniqueGrouping = React.useMemo( + () => [...new Set(grouping)], + [grouping] + ) + + // ----------------------------- + // 2) Groupable columns + // ----------------------------- + const groupableColumns = React.useMemo( + () => + table + .getAllColumns() + .filter((col) => col.getCanGroup?.() !== false) + .map((col) => { + // If meta?.excelHeader is missing or undefined, fall back to `col.id` + const friendlyName = + typeof col.columnDef?.meta?.excelHeader === "string" + ? col.columnDef.meta.excelHeader + : col.id + + return { + id: col.id, + // Ensure it's always a string, so no type error: + label: toSentenceCase(friendlyName), + } + }), + [table] + ) + + const ungroupedColumns = React.useMemo( + () => groupableColumns.filter((c) => !uniqueGrouping.includes(c.id)), + [groupableColumns, uniqueGrouping] + ) + + // ----------------------------- + // 3) Handlers + // ----------------------------- + // Add the first ungrouped column + function addGroup() { + const firstAvailable = ungroupedColumns[0] + if (!firstAvailable) return + setGrouping((prev) => [...prev, firstAvailable.id]) + } + + // Remove a group + function removeGroup(colId: string) { + setGrouping((prev) => prev.filter((g) => g !== colId)) + } + + // Reset grouping entirely + function resetGrouping() { + setGrouping([]) + } + + // Reorder groups via Sortable + function onGroupOrderChange(newGroups: string[]) { + setGrouping(newGroups) + } + + // ----------------------------- + // 4) Render + // ----------------------------- + return ( + <Sortable + value={uniqueGrouping.map((id) => ({ id }))} + onValueChange={(items) => onGroupOrderChange(items.map((i) => i.id))} + overlay={ + <div className="flex items-center gap-2"> + <div className="h-8 w-[11.25rem] rounded-sm bg-primary/10" /> + <div className="h-8 w-24 rounded-sm bg-primary/10" /> + <div className="size-8 shrink-0 rounded-sm bg-primary/10" /> + </div> + } + > + <Popover> + <PopoverTrigger asChild> + <Button variant="outline" size="sm" className="gap-2"> + <Layers className="size-3" aria-hidden="true" /> + <span className="hidden sm:inline">Group</span> + {uniqueGrouping.length > 0 && ( + <Badge + variant="secondary" + className="h-[1.14rem] rounded-[0.2rem] px-[0.32rem] font-mono text-[0.65rem] font-normal" + > + {uniqueGrouping.length} + </Badge> + )} + </Button> + </PopoverTrigger> + + <PopoverContent + align="start" + collisionPadding={16} + className={cn( + "flex w-[calc(100vw-theme(spacing.20))] min-w-72 max-w-[25rem] flex-col p-4 sm:w-[25rem]", + uniqueGrouping.length > 0 ? "gap-3.5" : "gap-2" + )} + > + {uniqueGrouping.length > 0 ? ( + <> + <h4 className="font-medium leading-none">Group by</h4> + <p className="text-sm text-muted-foreground"> + Grouping is applied to the currently loaded data only. + </p> + </> + ) : ( + <div className="flex flex-col gap-1"> + <h4 className="font-medium leading-none">No grouping applied</h4> + <p className="text-sm text-muted-foreground"> + Add grouping to organize your results. + </p> + </div> + )} + + {/* Current groups */} + <div className="flex max-h-40 flex-col gap-2 overflow-y-auto p-0.5"> + <div className="flex w-full flex-col gap-2"> + {uniqueGrouping.map((colId) => { + // Find the column's friendly label + const colDef = groupableColumns.find((c) => c.id === colId) + const label = colDef?.label ?? toSentenceCase(colId) + + return ( + <SortableItem key={colId} value={colId} asChild> + <div className="flex items-center gap-2"> + <Popover modal> + <PopoverTrigger asChild> + <Button + variant="outline" + size="sm" + role="combobox" + className="h-8 w-44 justify-between gap-2 rounded focus:outline-none focus:ring-1 focus:ring-ring" + aria-label={`Select column for group ${colId}`} + > + <span className="truncate">{label}</span> + <div className="ml-auto flex items-center gap-1"> + <ChevronsUpDown + className="size-4 shrink-0 opacity-50" + aria-hidden="true" + /> + </div> + </Button> + </PopoverTrigger> + <PopoverContent + className="w-[var(--radix-popover-trigger-width)] p-0" + > + <Command> + <CommandInput placeholder="Search columns..." /> + <CommandList> + <CommandEmpty>No columns found.</CommandEmpty> + <CommandGroup> + {ungroupedColumns.map((column) => ( + <CommandItem + key={column.id} + value={column.id} + onSelect={(value) => { + // Replace colId with new value + setGrouping((prev) => + prev.map((g) => (g === colId ? value : g)) + ) + }} + > + <span className="mr-1.5 truncate"> + {column.label} + </span> + <Check + className={cn( + "ml-auto size-4 shrink-0", + column.id === colId + ? "opacity-100" + : "opacity-0" + )} + aria-hidden="true" + /> + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + + {/* remove group */} + <Button + variant="outline" + size="icon" + aria-label={`Remove group ${colId}`} + className="size-8 shrink-0 rounded" + onClick={() => removeGroup(colId)} + > + <XCircle className="size-3.5" aria-hidden="true" /> + </Button> + + {/* drag handle */} + <SortableDragHandle + variant="outline" + size="icon" + className="size-8 shrink-0 rounded" + > + <GripVertical className="size-3.5" aria-hidden="true" /> + </SortableDragHandle> + </div> + </SortableItem> + ) + })} + </div> + </div> + + {/* Footer: "Add group" & "Reset grouping" */} + <div className="flex w-full items-center gap-2"> + <Button + size="sm" + className="h-[1.85rem] rounded" + onClick={addGroup} + disabled={uniqueGrouping.length >= groupableColumns.length} + > + Add group + </Button> + {uniqueGrouping.length > 0 && ( + <Button + size="sm" + variant="outline" + className="rounded" + onClick={resetGrouping} + > + Reset grouping + </Button> + )} + </div> + </PopoverContent> + </Popover> + </Sortable> + ) +}
\ No newline at end of file diff --git a/components/client-data-table/data-table-pagination.tsx b/components/client-data-table/data-table-pagination.tsx new file mode 100644 index 00000000..5abd3470 --- /dev/null +++ b/components/client-data-table/data-table-pagination.tsx @@ -0,0 +1,132 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { + ChevronLeft, + ChevronRight, + ChevronsLeft, + ChevronsRight, +} from "lucide-react" + +import { Button } from "@/components/ui/button" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" + +interface DataTablePaginationProps<TData> { + table: Table<TData> + pageSizeOptions?: Array<number | "All"> +} + +export function ClientDataTablePagination<TData>({ + table, + pageSizeOptions = [10, 20, 30, 40, 50, "All"], +}: DataTablePaginationProps<TData>) { + // 현재 테이블 pageSize + const currentPageSize = table.getState().pagination.pageSize + + // "All"을 1,000,000으로 처리할 것이므로, + // 만약 현재 pageSize가 1,000,000이면 화면상 "All"로 표시 + const selectValue = + currentPageSize === 1_000_000 + ? "All" + : String(currentPageSize) + + return ( + <div className="flex w-full flex-col-reverse items-center justify-between gap-4 overflow-auto p-1 sm:flex-row sm:gap-8"> + <div className="flex-1 whitespace-nowrap text-sm text-muted-foreground"> + {table.getFilteredSelectedRowModel().rows.length} of{" "} + {table.getFilteredRowModel().rows.length} row(s) selected. + </div> + <div className="flex flex-col-reverse items-center gap-4 sm:flex-row sm:gap-6 lg:gap-8"> + {/* Rows per page Select */} + <div className="flex items-center space-x-2"> + <p className="whitespace-nowrap text-sm font-medium">Rows per page</p> + <Select + value={selectValue} + onValueChange={(value) => { + if (value === "All") { + // "All"을 1,000,000으로 치환 + table.setPageSize(1_000_000) + } else { + table.setPageSize(Number(value)) + } + }} + > + <SelectTrigger className="h-8 w-[4.5rem]"> + <SelectValue placeholder={selectValue} /> + </SelectTrigger> + <SelectContent side="top"> + {pageSizeOptions.map((option) => { + // 화면에 표시할 라벨 + const label = option === "All" ? "All" : String(option) + // value도 문자열화 + const val = option === "All" ? "All" : String(option) + + return ( + <SelectItem key={val} value={val}> + {label} + </SelectItem> + ) + })} + </SelectContent> + </Select> + </div> + + {/* 현재 페이지 / 전체 페이지 */} + <div className="flex items-center justify-center text-sm font-medium"> + Page {table.getState().pagination.pageIndex + 1} of{" "} + {table.getPageCount()} + </div> + + {/* 페이지 이동 버튼 */} + <div className="flex items-center space-x-2"> + <Button + aria-label="Go to first page" + variant="outline" + className="hidden size-8 p-0 lg:flex" + onClick={() => table.setPageIndex(0)} + disabled={!table.getCanPreviousPage()} + > + <ChevronsLeft className="size-4" aria-hidden="true" /> + </Button> + <Button + aria-label="Go to previous page" + variant="outline" + size="icon" + className="size-8" + onClick={() => table.previousPage()} + disabled={!table.getCanPreviousPage()} + > + <ChevronLeft className="size-4" aria-hidden="true" /> + </Button> + <Button + aria-label="Go to next page" + variant="outline" + size="icon" + className="size-8" + onClick={() => table.nextPage()} + disabled={!table.getCanNextPage()} + > + <ChevronRight className="size-4" aria-hidden="true" /> + </Button> + <Button + aria-label="Go to last page" + variant="outline" + size="icon" + className="hidden size-8 lg:flex" + onClick={() => table.setPageIndex(table.getPageCount() - 1)} + disabled={!table.getCanNextPage()} + > + <ChevronsRight className="size-4" aria-hidden="true" /> + </Button> + </div> + </div> + </div> + ) +}
\ No newline at end of file diff --git a/components/client-data-table/data-table-resizer.tsx b/components/client-data-table/data-table-resizer.tsx new file mode 100644 index 00000000..7dc8f523 --- /dev/null +++ b/components/client-data-table/data-table-resizer.tsx @@ -0,0 +1,119 @@ +"use client" + +import * as React from "react" +import { cn } from "@/lib/utils" +import { Header } from "@tanstack/react-table" + +interface DataTableResizerProps<TData, TValue> + extends React.HTMLAttributes<HTMLDivElement> { + header: Header<TData, TValue> + onResizeStart?: () => void + onResizeEnd?: () => void +} + +export function DataTableResizer<TData, TValue>({ + header, + onResizeStart, + onResizeEnd, + className, + ...props +}: DataTableResizerProps<TData, TValue>) { + const contentRef = React.useRef<HTMLDivElement>(null) + + // 더블클릭 시 너비 자동 조정 함수 + const handleDoubleClick = React.useCallback((e: React.MouseEvent) => { + e.stopPropagation(); // 이벤트 버블링 중지 + + // 테이블 인스턴스 가져오기 + const table = header.getContext().table + + // 0. 몇 가지 기본 설정 + const defaultMinWidth = 80 // 기본 최소 너비 + const extraPadding = 24 // 여유 공간 + + // 헤더 타이틀 얻기 시도 + const headerElement = contentRef.current?.closest('th') + const headerText = headerElement?.textContent || "" + + // 1. 컬럼 ID 가져오기 + const columnId = header.column.id + + // 2. 테이블 바디에서 해당 ID를 가진 모든 셀 선택 + const allCells = document.querySelectorAll(`tbody td[data-column-id="${columnId}"]`) + + // 3. 최대 컨텐츠 너비 측정을 위한 임시 요소 생성 + const measureElement = document.createElement('div') + measureElement.style.position = 'absolute' + measureElement.style.visibility = 'hidden' + measureElement.style.whiteSpace = 'nowrap' // 내용이 줄바꿈되지 않도록 + measureElement.style.font = window.getComputedStyle(headerElement || document.body).font // 동일한 폰트 사용 + document.body.appendChild(measureElement) + + // 4. 헤더 너비 측정 + measureElement.textContent = headerText + let maxWidth = measureElement.getBoundingClientRect().width + + // 5. 모든 셀의 내용 너비 측정하고 최대값 찾기 + Array.from(allCells).forEach(cell => { + const cellText = cell.textContent || "" + measureElement.textContent = cellText + const cellWidth = measureElement.getBoundingClientRect().width + maxWidth = Math.max(maxWidth, cellWidth) + }) + + // 6. 측정용 요소 제거 + document.body.removeChild(measureElement) + + // 7. 계산된 너비에 여유 공간 추가 + let finalWidth = maxWidth + extraPadding + + // 8. 최소 너비 적용 + const minWidth = header.column.columnDef.minSize || defaultMinWidth + finalWidth = Math.max(finalWidth, minWidth) + + // 9. 컬럼 사이즈 업데이트 + const columnSizingInfo = table.getState().columnSizing + const updatedSizing = { + ...columnSizingInfo, + [columnId]: finalWidth + } + + // 사이즈 업데이트 + table.setColumnSizing(updatedSizing) + + // 콘솔 로그 추가 (디버깅용) + console.log(`컬럼 [${columnId}] 너비 자동 조정: ${finalWidth}px`); + }, [header]) + + // 마우스 다운 핸들러 (리사이징 시작) + const handleMouseDown = React.useCallback((e: React.MouseEvent | React.TouchEvent) => { + // 리사이즈 시작을 알림 + if (onResizeStart) onResizeStart() + + // 기존 리사이즈 핸들러 호출 + if (header.getResizeHandler()) { + header.getResizeHandler()(e as any) + } + }, [header, onResizeStart]) + + return ( + <> + {/* 헤더 콘텐츠 참조를 위한 요소 */} + <div ref={contentRef} className="absolute opacity-0 pointer-events-none" /> + + {/* 리사이저 */} + <div + {...props} + onMouseDown={handleMouseDown} + onTouchStart={handleMouseDown} + onDoubleClick={handleDoubleClick} + className={cn( + "absolute right-0 top-0 h-full w-1 cursor-col-resize bg-transparent hover:bg-gray-300 active:bg-gray-400", + header.column.getIsResizing() ? "bg-gray-400" : "", + className + )} + title="더블 클릭하여 내용에 맞게 크기 조정" // 힌트 추가 + /> + </> + ) +}
\ No newline at end of file diff --git a/components/client-data-table/data-table-sort-list.tsx b/components/client-data-table/data-table-sort-list.tsx new file mode 100644 index 00000000..b67fdde3 --- /dev/null +++ b/components/client-data-table/data-table-sort-list.tsx @@ -0,0 +1,272 @@ +"use client" + +import * as React from "react" +import { type SortingState, type Table } from "@tanstack/react-table" +import { + ArrowDownUp, + ChevronsUpDown, + GripVertical, + Trash2, +} from "lucide-react" + +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command" +import { + Sortable, + SortableItem, + SortableDragHandle, +} from "@/components/ui/sortable" +import { cn, toSentenceCase } from "@/lib/utils" + + +/** + * A simpler, local-state version of the column "sort list". + * - No `useQueryState` or URL sync + * - We store a local `sorting: SortingState` and whenever it changes, we do `table.setSorting(sorting)`. + */ +interface DataTableSortListLocalProps<TData> { + /** TanStack Table instance */ + table: Table<TData> +} + +export function ClientDataTableSortList<TData>({ table }: DataTableSortListLocalProps<TData>) { + + + // 2) local SortingState + const [sorting, setSorting] = React.useState<SortingState>([]) + + // 3) Keep the table in sync + React.useEffect(() => { + table.setSorting(sorting) + }, [sorting, table]) + + // 4) columns that can be sorted + const sortableColumns = React.useMemo(() => { + return table + .getAllColumns() + .filter((col) => col.getCanSort()) + .map((col) => ({ + id: col.id, + // excelHeader 를 사용 중이면 label이 없을 수 있으니 fallback + label: (col.columnDef.meta?.excelHeader as string) || toSentenceCase(col.id), + })) + }, [table]) + + // 5) "Add sort" → pick first unsorted column + function addSort() { + const used = new Set(sorting.map((s) => s.id)) + const firstUnused = sortableColumns.find((col) => !used.has(col.id)) + if (!firstUnused) return + setSorting((prev) => [...prev, { id: firstUnused.id, desc: false }]) + } + + // 6) update sort item by column id + function updateSort( + columnId: string, + patch: Partial<{ id: string; desc: boolean }> + ) { + setSorting((prev) => + prev.map((s) => (s.id === columnId ? { ...s, ...patch } : s)) + ) + } + + // 7) remove a sort item + function removeSort(columnId: string) { + setSorting((prev) => prev.filter((s) => s.id !== columnId)) + } + + // 8) reorder sorting items via drag + function moveSort(activeIndex: number, overIndex: number) { + setSorting((prev) => { + const arr = [...prev] + const [removed] = arr.splice(activeIndex, 1) + if (!removed) return prev + arr.splice(overIndex, 0, removed) + return arr + }) + } + + const isSortingEmpty = sorting.length === 0 + + return ( + <Sortable + value={sorting} + onValueChange={setSorting} + onMove={({ activeIndex, overIndex }) => moveSort(activeIndex, overIndex)} + overlay={ + <div className="flex items-center gap-2"> + <div className="h-8 w-[11.25rem] rounded-sm bg-primary/10" /> + <div className="h-8 w-24 rounded-sm bg-primary/10" /> + <div className="size-8 shrink-0 rounded-sm bg-primary/10" /> + <div className="size-8 shrink-0 rounded-sm bg-primary/10" /> + </div> + } + > + <Popover> + <PopoverTrigger asChild> + <Button variant="outline" size="sm" className="gap-2"> + <ArrowDownUp className="h-4 w-4" aria-hidden="true" /> + { "Sort"} + {sorting.length > 0 && ( + <Badge + variant="secondary" + className="h-[1.14rem] rounded-[0.2rem] px-[0.32rem] font-mono text-[0.65rem] font-normal" + > + {sorting.length} + </Badge> + )} + </Button> + </PopoverTrigger> + + <PopoverContent + align="start" + collisionPadding={16} + className={cn( + "flex w-[calc(100vw-theme(spacing.20))] min-w-72 max-w-[25rem] origin-[var(--radix-popover-content-transform-origin)] flex-col p-4 sm:w-[25rem]", + sorting.length > 0 ? "gap-3.5" : "gap-2" + )} + > + {isSortingEmpty ? ( + <div className="flex flex-col gap-1"> + <h4 className="font-medium leading-none"> + { "No sorting applied"} + </h4> + <p className="text-sm text-muted-foreground"> + { "Add sorting to organize your results."} + </p> + </div> + ) : ( + <h4 className="font-medium leading-none"> + { "Sort by"} + </h4> + )} + + {/* Sorting items */} + {sorting.length > 0 && ( + <div className="flex max-h-40 flex-col gap-2 overflow-y-auto p-0.5"> + {sorting.map((sortItem) => { + const col = sortableColumns.find((c) => c.id === sortItem.id) + const columnLabel = col ? col.label : toSentenceCase(sortItem.id) + + return ( + <SortableItem key={sortItem.id} value={sortItem.id} asChild> + <div className="flex items-center gap-2"> + {/* Column name selector */} + <Popover modal> + <PopoverTrigger asChild> + <Button + variant="outline" + size="sm" + className="h-8 w-[11.25rem] justify-between" + > + <span className="truncate">{columnLabel}</span> + <ChevronsUpDown className="h-4 w-4 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0"> + <Command> + <CommandInput placeholder={"Search columns..." } /> + <CommandList> + <CommandEmpty> + { "No columns found."} + </CommandEmpty> + <CommandGroup> + {sortableColumns.map((col) => ( + <CommandItem + key={col.id} + value={col.id} + onSelect={(val) => { + // change the ID of the sort item + updateSort(sortItem.id, { id: val, desc: false }) + }} + > + {col.label} + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + + {/* Sort direction */} + <Select + value={sortItem.desc ? "desc" : "asc"} + onValueChange={(val) => + updateSort(sortItem.id, { desc: val === "desc" }) + } + > + <SelectTrigger className="h-8 w-24"> + <SelectValue /> + </SelectTrigger> + <SelectContent className="min-w-[var(--radix-select-trigger-width)]"> + <SelectItem value="asc"> + {"Asc" } + </SelectItem> + <SelectItem value="desc"> + { "Desc"} + </SelectItem> + </SelectContent> + </Select> + + {/* remove sort */} + <Button + variant="outline" + size="icon" + onClick={() => removeSort(sortItem.id)} + className="size-8 shrink-0 rounded" + > + <Trash2 className="size-3.5" aria-hidden="true" /> + </Button> + + {/* drag handle */} + <SortableDragHandle + variant="outline" + size="icon" + className="size-8 shrink-0 rounded" + > + <GripVertical className="size-3.5" aria-hidden="true" /> + </SortableDragHandle> + </div> + </SortableItem> + ) + })} + </div> + )} + + {/* Footer: "Add sort" & "Reset" */} + <div className="flex items-center gap-2"> + <Button size="sm" onClick={addSort}> + {"Add sort" } + </Button> + {sorting.length > 0 && ( + <Button size="sm" variant="outline" onClick={() => setSorting([])}> + {"Reset sorting" } + </Button> + )} + </div> + </PopoverContent> + </Popover> + </Sortable> + ) +}
\ No newline at end of file diff --git a/components/client-data-table/data-table-toolbar.tsx b/components/client-data-table/data-table-toolbar.tsx new file mode 100644 index 00000000..286cffd6 --- /dev/null +++ b/components/client-data-table/data-table-toolbar.tsx @@ -0,0 +1,100 @@ +"use client" + +import * as React from "react" +import type { DataTableAdvancedFilterField } from "@/types/table" +import { type Table } from "@tanstack/react-table" + +import { cn } from "@/lib/utils" +import { ClientDataTableViewOptions } from "./data-table-view-options" +import { Button } from "../ui/button" +import { Download } from "lucide-react" + +import { exportTableToExcel } from "@/lib/export_all" +import { ClientDataTableSortList } from "./data-table-sort-list" +import { ClientDataTableGroupList } from "./data-table-group-list" +import { ClientDataTableAdvancedFilter } from "./data-table-filter-list" + +interface DataTableAdvancedToolbarProps<TData> + extends React.HTMLAttributes<HTMLDivElement> { + table: Table<TData> + filterFields: DataTableAdvancedFilterField<TData>[] + debounceMs?: number + shallow?: boolean + children?: React.ReactNode +} + +export function ClientDataTableAdvancedToolbar<TData>({ + table, + filterFields = [], + debounceMs = 300, + shallow = true, + children, + className, + ...props +}: DataTableAdvancedToolbarProps<TData>) { + + // 전체 엑셀 내보내기 + const handleExportAll = async () => { + try { + await exportTableToExcel(table, { + filename: "my-data", + onlySelected: false, + excludeColumns: ["select", "actions", "validation", "requestedAmount", "update"], + useGroupHeader: false, + allPages: true, + + }) + } catch (err) { + console.error("Export error:", err) + // 필요하면 토스트나 알림 처리 + } + } + + + + return ( + <div + className={cn( + "flex w-full items-center justify-between gap-2 overflow-auto p-1", + className + )} + {...props} + > + {/* 왼쪽: 필터 & 정렬 & 뷰 옵션 */} + <div className="flex items-center gap-2"> + <ClientDataTableAdvancedFilter + table={table} + filterFields={filterFields} + /> + <ClientDataTableSortList table={table} /> + <ClientDataTableViewOptions table={table} /> + <ClientDataTableGroupList table={table}/> + <Button variant="outline" size="sm" onClick={handleExportAll}> + <Download className="size-4" /> + {/* i18n 버튼 문구 */} + <span className="hidden sm:inline"> + {"Export All" } + </span> + </Button> + </div> + + {/* 오른쪽: Export 버튼 + children */} + <div className="flex items-center gap-2"> + + + {/* 자식 컴포넌트(추가 버튼 등) */} + {children} + + {/* 선택된 행만 내보내기 버튼 예시 (필요 시 주석 해제) */} + {/* + <Button variant="outline" size="sm" onClick={handleExportSelected}> + <Download className="size-4" /> + <span className="hidden sm:inline"> + {t("common.exportSelected", { defaultValue: "Export Selected" })} + </span> + </Button> + */} + </div> + </div> + ) +}
\ No newline at end of file diff --git a/components/client-data-table/data-table-view-options.tsx b/components/client-data-table/data-table-view-options.tsx new file mode 100644 index 00000000..68026ff5 --- /dev/null +++ b/components/client-data-table/data-table-view-options.tsx @@ -0,0 +1,192 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { + ArrowUpDown, + Check, + ChevronsUpDown, + GripVertical, + Settings2, +} from "lucide-react" + +import { cn, toSentenceCase } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" + +// Sortable +import { + Sortable, + SortableItem, + SortableDragHandle, +} from "@/components/ui/sortable" + + +/** + * ViewOptionsProps: + * - table: TanStack Table instance + */ +interface DataTableViewOptionsProps<TData> { + table: Table<TData> +} + +declare module "@tanstack/react-table" { + interface ColumnMeta<TData, TValue> { + excelHeader?: string + group?: string + type?: string + // or whatever other fields you actually use + } +} +/** + * DataTableViewOptions: + * - Renders a Popover with hideable columns + * - Lets user reorder columns (drag & drop) + toggle visibility + */ +export function ClientDataTableViewOptions<TData>({ + table, +}: DataTableViewOptionsProps<TData>) { + const triggerRef = React.useRef<HTMLButtonElement>(null) + + // 1) Identify columns that can be hidden + const hideableCols = React.useMemo(() => { + return table + .getAllLeafColumns() + .filter((col) => col.getCanHide() && col.accessorFn !== undefined) + }, [table]) + + // 2) local state for "columnOrder" (just the ID of hideable columns) + // We'll reorder these with drag & drop + const [columnOrder, setColumnOrder] = React.useState<string[]>(() => + hideableCols.map((c) => c.id) + ) + + // 3) onMove: when user finishes drag + // - update local `columnOrder` only (no table.setColumnOrder yet) + const handleMove = React.useCallback( + ({ activeIndex, overIndex }: { activeIndex: number; overIndex: number }) => { + setColumnOrder((prev) => { + const newOrder = [...prev] + const [removed] = newOrder.splice(activeIndex, 1) + newOrder.splice(overIndex, 0, removed) + return newOrder + }) + }, + [] + ) + + + + // 4) After local state changes, reflect in tanstack table + // - We do this in useEffect to avoid "update a different component" error + React.useEffect(() => { + // Also consider "non-hideable" columns, if any, to keep them in original positions + const nonHideable = table + .getAllColumns() + .filter((col) => col.getCanHide() && col.accessorFn !== undefined) + .filter((col) => !hideableCols.some((hc) => hc.id === col.id)) + .map((c) => c.id) + + // e.g. place nonHideable at the front, then our local hideable order + const finalOrder = ["select",...nonHideable, ...columnOrder] + + // Now we set the table's official column order + table.setColumnOrder(finalOrder) + }, [columnOrder, hideableCols, table]) + + + return ( + <Popover modal> + <PopoverTrigger asChild> + <Button + ref={triggerRef} + aria-label="Toggle columns" + variant="outline" + role="combobox" + size="sm" + className="gap-2" + > + <Settings2 className="size-4" /> + <span className="hidden sm:inline">View</span> + <ChevronsUpDown className="ml-auto size-4 shrink-0 opacity-50 hidden sm:inline" /> + </Button> + </PopoverTrigger> + + <PopoverContent + align="end" + className="w-44 p-0" + onCloseAutoFocus={() => triggerRef.current?.focus()} + > + <Command> + <CommandInput placeholder="Search columns..." /> + <CommandList> + <CommandEmpty>No columns found.</CommandEmpty> + + <CommandGroup> + {/** + * 5) Sortable: we pass an array of { id: string } from `columnOrder`, + * so we can reorder them with drag & drop + */} + <Sortable + value={columnOrder.map((id) => ({ id }))} + onMove={handleMove} + > + {columnOrder.map((colId) => { + // find column instance + const column = hideableCols.find((c) => c.id === colId) + + + if (!column) return null + + return ( + <SortableItem key={colId} value={colId} asChild> + <CommandItem + onSelect={() => + column.toggleVisibility(!column.getIsVisible()) + } + > + {/* Drag handle on the left */} + <SortableDragHandle + variant="outline" + size="icon" + className="mr-2 size-5 shrink-0 rounded cursor-grab active:cursor-grabbing" + > + <ArrowUpDown className="size-2.5" aria-hidden="true" /> + </SortableDragHandle> + + {/* label */} + <span className="truncate"> + {column?.columnDef?.meta?.excelHeader} + </span> + + {/* check if visible */} + <Check + className={cn( + "ml-auto size-4 shrink-0", + column.getIsVisible() ? "opacity-100" : "opacity-0" + )} + /> + </CommandItem> + </SortableItem> + ) + })} + </Sortable> + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + ) +}
\ No newline at end of file diff --git a/components/client-data-table/data-table.tsx b/components/client-data-table/data-table.tsx new file mode 100644 index 00000000..ff10bfe4 --- /dev/null +++ b/components/client-data-table/data-table.tsx @@ -0,0 +1,336 @@ +"use client" + +import * as React from "react" +import { + ColumnDef, + ColumnFiltersState, + SortingState, + VisibilityState, + flexRender, + getCoreRowModel, + getFacetedRowModel, + getFacetedUniqueValues, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, + Table, + getGroupedRowModel, + getExpandedRowModel, + ColumnSizingState, ColumnPinningState +} from "@tanstack/react-table" +import { + Table as UiTable, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { getCommonPinningStyles } from "@/lib/data-table" +import { ChevronRight, ChevronUp } from "lucide-react" + +import { ClientDataTableAdvancedToolbar } from "./data-table-toolbar" +import { ClientDataTablePagination } from "./data-table-pagination" +import { DataTableResizer } from "./data-table-resizer" +import { useAutoSizeColumns } from "@/hooks/useAutoSizeColumns" + +interface DataTableProps<TData, TValue> { + columns: ColumnDef<TData, TValue>[] + data: TData[] + advancedFilterFields: any[] + autoSizeColumns?: boolean + onSelectedRowsChange?: (selected: TData[]) => void + + /** 추가로 표시할 버튼/컴포넌트 */ + children?: React.ReactNode +} + +export function ClientDataTable<TData, TValue>({ + columns, + data, + advancedFilterFields, + autoSizeColumns = true, + children, + onSelectedRowsChange +}: DataTableProps<TData, TValue>) { + + + // (1) React Table 상태 + const [rowSelection, setRowSelection] = React.useState({}) + const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({}) + const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([]) + const [sorting, setSorting] = React.useState<SortingState>([]) + const [grouping, setGrouping] = React.useState<string[]>([]) + const [columnSizing, setColumnSizing] = React.useState<ColumnSizingState>({}) + + // 실제 리사이징 상태만 추적 + const [isResizing, setIsResizing] = React.useState(false) + + // 리사이징 상태를 추적하기 위한 ref + const isResizingRef = React.useRef(false) + + // 리사이징 이벤트 핸들러 + const handleResizeStart = React.useCallback(() => { + isResizingRef.current = true + setIsResizing(true) + }, []) + + const handleResizeEnd = React.useCallback(() => { + isResizingRef.current = false + setIsResizing(false) + }, []) + + const [columnPinning, setColumnPinning] = React.useState<ColumnPinningState>({ + left: [], + right: ["update"], + }) + + const table = useReactTable({ + data, + columns, + state: { + sorting, + columnVisibility, + rowSelection, + columnFilters, + grouping, + columnSizing, + columnPinning + }, + columnResizeMode: "onChange", + onColumnSizingChange: setColumnSizing, + enableRowSelection: true, + onRowSelectionChange: setRowSelection, + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + onColumnVisibilityChange: setColumnVisibility, + onGroupingChange: setGrouping, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFacetedRowModel: getFacetedRowModel(), + getFacetedUniqueValues: getFacetedUniqueValues(), + getGroupedRowModel: getGroupedRowModel(), + autoResetPageIndex: false, + getExpandedRowModel: getExpandedRowModel(), + enableColumnPinning:true, + onColumnPinningChange:setColumnPinning + + }) + + useAutoSizeColumns(table, autoSizeColumns) + + // 컴포넌트 마운트 시 강제로 리사이징 상태 초기화 + React.useEffect(() => { + // 강제로 초기 상태는 리사이징 비활성화 + setIsResizing(false) + isResizingRef.current = false + + // 전역 마우스 이벤트 핸들러 + const handleMouseUp = () => { + if (isResizingRef.current) { + handleResizeEnd() + } + } + + // 이벤트 리스너 등록 + window.addEventListener('mouseup', handleMouseUp) + window.addEventListener('touchend', handleMouseUp) + + return () => { + // 이벤트 리스너 정리 + window.removeEventListener('mouseup', handleMouseUp) + window.removeEventListener('touchend', handleMouseUp) + + // 컴포넌트 언마운트 시 정리 + setIsResizing(false) + isResizingRef.current = false + } + }, [handleResizeEnd]) + + React.useEffect(() => { + if (!onSelectedRowsChange) return + const selectedRows = table + .getSelectedRowModel() + .flatRows.map((row) => row.original) + onSelectedRowsChange(selectedRows) + }, [rowSelection, table, onSelectedRowsChange]) + + // (2) 렌더 + return ( + <div className="space-y-4"> + {/* 툴바에 children을 넘기기 */} + <ClientDataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + {children} + </ClientDataTableAdvancedToolbar> + + <div className="rounded-md border"> + <div className="overflow-auto" style={{maxHeight:'33.6rem'}}> + <UiTable + className="[&>thead]:sticky [&>thead]:top-0 [&>thead]:z-10 table-fixed" + > + <TableHeader> + {table.getHeaderGroups().map((headerGroup) => ( + <TableRow key={headerGroup.id}> + {headerGroup.headers.map((header) => { + // 만약 이 컬럼이 현재 "그룹핑" 상태라면 헤더도 표시하지 않음 + if (header.column.getIsGrouped()) { + return null + } + + return ( + <TableHead + key={header.id} + colSpan={header.colSpan} + className="relative" + style={{ + ...getCommonPinningStyles({ column: header.column }), + width: header.getSize() + }} + > + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + {/* 리사이즈 핸들 - 헤더에만 추가 */} + {header.column.getCanResize() && ( + <DataTableResizer + header={header} + onResizeStart={handleResizeStart} + onResizeEnd={handleResizeEnd} + /> + )} + </TableHead> + ) + })} + </TableRow> + ))} + </TableHeader> + <TableBody> + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => { + // --------------------------------------------------- + // 1) "그룹핑 헤더" Row인지 확인 + // --------------------------------------------------- + if (row.getIsGrouped()) { + // row.groupingColumnId로 어떤 컬럼을 기준으로 그룹화 되었는지 알 수 있음 + const groupingColumnId = row.groupingColumnId ?? "" + const groupingColumn = table.getColumn(groupingColumnId) // 해당 column 객체 + + // 컬럼 라벨 가져오기 + let columnLabel = groupingColumnId + if (groupingColumn) { + const headerDef = groupingColumn.columnDef.meta?.excelHeader + if (typeof headerDef === "string") { + columnLabel = headerDef + } + } + + return ( + <TableRow + key={row.id} + className="bg-muted/20" + data-state={row.getIsExpanded() && "expanded"} + > + {/* 그룹 헤더는 한 줄에 합쳐서 보여주고, 토글 버튼 + 그룹 라벨 + 값 표기 */} + <TableCell colSpan={table.getVisibleFlatColumns().length}> + {/* 확장/축소 버튼 (아이콘 중앙 정렬 + Indent) */} + {row.getCanExpand() && ( + <button + onClick={row.getToggleExpandedHandler()} + className="inline-flex items-center justify-center mr-2 w-5 h-5" + style={{ + // row.depth: 0이면 top-level, 1이면 그 하위 등 + marginLeft: `${row.depth * 1.5}rem`, + }} + > + {row.getIsExpanded() ? ( + <ChevronUp size={16} /> + ) : ( + <ChevronRight size={16} /> + )} + </button> + )} + + {/* Group Label + 값 */} + <span className="font-semibold"> + {columnLabel}: {row.getValue(groupingColumnId)} + </span> + <span className="ml-2 text-xs text-muted-foreground"> + ({row.subRows.length} rows) + </span> + </TableCell> + </TableRow> + ) + } + + // --------------------------------------------------- + // 2) 일반 Row + // → "그룹핑된 컬럼"은 숨긴다 + // --------------------------------------------------- + return ( + <TableRow + key={row.id} + data-state={row.getIsSelected() && "selected"} + > + {row.getVisibleCells().map((cell) => { + // 이 셀의 컬럼이 grouped라면 숨긴다 + if (cell.column.getIsGrouped()) { + return null + } + + return ( + <TableCell + key={cell.id} + data-column-id={cell.column.id} + style={{ + ...getCommonPinningStyles({ column: cell.column }), + width: cell.column.getSize() + }} + > + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + </TableCell> + ) + })} + </TableRow> + ) + }) + ) : ( + // --------------------------------------------------- + // 3) 데이터가 없을 때 + // --------------------------------------------------- + <TableRow> + <TableCell + colSpan={table.getAllColumns().length} + className="h-24 text-center" + > + No results. + </TableCell> + </TableRow> + )} + </TableBody> + </UiTable> + + {/* 리사이징 시에만 캡처 레이어 활성화 */} + {isResizing && ( + <div className="fixed inset-0 cursor-col-resize select-none z-50" /> + )} + </div> + </div> + + <ClientDataTablePagination table={table} /> + </div> + ) +}
\ No newline at end of file diff --git a/components/data-table/data-table-advanced-toolbar.tsx b/components/data-table/data-table-advanced-toolbar.tsx new file mode 100644 index 00000000..7c126c51 --- /dev/null +++ b/components/data-table/data-table-advanced-toolbar.tsx @@ -0,0 +1,104 @@ +"use client" + +import * as React from "react" +import type { DataTableAdvancedFilterField } from "@/types/table" +import { type Table } from "@tanstack/react-table" + +import { cn } from "@/lib/utils" +import { DataTableFilterList } from "@/components/data-table/data-table-filter-list" +import { DataTableSortList } from "@/components/data-table/data-table-sort-list" +import { DataTableViewOptions } from "@/components/data-table/data-table-view-options" +import { DataTablePinList } from "./data-table-pin" +import { PinLeftButton } from "./data-table-pin-left" +import { PinRightButton } from "./data-table-pin-right" +import { DataTableGlobalFilter } from "./data-table-grobal-filter" +import { DataTableGroupList } from "./data-table-group-list" + +interface DataTableAdvancedToolbarProps<TData> + extends React.HTMLAttributes<HTMLDivElement> { + /** + * The table instance returned from useDataTable hook with pagination, sorting, filtering, etc. + * @type Table<TData> + */ + table: Table<TData> + + /** + * An array of filter field configurations for the data table. + * @type DataTableAdvancedFilterField<TData>[] + * @example + * const filterFields = [ + * { + * id: 'name', + * label: 'Name', + * type: 'text', + * placeholder: 'Filter by name...' + * }, + * { + * id: 'status', + * label: 'Status', + * type: 'select', + * options: [ + * { label: 'Active', value: 'active', count: 10 }, + * { label: 'Inactive', value: 'inactive', count: 5 } + * ] + * } + * ] + */ + filterFields: DataTableAdvancedFilterField<TData>[] + + /** + * Debounce time (ms) for filter updates to enhance performance during rapid input. + * @default 300 + */ + debounceMs?: number + + /** + * Shallow mode keeps query states client-side, avoiding server calls. + * Setting to `false` triggers a network request with the updated querystring. + * @default true + */ + shallow?: boolean +} + +export function DataTableAdvancedToolbar<TData>({ + table, + filterFields = [], + debounceMs = 300, + shallow = true, + children, + className, + ...props +}: DataTableAdvancedToolbarProps<TData>) { + return ( + <div + className={cn( + "flex w-full items-center justify-between gap-2 overflow-auto p-1", + className + )} + {...props} + > + <div className="flex items-center gap-2"> + <DataTableViewOptions table={table} /> + <DataTableFilterList + table={table} + filterFields={filterFields} + debounceMs={debounceMs} + shallow={shallow} + /> + <DataTableSortList + table={table} + debounceMs={debounceMs} + shallow={shallow} + /> + <DataTableGroupList table={table} debounceMs={debounceMs}/> + <PinLeftButton table={table}/> + <PinRightButton table={table}/> + <DataTableGlobalFilter /> + </div> + <div className="flex items-center gap-2"> + {children} + + </div> + </div> + ) +} diff --git a/components/data-table/data-table-column-header.tsx b/components/data-table/data-table-column-header.tsx new file mode 100644 index 00000000..aa0c754b --- /dev/null +++ b/components/data-table/data-table-column-header.tsx @@ -0,0 +1,109 @@ +"use client" + +import { SelectIcon } from "@radix-ui/react-select" +import { type Column } from "@tanstack/react-table" +import { ArrowDown, ArrowUp, ChevronsUpDown, EyeOff } from "lucide-react" + +import { cn } from "@/lib/utils" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, +} from "@/components/ui/select" + +interface DataTableColumnHeaderProps<TData, TValue> + extends React.HTMLAttributes<HTMLDivElement> { + column: Column<TData, TValue> + title: string +} + +export function DataTableColumnHeader<TData, TValue>({ + column, + title, + className, +}: DataTableColumnHeaderProps<TData, TValue>) { + if (!column.getCanSort() && !column.getCanHide()) { + return <div className={cn(className)}>{title}</div> + } + + const ascValue = `${column.id}-asc` + const descValue = `${column.id}-desc` + const hideValue = `${column.id}-hide` + + return ( + <div className={cn("flex items-center gap-2", className)}> + <Select + value={ + column.getIsSorted() === "desc" + ? descValue + : column.getIsSorted() === "asc" + ? ascValue + : undefined + } + onValueChange={(value) => { + if (value === ascValue) column.toggleSorting(false) + else if (value === descValue) column.toggleSorting(true) + else if (value === hideValue) column.toggleVisibility(false) + }} + > + <SelectTrigger + aria-label={ + column.getIsSorted() === "desc" + ? "Sorted descending. Click to sort ascending." + : column.getIsSorted() === "asc" + ? "Sorted ascending. Click to sort descending." + : "Not sorted. Click to sort ascending." + } + className="-ml-3 h-8 w-fit border-none text-xs hover:bg-accent hover:text-accent-foreground data-[state=open]:bg-accent [&>svg:last-child]:hidden" + > + {title} + <SelectIcon asChild> + {column.getCanSort() && column.getIsSorted() === "desc" ? ( + <ArrowDown className="ml-2.5 size-4" aria-hidden="true" /> + ) : column.getIsSorted() === "asc" ? ( + <ArrowUp className="ml-2.5 size-4" aria-hidden="true" /> + ) : ( + <ChevronsUpDown className="ml-2.5 size-4" aria-hidden="true" /> + )} + </SelectIcon> + </SelectTrigger> + <SelectContent align="start"> + {column.getCanSort() && ( + <> + <SelectItem value={ascValue}> + <span className="flex items-center"> + <ArrowUp + className="mr-2 size-3.5 text-muted-foreground/70" + aria-hidden="true" + /> + Asc + </span> + </SelectItem> + <SelectItem value={descValue}> + <span className="flex items-center"> + <ArrowDown + className="mr-2 size-3.5 text-muted-foreground/70" + aria-hidden="true" + /> + Desc + </span> + </SelectItem> + </> + )} + {column.getCanHide() && ( + <SelectItem value={hideValue}> + <span className="flex items-center"> + <EyeOff + className="mr-2 size-3.5 text-muted-foreground/70" + aria-hidden="true" + /> + Hide + </span> + </SelectItem> + )} + </SelectContent> + </Select> + </div> + ) +} diff --git a/components/data-table/data-table-column-resizable.tsx b/components/data-table/data-table-column-resizable.tsx new file mode 100644 index 00000000..2a91c998 --- /dev/null +++ b/components/data-table/data-table-column-resizable.tsx @@ -0,0 +1,57 @@ +"use client" + +import * as React from "react" +import { cn } from "@/lib/utils" +import { type Column } from "@tanstack/react-table" +import { ArrowDown, ArrowUp, ChevronsUpDown } from "lucide-react" + +interface DataTableColumnHeaderProps<TData, TValue> + extends React.HTMLAttributes<HTMLDivElement> { + column: Column<TData, TValue> + title: string +} + +export function DataTableColumnHeaderResizable<TData, TValue>({ + column, + title, + className, +}: DataTableColumnHeaderProps<TData, TValue>) { + // 정렬 상태: "asc" | "desc" | false + const sorted = column.getIsSorted() + + // 아이콘 결정 + let icon = <ChevronsUpDown className="ml-1 size-4" aria-hidden="true" /> + if (sorted === "asc") { + icon = <ArrowUp className="ml-1 size-4" aria-hidden="true" /> + } else if (sorted === "desc") { + icon = <ArrowDown className="ml-1 size-4" aria-hidden="true" /> + } + + // 클릭 핸들러: 무정렬 → asc → desc → 무정렬 + function handleClick() { + if (!sorted) { + // 현재 무정렬 → asc + column.toggleSorting(false) + } else if (sorted === "asc") { + // asc → desc + column.toggleSorting(true) + } else { + // desc → 무정렬 + column.toggleSorting(false) + } + } + + return ( + <div className={cn("relative flex items-center", className)}> + <div + onClick={handleClick} + className={cn( + "flex cursor-pointer select-none items-center gap-1" + )} + > + <span>{title}</span> + {column.getCanSort() && icon} + </div> + </div> + ) +}
\ No newline at end of file diff --git a/components/data-table/data-table-column-simple-header.tsx b/components/data-table/data-table-column-simple-header.tsx new file mode 100644 index 00000000..a865df24 --- /dev/null +++ b/components/data-table/data-table-column-simple-header.tsx @@ -0,0 +1,61 @@ +"use client" + +import * as React from "react" +import { cn } from "@/lib/utils" +import { type Column } from "@tanstack/react-table" +import { ArrowDown, ArrowUp, ChevronsUpDown } from "lucide-react" + +interface DataTableColumnHeaderSimpleProps<TData, TValue> + extends React.HTMLAttributes<HTMLDivElement> { + column: Column<TData, TValue> + title: string +} + +export function DataTableColumnHeaderSimple<TData, TValue>({ + column, + title, + className, +}: DataTableColumnHeaderSimpleProps<TData, TValue>) { + // 정렬 불가능 시 → 제목만 보여주기 + if (!column.getCanSort()) { + return <div className={cn(className)}>{title}</div> + } + + // 정렬 상태: "asc" | "desc" | false + const sorted = column.getIsSorted() + + // 아이콘 결정 + let icon = <ChevronsUpDown className="w-4 h-4" aria-hidden="true" /> + if (sorted === "asc") { + icon = <ArrowUp className="w-4 h-4" aria-hidden="true" /> + } else if (sorted === "desc") { + icon = <ArrowDown className="w-4 h-4" aria-hidden="true" /> + } + + // 클릭 핸들러: 무정렬 → asc → desc → 무정렬 + function handleClick() { + if (!sorted) { + // 현재 무정렬 → asc + column.toggleSorting(false) + } else if (sorted === "asc") { + // asc → desc + column.toggleSorting(true) + } else { + // desc → 무정렬 + column.toggleSorting(false) + } + } + + return ( + <div + onClick={handleClick} + className={cn( + "flex items-center justify-between cursor-pointer select-none gap-2", + className + )} + > + <span>{title}</span> + {icon} + </div> + ) +}
\ No newline at end of file diff --git a/components/data-table/data-table-faceted-filter.tsx b/components/data-table/data-table-faceted-filter.tsx new file mode 100644 index 00000000..d89ef03f --- /dev/null +++ b/components/data-table/data-table-faceted-filter.tsx @@ -0,0 +1,151 @@ +"use client" + +import type { Option } from "@/types/table" +import type { Column } from "@tanstack/react-table" +import { Check, PlusCircle } from "lucide-react" + +import { cn } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from "@/components/ui/command" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { Separator } from "@/components/ui/separator" + +interface DataTableFacetedFilterProps<TData, TValue> { + column?: Column<TData, TValue> + title?: string + options: Option[] +} + +export function DataTableFacetedFilter<TData, TValue>({ + column, + title, + options, +}: DataTableFacetedFilterProps<TData, TValue>) { + const unknownValue = column?.getFilterValue() + const selectedValues = new Set( + Array.isArray(unknownValue) ? unknownValue : [] + ) + + return ( + <Popover> + <PopoverTrigger asChild> + <Button variant="outline" size="sm" className="h-8 border-dashed"> + <PlusCircle className="mr-2 size-4" /> + {title} + {selectedValues?.size > 0 && ( + <> + <Separator orientation="vertical" className="mx-2 h-4" /> + <Badge + variant="secondary" + className="rounded-sm px-1 font-normal lg:hidden" + > + {selectedValues.size} + </Badge> + <div className="hidden space-x-1 lg:flex"> + {selectedValues.size > 2 ? ( + <Badge + variant="secondary" + className="rounded-sm px-1 font-normal" + > + {selectedValues.size} selected + </Badge> + ) : ( + options + .filter((option) => selectedValues.has(option.value)) + .map((option) => ( + <Badge + variant="secondary" + key={option.value} + className="rounded-sm px-1 font-normal" + > + {option.label} + </Badge> + )) + )} + </div> + </> + )} + </Button> + </PopoverTrigger> + <PopoverContent className="w-[12.5rem] p-0" align="start"> + <Command> + <CommandInput placeholder={title} /> + <CommandList className="max-h-full"> + <CommandEmpty>No results found.</CommandEmpty> + <CommandGroup className="max-h-[18.75rem] overflow-y-auto overflow-x-hidden"> + {options.map((option) => { + const isSelected = selectedValues.has(option.value) + + return ( + <CommandItem + key={option.value} + onSelect={() => { + if (isSelected) { + selectedValues.delete(option.value) + } else { + selectedValues.add(option.value) + } + const filterValues = Array.from(selectedValues) + column?.setFilterValue( + filterValues.length ? filterValues : undefined + ) + }} + > + <div + className={cn( + "mr-2 flex size-4 items-center justify-center rounded-sm border border-primary", + isSelected + ? "bg-primary text-primary-foreground" + : "opacity-50 [&_svg]:invisible" + )} + > + <Check className="size-4" aria-hidden="true" /> + </div> + {option.icon && ( + <option.icon + className="mr-2 size-4 text-muted-foreground" + aria-hidden="true" + /> + )} + <span>{option.label}</span> + {option.count && ( + <span className="ml-auto flex size-4 items-center justify-center font-mono text-xs"> + {option.count} + </span> + )} + </CommandItem> + ) + })} + </CommandGroup> + {selectedValues.size > 0 && ( + <> + <CommandSeparator /> + <CommandGroup> + <CommandItem + onSelect={() => column?.setFilterValue(undefined)} + className="justify-center text-center" + > + Clear filters + </CommandItem> + </CommandGroup> + </> + )} + </CommandList> + </Command> + </PopoverContent> + </Popover> + ) +} diff --git a/components/data-table/data-table-filter-list.tsx b/components/data-table/data-table-filter-list.tsx new file mode 100644 index 00000000..c51d4374 --- /dev/null +++ b/components/data-table/data-table-filter-list.tsx @@ -0,0 +1,787 @@ +"use client" + +import * as React from "react" +import type { + DataTableAdvancedFilterField, + Filter, + FilterOperator, + JoinOperator, + StringKeyOf, +} from "@/types/table" +import { type Table } from "@tanstack/react-table" +import { + CalendarIcon, + Check, + ChevronsUpDown, + GripVertical, + ListFilter, + Trash2, +} from "lucide-react" +import { customAlphabet } from "nanoid" +import { parseAsStringEnum, useQueryState } from "nuqs" + +import { dataTableConfig } from "@/config/data-table" +import { getDefaultFilterOperator, getFilterOperators } from "@/lib/data-table" +import { getFiltersStateParser } from "@/lib/parsers" +import { cn, formatDate } from "@/lib/utils" +import { useDebouncedCallback } from "@/hooks/use-debounced-callback" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Calendar } from "@/components/ui/calendar" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command" +import { + FacetedFilter, + FacetedFilterContent, + FacetedFilterEmpty, + FacetedFilterGroup, + FacetedFilterInput, + FacetedFilterItem, + FacetedFilterList, + FacetedFilterTrigger, +} from "@/components/ui/faceted-filter" +import { Input } from "@/components/ui/input" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Sortable, + SortableDragHandle, + SortableItem, +} from "@/components/ui/sortable" +import { useParams } from 'next/navigation'; +import { useTranslation } from '@/i18n/client' + +interface DataTableFilterListProps<TData> { + table: Table<TData> + filterFields: DataTableAdvancedFilterField<TData>[] + debounceMs: number + shallow?: boolean +} + +export function DataTableFilterList<TData>({ + table, + filterFields, + debounceMs, + shallow, +}: DataTableFilterListProps<TData>) { + + const params = useParams(); + const lng = params.lng as string; + + const { t, i18n } = useTranslation(lng); + + const id = React.useId() + const [filters, setFilters] = useQueryState( + "filters", + getFiltersStateParser(table.getRowModel().rows[0]?.original) + .withDefault([]) + .withOptions({ + clearOnDefault: true, + shallow, + }) + ) + + const [joinOperator, setJoinOperator] = useQueryState( + "joinOperator", + parseAsStringEnum(["and", "or"]).withDefault("and").withOptions({ + clearOnDefault: true, + shallow, + }) + ) + + const debouncedSetFilters = useDebouncedCallback(setFilters, debounceMs) + + function addFilter() { + const filterField = filterFields[0] + + if (!filterField) return + + void setFilters([ + ...filters, + { + id: filterField.id, + value: "", + type: filterField.type, + operator: getDefaultFilterOperator(filterField.type), + rowId: customAlphabet( + "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", + 6 + )(), + }, + ]) + } + + function updateFilter({ + rowId, + field, + debounced = false, + }: { + rowId: string + field: Omit<Partial<Filter<TData>>, "rowId"> + debounced?: boolean + }) { + const updateFunction = debounced ? debouncedSetFilters : setFilters + updateFunction((prevFilters) => { + const updatedFilters = prevFilters.map((filter) => { + if (filter.rowId === rowId) { + return { ...filter, ...field } + } + return filter + }) + return updatedFilters + }) + } + + function removeFilter(rowId: string) { + const updatedFilters = filters.filter((filter) => filter.rowId !== rowId) + void setFilters(updatedFilters) + } + + function moveFilter(activeIndex: number, overIndex: number) { + void setFilters((prevFilters) => { + const newFilters = [...prevFilters] + const [removed] = newFilters.splice(activeIndex, 1) + if (!removed) return prevFilters + newFilters.splice(overIndex, 0, removed) + return newFilters + }) + } + + function renderFilterInput({ + filter, + inputId, + }: { + filter: Filter<TData> + inputId: string + }) { + const filterField = filterFields.find((f) => f.id === filter.id) + + if (!filterField) return null + + if (filter.operator === "isEmpty" || filter.operator === "isNotEmpty") { + return ( + <div + id={inputId} + role="status" + aria-live="polite" + aria-label={`${filterField.label} filter is ${filter.operator === "isEmpty" ? "empty" : "not empty"}`} + className="h-8 w-full rounded border border-dashed" + /> + ) + } + + switch (filter.type) { + case "text": + case "number": + return ( + <Input + id={inputId} + type={filter.type} + aria-label={`${filterField.label} filter value`} + aria-describedby={`${inputId}-description`} + placeholder={filterField.placeholder ?? t('filterInputPlaceholder')} + className="h-8 w-full focus:outline-none !important focus:ring-offset-4 !important" + defaultValue={ + typeof filter.value === "string" ? filter.value : undefined + } + onChange={(event) => + updateFilter({ + rowId: filter.rowId, + field: { value: event.target.value }, + debounced: true, + }) + } + /> + ) + case "select": + return ( + <FacetedFilter> + <FacetedFilterTrigger asChild> + <Button + id={inputId} + variant="outline" + size="sm" + aria-label={`${filterField.label} filter value`} + aria-controls={`${inputId}-listbox`} + className="h-8 w-full justify-start gap-2 rounded px-1.5 text-left text-muted-foreground hover:text-muted-foreground" + > + {filter.value && typeof filter.value === "string" ? ( + <Badge + variant="secondary" + className="rounded-sm px-1 font-normal" + > + {filterField?.options?.find( + (option) => option.value === filter.value + )?.label || filter.value} + </Badge> + ) : ( + <> + {filterField.placeholder ?? "Select an option..."} + <ChevronsUpDown className="size-4" aria-hidden="true" /> + </> + )} + </Button> + </FacetedFilterTrigger> + <FacetedFilterContent + id={`${inputId}-listbox`} + className="w-[12.5rem] origin-[var(--radix-popover-content-transform-origin)]" + > + <FacetedFilterInput + placeholder={filterField?.label ?? "Search options..."} + aria-label={`Search ${filterField?.label} options`} + /> + <FacetedFilterList> + <FacetedFilterEmpty>No options found.</FacetedFilterEmpty> + <FacetedFilterGroup> + {filterField?.options?.map((option) => ( + <FacetedFilterItem + key={option.value} + value={String(option.value)} + selected={filter.value === option.value} + onSelect={(value) => { + updateFilter({ rowId: filter.rowId, field: { value } }) + setTimeout(() => { + document.getElementById(inputId)?.click() + }, 0) + }} + > + {option.icon && ( + <option.icon + className="mr-2 size-4 text-muted-foreground" + aria-hidden="true" + /> + )} + <span>{option.label}</span> + {option.count && ( + <span className="ml-auto flex size-4 items-center justify-center font-mono text-xs"> + {option.count} + </span> + )} + </FacetedFilterItem> + ))} + </FacetedFilterGroup> + </FacetedFilterList> + </FacetedFilterContent> + </FacetedFilter> + ) + case "multi-select": + const selectedValues = new Set( + Array.isArray(filter.value) ? filter.value : [] + ) + + return ( + <FacetedFilter> + <FacetedFilterTrigger asChild> + <Button + id={inputId} + variant="outline" + size="sm" + aria-label={`${filterField.label} filter values`} + aria-controls={`${inputId}-listbox`} + className="h-8 w-full justify-between gap-2 rounded px-1.5 text-left text-muted-foreground hover:text-muted-foreground" + > + <> + {selectedValues.size === 0 && ( + <> + {filterField.placeholder ?? t("Select options")} + <ChevronsUpDown className="size-4" aria-hidden="true" /> + </> + )} + </> + {selectedValues?.size > 0 && ( + <div className="flex items-center"> + <Badge + variant="secondary" + className="rounded-sm px-1 font-normal lg:hidden" + > + {selectedValues.size} + </Badge> + <div className="hidden min-w-0 gap-1 lg:flex"> + {selectedValues.size > 2 ? ( + <Badge + variant="secondary" + className="rounded-sm px-1 font-normal" + > + {selectedValues.size} selected + </Badge> + ) : ( + filterField?.options + ?.filter((option) => selectedValues.has(String(option.value))) + .map((option) => ( + <Badge + variant="secondary" + key={option.value} + className="truncate rounded-sm px-1 font-normal" + > + {option.label} + </Badge> + )) + )} + </div> + </div> + )} + </Button> + </FacetedFilterTrigger> + <FacetedFilterContent + id={`${inputId}-listbox`} + className="w-[12.5rem] origin-[var(--radix-popover-content-transform-origin)]" + > + <FacetedFilterInput + aria-label={`Search ${filterField?.label} options`} + placeholder={filterField?.label ?? "Search options..."} + /> + <FacetedFilterList> + <FacetedFilterEmpty>No options found.</FacetedFilterEmpty> + <FacetedFilterGroup> + {filterField?.options?.map((option) => ( + <FacetedFilterItem + key={option.value} + value={String(option.value)} + selected={selectedValues.has(String(option.value))} + onSelect={(value) => { + const currentValue = Array.isArray(filter.value) + ? filter.value + : [] + const newValue = currentValue.includes(value) + ? currentValue.filter((v) => v !== value) + : [...currentValue, value] + updateFilter({ + rowId: filter.rowId, + field: { value: newValue }, + }) + }} + > + {option.icon && ( + <option.icon + className="mr-2 size-4 text-muted-foreground" + aria-hidden="true" + /> + )} + <span>{option.label}</span> + {option.count && ( + <span className="ml-auto flex size-4 items-center justify-center font-mono text-xs"> + {option.count} + </span> + )} + </FacetedFilterItem> + ))} + </FacetedFilterGroup> + </FacetedFilterList> + </FacetedFilterContent> + </FacetedFilter> + ) + case "date": + const dateValue = Array.isArray(filter.value) + ? filter.value.filter(Boolean) + : [filter.value, filter.value].filter(Boolean) + + const displayValue = + filter.operator === "isBetween" && dateValue.length === 2 + ? `${formatDate(dateValue[0] ?? new Date())} - ${formatDate( + dateValue[1] ?? new Date() + )}` + : dateValue[0] + ? formatDate(dateValue[0]) + : "Pick a date" + + return ( + <Popover> + <PopoverTrigger asChild> + <Button + id={inputId} + variant="outline" + size="sm" + aria-label={`${filterField.label} date filter`} + aria-controls={`${inputId}-calendar`} + className={cn( + "h-8 w-full justify-start gap-2 rounded text-left font-normal", + !filter.value && "text-muted-foreground" + )} + > + <CalendarIcon + className="size-3.5 shrink-0" + aria-hidden="true" + /> + <span className="truncate">{displayValue}</span> + </Button> + </PopoverTrigger> + <PopoverContent + id={`${inputId}-calendar`} + align="start" + className="w-auto p-0" + > + {filter.operator === "isBetween" ? ( + <Calendar + id={`${inputId}-calendar`} + mode="range" + aria-label={`Select ${filterField.label} date range`} + selected={ + dateValue.length === 2 + ? { + from: new Date(dateValue[0] ?? ""), + to: new Date(dateValue[1] ?? ""), + } + : { + from: new Date(), + to: new Date(), + } + } + onSelect={(date) => { + updateFilter({ + rowId: filter.rowId, + field: { + value: date + ? [ + date.from?.toISOString() ?? "", + date.to?.toISOString() ?? "", + ] + : [], + }, + }) + }} + initialFocus + numberOfMonths={1} + /> + ) : ( + <Calendar + id={`${inputId}-calendar`} + mode="single" + aria-label={`Select ${filterField.label} date`} + selected={dateValue[0] ? new Date(dateValue[0]) : undefined} + onSelect={(date) => { + updateFilter({ + rowId: filter.rowId, + field: { value: date?.toISOString() ?? "" }, + }) + + setTimeout(() => { + document.getElementById(inputId)?.click() + }, 0) + }} + initialFocus + /> + )} + </PopoverContent> + </Popover> + ) + case "boolean": { + if (Array.isArray(filter.value)) return null + + return ( + <Select + value={filter.value} + onValueChange={(value) => + updateFilter({ rowId: filter.rowId, field: { value } }) + } + > + <SelectTrigger + id={inputId} + aria-label={`${filterField.label} boolean filter`} + aria-controls={`${inputId}-listbox`} + className="h-8 w-full rounded bg-transparent" + > + <SelectValue placeholder={filter.value ? "True" : "False"} /> + </SelectTrigger> + <SelectContent id={`${inputId}-listbox`}> + <SelectItem value="true">True</SelectItem> + <SelectItem value="false">False</SelectItem> + </SelectContent> + </Select> + ) + } + default: + return null + } + } + + return ( + <Sortable + value={filters.map((item) => ({ id: item.rowId }))} + onMove={({ activeIndex, overIndex }) => + moveFilter(activeIndex, overIndex) + } + overlay={ + <div className="flex items-center gap-2"> + <div className="h-8 min-w-[4.5rem] rounded-sm bg-primary/10" /> + <div className="h-8 w-32 rounded-sm bg-primary/10" /> + <div className="h-8 w-32 rounded-sm bg-primary/10" /> + <div className="h-8 min-w-36 flex-1 rounded-sm bg-primary/10" /> + <div className="size-8 shrink-0 rounded-sm bg-primary/10" /> + <div className="size-8 shrink-0 rounded-sm bg-primary/10" /> + </div> + } + > + <Popover> + <PopoverTrigger asChild> + <Button + variant="outline" + size="sm" + className="gap-2" + aria-label="Open filters" + aria-controls={`${id}-filter-dialog`} + > + {/* 아이콘은 항상 표시 */} + <ListFilter className="size-3" aria-hidden="true" /> + + {/* 텍스트는 모바일에서 숨기고, sm 이상에서만 보임 */} + <span className="hidden sm:inline"> + {t("Filters")} + </span> + + {filters.length > 0 && ( + <Badge + variant="secondary" + className="h-[1.14rem] rounded-[0.2rem] px-[0.32rem] font-mono text-[0.65rem] font-normal" + > + {filters.length} + </Badge> + )} + </Button> + </PopoverTrigger> + <PopoverContent + id={`${id}-filter-dialog`} + align="start" + collisionPadding={16} + className={cn( + "flex w-[calc(100vw-theme(spacing.12))] min-w-60 origin-[var(--radix-popover-content-transform-origin)] flex-col p-4 sm:w-[39rem]", + filters.length > 0 ? "gap-3.5" : "gap-2" + )} + > + {filters.length > 0 ? ( + <h4 className="font-medium leading-none"> {t("Filters")}</h4> + ) : ( + <div className="flex flex-col gap-1"> + <h4 className="font-medium leading-none">{t("nofilters")}</h4> + <p className="text-sm text-muted-foreground"> + {t("addfilters")} + </p> + </div> + )} + <div className="flex max-h-40 flex-col gap-2 overflow-y-auto py-0.5 pr-1"> + {filters.map((filter, index) => { + const filterId = `${id}-filter-${filter.rowId}` + const joinOperatorListboxId = `${filterId}-join-operator-listbox` + const fieldListboxId = `${filterId}-field-listbox` + const fieldTriggerId = `${filterId}-field-trigger` + const operatorListboxId = `${filterId}-operator-listbox` + const inputId = `${filterId}-input` + + return ( + <SortableItem key={filter.rowId} value={filter.rowId} asChild> + <div className="flex items-center gap-2"> + <div className="w-[4.5rem] text-center"> + {index === 0 ? ( + <span className="text-sm text-muted-foreground"> + {t("Where")} + </span> + ) : index === 1 ? ( + <Select + value={joinOperator} + onValueChange={(value: JoinOperator) => + setJoinOperator(value) + } + > + <SelectTrigger + aria-label="Select join operator" + aria-controls={joinOperatorListboxId} + className="h-8 rounded lowercase" + > + <SelectValue placeholder={joinOperator} /> + </SelectTrigger> + <SelectContent + id={joinOperatorListboxId} + position="popper" + className="min-w-[var(--radix-select-trigger-width)] lowercase" + > + {dataTableConfig.joinOperators.map((op) => ( + <SelectItem key={op.value} value={op.value}> + {t(op.label)} + </SelectItem> + ))} + </SelectContent> + </Select> + ) : ( + <span className="text-sm text-muted-foreground"> + {joinOperator} + </span> + )} + </div> + <Popover modal> + <PopoverTrigger asChild> + <Button + id={fieldTriggerId} + variant="outline" + size="sm" + role="combobox" + aria-label="Select filter field" + aria-controls={fieldListboxId} + className="h-8 w-32 justify-between gap-2 rounded focus:outline-none focus:ring-1 focus:ring-ring focus-visible:ring-0" + > + <span className="truncate"> + {filterFields.find( + (field) => field.id === filter.id + )?.label ?? "Select field"} + </span> + <ChevronsUpDown className="size-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent + id={fieldListboxId} + align="start" + className="w-40 p-0" + onCloseAutoFocus={() => + document.getElementById(fieldTriggerId)?.focus({ + preventScroll: true, + }) + } + > + <Command> + <CommandInput placeholder={t('searchFileds')}/> + <CommandList> + <CommandEmpty>{t("noFields")}</CommandEmpty> + <CommandGroup> + {filterFields.map((field) => ( + <CommandItem + key={field.id} + value={field.id} + onSelect={(value) => { + const filterField = filterFields.find( + (col) => col.id === value + ) + + if (!filterField) return + + updateFilter({ + rowId: filter.rowId, + field: { + id: value as StringKeyOf<TData>, + type: filterField.type, + operator: getDefaultFilterOperator( + filterField.type + ), + value: "", + }, + }) + + document + .getElementById(fieldTriggerId) + ?.click() + }} + > + <span className="mr-1.5 truncate"> + {field.label} + </span> + <Check + className={cn( + "ml-auto size-4 shrink-0", + field.id === filter.id + ? "opacity-100" + : "opacity-0" + )} + /> + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + <Select + value={filter.operator} + onValueChange={(value: FilterOperator) => + updateFilter({ + rowId: filter.rowId, + field: { + operator: value, + value: + value === "isEmpty" || value === "isNotEmpty" + ? "" + : filter.value, + }, + }) + } + > + <SelectTrigger + aria-label="Select filter operator" + aria-controls={operatorListboxId} + className="h-8 w-32 rounded" + > + <div className="truncate"> + <SelectValue placeholder={filter.operator} /> + </div> + </SelectTrigger> + <SelectContent id={operatorListboxId}> + {getFilterOperators(filter.type).map((op) => ( + <SelectItem key={op.value} value={op.value}> + {t(op.label)} + </SelectItem> + ))} + </SelectContent> + </Select> + <div className="min-w-36 flex-1 truncate"> + {renderFilterInput({ filter, inputId })} + </div> + <Button + variant="outline" + size="icon" + aria-label={`Remove filter ${index + 1}`} + className="size-8 shrink-0 rounded" + onClick={() => removeFilter(filter.rowId)} + > + <Trash2 className="size-3.5" aria-hidden="true" /> + </Button> + <SortableDragHandle + variant="outline" + size="icon" + className="size-8 shrink-0 rounded" + > + <GripVertical className="size-3.5" aria-hidden="true" /> + </SortableDragHandle> + </div> + </SortableItem> + ) + })} + </div> + <div className="flex w-full items-center gap-2"> + <Button + size="sm" + className="h-[1.85rem] rounded" + onClick={addFilter} + > + {t('addFilter')} + </Button> + {filters.length > 0 ? ( + <Button + size="sm" + variant="outline" + className="rounded" + onClick={() => { + void setFilters(null) + void setJoinOperator("and") + }} + > + {t('resetFilters')} + </Button> + ) : null} + </div> + </PopoverContent> + </Popover> + </Sortable> + ) +} diff --git a/components/data-table/data-table-grobal-filter.tsx b/components/data-table/data-table-grobal-filter.tsx new file mode 100644 index 00000000..a1f0a6f3 --- /dev/null +++ b/components/data-table/data-table-grobal-filter.tsx @@ -0,0 +1,49 @@ +"use client" + +import * as React from "react" +import { useQueryState } from "nuqs" +import { Input } from "@/components/ui/input" +import { useDebouncedCallback } from "@/hooks/use-debounced-callback" + +/** + * A generic "Global Filter" input that syncs its value with `?search=...` in the URL (shallow). + * Uses a custom `useDebouncedCallback` to reduce rapid updates. + */ +export function DataTableGlobalFilter() { + // The actual "search" state is still read/written from URL + const [searchValue, setSearchValue] = useQueryState("search", { + parse: (str) => str, + serialize: (val) => val, + eq: (a, b) => a === b, + clearOnDefault: true, + shallow: false, + }) + + // Local tempValue to update instantly on user keystroke + const [tempValue, setTempValue] = React.useState(searchValue ?? "") + + // Debounced callback that sets the URL param after `delay` ms + const debouncedSetSearch = useDebouncedCallback((value: string) => { + setSearchValue(value) + }, 300) // 300ms or chosen delay + + // When user types, update local `tempValue` immediately, + // then call the debounced function to update the query param + const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { + const val = e.target.value + setTempValue(val) + debouncedSetSearch(val) + } + + // Debug + console.log("tempValue:", tempValue, "searchValue:", searchValue) + + return ( + <Input + value={tempValue} + onChange={handleChange} + placeholder="Search..." + className="h-8 w-24 sm:w-40" + /> + ) +}
\ No newline at end of file diff --git a/components/data-table/data-table-group-list.tsx b/components/data-table/data-table-group-list.tsx new file mode 100644 index 00000000..cde1cadd --- /dev/null +++ b/components/data-table/data-table-group-list.tsx @@ -0,0 +1,317 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { useQueryState, parseAsArrayOf, parseAsString } from "nuqs" +import { Layers, Check, ChevronsUpDown, GripVertical, XCircle } from "lucide-react" + +import { toSentenceCase, cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { + Popover, + PopoverTrigger, + PopoverContent, +} from "@/components/ui/popover" +import { + Command, + CommandList, + CommandGroup, + CommandItem, + CommandInput, + CommandEmpty, +} from "@/components/ui/command" +import { + Sortable, + SortableItem, + SortableDragHandle, +} from "@/components/ui/sortable" + +interface DataTableGroupListProps<TData> { + /** TanStack Table 인스턴스 (grouping을 이미 사용할 수 있어야 함) */ + table: Table<TData> + /** 정렬과 동일하게 URL 쿼리에 grouping을 저장할 때 쓰는 debounce 시간 (ms) */ + debounceMs: number + /** shallow 라우팅 여부 */ + shallow?: boolean +} + +export function DataTableGroupList<TData>({ + table, + debounceMs, + shallow, +}: DataTableGroupListProps<TData>) { + const id = React.useId() + + // ------------------------------------------------------ + // 1) 초기 그룹핑 상태 + URL Query State 동기화 + // ------------------------------------------------------ + const initialGrouping = (table.initialState.grouping ?? []) as string[] + + // group 쿼리 파라미터를 string[]로 파싱 + // parseAsArrayOf(parseAsString, ',')를 이용 + const [grouping, setGrouping] = useQueryState( + "group", + parseAsArrayOf(parseAsString, ",") + .withDefault(initialGrouping) + .withOptions({ + clearOnDefault: true, + shallow, + }) + ) + + // TanStack Table의 `table.setGrouping()`과 동기화 + // (정렬 모달 예시에서 setSorting()을 쓰듯이 여기서는 setGrouping() 호출) + React.useEffect(() => { + table.setGrouping(grouping) + }, [grouping, table]) + + // 이미 중복 추가된 그룹은 제거 + // (정렬 예시에서도 uniqueSorting 했듯이) + const uniqueGrouping = React.useMemo( + () => grouping.filter((id, i, self) => self.indexOf(id) === i), + [grouping] + ) + + // ------------------------------------------------------ + // 2) 그룹핑 가능한 컬럼만 골라내기 + // ------------------------------------------------------ + const groupableColumns = React.useMemo( + () => + table + .getAllColumns() + .filter((col) => col.getCanGroup?.() !== false) + .map((col) => ({ + id: col.id, + label: toSentenceCase(col.id), + })), + [table] + ) + + // 이미 그룹핑 중인 컬럼 제외하고 "추가 가능"한 컬럼들 + const ungroupedColumns = React.useMemo(() => { + return groupableColumns.filter( + (column) => !grouping.includes(column.id) + ) + }, [groupableColumns, grouping]) + + + + // ------------------------------------------------------ + // 3) 그룹 배열을 업데이트하는 함수들 + // ------------------------------------------------------ + + // 드래그/드롭으로 순서 변경 + function onGroupOrderChange(newGroups: string[]) { + setGrouping(newGroups) + } + + // "Add group" : 아직 그룹핑되지 않은 첫 번째 컬럼 추가 + function addGroup() { + const firstAvailable = ungroupedColumns[0] + if (!firstAvailable) return + setGrouping([...grouping, firstAvailable.id]) + } + + // 특정 아이템(그룹 컬럼 id) 제거 + function removeGroup(id: string) { + setGrouping((prev) => prev.filter((g) => g !== id)) + } + + // 전체 그룹핑 초기화 + function resetGrouping() { + setGrouping([]) + } + + // ------------------------------------------------------ + // 4) 렌더링 + // ------------------------------------------------------ + + return ( + <Sortable + // sorting 예시처럼 Sortable 컨테이너로 감싸기 + // 여기선 "grouping"을 바로 value로 넘길 수 없고, + // Sortable는 { id: UniqueIdentifier }[] 형태를 요구하므로 변환 필요 + value={grouping.map((id) => ({ id }))} + onValueChange={(items) => { + // 드래그 완료 시 string[] 형태로 되돌림 + onGroupOrderChange(items.map((i) => i.id)) + }} + // overlay : 드래그 중 placeholder UI + overlay={ + <div className="flex items-center gap-2"> + <div className="h-8 w-[11.25rem] rounded-sm bg-primary/10" /> + <div className="h-8 w-24 rounded-sm bg-primary/10" /> + <div className="size-8 shrink-0 rounded-sm bg-primary/10" /> + </div> + } + > + <Popover> + <PopoverTrigger asChild> + <Button + variant="outline" + size="sm" + className="gap-2" + aria-label="Open grouping" + aria-controls={`${id}-group-dialog`} + > + <Layers className="size-3" aria-hidden="true" /> + <span className="hidden sm:inline">Group</span> + {uniqueGrouping.length > 0 && ( + <Badge + variant="secondary" + className="h-[1.14rem] rounded-[0.2rem] px-[0.32rem] font-mono text-[0.65rem] font-normal" + > + {uniqueGrouping.length} + </Badge> + )} + </Button> + </PopoverTrigger> + + <PopoverContent + id={`${id}-group-dialog`} + align="start" + collisionPadding={16} + className={cn( + "flex w-[calc(100vw-theme(spacing.20))] min-w-72 max-w-[25rem] origin-[var(--radix-popover-content-transform-origin)] flex-col p-4 sm:w-[25rem]", + grouping.length > 0 ? "gap-3.5" : "gap-2" + )} + > + {uniqueGrouping.length > 0 ? ( + <> + <h4 className="font-medium leading-none">Group by</h4> + <p className="text-sm text-muted-foreground"> + 그룹핑은 불러온 데이터에 한해서 그룹핑이 됩니다. + </p> + </> + + ) : ( + <div className="flex flex-col gap-1"> + <h4 className="font-medium leading-none">No grouping applied</h4> + <p className="text-sm text-muted-foreground"> + Add grouping to organize your results. + </p> + </div> + )} + + {/* 그룹 목록 */} + <div className="flex max-h-40 flex-col gap-2 overflow-y-auto p-0.5"> + <div className="flex w-full flex-col gap-2"> + {uniqueGrouping.map((colId) => { + // SortableItem에 key로 colId + return ( + <SortableItem key={colId} value={colId} asChild> + <div className="flex items-center gap-2"> + <Popover modal> + <PopoverTrigger asChild> + <Button + variant="outline" + size="sm" + role="combobox" + className="h-8 w-44 justify-between gap-2 rounded focus:outline-none focus:ring-1 focus:ring-ring" + aria-label={`Select column for group ${colId}`} + > + <span className="truncate"> + {toSentenceCase(colId)} + </span> + <div className="ml-auto flex items-center gap-1"> + <ChevronsUpDown + className="size-4 shrink-0 opacity-50" + aria-hidden="true" + /> + </div> + </Button> + </PopoverTrigger> + <PopoverContent + className="w-[var(--radix-popover-trigger-width)] p-0" + > + <Command> + <CommandInput placeholder="Search columns..." /> + <CommandList> + <CommandEmpty>No columns found.</CommandEmpty> + <CommandGroup> + {ungroupedColumns.map((column) => ( + <CommandItem + key={column.id} + value={column.id} + onSelect={(value) => { + // colId -> 새로 선택한 value로 교체 + setGrouping((prev) => + prev.map((g) => + g === colId ? value : g + ) + ) + }} + > + <span className="mr-1.5 truncate"> + {column.label} + </span> + <Check + className={cn( + "ml-auto size-4 shrink-0", + column.id === colId + ? "opacity-100" + : "opacity-0" + )} + aria-hidden="true" + /> + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + + {/* remove group */} + <Button + variant="outline" + size="icon" + aria-label={`Remove group ${colId}`} + className="size-8 shrink-0 rounded" + onClick={() => removeGroup(colId)} + > + <XCircle className="size-3.5" aria-hidden="true" /> + </Button> + + {/* drag handle */} + <SortableDragHandle + variant="outline" + size="icon" + className="size-8 shrink-0 rounded" + > + <GripVertical className="size-3.5" aria-hidden="true" /> + </SortableDragHandle> + </div> + </SortableItem> + ) + })} + </div> + </div> + + <div className="flex w-full items-center gap-2"> + {/* 새 그룹 추가 */} + <Button + size="sm" + className="h-[1.85rem] rounded" + onClick={addGroup} + disabled={grouping.length >= groupableColumns.length} + > + Add group + </Button> + {grouping.length > 0 && ( + <Button + size="sm" + variant="outline" + className="rounded" + onClick={resetGrouping} + > + Reset grouping + </Button> + )} + </div> + </PopoverContent> + </Popover> + </Sortable> + ) +}
\ No newline at end of file diff --git a/components/data-table/data-table-pagination.tsx b/components/data-table/data-table-pagination.tsx new file mode 100644 index 00000000..7a2a03f8 --- /dev/null +++ b/components/data-table/data-table-pagination.tsx @@ -0,0 +1,132 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { + ChevronLeft, + ChevronRight, + ChevronsLeft, + ChevronsRight, +} from "lucide-react" + +import { Button } from "@/components/ui/button" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" + +interface DataTablePaginationProps<TData> { + table: Table<TData> + pageSizeOptions?: Array<number | "All"> +} + +export function DataTablePagination<TData>({ + table, + pageSizeOptions = [10, 20, 30, 40, 50, "All"], +}: DataTablePaginationProps<TData>) { + // 현재 테이블 pageSize + const currentPageSize = table.getState().pagination.pageSize + + // "All"을 1,000,000으로 처리할 것이므로, + // 만약 현재 pageSize가 1,000,000이면 화면상 "All"로 표시 + const selectValue = + currentPageSize === 1_000_000 + ? "All" + : String(currentPageSize) + + return ( + <div className="flex w-full flex-col-reverse items-center justify-between gap-4 overflow-auto p-1 sm:flex-row sm:gap-8"> + <div className="flex-1 whitespace-nowrap text-sm text-muted-foreground"> + {table.getFilteredSelectedRowModel().rows.length} of{" "} + {table.getFilteredRowModel().rows.length} row(s) selected. + </div> + <div className="flex flex-col-reverse items-center gap-4 sm:flex-row sm:gap-6 lg:gap-8"> + {/* Rows per page Select */} + <div className="flex items-center space-x-2"> + <p className="whitespace-nowrap text-sm font-medium">Rows per page</p> + <Select + value={selectValue} + onValueChange={(value) => { + if (value === "All") { + // "All"을 1,000,000으로 치환 + table.setPageSize(1_000_000) + } else { + table.setPageSize(Number(value)) + } + }} + > + <SelectTrigger className="h-8 w-[4.5rem]"> + <SelectValue placeholder={selectValue} /> + </SelectTrigger> + <SelectContent side="top"> + {pageSizeOptions.map((option) => { + // 화면에 표시할 라벨 + const label = option === "All" ? "All" : String(option) + // value도 문자열화 + const val = option === "All" ? "All" : String(option) + + return ( + <SelectItem key={val} value={val}> + {label} + </SelectItem> + ) + })} + </SelectContent> + </Select> + </div> + + {/* 현재 페이지 / 전체 페이지 */} + <div className="flex items-center justify-center text-sm font-medium"> + Page {table.getState().pagination.pageIndex + 1} of{" "} + {table.getPageCount()} + </div> + + {/* 페이지 이동 버튼 */} + <div className="flex items-center space-x-2"> + <Button + aria-label="Go to first page" + variant="outline" + className="hidden size-8 p-0 lg:flex" + onClick={() => table.setPageIndex(0)} + disabled={!table.getCanPreviousPage()} + > + <ChevronsLeft className="size-4" aria-hidden="true" /> + </Button> + <Button + aria-label="Go to previous page" + variant="outline" + size="icon" + className="size-8" + onClick={() => table.previousPage()} + disabled={!table.getCanPreviousPage()} + > + <ChevronLeft className="size-4" aria-hidden="true" /> + </Button> + <Button + aria-label="Go to next page" + variant="outline" + size="icon" + className="size-8" + onClick={() => table.nextPage()} + disabled={!table.getCanNextPage()} + > + <ChevronRight className="size-4" aria-hidden="true" /> + </Button> + <Button + aria-label="Go to last page" + variant="outline" + size="icon" + className="hidden size-8 lg:flex" + onClick={() => table.setPageIndex(table.getPageCount() - 1)} + disabled={!table.getCanNextPage()} + > + <ChevronsRight className="size-4" aria-hidden="true" /> + </Button> + </div> + </div> + </div> + ) +}
\ No newline at end of file diff --git a/components/data-table/data-table-pin-left.tsx b/components/data-table/data-table-pin-left.tsx new file mode 100644 index 00000000..81e83564 --- /dev/null +++ b/components/data-table/data-table-pin-left.tsx @@ -0,0 +1,95 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Check, ChevronsUpDown, MoveLeft } from "lucide-react" + +import { cn, toSentenceCase } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" + +/** + * “Pin Left” Popover. Lists columns that can be pinned. + * If pinned===‘left’ → checked, if pinned!==‘left’ → unchecked. + * Toggling check => pin(‘left’) or pin(false). + */ +export function PinLeftButton<TData>({ table }: { table: Table<TData> }) { + const [open, setOpen] = React.useState(false) + const triggerRef = React.useRef<HTMLButtonElement>(null) + + return ( + <Popover open={open} onOpenChange={setOpen}> + <PopoverTrigger asChild> + <Button + ref={triggerRef} + variant="outline" + size="sm" + className="h-8 gap-2" + > + <MoveLeft className="size-4" /> + + <span className="hidden sm:inline"> + Left + </span> + + <ChevronsUpDown className="ml-1 size-4 opacity-50 hidden sm:inline" /> + </Button> + </PopoverTrigger> + + <PopoverContent + align="end" + className="w-44 p-0" + onCloseAutoFocus={() => triggerRef.current?.focus()} + > + <Command> + <CommandInput placeholder="Search columns..." /> + <CommandList> + <CommandEmpty>No columns found.</CommandEmpty> + <CommandGroup> + {table + .getAllLeafColumns() + .filter((col) => col.getCanPin?.()) + .map((column) => { + const pinned = column.getIsPinned?.() // 'left'|'right'|false + // => pinned === 'left' => checked + return ( + <CommandItem + key={column.id} + onSelect={() => { + // if currently pinned===left => unpin + // else => pin left + column.pin?.(pinned === "left" ? false : "left") + }} + > + <span className="truncate"> + {toSentenceCase(column.id)} + </span> + {/* Check if pinned===‘left’ */} + <Check + className={cn( + "ml-auto size-4 shrink-0", + pinned === "left" ? "opacity-100" : "opacity-0" + )} + /> + </CommandItem> + ) + })} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + ) +}
\ No newline at end of file diff --git a/components/data-table/data-table-pin-right.tsx b/components/data-table/data-table-pin-right.tsx new file mode 100644 index 00000000..051dd985 --- /dev/null +++ b/components/data-table/data-table-pin-right.tsx @@ -0,0 +1,88 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Check, ChevronsUpDown, MoveRight } from "lucide-react" + +import { cn, toSentenceCase } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" + +/** + * “Pin Right” Popover. Similar to PinLeftButton, but pins columns to "right". + */ +export function PinRightButton<TData>({ table }: { table: Table<TData> }) { + const [open, setOpen] = React.useState(false) + const triggerRef = React.useRef<HTMLButtonElement>(null) + + return ( + <Popover open={open} onOpenChange={setOpen}> + <PopoverTrigger asChild> + <Button + ref={triggerRef} + variant="outline" + size="sm" + className="h-8 gap-2" + > + <MoveRight className="size-4" /> + + <span className="hidden sm:inline"> + Right + </span> + <ChevronsUpDown className="ml-1 size-4 opacity-50 hidden sm:inline" /> + </Button> + </PopoverTrigger> + + <PopoverContent + align="end" + className="w-44 p-0" + onCloseAutoFocus={() => triggerRef.current?.focus()} + > + <Command> + <CommandInput placeholder="Search columns..." /> + <CommandList> + <CommandEmpty>No columns found.</CommandEmpty> + <CommandGroup> + {table + .getAllLeafColumns() + .filter((col) => col.getCanPin?.()) + .map((column) => { + const pinned = column.getIsPinned?.() + return ( + <CommandItem + key={column.id} + onSelect={() => { + column.pin?.(pinned === "right" ? false : "right") + }} + > + <span className="truncate"> + {toSentenceCase(column.id)} + </span> + <Check + className={cn( + "ml-auto size-4 shrink-0", + pinned === "right" ? "opacity-100" : "opacity-0" + )} + /> + </CommandItem> + ) + })} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + ) +}
\ No newline at end of file diff --git a/components/data-table/data-table-pin.tsx b/components/data-table/data-table-pin.tsx new file mode 100644 index 00000000..991152f6 --- /dev/null +++ b/components/data-table/data-table-pin.tsx @@ -0,0 +1,146 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { + MoveLeft, + MoveRight, + Slash, + ChevronsUpDown, +} from "lucide-react" + +import { cn, toSentenceCase } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" + +/** + * Button that opens a popover with a list of columns. + * Each column can be pinned left, pinned right, or unpinned. + */ +export function DataTablePinList<TData>({ table }: { table: Table<TData> }) { + const [open, setOpen] = React.useState(false) + + return ( + <Popover open={open} onOpenChange={setOpen}> + <PopoverTrigger asChild> + <Button + variant="outline" + size="sm" + className="h-8 gap-2" + > + Pin + <ChevronsUpDown className="size-4 opacity-50" aria-hidden="true" /> + </Button> + </PopoverTrigger> + <PopoverContent align="end" className="w-48 p-0"> + <Command> + <CommandInput placeholder="Search columns..." /> + <CommandList> + <CommandEmpty>No columns found.</CommandEmpty> + <CommandGroup> + {table + .getAllLeafColumns() + .filter((col) => col.getCanPin?.()) // Only show columns that can be pinned + .map((column) => { + const pinned = column.getIsPinned?.() // 'left' | 'right' | false + return ( + <PinColumnItem + key={column.id} + column={column} + pinned={pinned} + onPinnedChange={(newPin) => { + column.pin?.(newPin === "none" ? false : newPin) + }} + /> + ) + })} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + ) +} + +/** + * Renders a single column row (CommandItem) with sub-options: + * Left, Right, or Unpin + */ +function PinColumnItem({ + column, + pinned, + onPinnedChange, +}: { + column: any + pinned: "left" | "right" | false + onPinnedChange: (newPin: "left" | "right" | "none") => void +}) { + const [subOpen, setSubOpen] = React.useState(false) + const colId = column.id + + const handleMainSelect = () => { + // Toggle subOpen to show sub-options + setSubOpen((prev) => !prev) + } + + return ( + <> + <CommandItem onSelect={handleMainSelect}> + <span className="truncate">{toSentenceCase(colId)}</span> + {pinned === "left" && ( + <MoveLeft className="ml-auto size-4 text-primary" /> + )} + {pinned === "right" && ( + <MoveRight className="ml-auto size-4 text-primary" /> + )} + {pinned === false && ( + <Slash className="ml-auto size-4 opacity-50" /> + )} + </CommandItem> + + {subOpen && ( + <div className="ml-4 flex flex-col gap-1 border-l pl-2 pt-1"> + <CommandItem + onSelect={() => { + onPinnedChange("left") + setSubOpen(false) + }} + > + <MoveLeft className="mr-2 size-4" /> + Pin Left + </CommandItem> + <CommandItem + onSelect={() => { + onPinnedChange("right") + setSubOpen(false) + }} + > + <MoveRight className="mr-2 size-4" /> + Pin Right + </CommandItem> + <CommandItem + onSelect={() => { + onPinnedChange("none") + setSubOpen(false) + }} + > + <Slash className="mr-2 size-4" /> + No Pin + </CommandItem> + </div> + )} + </> + ) +}
\ No newline at end of file diff --git a/components/data-table/data-table-resizer.tsx b/components/data-table/data-table-resizer.tsx new file mode 100644 index 00000000..9723a0b4 --- /dev/null +++ b/components/data-table/data-table-resizer.tsx @@ -0,0 +1,98 @@ +"use client" + +import * as React from "react" +import { cn } from "@/lib/utils" +import { Header } from "@tanstack/react-table" + +interface DataTableResizerProps<TData, TValue> + extends React.HTMLAttributes<HTMLDivElement> { + header: Header<TData, TValue> +} + +export function DataTableResizer<TData, TValue>({ + header, + className, + ...props +}: DataTableResizerProps<TData, TValue>) { + const contentRef = React.useRef<HTMLDivElement>(null) + + // 더블클릭 시 너비 자동 조정 함수 + const handleDoubleClick = React.useCallback(() => { + // 테이블 인스턴스 가져오기 + const table = header.getContext().table + + // 0. 몇 가지 기본 설정 + const defaultMinWidth = 80 // 기본 최소 너비 + const extraPadding = 24 // 여유 공간 + + // 헤더 타이틀 얻기 시도 + const headerElement = contentRef.current?.closest('th') + const headerText = headerElement?.textContent || "" + + // 1. 컬럼 ID 가져오기 + const columnId = header.column.id + + // 2. 테이블 바디에서 해당 ID를 가진 모든 셀 선택 + const allCells = document.querySelectorAll(`tbody td[data-column-id="${columnId}"]`) + + // 3. 최대 컨텐츠 너비 측정을 위한 임시 요소 생성 + const measureElement = document.createElement('div') + measureElement.style.position = 'absolute' + measureElement.style.visibility = 'hidden' + measureElement.style.whiteSpace = 'nowrap' // 내용이 줄바꿈되지 않도록 + measureElement.style.font = window.getComputedStyle(headerElement || document.body).font // 동일한 폰트 사용 + document.body.appendChild(measureElement) + + // 4. 헤더 너비 측정 + measureElement.textContent = headerText + let maxWidth = measureElement.getBoundingClientRect().width + + // 5. 모든 셀의 내용 너비 측정하고 최대값 찾기 + Array.from(allCells).forEach(cell => { + const cellText = cell.textContent || "" + measureElement.textContent = cellText + const cellWidth = measureElement.getBoundingClientRect().width + maxWidth = Math.max(maxWidth, cellWidth) + }) + + // 6. 측정용 요소 제거 + document.body.removeChild(measureElement) + + // 7. 계산된 너비에 여유 공간 추가 + let finalWidth = maxWidth + extraPadding + + // 8. 최소 너비 적용 + const minWidth = header.column.columnDef.minSize || defaultMinWidth + finalWidth = Math.max(finalWidth, minWidth) + + // 9. 컬럼 사이즈 업데이트 + const columnSizingInfo = table.getState().columnSizing + const updatedSizing = { + ...columnSizingInfo, + [columnId]: finalWidth + } + + table.setColumnSizing(updatedSizing) + }, [header]) + + return ( + <> + {/* 헤더 콘텐츠 참조를 위한 요소 */} + <div ref={contentRef} className="absolute opacity-0 pointer-events-none" /> + + {/* 리사이저 */} + <div + {...props} + onMouseDown={header.getResizeHandler()} + onTouchStart={header.getResizeHandler()} + onDoubleClick={handleDoubleClick} // 더블클릭 핸들러 추가 + className={cn( + "absolute right-0 top-0 h-full w-1 cursor-col-resize bg-transparent hover:bg-gray-300 active:bg-gray-400", + header.column.getIsResizing() ? "bg-gray-400" : "", + className + )} + title="더블 클릭하여 내용에 맞게 크기 조정" // 힌트 추가 + /> + </> + ) +}
\ No newline at end of file diff --git a/components/data-table/data-table-skeleton.tsx b/components/data-table/data-table-skeleton.tsx new file mode 100644 index 00000000..09c394e1 --- /dev/null +++ b/components/data-table/data-table-skeleton.tsx @@ -0,0 +1,169 @@ +"use client" + +import { cn } from "@/lib/utils" +import { Skeleton } from "@/components/ui/skeleton" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" + +interface DataTableSkeletonProps extends React.HTMLAttributes<HTMLDivElement> { + /** + * The number of columns in the table. + * @type number + */ + columnCount: number + + /** + * The number of rows in the table. + * @default 10 + * @type number | undefined + */ + rowCount?: number + + /** + * The number of searchable columns in the table. + * @default 0 + * @type number | undefined + */ + searchableColumnCount?: number + + /** + * The number of filterable columns in the table. + * @default 0 + * @type number | undefined + */ + filterableColumnCount?: number + + /** + * Flag to show the table view options. + * @default undefined + * @type boolean | undefined + */ + showViewOptions?: boolean + + /** + * The width of each cell in the table. + * The length of the array should be equal to the columnCount. + * Any valid CSS width value is accepted. + * @default ["auto"] + * @type string[] | undefined + */ + cellWidths?: string[] + + /** + * Flag to show the pagination bar. + * @default true + * @type boolean | undefined + */ + withPagination?: boolean + + /** + * Flag to prevent the table cells from shrinking. + * @default false + * @type boolean | undefined + */ + shrinkZero?: boolean +} + +export function DataTableSkeleton(props: DataTableSkeletonProps) { + const { + columnCount, + rowCount = 10, + searchableColumnCount = 0, + filterableColumnCount = 0, + showViewOptions = true, + cellWidths = ["auto"], + withPagination = true, + shrinkZero = false, + className, + ...skeletonProps + } = props + + return ( + <div + className={cn("w-full space-y-2.5 overflow-auto", className)} + {...skeletonProps} + > + <div className="flex w-full items-center justify-between space-x-2 overflow-auto p-1"> + <div className="flex flex-1 items-center space-x-2"> + {searchableColumnCount > 0 + ? Array.from({ length: searchableColumnCount }).map((_, i) => ( + <Skeleton key={i} className="h-7 w-40 lg:w-60" /> + )) + : null} + {filterableColumnCount > 0 + ? Array.from({ length: filterableColumnCount }).map((_, i) => ( + <Skeleton key={i} className="h-7 w-[4.5rem] border-dashed" /> + )) + : null} + </div> + {showViewOptions ? ( + <Skeleton className="ml-auto hidden h-7 w-[4.5rem] lg:flex" /> + ) : null} + </div> + <div className="rounded-md border"> + <Table> + <TableHeader> + {Array.from({ length: 1 }).map((_, i) => ( + <TableRow key={i} className="hover:bg-transparent"> + {Array.from({ length: columnCount }).map((_, j) => ( + <TableHead + key={j} + style={{ + width: cellWidths[j], + minWidth: shrinkZero ? cellWidths[j] : "auto", + }} + > + <Skeleton className="h-6 w-full" /> + </TableHead> + ))} + </TableRow> + ))} + </TableHeader> + <TableBody> + {Array.from({ length: rowCount }).map((_, i) => ( + <TableRow key={i} className="hover:bg-transparent"> + {Array.from({ length: columnCount }).map((_, j) => ( + <TableCell + key={j} + style={{ + width: cellWidths[j], + minWidth: shrinkZero ? cellWidths[j] : "auto", + }} + > + <Skeleton className="h-6 w-full" /> + </TableCell> + ))} + </TableRow> + ))} + </TableBody> + </Table> + </div> + {withPagination ? ( + <div className="flex w-full items-center justify-between gap-4 overflow-auto p-1 sm:gap-8"> + <Skeleton className="h-7 w-40 shrink-0" /> + <div className="flex items-center gap-4 sm:gap-6 lg:gap-8"> + <div className="flex items-center space-x-2"> + <Skeleton className="h-7 w-24" /> + <Skeleton className="h-7 w-[4.5rem]" /> + </div> + <div className="flex items-center justify-center text-sm font-medium"> + <Skeleton className="h-7 w-20" /> + </div> + <div className="flex items-center space-x-2"> + <Skeleton className="hidden size-7 lg:block" /> + <Skeleton className="size-7" /> + <Skeleton className="size-7" /> + <Skeleton className="hidden size-7 lg:block" /> + </div> + </div> + </div> + ) : null} + </div> + ) +} diff --git a/components/data-table/data-table-sort-list.tsx b/components/data-table/data-table-sort-list.tsx new file mode 100644 index 00000000..686545fc --- /dev/null +++ b/components/data-table/data-table-sort-list.tsx @@ -0,0 +1,370 @@ +"use client" + +import * as React from "react" +import type { + ExtendedColumnSort, + ExtendedSortingState, + StringKeyOf, +} from "@/types/table" +import type { SortDirection, Table } from "@tanstack/react-table" +import { + ArrowDownUp, + Check, + ChevronsUpDown, + GripVertical, + Trash2, +} from "lucide-react" +import { useQueryState } from "nuqs" + +import { dataTableConfig } from "@/config/data-table" +import { getSortingStateParser } from "@/lib/parsers" +import { cn, toSentenceCase } from "@/lib/utils" +import { useDebouncedCallback } from "@/hooks/use-debounced-callback" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Sortable, + SortableDragHandle, + SortableItem, +} from "@/components/ui/sortable" + +interface DataTableSortListProps<TData> { + table: Table<TData> + debounceMs: number + shallow?: boolean +} + +export function DataTableSortList<TData>({ + table, + debounceMs, + shallow, +}: DataTableSortListProps<TData>) { + const id = React.useId() + + const initialSorting = (table.initialState.sorting ?? + []) as ExtendedSortingState<TData> + + const [sorting, setSorting] = useQueryState( + "sort", + getSortingStateParser(table.getRowModel().rows[0]?.original) + .withDefault(initialSorting) + .withOptions({ + clearOnDefault: true, + shallow, + }) + ) + + const uniqueSorting = React.useMemo( + () => + sorting.filter( + (sort, index, self) => index === self.findIndex((t) => t.id === sort.id) + ), + [sorting] + ) + + const debouncedSetSorting = useDebouncedCallback(setSorting, debounceMs) + + const sortableColumns = React.useMemo( + () => + table + .getAllColumns() + .filter( + (column) => + column.getCanSort() && !sorting.some((s) => s.id === column.id) + ) + .map((column) => ({ + id: column.id, + label: toSentenceCase(column.id), + selected: false, + })), + [sorting, table] + ) + + function addSort() { + const firstAvailableColumn = sortableColumns.find( + (column) => !sorting.some((s) => s.id === column.id) + ) + if (!firstAvailableColumn) return + + void setSorting([ + ...sorting, + { + id: firstAvailableColumn.id as StringKeyOf<TData>, + desc: false, + }, + ]) + } + + function updateSort({ + id, + field, + debounced = false, + }: { + id: string + field: Partial<ExtendedColumnSort<TData>> + debounced?: boolean + }) { + const updateFunction = debounced ? debouncedSetSorting : setSorting + + updateFunction((prevSorting) => { + if (!prevSorting) return prevSorting + + const updatedSorting = prevSorting.map((sort) => + sort.id === id ? { ...sort, ...field } : sort + ) + return updatedSorting + }) + } + + function removeSort(id: string) { + void setSorting((prevSorting) => + prevSorting.filter((item) => item.id !== id) + ) + } + + return ( + <Sortable + value={sorting} + onValueChange={setSorting} + overlay={ + <div className="flex items-center gap-2"> + <div className="h-8 w-[11.25rem] rounded-sm bg-primary/10" /> + <div className="h-8 w-24 rounded-sm bg-primary/10" /> + <div className="size-8 shrink-0 rounded-sm bg-primary/10" /> + <div className="size-8 shrink-0 rounded-sm bg-primary/10" /> + </div> + } + > + <Popover> + <PopoverTrigger asChild> + <Button + variant="outline" + size="sm" + className="gap-2" + aria-label="Open sorting" + aria-controls={`${id}-sort-dialog`} + > + <ArrowDownUp className="size-3" aria-hidden="true" /> + + <span className="hidden sm:inline"> + Sort + </span> + + {uniqueSorting.length > 0 && ( + <Badge + variant="secondary" + className="h-[1.14rem] rounded-[0.2rem] px-[0.32rem] font-mono text-[0.65rem] font-normal" + > + {uniqueSorting.length} + </Badge> + )} + </Button> + </PopoverTrigger> + <PopoverContent + id={`${id}-sort-dialog`} + align="start" + collisionPadding={16} + className={cn( + "flex w-[calc(100vw-theme(spacing.20))] min-w-72 max-w-[25rem] origin-[var(--radix-popover-content-transform-origin)] flex-col p-4 sm:w-[25rem]", + sorting.length > 0 ? "gap-3.5" : "gap-2" + )} + > + {uniqueSorting.length > 0 ? ( + <h4 className="font-medium leading-none">Sort by</h4> + ) : ( + <div className="flex flex-col gap-1"> + <h4 className="font-medium leading-none">No sorting applied</h4> + <p className="text-sm text-muted-foreground"> + Add sorting to organize your results. + </p> + </div> + )} + <div className="flex max-h-40 flex-col gap-2 overflow-y-auto p-0.5"> + <div className="flex w-full flex-col gap-2"> + {uniqueSorting.map((sort) => { + const sortId = `${id}-sort-${sort.id}` + const fieldListboxId = `${sortId}-field-listbox` + const fieldTriggerId = `${sortId}-field-trigger` + const directionListboxId = `${sortId}-direction-listbox` + + return ( + <SortableItem key={sort.id} value={sort.id} asChild> + <div className="flex items-center gap-2"> + <Popover modal> + <PopoverTrigger asChild> + <Button + id={fieldTriggerId} + variant="outline" + size="sm" + role="combobox" + className="h-8 w-44 justify-between gap-2 rounded focus:outline-none focus:ring-1 focus:ring-ring" + aria-controls={fieldListboxId} + > + <span className="truncate"> + {toSentenceCase(sort.id)} + </span> + <div className="ml-auto flex items-center gap-1"> + {initialSorting.length === 1 && + initialSorting[0]?.id === sort.id ? ( + <Badge + variant="secondary" + className="h-[1.125rem] rounded px-1 font-mono text-[0.65rem] font-normal" + > + Default + </Badge> + ) : null} + <ChevronsUpDown + className="size-4 shrink-0 opacity-50" + aria-hidden="true" + /> + </div> + </Button> + </PopoverTrigger> + <PopoverContent + id={fieldListboxId} + className="w-[var(--radix-popover-trigger-width)] p-0" + onCloseAutoFocus={() => + document.getElementById(fieldTriggerId)?.focus() + } + > + <Command> + <CommandInput placeholder="Search fields..." /> + <CommandList> + <CommandEmpty>No fields found.</CommandEmpty> + <CommandGroup> + {sortableColumns.map((column) => ( + <CommandItem + key={column.id} + value={column.id} + onSelect={(value) => { + const newFieldTriggerId = `${id}-sort-${value}-field-trigger` + + updateSort({ + id: sort.id, + field: { + id: value as StringKeyOf<TData>, + }, + }) + + requestAnimationFrame(() => { + document + .getElementById(newFieldTriggerId) + ?.focus() + }) + }} + > + <span className="mr-1.5 truncate"> + {column.label} + </span> + <Check + className={cn( + "ml-auto size-4 shrink-0", + column.id === sort.id + ? "opacity-100" + : "opacity-0" + )} + aria-hidden="true" + /> + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + <Select + value={sort.desc ? "desc" : "asc"} + onValueChange={(value: SortDirection) => + updateSort({ + id: sort.id, + field: { id: sort.id, desc: value === "desc" }, + }) + } + > + <SelectTrigger + aria-label="Select sort direction" + aria-controls={directionListboxId} + className="h-8 w-24 rounded" + > + <div className="truncate"> + <SelectValue /> + </div> + </SelectTrigger> + <SelectContent + id={directionListboxId} + className="min-w-[var(--radix-select-trigger-width)]" + > + {dataTableConfig.sortOrders.map((order) => ( + <SelectItem key={order.value} value={order.value}> + {order.label} + </SelectItem> + ))} + </SelectContent> + </Select> + <Button + variant="outline" + size="icon" + aria-label={`Remove sort ${sort.id}`} + className="size-8 shrink-0 rounded" + onClick={() => removeSort(sort.id)} + > + <Trash2 className="size-3.5" aria-hidden="true" /> + </Button> + <SortableDragHandle + variant="outline" + size="icon" + className="size-8 shrink-0 rounded" + > + <GripVertical className="size-3.5" aria-hidden="true" /> + </SortableDragHandle> + </div> + </SortableItem> + ) + })} + </div> + </div> + <div className="flex w-full items-center gap-2"> + <Button + size="sm" + className="h-[1.85rem] rounded" + onClick={addSort} + disabled={sorting.length >= sortableColumns.length} + > + Add sort + </Button> + {sorting.length > 0 ? ( + <Button + size="sm" + variant="outline" + className="rounded" + onClick={() => setSorting(null)} + > + Reset sorting + </Button> + ) : null} + </div> + </PopoverContent> + </Popover> + </Sortable> + ) +} diff --git a/components/data-table/data-table-toolbar.tsx b/components/data-table/data-table-toolbar.tsx new file mode 100644 index 00000000..78c7c39d --- /dev/null +++ b/components/data-table/data-table-toolbar.tsx @@ -0,0 +1,119 @@ +"use client" + +import * as React from "react" +import type { DataTableFilterField } from "@/types/table" +import type { Table } from "@tanstack/react-table" +import { X } from "lucide-react" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { DataTableFacetedFilter } from "@/components/data-table/data-table-faceted-filter" +import { DataTableViewOptions } from "@/components/data-table/data-table-view-options" + +interface DataTableToolbarProps<TData> + extends React.HTMLAttributes<HTMLDivElement> { + table: Table<TData> + /** + * An array of filter field configurations for the data table. + * When options are provided, a faceted filter is rendered. + * Otherwise, a search filter is rendered. + * + * @example + * const filterFields = [ + * { + * id: 'name', + * label: 'Name', + * placeholder: 'Filter by name...' + * }, + * { + * id: 'status', + * label: 'Status', + * options: [ + * { label: 'Active', value: 'active', icon: ActiveIcon, count: 10 }, + * { label: 'Inactive', value: 'inactive', icon: InactiveIcon, count: 5 } + * ] + * } + * ] + */ + filterFields?: DataTableFilterField<TData>[] +} + +export function DataTableToolbar<TData>({ + table, + filterFields = [], + children, + className, + ...props +}: DataTableToolbarProps<TData>) { + const isFiltered = table.getState().columnFilters.length > 0 + + // Memoize computation of searchableColumns and filterableColumns + const { searchableColumns, filterableColumns } = React.useMemo(() => { + return { + searchableColumns: filterFields.filter((field) => !field.options), + filterableColumns: filterFields.filter((field) => field.options), + } + }, [filterFields]) + + return ( + <div + className={cn( + "flex w-full items-center justify-between gap-2 overflow-auto p-1", + className + )} + {...props} + > + <div className="flex flex-1 items-center gap-2"> + {searchableColumns.length > 0 && + searchableColumns.map( + (column) => + table.getColumn(column.id ? String(column.id) : "") && ( + <Input + key={String(column.id)} + placeholder={column.placeholder} + value={ + (table + .getColumn(String(column.id)) + ?.getFilterValue() as string) ?? "" + } + onChange={(event) => + table + .getColumn(String(column.id)) + ?.setFilterValue(event.target.value) + } + className="h-8 w-40 lg:w-64" + /> + ) + )} + {filterableColumns.length > 0 && + filterableColumns.map( + (column) => + table.getColumn(column.id ? String(column.id) : "") && ( + <DataTableFacetedFilter + key={String(column.id)} + column={table.getColumn(column.id ? String(column.id) : "")} + title={column.label} + options={column.options ?? []} + /> + ) + )} + {isFiltered && ( + <Button + aria-label="Reset filters" + variant="ghost" + className="h-8 px-2 lg:px-3" + onClick={() => table.resetColumnFilters()} + > + Reset + <X className="ml-2 size-4" aria-hidden="true" /> + </Button> + )} + </div> + <div className="flex items-center gap-2"> + {children} + <DataTableViewOptions table={table} /> + </div> + </div> + ) +} diff --git a/components/data-table/data-table-view-options.tsx b/components/data-table/data-table-view-options.tsx new file mode 100644 index 00000000..6120fff9 --- /dev/null +++ b/components/data-table/data-table-view-options.tsx @@ -0,0 +1,191 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { + Check, + ChevronsUpDown, + GripVertical, + Settings2, +} from "lucide-react" + +import { cn, toSentenceCase } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" + +// Sortable +import { + Sortable, + SortableItem, + SortableDragHandle, +} from "@/components/ui/sortable" + + +/** + * ViewOptionsProps: + * - table: TanStack Table instance + */ +interface DataTableViewOptionsProps<TData> { + table: Table<TData> +} + +declare module "@tanstack/react-table" { + interface ColumnMeta<TData, TValue> { + excelHeader?: string + group?: string + type?: string + // or whatever other fields you actually use + } +} +/** + * DataTableViewOptions: + * - Renders a Popover with hideable columns + * - Lets user reorder columns (drag & drop) + toggle visibility + */ +export function DataTableViewOptions<TData>({ + table, +}: DataTableViewOptionsProps<TData>) { + const triggerRef = React.useRef<HTMLButtonElement>(null) + + // 1) Identify columns that can be hidden + const hideableCols = React.useMemo(() => { + + + return table + .getAllLeafColumns() + .filter((col) => col.getCanHide()) + }, [table]) + + + // 2) local state for "columnOrder" (just the ID of hideable columns) + // We'll reorder these with drag & drop + const [columnOrder, setColumnOrder] = React.useState<string[]>(() => + hideableCols.map((c) => c.id) + ) + + // 3) onMove: when user finishes drag + // - update local `columnOrder` only (no table.setColumnOrder yet) + const handleMove = React.useCallback( + ({ activeIndex, overIndex }: { activeIndex: number; overIndex: number }) => { + setColumnOrder((prev) => { + const newOrder = [...prev] + const [removed] = newOrder.splice(activeIndex, 1) + newOrder.splice(overIndex, 0, removed) + return newOrder + }) + }, + [] + ) + + // 4) After local state changes, reflect in tanstack table + // - We do this in useEffect to avoid "update a different component" error + React.useEffect(() => { + // Also consider "non-hideable" columns, if any, to keep them in original positions + const nonHideable = table + .getAllColumns() + .filter((col) => !hideableCols.some((hc) => hc.id === col.id)) + .map((c) => c.id) + + // e.g. place nonHideable at the front, then our local hideable order + const finalOrder = [...nonHideable, ...columnOrder] + + // Now we set the table's official column order + table.setColumnOrder(finalOrder) + }, [columnOrder, hideableCols, table]) + + + return ( + <Popover modal> + <PopoverTrigger asChild> + <Button + ref={triggerRef} + aria-label="Toggle columns" + variant="outline" + role="combobox" + size="sm" + className="gap-2" + > + <Settings2 className="size-4" /> + <span className="hidden sm:inline">View</span> + <ChevronsUpDown className="ml-auto size-4 shrink-0 opacity-50 hidden sm:inline" /> + </Button> + </PopoverTrigger> + + <PopoverContent + align="end" + className="w-44 p-0" + onCloseAutoFocus={() => triggerRef.current?.focus()} + > + <Command> + <CommandInput placeholder="Search columns..." /> + <CommandList> + <CommandEmpty>No columns found.</CommandEmpty> + + <CommandGroup> + {/** + * 5) Sortable: we pass an array of { id: string } from `columnOrder`, + * so we can reorder them with drag & drop + */} + <Sortable + value={columnOrder.map((id) => ({ id }))} + onMove={handleMove} + > + {columnOrder.map((colId) => { + // find column instance + const column = hideableCols.find((c) => c.id === colId) + + + if (!column) return null + + return ( + <SortableItem key={colId} value={colId} asChild> + <CommandItem + onSelect={() => + column.toggleVisibility(!column.getIsVisible()) + } + > + {/* Drag handle on the left */} + <SortableDragHandle + variant="outline" + size="icon" + className="mr-2 size-5 shrink-0 rounded cursor-grab active:cursor-grabbing" + > + <GripVertical className="size-3.5" aria-hidden="true" /> + </SortableDragHandle> + + {/* label */} + <span className="truncate"> + {column?.columnDef?.meta?.excelHeader} + </span> + + {/* check if visible */} + <Check + className={cn( + "ml-auto size-4 shrink-0", + column.getIsVisible() ? "opacity-100" : "opacity-0" + )} + /> + </CommandItem> + </SortableItem> + ) + })} + </Sortable> + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + ) +}
\ No newline at end of file diff --git a/components/data-table/data-table.tsx b/components/data-table/data-table.tsx new file mode 100644 index 00000000..3d01994a --- /dev/null +++ b/components/data-table/data-table.tsx @@ -0,0 +1,209 @@ +"use client" + +import * as React from "react" +import { flexRender, type Table as TanstackTable } from "@tanstack/react-table" +import { ChevronRight, ChevronUp } from "lucide-react" + +import { cn } from "@/lib/utils" +import { getCommonPinningStyles } from "@/lib/data-table" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { DataTablePagination } from "@/components/data-table/data-table-pagination" +import { DataTableResizer } from "@/components/data-table/data-table-resizer" +import { useAutoSizeColumns } from "@/hooks/useAutoSizeColumns" + +interface DataTableProps<TData> extends React.HTMLAttributes<HTMLDivElement> { + table: TanstackTable<TData> + floatingBar?: React.ReactNode | null + autoSizeColumns?: boolean +} + +/** + * 멀티 그룹핑 + 그룹 토글 + 그룹 컬럼/헤더 숨김 + Indent + 리사이징 + */ +export function DataTable<TData>({ + table, + floatingBar = null, + autoSizeColumns = true, + children, + className, + ...props +}: DataTableProps<TData>) { + + useAutoSizeColumns(table, autoSizeColumns) + + return ( + <div className={cn("w-full space-y-2.5 overflow-auto", className)} {...props}> + {children} + <div className="max-w-[100vw] overflow-auto" style={{maxHeight:'36.1rem'}}> + <Table className="[&>thead]:sticky [&>thead]:top-0 [&>thead]:z-10 table-fixed"> + {/* ------------------------------- + Table Header + → 그룹핑된 컬럼의 헤더는 숨김 처리 + ------------------------------- */} + <TableHeader> + {table.getHeaderGroups().map((headerGroup) => ( + <TableRow key={headerGroup.id}> + {headerGroup.headers.map((header) => { + // 만약 이 컬럼이 현재 "그룹핑" 상태라면 헤더도 표시하지 않음 + if (header.column.getIsGrouped()) { + return null + } + + return ( + <TableHead + key={header.id} + colSpan={header.colSpan} + data-column-id={header.column.id} + style={{ + ...getCommonPinningStyles({ column: header.column }), + width: header.getSize(), // 리사이징을 위한 너비 설정 + position: "relative" // 리사이저를 위한 포지셔닝 + }} + > + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + {/* 리사이즈 핸들 - 별도의 컴포넌트로 분리 */} + {header.column.getCanResize() && ( + <DataTableResizer header={header} /> + )} + </TableHead> + ) + })} + </TableRow> + ))} + </TableHeader> + + {/* ------------------------------- + Table Body + ------------------------------- */} + <TableBody> + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => { + // --------------------------------------------------- + // 1) "그룹핑 헤더" Row인지 확인 + // --------------------------------------------------- + if (row.getIsGrouped()) { + // row.groupingColumnId로 어떤 컬럼을 기준으로 그룹화 되었는지 알 수 있음 + const groupingColumnId = row.groupingColumnId ?? "" + const groupingColumn = table.getColumn(groupingColumnId) // 해당 column 객체 + + // 컬럼 라벨 가져오기 + let columnLabel = groupingColumnId + if (groupingColumn) { + const headerDef = groupingColumn.columnDef.meta?.excelHeader + if (typeof headerDef === "string") { + columnLabel = headerDef + } + } + + return ( + <TableRow + key={row.id} + className="bg-muted/20" + data-state={row.getIsExpanded() && "expanded"} + > + {/* 그룹 헤더는 한 줄에 합쳐서 보여주고, 토글 버튼 + 그룹 라벨 + 값 표기 */} + <TableCell colSpan={table.getVisibleFlatColumns().length}> + {/* 확장/축소 버튼 (아이콘 중앙 정렬 + Indent) */} + {row.getCanExpand() && ( + <button + onClick={row.getToggleExpandedHandler()} + className="inline-flex items-center justify-center mr-2 w-5 h-5" + style={{ + // row.depth: 0이면 top-level, 1이면 그 하위 등 + marginLeft: `${row.depth * 1.5}rem`, + }} + > + {row.getIsExpanded() ? ( + <ChevronUp size={16} /> + ) : ( + <ChevronRight size={16} /> + )} + </button> + )} + + {/* Group Label + 값 */} + <span className="font-semibold"> + {columnLabel}: {row.getValue(groupingColumnId)} + </span> + <span className="ml-2 text-xs text-muted-foreground"> + ({row.subRows.length} rows) + </span> + </TableCell> + </TableRow> + ) + } + + // --------------------------------------------------- + // 2) 일반 Row + // → "그룹핑된 컬럼"은 숨긴다 + // --------------------------------------------------- + return ( + <TableRow + key={row.id} + data-state={row.getIsSelected() && "selected"} + > + {row.getVisibleCells().map((cell) => { + // 이 셀의 컬럼이 grouped라면 숨긴다 + if (cell.column.getIsGrouped()) { + return null + } + + return ( + <TableCell + key={cell.id} + data-column-id={cell.column.id} + style={{ + ...getCommonPinningStyles({ column: cell.column }), + width: cell.column.getSize(), // 리사이징을 위한 너비 설정 + }} + > + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + </TableCell> + ) + })} + </TableRow> + ) + }) + ) : ( + // --------------------------------------------------- + // 3) 데이터가 없을 때 + // --------------------------------------------------- + <TableRow> + <TableCell + colSpan={table.getAllColumns().length} + className="h-24 text-center" + > + No results. + </TableCell> + </TableRow> + )} + </TableBody> + </Table> + </div> + + <div className="flex flex-col gap-2.5"> + {/* Pagination */} + <DataTablePagination table={table} /> + + {/* Floating Bar (선택된 행 있을 때) */} + {table.getFilteredSelectedRowModel().rows.length > 0 && floatingBar} + </div> + </div> + ) +}
\ No newline at end of file diff --git a/components/date-range-picker.tsx b/components/date-range-picker.tsx new file mode 100644 index 00000000..295160a5 --- /dev/null +++ b/components/date-range-picker.tsx @@ -0,0 +1,146 @@ +"use client" + +import * as React from "react" +import { format } from "date-fns" +import { CalendarIcon } from "lucide-react" +import { parseAsString, useQueryStates } from "nuqs" +import { type DateRange } from "react-day-picker" + +import { cn } from "@/lib/utils" +import { Button, type ButtonProps } from "@/components/ui/button" +import { Calendar } from "@/components/ui/calendar" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" + +interface DateRangePickerProps + extends React.ComponentPropsWithoutRef<typeof PopoverContent> { + /** + * The selected date range. + * @default undefined + * @type DateRange + * @example { from: new Date(), to: new Date() } + */ + defaultDateRange?: DateRange + + /** + * The placeholder text of the calendar trigger button. + * @default "Pick a date" + * @type string | undefined + */ + placeholder?: string + + /** + * The variant of the calendar trigger button. + * @default "outline" + * @type "default" | "outline" | "secondary" | "ghost" + */ + triggerVariant?: Exclude<ButtonProps["variant"], "destructive" | "link"> + + /** + * The size of the calendar trigger button. + * @default "default" + * @type "default" | "sm" | "lg" + */ + triggerSize?: Exclude<ButtonProps["size"], "icon"> + + /** + * The class name of the calendar trigger button. + * @default undefined + * @type string + */ + triggerClassName?: string + + /** + * Controls whether query states are updated client-side only (default: true). + * Setting to `false` triggers a network request to update the querystring. + * @default true + */ + shallow?: boolean +} + +export function DateRangePicker({ + defaultDateRange, + placeholder = "Pick a date", + triggerVariant = "outline", + triggerSize = "default", + triggerClassName, + shallow = true, + className, + ...props +}: DateRangePickerProps) { + const [dateParams, setDateParams] = useQueryStates( + { + from: parseAsString.withDefault( + defaultDateRange?.from?.toISOString() ?? "" + ), + to: parseAsString.withDefault(defaultDateRange?.to?.toISOString() ?? ""), + }, + { + clearOnDefault: true, + shallow, + } + ) + + const date = React.useMemo(() => { + function parseDate(dateString: string | null) { + if (!dateString) return undefined + const parsedDate = new Date(dateString) + return isNaN(parsedDate.getTime()) ? undefined : parsedDate + } + + return { + from: parseDate(dateParams.from) ?? defaultDateRange?.from, + to: parseDate(dateParams.to) ?? defaultDateRange?.to, + } + }, [dateParams, defaultDateRange]) + + return ( + <div className="grid gap-2"> + <Popover> + <PopoverTrigger asChild> + <Button + variant={triggerVariant} + size={triggerSize} + className={cn( + "w-full justify-start gap-2 truncate text-left font-normal", + !date && "text-muted-foreground", + triggerClassName + )} + > + <CalendarIcon className="size-4" /> + {date?.from ? ( + date.to ? ( + <> + {format(date.from, "LLL dd, y")} -{" "} + {format(date.to, "LLL dd, y")} + </> + ) : ( + format(date.from, "LLL dd, y") + ) + ) : ( + <span>{placeholder}</span> + )} + </Button> + </PopoverTrigger> + <PopoverContent className={cn("w-auto p-0", className)} {...props}> + <Calendar + initialFocus + mode="range" + defaultMonth={date?.from} + selected={date} + onSelect={(newDateRange) => { + void setDateParams({ + from: newDateRange?.from?.toISOString() ?? "", + to: newDateRange?.to?.toISOString() ?? "", + }) + }} + numberOfMonths={2} + /> + </PopoverContent> + </Popover> + </div> + ) +} diff --git a/components/document-lists/vendor-doc-list-client.tsx b/components/document-lists/vendor-doc-list-client.tsx new file mode 100644 index 00000000..17137650 --- /dev/null +++ b/components/document-lists/vendor-doc-list-client.tsx @@ -0,0 +1,81 @@ +"use client" + +import * as React from "react" +import { useRouter, useParams } from "next/navigation" + +import DocumentContainer from "@/components/documents/document-container" +import { ProjectInfo, ProjectSwitcher } from "@/components/documents/project-swicher" + +interface VendorDocumentsClientProps { + projects: ProjectInfo[] + children: React.ReactNode +} + +export default function VendorDocumentListClient({ + projects, + children, +}: VendorDocumentsClientProps) { + const router = useRouter() + const params = useParams() + + // Get the contractId from route parameters + const contractIdFromUrl = React.useMemo(() => { + if (params?.contractId) { + const contractId = Array.isArray(params.contractId) + ? params.contractId[0] + : params.contractId + return Number(contractId) + } + return null + }, [params]) + + // Use the URL contractId as the selected contract + const [selectedContractId, setSelectedContractId] = React.useState<number | null>( + contractIdFromUrl + ) + + // Update selectedContractId when URL changes + React.useEffect(() => { + if (contractIdFromUrl) { + setSelectedContractId(contractIdFromUrl) + } + }, [contractIdFromUrl]) + + + // Handle contract selection + function handleSelectContract(projectId: number, contractId: number) { + const projectType = projects.find(v=>v.projectId === projectId)?.projectType || "ship" + setSelectedContractId(contractId) + + // Navigate to the contract's documents page + router.push(`/partners/document-list/${contractId}?projectType=${projectType}`) + } + + return ( + <> + {/* 상단 영역: 제목 왼쪽 / ProjectSwitcher 오른쪽 */} + <div className="flex items-center justify-between"> + {/* 왼쪽: 타이틀 & 설명 */} + <div> + <h2 className="text-2xl font-bold tracking-tight">Vendor Document List</h2> + <p className="text-muted-foreground"> + 문서리스트와 이슈스테이지를 생성하고 관리할 수 있으며 삼성중공업으로 전달할 수 있습니다. + </p> + </div> + + {/* 오른쪽: ProjectSwitcher */} + <ProjectSwitcher + isCollapsed={false} + projects={projects} + selectedContractId={selectedContractId} + onSelectContract={handleSelectContract} + /> + </div> + + {/* 문서 목록/테이블 영역 */} + <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow p-5"> + {children} + </section> + </> + ) +}
\ No newline at end of file diff --git a/components/documents/RevisionForm.tsx b/components/documents/RevisionForm.tsx new file mode 100644 index 00000000..9eea04c5 --- /dev/null +++ b/components/documents/RevisionForm.tsx @@ -0,0 +1,115 @@ +"use client" + +import React, { useState } from "react" + +// shadcn/ui Components +import { Label } from "@/components/ui/label" +import { Input } from "@/components/ui/input" +import { Button } from "@/components/ui/button" +import { Separator } from "@/components/ui/separator" + +type RevisionFormProps = { + document: any +} + +export default function RevisionForm({ document }: RevisionFormProps) { + const [stage, setStage] = useState("") + const [revision, setRevision] = useState("") + const [planDate, setPlanDate] = useState("") + const [actualDate, setActualDate] = useState("") + const [file, setFile] = useState<File | null>(null) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!document?.id) return + // server action 호출 예시 + // await createDocumentVersion({ + // documentId: document.id, + // stage, + // revision, + // planDate, + // actualDate, + // file, + // }); + alert("리비전이 등록되었습니다.") + // 이후 상태 초기화나 revalidation 등 필요에 따라 처리 + } + + return ( + <div className="p-3"> + <h2 className="text-lg font-semibold">리비전 등록</h2> + <Separator className="my-2" /> + + <form onSubmit={handleSubmit} className="space-y-4"> + {/* Stage */} + <div> + <Label htmlFor="stage" className="mb-1"> + Stage + </Label> + <Input + id="stage" + type="text" + value={stage} + onChange={(e) => setStage(e.target.value)} + /> + </div> + + {/* Revision */} + <div> + <Label htmlFor="revision" className="mb-1"> + Revision + </Label> + <Input + id="revision" + type="text" + value={revision} + onChange={(e) => setRevision(e.target.value)} + /> + </div> + + {/* 계획일 */} + <div> + <Label htmlFor="planDate" className="mb-1"> + 계획일 + </Label> + <Input + id="planDate" + type="date" + value={planDate} + onChange={(e) => setPlanDate(e.target.value)} + /> + </div> + + {/* 실제일 */} + <div> + <Label htmlFor="actualDate" className="mb-1"> + 실제일 + </Label> + <Input + id="actualDate" + type="date" + value={actualDate} + onChange={(e) => setActualDate(e.target.value)} + /> + </div> + + {/* 파일 업로드 */} + <div> + <Label htmlFor="file" className="mb-1"> + 파일 업로드 + </Label> + <Input + id="file" + type="file" + onChange={(e) => setFile(e.target.files?.[0] ?? null)} + /> + </div> + + {/* 제출 버튼 */} + <Button type="submit" variant="default"> + 등록하기 + </Button> + </form> + </div> + ) +}
\ No newline at end of file diff --git a/components/documents/StageList.tsx b/components/documents/StageList.tsx new file mode 100644 index 00000000..81f8a5ca --- /dev/null +++ b/components/documents/StageList.tsx @@ -0,0 +1,256 @@ +"use client" + +import React, { useEffect, useState, useMemo } from "react" +import { ScrollArea } from "@/components/ui/scroll-area" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger +} from "@/components/ui/tooltip" +import { Building2, FileIcon, Loader2 } from "lucide-react" +import { Button } from "@/components/ui/button" +import { AddDocumentDialog } from "./add-document-dialog" +import { ViewDocumentDialog } from "./view-document-dialog" +import { getDocumentVersionsByDocId, getStageNamesByDocumentId } from "@/lib/vendor-document/service" +import { Badge } from "@/components/ui/badge" +import { Checkbox } from "@/components/ui/checkbox" +import { formatDate } from "@/lib/utils" + +type StageListProps = { + document: { + id: number + docNumber: string + title: string + // ... + } +} + +// 인터페이스 +interface Attachment { + id: number + fileName: string + filePath: string + fileType?: string +} + +interface Version { + id: number + stage: string + revision: string + uploaderType: string + uploaderName: string | null + comment: string | null + status: string | null + planDate: string | null + actualDate: string | null + approvedDate: string | null + DocumentSubmitDate: Date + attachments: Attachment[] + selected?: boolean +} + +export default function StageList({ document }: StageListProps) { + const [versions, setVersions] = useState<Version[]>([]) + +console.log(versions) + + const [stageOptions, setStageOptions] = useState<string[]>([]) + + const [isLoading, setIsLoading] = useState<boolean>(false) + + useEffect(() => { + if (!document?.id) return + + // 로딩 상태 시작 + setIsLoading(true) + + // 데이터 로딩 프로미스들 + const loadVersions = getDocumentVersionsByDocId(document.id) + .then((data) => { + setVersions(data.map(c => {{return {...c, selected: false}}})) + }) + .catch((error) => { + console.error("Failed to load document versions:", error) + }) + + const loadStageOptions = getStageNamesByDocumentId(document.id) + .then((stageNames) => { + setStageOptions(stageNames) + }) + .catch((error) => { + console.error("Failed to load stage options:", error) + }) + + // 모든 데이터 로딩이 완료되면 로딩 상태 종료 + Promise.all([loadVersions, loadStageOptions]) + .finally(() => { + setIsLoading(false) + }) + }, [document]) + + // Handle file download with original filename + const handleDownload = (attachmentPath: string, fileName: string) => { + if (attachmentPath) { + // Use window.document to avoid collision with the document prop + const link = window.document.createElement('a'); + link.href = attachmentPath; + link.download = fileName || 'download'; // Use the original filename or a default + window.document.body.appendChild(link); + link.click(); + window.document.body.removeChild(link); + } + } + + // 파일 확장자에 따른 아이콘 색상 반환 + const getFileIconColor = (fileName: string) => { + const ext = fileName.split('.').pop()?.toLowerCase(); + + switch(ext) { + case 'pdf': + return 'text-red-500'; + case 'doc': + case 'docx': + return 'text-blue-500'; + case 'xls': + case 'xlsx': + return 'text-green-500'; + case 'dwg': + return 'text-amber-500'; + default: + return 'text-gray-500'; + } + } + + const selectItems = useMemo(() => { + return versions.filter(c => c.selected && c.attachments && c.attachments.length > 0) + }, [versions]) + + return ( + <> + <div className="flex items-center justify-between p-2"> + <h2 className="font-semibold text-base flex items-center gap-2"> + {/* <Building2 className="h-4 w-4 text-blue-600" /> */} + Document: {document.docNumber} {document.title} + </h2> + + + <div className="flex flex-row gap-2"> + {selectItems.length > 0 && <ViewDocumentDialog versions={versions}/>} + + + <AddDocumentDialog + stageOptions={stageOptions} + documentId={document.id} + documentNo={document.docNumber} + uploaderType="vendor" + onSuccess={() => { + // 새 데이터 생성 후 목록을 다시 불러오려면 + getDocumentVersionsByDocId(document.id).then((data) => { + setVersions(data.map(c => {{return {...c, selected: false}}})) + }) + }} + buttonLabel="업체 문서 추가" + /> + </div> + </div> + + <ScrollArea className="h-full p-2"> + {isLoading ? ( + <div className="flex flex-col items-center justify-center py-12"> + <Loader2 className="h-8 w-8 text-blue-500 animate-spin mb-4" /> + <p className="text-sm text-muted-foreground">문서 로딩 중...</p> + </div> + ) : ( + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-[40px]"></TableHead> + <TableHead className="w-[100px]"></TableHead> + <TableHead className="w-[100px]">Stage</TableHead> + <TableHead className="w-[100px]">Revision</TableHead> + <TableHead className="w-[150px]">첨부파일</TableHead> + <TableHead className="w-[150px]">생성일</TableHead> + <TableHead className="w-[120px]">계획일</TableHead> + <TableHead className="w-[120px]">실제일</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {versions.length ? ( + versions.map((ver) => ( + <TableRow key={ver.id}> + <TableCell> + <Checkbox + checked={ver.selected} + onCheckedChange={(value) => { + setVersions(prev => prev.map(c => { + if(c.id === ver.id){ + return {...c, selected: !c.selected} + } + + return {...c} + })) + }} + aria-label="Select row" + className="translate-y-0.5" + /> + </TableCell> + <TableCell>{ver.uploaderType}</TableCell> + <TableCell>{ver.stage}</TableCell> + <TableCell>{ver.revision}</TableCell> + <TableCell> + <div className="flex flex-wrap gap-2"> + {ver.attachments && ver.attachments.length > 0 ? ( + ver.attachments.map((file) => ( + <TooltipProvider key={file.id}> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="sm" + className="h-4 w-4 p-0" + onClick={() => handleDownload(file.filePath, file.fileName)} + > + <FileIcon className={`h-5 w-5 ${getFileIconColor(file.fileName)}`} /> + </Button> + </TooltipTrigger> + <TooltipContent> + <p>{file.fileName || "Download file"}</p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + )) + ) : ( + <Badge variant="outline" className="text-xs"> + 파일 없음 + </Badge> + )} + </div> + </TableCell> + <TableCell>{formatDate(ver.DocumentSubmitDate) ?? "-"}</TableCell> + <TableCell>{ver.planDate ?? "-"}</TableCell> + <TableCell>{ver.actualDate ?? "-"}</TableCell> + </TableRow> + )) + ) : ( + <TableRow> + <TableCell colSpan={7} className="text-center"> + 업체 문서가 없습니다. + </TableCell> + </TableRow> + )} + </TableBody> + </Table> + )} + </ScrollArea> + </> + ) +}
\ No newline at end of file diff --git a/components/documents/StageListfromSHI.tsx b/components/documents/StageListfromSHI.tsx new file mode 100644 index 00000000..9c3c662c --- /dev/null +++ b/components/documents/StageListfromSHI.tsx @@ -0,0 +1,187 @@ +"use client" + +import React, { useEffect, useState } from "react" +import { ScrollArea } from "@/components/ui/scroll-area" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger +} from "@/components/ui/tooltip" +import { FileIcon, Building } from "lucide-react" +import { Button } from "@/components/ui/button" +import { getDocumentVersionsByDocId } from "@/lib/vendor-document/service" +import { Badge } from "@/components/ui/badge" + +type StageListProps = { + document: { + id: number + docNumber: string + title: string + // ... + } +} + +// 인터페이스 +interface Attachment { + id: number + fileName: string + filePath: string + fileType?: string +} + +interface Version { + id: number + stage: string + revision: string + uploaderType: string + uploaderName: string | null + comment: string | null + status: string | null + planDate: string | null + actualDate: string | null + approvedDate: string | null + attachments: Attachment[] +} + +export default function StageSHIList({ document }: StageListProps) { + const [versions, setVersions] = useState<Version[]>([]) + + useEffect(() => { + if (!document?.id) return + // shi 업로더 타입만 필터링 + getDocumentVersionsByDocId(document.id, ['shi']).then((data) => { + setVersions(data) + }) + }, [document]) + + // 스테이지 옵션 추출 + const stageOptions = React.useMemo(() => { + const stageSet = new Set<string>() + for (const v of versions) { + if (v.stage) { + stageSet.add(v.stage) + } + } + return Array.from(stageSet) + }, [versions]) + + // Handle file download with original filename + const handleDownload = (attachmentPath: string, fileName: string) => { + if (attachmentPath) { + // Use window.document to avoid collision with the document prop + const link = window.document.createElement('a'); + link.href = attachmentPath; + link.download = fileName || 'download'; // Use the original filename or a default + window.document.body.appendChild(link); + link.click(); + window.document.body.removeChild(link); + } + } + + // 파일 확장자에 따른 아이콘 색상 반환 + const getFileIconColor = (fileName: string) => { + const ext = fileName.split('.').pop()?.toLowerCase(); + + switch(ext) { + case 'pdf': + return 'text-red-500'; + case 'doc': + case 'docx': + return 'text-blue-500'; + case 'xls': + case 'xlsx': + return 'text-green-500'; + case 'dwg': + return 'text-amber-500'; + default: + return 'text-gray-500'; + } + } + + return ( + <> + <div className="flex items-center justify-between p-2"> + <h2 className="font-semibold text-base flex items-center gap-2"> + {/* <Building className="h-4 w-4 text-amber-600" /> */} + From 삼성중공업 ({document.docNumber} {document.title}) + </h2> + </div> + + <ScrollArea className="h-full p-2"> + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-[100px]">Stage</TableHead> + <TableHead className="w-[100px]">Revision</TableHead> + <TableHead className="w-[150px]">첨부파일</TableHead> + <TableHead className="w-[100px]">상태</TableHead> + <TableHead className="w-[120px]">코멘트</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {versions.length ? ( + versions.map((ver) => ( + <TableRow key={ver.id}> + <TableCell>{ver.stage}</TableCell> + <TableCell>{ver.revision}</TableCell> + <TableCell> + <div className="flex flex-wrap gap-2"> + {ver.attachments && ver.attachments.length > 0 ? ( + ver.attachments.map((file) => ( + <TooltipProvider key={file.id}> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="sm" + className="h-8 w-8 p-0" + onClick={() => handleDownload(file.filePath, file.fileName)} + > + <FileIcon className={`h-5 w-5 ${getFileIconColor(file.fileName)}`} /> + </Button> + </TooltipTrigger> + <TooltipContent> + <p>{file.fileName || "Download file"}</p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + )) + ) : ( + <Badge variant="outline" className="text-xs"> + 파일 없음 + </Badge> + )} + </div> + </TableCell> + <TableCell> + {ver.status && ( + <Badge variant="outline" className="bg-amber-50 text-amber-800"> + {ver.status} + </Badge> + )} + </TableCell> + <TableCell>{ver.comment || "-"}</TableCell> + </TableRow> + )) + ) : ( + <TableRow> + <TableCell colSpan={5} className="text-center"> + 삼성중공업 문서가 없습니다. + </TableCell> + </TableRow> + )} + </TableBody> + </Table> + </ScrollArea> + </> + ) +}
\ No newline at end of file diff --git a/components/documents/add-document-dialog.tsx b/components/documents/add-document-dialog.tsx new file mode 100644 index 00000000..15c1e021 --- /dev/null +++ b/components/documents/add-document-dialog.tsx @@ -0,0 +1,515 @@ +"use client" + +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { z } from "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" +import { + Form, FormControl, FormField, FormItem, FormLabel, FormMessage, FormDescription +} from "@/components/ui/form" +import { + Select, SelectContent, SelectGroup, + SelectItem, SelectTrigger, SelectValue +} from "@/components/ui/select" +import { Textarea } from "@/components/ui/textarea" +import { createRevisionAction } from "@/lib/vendor-document/service" +import { useToast } from "@/hooks/use-toast" +import { FilePlus, X, Loader2, Building2, User, Building, Plus } from "lucide-react" +import { Badge } from "@/components/ui/badge" +import { + Dropzone, + DropzoneDescription, + DropzoneInput, + DropzoneTitle, + DropzoneUploadIcon, + DropzoneZone, +} from "@/components/ui/dropzone" +import { + FileList, + FileListAction, + FileListDescription, + FileListHeader, + FileListIcon, + FileListInfo, + FileListItem, + FileListName, + FileListSize, +} from "@/components/ui/file-list" +import prettyBytes from "pretty-bytes" +import { ScrollArea } from "../ui/scroll-area" + +// 최대 파일 크기 설정 (3000MB) +const MAX_FILE_SIZE = 3e9 + +// zod 스키마 +export const createDocumentVersionSchema = z.object({ + attachments: z.array(z.instanceof(File)).min(1, "At least one file is required"), + stage: z.string().min(1, "Stage is required"), + revision: z.string().min(1, "Revision is required"), + uploaderType: z.enum(["vendor", "client", "shi"]).default("vendor"), + uploaderName: z.string().optional(), + comment: z.string().optional(), +}) +export type CreateDocumentVersionSchema = z.infer<typeof createDocumentVersionSchema> + +// First, let's add a function to generate filenames based on your pattern +const generateFileName = ( + documentNo: string, + stage: string, + revision: string, + originalFileName: string, + index: number, + totalFiles: number +) => { + // Get the file extension + const extension = originalFileName.split('.').pop() || ''; + + // Base name without extension + const baseName = `${documentNo}_${stage}_${revision}`; + + // For multiple files, add a suffix + if (totalFiles > 1) { + return `${baseName}_${index + 1}.${extension}`; + } + + // For a single file, no suffix needed + return `${baseName}.${extension}`; +}; + +// AddDocumentDialog Props +interface AddDocumentDialogProps { + onSuccess?: () => void + stageOptions?: string[] + documentId: number + documentNo: string + // 업로더 타입 + uploaderType?: "vendor" | "client" | "shi" + // 버튼 라벨 추가 + buttonLabel?: string +} + +export function AddDocumentDialog({ + onSuccess, + stageOptions = [], + documentId, + documentNo, + uploaderType = "vendor", + buttonLabel = "Add Document", +}: AddDocumentDialogProps) { + const [open, setOpen] = React.useState(false) + const [selectedFiles, setSelectedFiles] = React.useState<File[]>([]) + const [isUploading, setIsUploading] = React.useState(false) + const [uploadProgress, setUploadProgress] = React.useState(0) + const { toast } = useToast() + + const form = useForm<CreateDocumentVersionSchema>({ + resolver: zodResolver(createDocumentVersionSchema), + defaultValues: { + stage: "", + revision: "", + attachments: [], + uploaderType, + uploaderName: "", + comment: "", + }, + }) + + // 업로더 타입이 바뀌면 폼 값도 업데이트 + React.useEffect(() => { + form.setValue('uploaderType', uploaderType); + }, [uploaderType, form]); + + // 드롭존 - 파일 드랍 처리 + const handleDropAccepted = (acceptedFiles: File[]) => { + const newFiles = [...selectedFiles, ...acceptedFiles]; + setSelectedFiles(newFiles); + form.setValue('attachments', newFiles, { shouldValidate: true }); + }; + + // 드롭존 - 파일 거부(에러) 처리 + const handleDropRejected = (fileRejections: any[]) => { + fileRejections.forEach((rejection) => { + toast({ + variant: "destructive", + title: "File Error", + description: `${rejection.file.name}: ${rejection.errors[0]?.message || "Upload failed"}`, + }); + }); + }; + + // 파일 제거 핸들러 + const removeFile = (index: number) => { + const updatedFiles = [...selectedFiles] + updatedFiles.splice(index, 1) + setSelectedFiles(updatedFiles) + form.setValue('attachments', updatedFiles, { shouldValidate: true }) + } + + // Submit + async function onSubmit(data: CreateDocumentVersionSchema) { + setIsUploading(true) + setUploadProgress(0) + + try { + // 각 파일별로 별도 요청 + const totalFiles = data.attachments.length; + let successCount = 0; + + for (let i = 0; i < totalFiles; i++) { + const file = data.attachments[i]; + + const newFileName = generateFileName( + documentNo, + data.stage, + data.revision, + file.name, + i, + totalFiles + ); + + const fData = new FormData(); + fData.append("documentId", String(documentId)); + fData.append("stage", data.stage); + fData.append("revision", data.revision); + fData.append("uploaderType", data.uploaderType); + + if (data.uploaderName) { + fData.append("uploaderName", data.uploaderName); + } + + if (data.comment) { + fData.append("comment", data.comment); + } + + fData.append("attachment", file); + fData.append("customFileName", newFileName); + + // 각 파일 업로드를 요청 + await createRevisionAction(fData); + + // 진행 상황 업데이트 + successCount++; + setUploadProgress(Math.round((successCount / totalFiles) * 100)); + } + + // 성공 메시지 + toast({ + title: "Success", + description: `${successCount} 파일이 업로드되었습니다.`, + }); + + // 폼 초기화 + form.reset(); + setSelectedFiles([]); + setOpen(false); + + // 콜백 실행 + if (onSuccess) { + onSuccess(); + } + } catch (err) { + console.error(err); + toast({ + title: "Error", + description: "파일 업로드 중 오류가 발생했습니다.", + variant: "destructive", + }); + } finally { + setIsUploading(false); + setUploadProgress(0); + } + } + + function handleDialogOpenChange(nextOpen: boolean) { + if (!nextOpen) { + form.reset(); + form.setValue('uploaderType', uploaderType); + setSelectedFiles([]); + setIsUploading(false); + setUploadProgress(0); + } + setOpen(nextOpen); + } + + // 업로더 타입에 따른 UI 설정 + const uploaderConfig = { + vendor: { + icon: <Plus className="h-4 w-4" />, + buttonStyle: "bg-blue-600 hover:bg-blue-700", + badgeStyle: "bg-blue-100 text-blue-800", + title: "업체 문서 등록", + nameLabel: "업체 이름", + namePlaceholder: "예: 홍길동(ABC 업체)" + }, + client: { + icon: <User className="mr-2 h-4 w-4" />, + buttonStyle: "bg-amber-600 hover:bg-amber-700", + badgeStyle: "bg-amber-100 text-amber-800", + title: "고객사 문서 등록", + nameLabel: "담당자 이름", + namePlaceholder: "예: 김철수(고객사)" + }, + shi: { + icon: <Building className="mr-2 h-4 w-4" />, + buttonStyle: "bg-purple-600 hover:bg-purple-700", + badgeStyle: "bg-purple-100 text-purple-800", + title: "삼성중공업 문서 등록", + nameLabel: "담당자 이름", + namePlaceholder: "예: 이영희(삼성중공업)" + } + } + + const config = uploaderConfig[uploaderType as keyof typeof uploaderConfig]; + + return ( + <Dialog open={open} onOpenChange={handleDialogOpenChange}> + <DialogTrigger asChild> + <Button + size="sm" + className="border-blue-200" + variant="outline" + > + {config.icon} + {buttonLabel} + </Button> + </DialogTrigger> + + <DialogContent className="sm:max-w-md"> + <DialogHeader> + <DialogTitle> + {config.title} + </DialogTitle> + <DialogDescription> + 스테이지/리비전을 입력하고 파일을 업로드하세요. + </DialogDescription> + </DialogHeader> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} encType="multipart/form-data"> + <div className="space-y-4 py-4"> + {/* stage */} + <FormField + control={form.control} + name="stage" + render={({ field }) => ( + <FormItem> + <FormLabel>Stage</FormLabel> + <FormControl> + {stageOptions.length > 0 ? ( + <Select + onValueChange={field.onChange} + defaultValue={field.value} + > + <SelectTrigger> + <SelectValue placeholder="Select a stage" /> + </SelectTrigger> + <SelectContent> + <SelectGroup> + {stageOptions.map((st) => ( + <SelectItem key={st} value={st}> + {st} + </SelectItem> + ))} + </SelectGroup> + </SelectContent> + </Select> + ) : ( + <Input {...field} placeholder="예: Issued for Review" /> + )} + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* revision */} + <FormField + control={form.control} + name="revision" + render={({ field }) => ( + <FormItem> + <FormLabel>Revision</FormLabel> + <FormControl> + <Input {...field} placeholder="예: A, B, 1, 2..." /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* attachments - 드롭존 */} + <FormField + control={form.control} + name="attachments" + render={() => ( + <FormItem> + <FormLabel>파일 첨부</FormLabel> + <Dropzone + maxSize={MAX_FILE_SIZE} + multiple={true} + onDropAccepted={handleDropAccepted} + onDropRejected={handleDropRejected} + disabled={isUploading} + > + {({ maxSize }) => ( + <> + <DropzoneZone className="flex justify-center"> + <FormControl> + <DropzoneInput /> + </FormControl> + <div className="flex items-center gap-6"> + <DropzoneUploadIcon /> + <div className="grid gap-0.5"> + <DropzoneTitle>파일을 여기에 드롭하세요</DropzoneTitle> + <DropzoneDescription> + 또는 클릭하여 파일을 선택하세요. + 최대 크기: {maxSize ? prettyBytes(maxSize) : "무제한"} + </DropzoneDescription> + </div> + </div> + </DropzoneZone> + <FormDescription className="text-xs text-muted-foreground"> + 여러 파일을 선택할 수 있습니다. + </FormDescription> + </> + )} + </Dropzone> + <FormMessage /> + </FormItem> + )} + /> + + {/* 선택된 파일 목록 */} + + {selectedFiles.length > 0 && ( + <div className="grid gap-2"> + <div className="flex items-center justify-between"> + <h6 className="text-sm font-semibold"> + 선택된 파일 ({selectedFiles.length}) + </h6> + <Badge variant="secondary"> + {selectedFiles.length}개 파일 + </Badge> + </div> + <ScrollArea> + <FileList className="max-h-[200px] gap-3"> + {selectedFiles.map((file, index) => ( + <FileListItem key={index} className="p-3"> + <FileListHeader> + <FileListIcon /> + <FileListInfo> + <FileListName>{file.name}</FileListName> + <FileListDescription> + {prettyBytes(file.size)} + </FileListDescription> + </FileListInfo> + <FileListAction onClick={() => removeFile(index)} disabled={isUploading}> + <X className="h-4 w-4" /> + <span className="sr-only">Remove</span> + </FileListAction> + </FileListHeader> + </FileListItem> + ))} + </FileList> + </ScrollArea> + + </div> + )} + + {/* 업로드 진행 상태 */} + {isUploading && ( + <div className="flex flex-col gap-1 mt-2"> + <div className="flex items-center gap-2"> + <Loader2 className="h-4 w-4 animate-spin" /> + <span className="text-sm"> + {uploadProgress}% 업로드 중... + </span> + </div> + <div className="h-2 w-full bg-muted rounded-full overflow-hidden"> + <div + className="h-full bg-primary rounded-full transition-all" + style={{ width: `${uploadProgress}%` }} + /> + </div> + </div> + )} + + {/* 선택적 필드들 */} + {/* <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> + <FormField + control={form.control} + name="uploaderName" + render={({ field }) => ( + <FormItem> + <FormLabel>{config.nameLabel} (선택)</FormLabel> + <FormControl> + <Input + {...field} + placeholder={config.namePlaceholder} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> */} + + {/* comment (optional) */} + {/* <FormField + control={form.control} + name="comment" + render={({ field }) => ( + <FormItem> + <FormLabel>코멘트 (선택)</FormLabel> + <FormControl> + <Textarea + {...field} + placeholder="파일에 대한 설명이나 코멘트를 입력하세요." + className="resize-none" + rows={2} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> */} + </div> + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => { + // 파일 리스트 리셋 + setSelectedFiles([]); + form.reset({ + ...form.getValues(), + attachments: [] + }); + setOpen(false); + }} + disabled={isUploading} + > + 취소 + </Button> + <Button + type="submit" + disabled={isUploading || selectedFiles.length === 0 || !form.formState.isValid} + className={config.buttonStyle} + > + {isUploading ? "업로드 중..." : "등록"} + </Button> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/components/documents/document-container.tsx b/components/documents/document-container.tsx new file mode 100644 index 00000000..0a1a4a56 --- /dev/null +++ b/components/documents/document-container.tsx @@ -0,0 +1,85 @@ +"use client" + +import { useState } from "react" + +// shadcn/ui components +import { + ResizablePanelGroup, + ResizablePanel, + ResizableHandle, +} from "@/components/ui/resizable" + +import { cn } from "@/lib/utils" +import StageList from "./StageList" +import RevisionForm from "./RevisionForm" +import { getVendorDocumentLists } from "@/lib/vendor-document/service" +import { VendorDocumentsView } from "@/db/schema/vendorDocu" +import { DocumentListTable } from "@/lib/vendor-document/table/doc-table" +import StageSHIList from "./StageListfromSHI" + +interface DocumentContainerProps { + promises: Promise<[Awaited<ReturnType<typeof getVendorDocumentLists>>]> + selectedPackageId: number +} + +export default function DocumentContainer({ + promises, + selectedPackageId +}: DocumentContainerProps) { + // 선택된 문서를 이 state로 관리 + const [selectedDocument, setSelectedDocument] = useState<VendorDocumentsView | null>(null) + + // 패널 collapse 상태 + const [isTopCollapsed, setIsTopCollapsed] = useState(false) + const [isBottomLeftCollapsed, setIsBottomLeftCollapsed] = useState(false) + const [isBottomRightCollapsed, setIsBottomRightCollapsed] = useState(false) + + // 문서 선택 핸들러 + const handleSelectDocument = (document: VendorDocumentsView | null) => { + setSelectedDocument(document) + } + + return ( + // 명시적 높이 지정 + <div className="h-[calc(100vh-100px)] w-full"> + + <ResizablePanelGroup direction="vertical" className="h-full w-full"> + {/* 상단 패널 (문서 리스트 영역) */} + <ResizablePanel + defaultSize={65} + minSize={15} + maxSize={95} + collapsible + collapsedSize={10} + onCollapse={() => setIsTopCollapsed(true)} + onExpand={() => setIsTopCollapsed(false)} + className={cn("overflow-auto border-b", isTopCollapsed && "transition-all")} + > + <DocumentListTable + promises={promises} + selectedPackageId={selectedPackageId} + onSelectDocument={handleSelectDocument} + /> + </ResizablePanel> + + {/* 상/하 분할을 위한 핸들 */} + <ResizableHandle + withHandle + className="pointer-events-none data-[resize-handle]:pointer-events-auto" + /> + + <ResizablePanel minSize={0} defaultSize={35}> + + {selectedDocument ? ( + <StageList document={selectedDocument} /> + ) : ( + <div className="p-4 text-sm text-muted-foreground"> + 문서를 선택하면 이슈 스테이지가 표시됩니다. + </div> + )} + + </ResizablePanel> + </ResizablePanelGroup> + </div> + ) +}
\ No newline at end of file diff --git a/components/documents/project-swicher.tsx b/components/documents/project-swicher.tsx new file mode 100644 index 00000000..5c70ea88 --- /dev/null +++ b/components/documents/project-swicher.tsx @@ -0,0 +1,138 @@ +"use client" + +import * as React from "react" +import { cn } from "@/lib/utils" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" + +interface PackageItem { + itemId: number + itemName: string +} + +interface ContractInfo { + contractId: number + contractNo: string + contractName: string + packages: PackageItem[] +} + +export interface ProjectInfo { + projectId: number + projectCode: string + projectName: string + projectType: string + contracts: ContractInfo[] +} + +interface ProjectSwitcherProps { + isCollapsed: boolean + projects: ProjectInfo[] + + // 상위가 관리하는 "현재 선택된 contractId" + selectedContractId: number | null + + // 콜백: 사용자가 "어떤 contract"를 골랐는지 + onSelectContract: (projectId: number, contractId: number) => void +} + +/** + * ProjectSwitcher: + * - 프로젝트들(contracts 포함)을 그룹화하여 Select 표시 + * - 너무 긴 계약명 등을 ellipsis로 축약 + */ +export function ProjectSwitcher({ + isCollapsed, + projects, + selectedContractId, + onSelectContract, +}: ProjectSwitcherProps) { + // Select value = stringified contractId + const selectValue = selectedContractId ? String(selectedContractId) : "" + + // 현재 선택된 계약 정보를 찾기 + const selectedContract = React.useMemo(() => { + if (!selectedContractId) return null + for (const proj of projects) { + const found = proj.contracts.find((c) => c.contractId === selectedContractId) + if (found) { + return { ...found, projectId: proj.projectId } + } + } + return null + }, [projects, selectedContractId]) + + // Trigger Label => 계약 이름 or "Select a contract" + const triggerLabel = selectedContract?.contractName ?? "Select a contract" + + function handleValueChange(val: string) { + const contractId = Number(val) + let foundProjectId = 0 + + for (const proj of projects) { + const found = proj.contracts.find((c) => c.contractId === contractId) + if (found) { + foundProjectId = proj.projectId + break + } + } + onSelectContract(foundProjectId, contractId) + } + + return ( + <Select value={selectValue} onValueChange={handleValueChange}> + {/* + 아래 SelectTrigger에 max-w, whitespace-nowrap, overflow-hidden, text-ellipsis 적용 + 가로폭이 200px 넘어가면 "…" 으로 표시 + */} + <SelectTrigger + className={cn( + "flex items-center gap-2", + isCollapsed && "flex h-9 w-9 shrink-0 items-center justify-center p-0", + "max-w-[300px] whitespace-nowrap overflow-hidden text-ellipsis" + )} + aria-label="Select Contract" + > + <SelectValue placeholder="Select a contract"> + {/* 실제 표시부분에도 ellipsis 처리. */} + <span + className={cn( + "ml-2 block max-w-[250px] truncate", + isCollapsed && "hidden" + )} + > + {triggerLabel} + </span> + </SelectValue> + </SelectTrigger> + + <SelectContent> + {projects.map((project) => ( + <SelectGroup key={project.projectCode}> + {/* 프로젝트명 표시 */} + <SelectLabel> + {/* 필요하다면 projectCode만 보이도록 하는 등 조정 가능 */} + {project.projectName} + </SelectLabel> + {project.contracts.map((contract) => ( + <SelectItem + key={contract.contractId} + value={String(contract.contractId)} + > + {/* 계약명 + 계약번호 등 원하는 형식 */} + {contract.contractName} ({contract.contractNo}) + </SelectItem> + ))} + </SelectGroup> + ))} + </SelectContent> + </Select> + ) +}
\ No newline at end of file diff --git a/components/documents/vendor-docs.client.tsx b/components/documents/vendor-docs.client.tsx new file mode 100644 index 00000000..9bb7988c --- /dev/null +++ b/components/documents/vendor-docs.client.tsx @@ -0,0 +1,80 @@ +"use client" + +import * as React from "react" +import { useRouter, useParams } from "next/navigation" + +import DocumentContainer from "@/components/documents/document-container" +import { ProjectInfo, ProjectSwitcher } from "./project-swicher" + +interface VendorDocumentsClientProps { + projects: ProjectInfo[] + children: React.ReactNode +} + +export default function VendorDocumentsClient({ + projects, + children, +}: VendorDocumentsClientProps) { + const router = useRouter() + const params = useParams() + + // Get the contractId from route parameters + const contractIdFromUrl = React.useMemo(() => { + if (params?.contractId) { + const contractId = Array.isArray(params.contractId) + ? params.contractId[0] + : params.contractId + return Number(contractId) + } + return null + }, [params]) + + // Use the URL contractId as the selected contract + const [selectedContractId, setSelectedContractId] = React.useState<number | null>( + contractIdFromUrl + ) + + // Update selectedContractId when URL changes + React.useEffect(() => { + if (contractIdFromUrl) { + setSelectedContractId(contractIdFromUrl) + } + }, [contractIdFromUrl]) + + // Handle contract selection + function handleSelectContract(projectId: number, contractId: number) { + setSelectedContractId(contractId) + + // Navigate to the contract's documents page + router.push(`/partners/documents/${contractId}`, { scroll: false }) + } + + return ( + <> + {/* 상단 영역: 제목 왼쪽 / ProjectSwitcher 오른쪽 */} + <div className="flex items-center justify-between"> + {/* 왼쪽: 타이틀 & 설명 */} + <div> + <h2 className="text-2xl font-bold tracking-tight">Vendor Documents</h2> + <p className="text-muted-foreground"> + 문서리스트를 확인하고 리스트에 맞게 문서를 업로드하고 관리할 수 있으며 + 삼성중공업으로 전달할 수 있습니다. + </p> + </div> + + {/* 오른쪽: ProjectSwitcher */} + <ProjectSwitcher + isCollapsed={false} + projects={projects} + selectedContractId={selectedContractId} + onSelectContract={handleSelectContract} + /> + </div> + + {/* 문서 목록/테이블 영역 */} + <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow p-5"> + {children} + </section> + </> + ) +}
\ No newline at end of file diff --git a/components/documents/view-document-dialog.tsx b/components/documents/view-document-dialog.tsx new file mode 100644 index 00000000..752252ee --- /dev/null +++ b/components/documents/view-document-dialog.tsx @@ -0,0 +1,226 @@ +"use client" + +import * as React from "react" +import { WebViewerInstance } from "@pdftron/webviewer"; +import { + Dialog, DialogTrigger, DialogContent, DialogHeader, + DialogTitle, DialogDescription, DialogFooter +} from "@/components/ui/dialog" +import { Building2, FileIcon, Loader2 } from "lucide-react" +import { Button } from "@/components/ui/button" +import fs from "fs" + +interface Version { + id: number + stage: string + revision: string + uploaderType: string + uploaderName: string | null + comment: string | null + status: string | null + planDate: string | null + actualDate: string | null + approvedDate: string | null + DocumentSubmitDate: Date + attachments: Attachment[] + selected: boolean +} + +type ViewDocumentDialogProps = { + versions: Version[] +} + +export function ViewDocumentDialog({versions}: ViewDocumentDialogProps){ + const [open, setOpen] = React.useState(false) + + + return ( + <> + <Button + size="sm" + className="border-blue-200" + variant="outline" + onClick={() => setOpen(prev => !prev)} + > + 문서 보기 + </Button> + {open && <DocumentViewer + open={open} + setOpen={setOpen} + versions={versions} + /> + } + </> + ); +} + +function DocumentViewer({open, setOpen, versions}){ + const [instance, setInstance] = React.useState<null | WebViewerInstance>(null) + const [viwerLoading, setViewerLoading] = React.useState<boolean>(true) + const [fileSetLoading, setFileSetLoading] = React.useState<boolean>(true) + const viewer = React.useRef<HTMLDivElement>(null); + const initialized = React.useRef(false); + const isCancelled = React.useRef(false); // 초기화 중단용 flag + + const cleanupHtmlStyle = () => { + const htmlElement = document.documentElement; + + // 기존 style 속성 가져오기 + const originalStyle = htmlElement.getAttribute("style") || ""; + + // "color-scheme: light" 또는 "color-scheme: dark" 찾기 + const colorSchemeStyle = originalStyle + .split(";") + .map((s) => s.trim()) + .find((s) => s.startsWith("color-scheme:")); + + // 새로운 스타일 적용 (color-scheme만 유지) + if (colorSchemeStyle) { + htmlElement.setAttribute("style", colorSchemeStyle + ";"); + } else { + htmlElement.removeAttribute("style"); // color-scheme도 없으면 style 속성 자체 삭제 + } + + console.log("html style 삭제") + }; + + React.useEffect(() => { + if (open && !initialized.current) { + initialized.current = true; + isCancelled.current = false; // 다시 열릴 때는 false로 리셋 + + requestAnimationFrame(() => { + if (viewer.current) { + import("@pdftron/webviewer").then(({ default: WebViewer }) => { + console.log(isCancelled.current) + if (isCancelled.current) { + console.log("📛 WebViewer 초기화 취소됨 (Dialog 닫힘)"); + + return; + } + + WebViewer( + { + path: "/pdftronWeb", + licenseKey: "demo:1739264618684:616161d7030000000091db1c97c6f386d41d3506ab5b507381ef2ee2bd", + fullAPI: true, + css:"/globals.css" + }, + viewer.current as HTMLDivElement + ).then(async (instance: WebViewerInstance) => { + + + setInstance(instance); + instance.UI.enableFeatures([instance.UI.Feature.MultiTab]); + instance.UI.disableElements(["addTabButton", "multiTabsEmptyPage"]); + setViewerLoading(false); + + }); + }); + } + }); + } + + return async () => { + // cleanup 시에는 중단 flag 세움 + if(instance){ + await instance.UI.dispose() + } + await setTimeout(() => cleanupHtmlStyle(), 500) + }; + }, [open]); + + React.useEffect(() => { + const loadDocument = async () => { + + if(instance && versions.length > 0){ + const { UI } = instance; + + const optionsArray = [] + + versions.forEach(c => { + const {attachments} = c + attachments.forEach(c2 => { + const {fileName, filePath, fileType} = c2 + + const options = { + filename: fileName, + ...(fileType.includes("xlsx") && { + officeOptions: { + formatOptions: { + applyPageBreaksToSheet: true, + }, + }, + }), + }; + + optionsArray.push({ + filePath, + options + }) + }) + }) + + const tabIds = []; + + for (const option of optionsArray) { + const { filePath, options } = option; + const response = await fetch(filePath); + const blob = await response.blob(); + + const tab = await UI.TabManager.addTab(blob, options); + tabIds.push(tab); // 탭 ID 저장 + } + + if (tabIds.length > 0) { + await UI.TabManager.setActiveTab(tabIds[0]); + } + + setFileSetLoading(false) + } + } + loadDocument(); + }, [instance, versions]) + + + return ( + <Dialog open={open} onOpenChange={async (val) => { + console.log({val, fileSetLoading}) + if(!val && fileSetLoading){ + return; + } + + if (instance) { + try { + await instance.UI.dispose(); + setInstance(null); // 상태도 초기화 + + } catch (e) { + console.warn("dispose error", e); + } + } + + // cleanupHtmlStyle() + setViewerLoading(false); + setOpen(prev => !prev) + await setTimeout(() => cleanupHtmlStyle(), 1000) + }}> + <DialogContent className="w-[90vw] h-[90vh]" style={{maxWidth: "none"}}> + <DialogHeader className="h-[38px]"> + <DialogTitle> + 문서 미리보기 + </DialogTitle> + <DialogDescription> + 첨부파일 미리보기 + </DialogDescription> + </DialogHeader> + <div ref={viewer} style={{height: "calc(90vh - 20px - 38px - 1rem - 48px)"}}> + {viwerLoading && <div className="flex flex-col items-center justify-center py-12"> + <Loader2 className="h-8 w-8 text-blue-500 animate-spin mb-4" /> + <p className="text-sm text-muted-foreground">문서 뷰어 로딩 중...</p> + </div>} + </div> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file diff --git a/components/faq/FaqCard.tsx b/components/faq/FaqCard.tsx new file mode 100644 index 00000000..ef622e67 --- /dev/null +++ b/components/faq/FaqCard.tsx @@ -0,0 +1,36 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+
+export interface FaqItem {
+ title: string;
+ content: string[];
+}
+
+export interface FaqCategory {
+ label: string; // 카테고리 이름 (식별자로도 사용)
+ items: {
+ title: string;
+ content: string[];
+ }[];
+}
+
+interface FaqCardProps {
+ item: FaqItem;
+}
+
+export function FaqCard({ item }: FaqCardProps) {
+ return (
+ <Card>
+ <CardHeader>
+ <CardTitle>{item.title}</CardTitle>
+ </CardHeader>
+ <CardContent>
+ {item.content.map((line, index) => (
+ <div key={index}>
+ {line}
+ {index < item.content.length - 1 && <br />}
+ </div>
+ ))}
+ </CardContent>
+ </Card>
+ )
+}
\ No newline at end of file diff --git a/components/faq/FaqManager.tsx b/components/faq/FaqManager.tsx new file mode 100644 index 00000000..27755270 --- /dev/null +++ b/components/faq/FaqManager.tsx @@ -0,0 +1,192 @@ +'use client';
+
+import { useState } from 'react';
+import { FaqCategory } from './FaqCard';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent } from '@/components/ui/card';
+import { Input } from '@/components/ui/input';
+import { Textarea } from '@/components/ui/textarea';
+import { Plus, Trash, Save, Settings } from 'lucide-react';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
+import { useRouter } from 'next/navigation';
+
+interface FaqManagerProps {
+ initialData: FaqCategory[];
+ onSave: (data: FaqCategory[]) => Promise<void>;
+ lng: string;
+}
+
+export function FaqManager({ initialData, onSave, lng }: FaqManagerProps) {
+ const [categories, setCategories] = useState<FaqCategory[]>(initialData);
+ const [activeTab, setActiveTab] = useState<string>(categories[0]?.label || '');
+ const [isSettingsView, setIsSettingsView] = useState(false);
+ const [showSaveDialog, setShowSaveDialog] = useState(false);
+ const router = useRouter();
+
+ const addCategory = () => {
+ const newCategory = {
+ label: 'New Category',
+ items: []
+ };
+ setCategories([...categories, newCategory]);
+ setActiveTab(newCategory.label);
+ };
+
+ const addItem = (categoryIndex: number) => {
+ const newCategories = [...categories];
+ newCategories[categoryIndex].items.push({
+ title: 'New FAQ Item',
+ content: ['Enter content here']
+ });
+ setCategories(newCategories);
+ };
+
+ const handleSave = async () => {
+ await onSave(categories);
+ setShowSaveDialog(false);
+ router.push(`/${lng}/evcp/faq`);
+ };
+
+ const updateCategory = (index: number, label: string) => {
+ const newCategories = [...categories];
+ newCategories[index] = { ...newCategories[index], label };
+ setCategories(newCategories);
+ if (activeTab === categories[index].label) {
+ setActiveTab(label);
+ }
+ };
+
+ const updateItem = (categoryIndex: number, itemIndex: number, field: 'title' | 'content', value: string | string[]) => {
+ const newCategories = [...categories];
+ if (field === 'content') {
+ newCategories[categoryIndex].items[itemIndex][field] = (value as string).split('\n');
+ } else {
+ newCategories[categoryIndex].items[itemIndex][field] = value as string;
+ }
+ setCategories(newCategories);
+ };
+
+ const removeCategory = (index: number) => {
+ const newCategories = categories.filter((_, i) => i !== index);
+ setCategories(newCategories);
+ setActiveTab(newCategories[0]?.label || '');
+ };
+
+ const removeItem = (categoryIndex: number, itemIndex: number) => {
+ const newCategories = [...categories];
+ newCategories[categoryIndex].items = newCategories[categoryIndex].items.filter((_, i) => i !== itemIndex);
+ setCategories(newCategories);
+ };
+
+ return (
+ <div className="space-y-6">
+ <div className="flex justify-between items-center">
+ <div className="flex items-center space-x-2">
+ <Button
+ variant={isSettingsView ? "default" : "outline"}
+ onClick={() => setIsSettingsView(true)}
+ >
+ <Settings className="w-4 h-4 mr-2" />
+ Category Settings
+ </Button>
+ <Button
+ variant={!isSettingsView ? "default" : "outline"}
+ onClick={() => setIsSettingsView(false)}
+ >
+ FAQ Management
+ </Button>
+ </div>
+ <Button onClick={() => setShowSaveDialog(true)}>
+ <Save className="w-4 h-4 mr-2" />
+ Save Changes
+ </Button>
+ </div>
+
+ {isSettingsView ? (
+ <div className="grid gap-4">
+ {categories.map((category, index) => (
+ <Card key={index} className="p-4">
+ <div className="flex space-x-4 items-center">
+ <Input
+ value={category.label}
+ onChange={(e) => updateCategory(index, e.target.value)}
+ className="flex-1"
+ placeholder="Category Name"
+ />
+ <Button variant="destructive" size="icon" onClick={() => removeCategory(index)}>
+ <Trash className="w-4 h-4" />
+ </Button>
+ </div>
+ </Card>
+ ))}
+ <Button onClick={addCategory} variant="outline" className="w-full">
+ <Plus className="w-4 h-4 mr-2" />
+ Add Category
+ </Button>
+ </div>
+ ) : (
+ <Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
+ <TabsList>
+ {categories.map((category) => (
+ <TabsTrigger key={category.label} value={category.label}>
+ {category.label}
+ </TabsTrigger>
+ ))}
+ </TabsList>
+
+ {categories.map((category, categoryIndex) => (
+ <TabsContent key={category.label} value={category.label} className="space-y-4">
+ {category.items.map((item, itemIndex) => (
+ <Card key={itemIndex}>
+ <CardContent className="space-y-2 pt-6">
+ <div className="flex justify-between items-start">
+ <Input
+ value={item.title}
+ onChange={(e) => updateItem(categoryIndex, itemIndex, 'title', e.target.value)}
+ className="flex-1 mr-2"
+ placeholder="Question"
+ />
+ <Button variant="destructive" size="icon" onClick={() => removeItem(categoryIndex, itemIndex)}>
+ <Trash className="w-4 h-4" />
+ </Button>
+ </div>
+ <Textarea
+ value={item.content.join('\n')}
+ onChange={(e) => updateItem(categoryIndex, itemIndex, 'content', e.target.value)}
+ placeholder="Answer (separate lines with Enter)"
+ rows={3}
+ />
+ </CardContent>
+ </Card>
+ ))}
+ <Button variant="outline" onClick={() => addItem(categoryIndex)} className="w-full">
+ <Plus className="w-4 h-4 mr-2" />
+ Add FAQ Item
+ </Button>
+ </TabsContent>
+ ))}
+ </Tabs>
+ )}
+
+ <Dialog open={showSaveDialog} onOpenChange={setShowSaveDialog}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Save Changes</DialogTitle>
+ <DialogDescription>
+ Are you sure you want to save the changes? This will update the FAQ data.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter>
+ <Button variant="outline" onClick={() => setShowSaveDialog(false)}>
+ Cancel
+ </Button>
+ <Button onClick={handleSave}>
+ Save
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </div>
+ );
+}
\ No newline at end of file diff --git a/components/form-data/form-data-table-columns.tsx b/components/form-data/form-data-table-columns.tsx new file mode 100644 index 00000000..d44616f8 --- /dev/null +++ b/components/form-data/form-data-table-columns.tsx @@ -0,0 +1,138 @@ +import type { ColumnDef, Row } from "@tanstack/react-table" +import { ClientDataTableColumnHeaderSimple } from "../client-data-table/data-table-column-simple-header" +import { Button } from "@/components/ui/button" +import { Ellipsis } from "lucide-react" +import { formatDate } from "@/lib/utils" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +/** row 액션 관련 타입 */ +export interface DataTableRowAction<TData> { + row: Row<TData> + type: "open" | "edit" | "update" +} + +/** 컬럼 타입 (필요에 따라 확장) */ +export type ColumnType = "STRING" | "NUMBER" | "LIST" + + +export interface DataTableColumnJSON { + key: string + /** 실제 Excel 등에서 구분용으로 쓰이는 label (고정) */ + label: string + + /** UI 표시용 label (예: 단위를 함께 표시) */ + displayLabel?: string + + type: ColumnType + options?: string[] + uom?: string +} +/** + * getColumns 함수에 필요한 props + * - TData: 테이블에 표시할 행(Row)의 타입 + */ +interface GetColumnsProps<TData> { + columnsJSON: DataTableColumnJSON[] + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<TData> | null>> +} + +/** + * getColumns 함수 + * 1) columnsJSON 배열을 순회하면서 accessorKey / header / cell 등을 설정 + * 2) 마지막에 "Action" 칼럼(예: update 버튼) 추가 + */ +export function getColumns<TData extends object>({ + columnsJSON, + setRowAction, +}: GetColumnsProps<TData>): ColumnDef<TData>[] { + + // (1) 기본 컬럼들 + const baseColumns: ColumnDef<TData>[] = columnsJSON.map((col) => ({ + accessorKey: col.key, + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple + column={column} + title={col.displayLabel || col.label} + /> + ), + + meta: { + excelHeader: col.label, + minWidth: 80, + paddingFactor: 1.2, + maxWidth: col.key ==="tagNumber"?120:150, + }, + // (2) 실제 셀(cell) 렌더링: type에 따라 분기 가능 + cell: ({ row }) => { + const cellValue = row.getValue(col.key) + + // 데이터 타입별 처리 + switch (col.type) { + case "NUMBER": + // 예: number인 경우 콤마 등 표시 + return <div>{cellValue ? Number(cellValue).toLocaleString() : ""}</div> + + // case "date": + // // 예: 날짜 포맷팅 + // // 실제론 dayjs / date-fns 등으로 포맷 + // if (!cellValue) return <div></div> + // const dateString = cellValue as string + // if (!dateString) return null + // return formatDate(new Date(dateString)) + + case "LIST": + // 예: select인 경우 label만 표시 + return <div>{String(cellValue ?? "")}</div> + + case "STRING": + default: + return <div>{String(cellValue ?? "")}</div> + } + }, + })) + + // (3) 액션 칼럼 - update 버튼 예시 + const actionColumn: ColumnDef<TData> = { + id: "update", + header: "", + cell: ({ row }) => ( + <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> + </DropdownMenuContent> + </DropdownMenu> + ), + size:40, + meta:{ + maxWidth:40 + }, + enablePinning: true, + } + + // (4) 최종 반환 + return [...baseColumns, actionColumn] +}
\ No newline at end of file diff --git a/components/form-data/form-data-table.tsx b/components/form-data/form-data-table.tsx new file mode 100644 index 00000000..14fff12e --- /dev/null +++ b/components/form-data/form-data-table.tsx @@ -0,0 +1,545 @@ +"use client" + +import * as React from "react" +import { useParams } from "next/navigation" +import { useTranslation } from "@/i18n/client" + +import { ClientDataTable } from "../client-data-table/data-table" +import { + getColumns, + DataTableRowAction, + DataTableColumnJSON, + ColumnType, +} from "./form-data-table-columns" + +import type { DataTableAdvancedFilterField } from "@/types/table" +import { Button } from "../ui/button" +import { Download, Loader, Save, Upload } from "lucide-react" +import { toast } from "sonner" +import { syncMissingTags, updateFormDataInDB } from "@/lib/forms/services" +import { UpdateTagSheet } from "./update-form-sheet" + +import ExcelJS from "exceljs" +import { saveAs } from "file-saver" + +interface GenericData { + [key: string]: any +} + +export interface DynamicTableProps { + dataJSON: GenericData[] + columnsJSON: DataTableColumnJSON[] + contractItemId: number + formCode: string +} + +export default function DynamicTable({ + dataJSON, + columnsJSON, + contractItemId, + formCode, +}: DynamicTableProps) { + const params = useParams() + const lng = (params?.lng as string) || "ko" + const { t } = useTranslation(lng, "translation") + + const [rowAction, setRowAction] = React.useState<DataTableRowAction<GenericData> | null>(null) + const [tableData, setTableData] = React.useState<GenericData[]>(() => dataJSON) + const [isPending, setIsPending] = React.useState(false) + const [isSaving, setIsSaving] = React.useState(false) + + // Reference to the table instance + const tableRef = React.useRef(null) + + const columns = React.useMemo( + () => getColumns<GenericData>({ columnsJSON, setRowAction }), + [columnsJSON, setRowAction] + ) + + function mapColumnTypeToAdvancedFilterType( + columnType: ColumnType + ): DataTableAdvancedFilterField<GenericData>["type"] { + switch (columnType) { + case "STRING": + return "text" + case "NUMBER": + return "number" + case "LIST": + // "select"로 하셔도 되고 "multi-select"로 하셔도 됩니다. + return "select" + // 그 외 다른 타입들도 적절히 추가 매핑 + default: + // 예: 못 매핑한 경우 기본적으로 "text" 적용 + return "text" + } + } + + const advancedFilterFields = React.useMemo<DataTableAdvancedFilterField<GenericData>[]>( + () => { + return columnsJSON.map((col) => ({ + id: col.key, + label: col.label, + type: mapColumnTypeToAdvancedFilterType(col.type), + options: + col.type === "LIST" + ? col.options?.map((v) => ({ label: v, value: v })) + : undefined, + })) + }, + [columnsJSON] + ) + + // 1) 태그 불러오기 (기존) + async function handleSyncTags() { + try { + setIsPending(true) + const result = await syncMissingTags(contractItemId, formCode) + + // Prepare the toast messages based on what changed + const changes = [] + if (result.createdCount > 0) changes.push(`${result.createdCount}건 태그 생성`) + if (result.updatedCount > 0) changes.push(`${result.updatedCount}건 태그 업데이트`) + if (result.deletedCount > 0) changes.push(`${result.deletedCount}건 태그 삭제`) + + if (changes.length > 0) { + // If any changes were made, show success message and reload + toast.success(`동기화 완료: ${changes.join(', ')}`) + location.reload() + } else { + // If no changes were made, show an info message + toast.info("변경사항이 없습니다. 모든 태그가 최신 상태입니다.") + } + } catch (err) { + console.error(err) + toast.error("태그 동기화 중 에러가 발생했습니다.") + } finally { + setIsPending(false) + } + } + // 2) Excel Import (새로운 기능) + async function handleImportExcel(e: React.ChangeEvent<HTMLInputElement>) { + const file = e.target.files?.[0] + if (!file) return + + try { + setIsPending(true) + + // 기존 테이블 데이터의 tagNumber 목록 (이미 있는 태그) + const existingTagNumbers = new Set(tableData.map((d) => d.tagNumber)) + + const workbook = new ExcelJS.Workbook() + const arrayBuffer = await file.arrayBuffer() + await workbook.xlsx.load(arrayBuffer) + + const worksheet = workbook.worksheets[0] + + // (A) 헤더 파싱 (Error 열 추가 전에 먼저 체크) + const headerRow = worksheet.getRow(1) + const headerRowValues = headerRow.values as ExcelJS.CellValue[] + + // 디버깅용 로그 + console.log("원본 헤더 값:", headerRowValues) + + // Excel의 헤더와 columnsJSON의 label 매핑 생성 + // Excel의 행은 1부터 시작하므로 headerRowValues[0]은 undefined + const headerToIndexMap = new Map<string, number>() + for (let i = 1; i < headerRowValues.length; i++) { + const headerValue = String(headerRowValues[i] || "").trim() + if (headerValue) { + headerToIndexMap.set(headerValue, i) + } + } + + // (B) 헤더 검사 + let headerErrorMessage = "" + + // (1) "columnsJSON에 있는데 엑셀에 없는" 라벨 + columnsJSON.forEach((col) => { + const label = col.label + if (!headerToIndexMap.has(label)) { + headerErrorMessage += `Column "${label}" is missing. ` + } + }) + + // (2) "엑셀에는 있는데 columnsJSON에 없는" 라벨 검사 + headerToIndexMap.forEach((index, headerLabel) => { + const found = columnsJSON.some((col) => col.label === headerLabel) + if (!found) { + headerErrorMessage += `Unexpected column "${headerLabel}" found in Excel. ` + } + }) + + // (C) 이제 Error 열 추가 + const lastColIndex = worksheet.columnCount + 1 + worksheet.getRow(1).getCell(lastColIndex).value = "Error" + + // 헤더 에러가 있으면 기록 후 다운로드하고 중단 + if (headerErrorMessage) { + headerRow.getCell(lastColIndex).value = headerErrorMessage.trim() + + const outBuffer = await workbook.xlsx.writeBuffer() + saveAs(new Blob([outBuffer]), `import-check-result_${Date.now()}.xlsx`) + + toast.error(`Header mismatch found. Please check downloaded file.`) + return + } + + // -- 여기까지 왔다면, 헤더는 문제 없음 -- + + // 컬럼 키-인덱스 매핑 생성 (실제 데이터 행 파싱용) + // columnsJSON의 key와 Excel 열 인덱스 간의 매핑 + const keyToIndexMap = new Map<string, number>() + columnsJSON.forEach((col) => { + const index = headerToIndexMap.get(col.label) + if (index !== undefined) { + keyToIndexMap.set(col.key, index) + } + }) + + // 데이터 파싱 + const importedData: GenericData[] = [] + const lastRowNumber = worksheet.lastRow?.number || 1 + let errorCount = 0 + + // 실제 데이터 행 파싱 + for (let rowNum = 2; rowNum <= lastRowNumber; rowNum++) { + const row = worksheet.getRow(rowNum) + const rowValues = row.values as ExcelJS.CellValue[] + if (!rowValues || rowValues.length <= 1) continue // 빈 행 스킵 + + let errorMessage = "" + const rowObj: Record<string, any> = {} + + // 각 열에 대해 처리 + columnsJSON.forEach((col) => { + const colIndex = keyToIndexMap.get(col.key) + if (colIndex === undefined) return + + const cellValue = rowValues[colIndex] ?? "" + let stringVal = String(cellValue).trim() + + // 타입별 검사 + switch (col.type) { + case "STRING": + if (!stringVal && col.key === "tagNumber") { + errorMessage += `[${col.label}] is empty. ` + } + rowObj[col.key] = stringVal + break + + case "NUMBER": + if (stringVal) { + const num = parseFloat(stringVal) + if (isNaN(num)) { + errorMessage += `[${col.label}] '${stringVal}' is not a valid number. ` + } else { + rowObj[col.key] = num + } + } else { + rowObj[col.key] = null + } + break + + case "LIST": + if (stringVal && col.options && !col.options.includes(stringVal)) { + errorMessage += `[${col.label}] '${stringVal}' not in ${col.options.join(", ")}. ` + } + rowObj[col.key] = stringVal + break + + default: + rowObj[col.key] = stringVal + break + } + }) + + // tagNumber 검사 + const tagNum = rowObj["tagNumber"] + if (!tagNum) { + errorMessage += `No tagNumber found. ` + } else if (!existingTagNumbers.has(tagNum)) { + errorMessage += `TagNumber '${tagNum}' is not in current data. ` + } + + if (errorMessage) { + row.getCell(lastColIndex).value = errorMessage.trim() + errorCount++ + } else { + importedData.push(rowObj) + } + } + + // 에러가 있으면 재다운로드 후 import 중단 + if (errorCount > 0) { + const outBuffer = await workbook.xlsx.writeBuffer() + saveAs(new Blob([outBuffer]), `import-check-result_${Date.now()}.xlsx`) + toast.error(`There are ${errorCount} error row(s). Please check downloaded file.`) + return + } + + // 에러 없으니 tableData 병합 + setTableData((prev) => { + const newDataMap = new Map<string, GenericData>() + + // 기존 데이터를 맵에 추가 + prev.forEach((item) => { + if (item.tagNumber) { + newDataMap.set(item.tagNumber, { ...item }) + } + }) + + // 임포트 데이터로 기존 데이터 업데이트 + importedData.forEach((item) => { + const tag = item.tagNumber + if (!tag) return + const oldItem = newDataMap.get(tag) || {} + newDataMap.set(tag, { ...oldItem, ...item }) + }) + + return Array.from(newDataMap.values()) + }) + + toast.success(`Imported ${importedData.length} rows successfully.`) + } catch (err) { + console.error("Excel import error:", err) + toast.error("Excel import failed.") + } finally { + setIsPending(false) + e.target.value = "" + } + } + + // 3) Save -> 서버에 전체 tableData를 저장 + async function handleSave() { + try { + setIsSaving(true) + + // 유효성 검사 + const invalidData = tableData.filter(item => !item.tagNumber?.trim()) + if (invalidData.length > 0) { + toast.error(`태그 번호가 없는 항목이 ${invalidData.length}개 있습니다.`) + return + } + + // 서버 액션 호출 + const result = await updateFormDataInDB(formCode, contractItemId, tableData) + + if (result.success) { + toast.success(result.message) + } else { + toast.error(result.message) + } + } catch (err) { + console.error("Save error:", err) + toast.error("데이터 저장 중 오류가 발생했습니다.") + } finally { + setIsSaving(false) + } + } + + // 4) NEW: Excel Export with data validation for select columns using a hidden validation sheet + async function handleExportExcel() { + try { + setIsPending(true) + + // Create a new workbook + const workbook = new ExcelJS.Workbook() + + // 데이터 시트 생성 + const worksheet = workbook.addWorksheet("Data") + + // 유효성 검사용 숨김 시트 생성 + const validationSheet = workbook.addWorksheet("ValidationData") + validationSheet.state = 'hidden' // 시트 숨김 처리 + + // 1. 유효성 검사 시트에 select 옵션 추가 + const selectColumns = columnsJSON.filter(col => + col.type === "LIST" && col.options && col.options.length > 0 + ) + + // 유효성 검사 범위 저장 맵 (컬럼 키 -> 유효성 검사 범위) + const validationRanges = new Map<string, string>() + + selectColumns.forEach((col, idx) => { + const colIndex = idx + 1 + const colLetter = validationSheet.getColumn(colIndex).letter + + // 헤더 추가 (컬럼 레이블) + validationSheet.getCell(`${colLetter}1`).value = col.label + + // 옵션 추가 + if (col.options) { + col.options.forEach((option, optIdx) => { + validationSheet.getCell(`${colLetter}${optIdx + 2}`).value = option + }) + + // 유효성 검사 범위 저장 (ValidationData!$A$2:$A$4 형식) + validationRanges.set( + col.key, + `ValidationData!${colLetter}$2:${colLetter}${col.options.length + 1}` + ) + } + }) + + // 2. 데이터 시트에 헤더 추가 + const headers = columnsJSON.map(col => col.label) + worksheet.addRow(headers) + + // 헤더 스타일 적용 + const headerRow = worksheet.getRow(1) + headerRow.font = { bold: true } + headerRow.alignment = { horizontal: 'center' } + headerRow.eachCell((cell) => { + cell.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFCCCCCC' } + } + }) + + // 3. 데이터 행 추가 + tableData.forEach(row => { + const rowValues = columnsJSON.map(col => { + const value = row[col.key] + return value !== undefined && value !== null ? value : '' + }) + worksheet.addRow(rowValues) + }) + + // 4. 데이터 유효성 검사 적용 + const maxRows = 5000 // 데이터 유효성 검사를 적용할 최대 행 수 + + columnsJSON.forEach((col, idx) => { + if (col.type === "LIST" && validationRanges.has(col.key)) { + const colLetter = worksheet.getColumn(idx + 1).letter + const validationRange = validationRanges.get(col.key)! + + // 유효성 검사 정의 + const validation = { + type: 'list' as const, + allowBlank: true, + formulae: [validationRange], + showErrorMessage: true, + errorStyle: 'warning' as const, + errorTitle: '유효하지 않은 값', + error: '목록에서 값을 선택해주세요.' + } + + // 모든 데이터 행에 유효성 검사 적용 (최대 maxRows까지) + for (let rowIdx = 2; rowIdx <= Math.min(tableData.length + 1, maxRows); rowIdx++) { + worksheet.getCell(`${colLetter}${rowIdx}`).dataValidation = validation + } + + // 빈 행에도 적용 (최대 maxRows까지) + if (tableData.length + 1 < maxRows) { + for (let rowIdx = tableData.length + 2; rowIdx <= maxRows; rowIdx++) { + worksheet.getCell(`${colLetter}${rowIdx}`).dataValidation = validation + } + } + } + }) + + // 5. 컬럼 너비 자동 조정 + columnsJSON.forEach((col, idx) => { + const column = worksheet.getColumn(idx + 1) + + // 최적 너비 계산 + let maxLength = col.label.length + tableData.forEach(row => { + const value = row[col.key] + if (value !== undefined && value !== null) { + const valueLength = String(value).length + if (valueLength > maxLength) { + maxLength = valueLength + } + } + }) + + // 너비 설정 (최소 10, 최대 50) + column.width = Math.min(Math.max(maxLength + 2, 10), 50) + }) + + // 6. 파일 다운로드 + const buffer = await workbook.xlsx.writeBuffer() + saveAs(new Blob([buffer]), `${formCode}_data_${new Date().toISOString().slice(0, 10)}.xlsx`) + + toast.success("Excel 내보내기 완료!") + } catch (err) { + console.error("Excel export error:", err) + toast.error("Excel 내보내기 실패.") + } finally { + setIsPending(false) + } + } + + return ( + <> + <ClientDataTable + data={tableData} + columns={columns} + advancedFilterFields={advancedFilterFields} + // tableRef={tableRef} + > + {/* 버튼 그룹 */} + <div className="flex items-center gap-2"> + {/* 태그 불러오기 버튼 */} + <Button variant="default" size="sm" onClick={handleSyncTags} disabled={isPending}> + {isPending && <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />} + Sync Tags + </Button> + + {/* IMPORT 버튼 (파일 선택) */} + <Button asChild variant="outline" size="sm" disabled={isPending}> + <label> + <Upload className="size-4" /> + Import + <input + type="file" + accept=".xlsx,.xls" + onChange={handleImportExcel} + style={{ display: "none" }} + /> + </label> + </Button> + + {/* EXPORT 버튼 (새로 추가) */} + <Button variant="outline" size="sm" onClick={handleExportExcel} disabled={isPending}> + <Download className="mr-2 size-4" /> + Export Template + </Button> + + {/* SAVE 버튼 */} + <Button + variant="outline" + size="sm" + onClick={handleSave} + disabled={isPending || isSaving} + > + {isSaving ? ( + <> + <Loader className="mr-2 size-4 animate-spin" /> + 저장 중... + </> + ) : ( + <> + <Save className="mr-2 size-4" /> + Save + </> + )} + </Button> + </div> + </ClientDataTable> + + <UpdateTagSheet + open={rowAction?.type === "update"} + onOpenChange={(open) => { + if (!open) setRowAction(null) + }} + columns={columnsJSON} + rowData={rowAction?.row.original ?? null} + formCode={formCode} + contractItemId={contractItemId} + /> + </> + ) +}
\ No newline at end of file diff --git a/components/form-data/update-form-sheet.tsx b/components/form-data/update-form-sheet.tsx new file mode 100644 index 00000000..d5f7d21b --- /dev/null +++ b/components/form-data/update-form-sheet.tsx @@ -0,0 +1,239 @@ +"use client" + +import * as React from "react" +import { z } from "zod" +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm } from "react-hook-form" +import { Loader } from "lucide-react" +import { toast } from "sonner" + +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { + Form, + FormField, + FormItem, + FormLabel, + FormControl, + FormMessage, +} from "@/components/ui/form" +import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@/components/ui/select" + +import { DataTableColumnJSON } from "./form-data-table-columns" +import { updateFormDataInDB } from "@/lib/forms/services" + +interface UpdateTagSheetProps extends React.ComponentPropsWithoutRef<typeof Sheet> { + open: boolean + onOpenChange: (open: boolean) => void + columns: DataTableColumnJSON[] + rowData: Record<string, any> | null + formCode: string + contractItemId: number + /** 업데이트 성공 시 호출될 콜백 */ + onUpdateSuccess?: (updatedValues: Record<string, any>) => void +} + +export function UpdateTagSheet({ + open, + onOpenChange, + columns, + rowData, + formCode, + contractItemId, + onUpdateSuccess, + ...props +}: UpdateTagSheetProps) { + const [isPending, startTransition] = React.useTransition() + + // 1) zod 스키마 + const dynamicSchema = React.useMemo(() => { + const shape: Record<string, z.ZodType<any>> = {} + for (const col of columns) { + if (col.type === "NUMBER") { + shape[col.key] = z + .union([z.coerce.number(), z.nan()]) + .transform((val) => (isNaN(val) ? undefined : val)) + .optional() + } else { + shape[col.key] = z.string().optional() + } + } + return z.object(shape) + }, [columns]) + + // 2) form init + const form = useForm({ + resolver: zodResolver(dynamicSchema), + defaultValues: React.useMemo(() => { + if (!rowData) return {} + const defaults: Record<string, any> = {} + for (const col of columns) { + defaults[col.key] = rowData[col.key] ?? "" + } + return defaults + }, [rowData, columns]), + }) + + React.useEffect(() => { + if (!rowData) { + form.reset({}) + return + } + const defaults: Record<string, any> = {} + for (const col of columns) { + defaults[col.key] = rowData[col.key] ?? "" + } + form.reset(defaults) + }, [rowData, columns, form]) + + async function onSubmit(values: Record<string, any>) { + startTransition(async () => { + const { success, message } = await updateFormDataInDB(formCode, contractItemId, values) + if (!success) { + toast.error(message) + return + } + toast.success("Updated successfully!") + + // (A) 수정된 값(폼 데이터)을 부모 콜백에 전달 + onUpdateSuccess?.({ + // rowData(원본)와 values를 합쳐서 최종 "수정된 row"를 만든다. + // tagNumber는 기존 그대로 + ...rowData, + ...values, + tagNumber: rowData?.tagNumber, + }) + + onOpenChange(false) + }) + } + + return ( + <Sheet open={open} onOpenChange={onOpenChange} {...props}> + <SheetContent className="sm:max-w-xl md:max-w-3xl lg:max-w-4xl xl:max-w-5xl flex flex-col"> + <SheetHeader className="text-left"> + <SheetTitle>Update Row</SheetTitle> + <SheetDescription> + Modify the fields below and save changes + </SheetDescription> + </SheetHeader> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4"> + <div className="overflow-y-auto max-h-[80vh] flex-1 pr-4 -mr-4"> + <div className="flex flex-col gap-4 pt-2"> + {columns.map((col) => { + const isTagNumberField = col.key === "tagNumber" || col.key === "tagDescription" + return ( + <FormField + key={col.key} + control={form.control} + name={col.key} + render={({ field }) => { + switch (col.type) { + case "NUMBER": + return ( + <FormItem> + <FormLabel>{col.displayLabel}</FormLabel> + <FormControl> + <Input + type="number" + readOnly={isTagNumberField} + onChange={(e) => { + const num = parseFloat(e.target.value) + field.onChange(isNaN(num) ? "" : num) + }} + value={field.value ?? ""} + /> + </FormControl> + <FormMessage /> + </FormItem> + ) + + case "LIST": + return ( + <FormItem> + <FormLabel>{col.label}</FormLabel> + <Select + disabled={isTagNumberField} + value={field.value ?? ""} + onValueChange={(val) => field.onChange(val)} + > + <SelectTrigger> + <SelectValue placeholder="Select an option" /> + </SelectTrigger> + <SelectContent> + {col.options?.map((opt) => ( + <SelectItem key={opt} value={opt}> + {opt} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + ) + + // case "date": + // return ( + // <FormItem> + // <FormLabel>{col.label}</FormLabel> + // <FormControl> + // <Input + // type="date" + // readOnly={isTagNumberField} + // onChange={field.onChange} + // value={field.value ?? ""} + // /> + // </FormControl> + // <FormMessage /> + // </FormItem> + // ) + + case "STRING": + default: + return ( + <FormItem> + <FormLabel>{col.label}</FormLabel> + <FormControl> + <Input readOnly={isTagNumberField} {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + ) + } + }} + /> + ) + })} + + </div> + </div> + + <SheetFooter className="gap-2 pt-2"> + <SheetClose asChild> + <Button type="button" variant="outline"> + Cancel + </Button> + </SheetClose> + + <Button type="submit" disabled={isPending}> + {isPending && <Loader className="mr-2 h-4 w-4 animate-spin" />} + Save + </Button> + </SheetFooter> + </form> + </Form> + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file diff --git a/components/kbd.tsx b/components/kbd.tsx new file mode 100644 index 00000000..86af5c6f --- /dev/null +++ b/components/kbd.tsx @@ -0,0 +1,54 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const kbdVariants = cva( + "select-none rounded border px-1.5 py-px font-mono text-[0.7rem] font-normal shadow-sm disabled:opacity-50", + { + variants: { + variant: { + default: "bg-accent text-accent-foreground", + outline: "bg-background text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +export interface KbdProps + extends React.ComponentPropsWithoutRef<"kbd">, + VariantProps<typeof kbdVariants> { + /** + * The title of the `abbr` element inside the `kbd` element. + * @default undefined + * @type string | undefined + * @example title="Command" + */ + abbrTitle?: string +} + +const Kbd = React.forwardRef<HTMLUnknownElement, KbdProps>( + ({ abbrTitle, children, className, variant, ...props }, ref) => { + return ( + <kbd + className={cn(kbdVariants({ variant, className }))} + ref={ref} + {...props} + > + {abbrTitle ? ( + <abbr title={abbrTitle} className="no-underline"> + {children} + </abbr> + ) : ( + children + )} + </kbd> + ) + } +) +Kbd.displayName = "Kbd" + +export { Kbd } diff --git a/components/layout/Footer.tsx b/components/layout/Footer.tsx new file mode 100644 index 00000000..f7d6906d --- /dev/null +++ b/components/layout/Footer.tsx @@ -0,0 +1,16 @@ +import { siteConfig } from "@/config/site" + +export function SiteFooter() { + return ( + <footer className="border-grid border-t py-6 md:px-8 md:py-0"> + <div className="container-wrapper"> + <div className="container py-4"> + <div className="text-balance text-center text-sm leading-loose text-muted-foreground md:text-left"> + Built by{" "} + + </div> + </div> + </div> + </footer> + ) +} diff --git a/components/layout/Header.tsx b/components/layout/Header.tsx new file mode 100644 index 00000000..1b6c45bb --- /dev/null +++ b/components/layout/Header.tsx @@ -0,0 +1,225 @@ +"use client"; + +import * as React from "react"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + NavigationMenu, + NavigationMenuContent, + NavigationMenuItem, + NavigationMenuLink, + NavigationMenuList, + NavigationMenuTrigger, + navigationMenuTriggerStyle, +} from "@/components/ui/navigation-menu"; +import { SearchIcon, BellIcon, Menu } from "lucide-react"; +import { useParams, usePathname } from "next/navigation"; +import { cn } from "@/lib/utils"; +import Image from "next/image"; +import { mainNav, additionalNav, MenuSection, mainNavVendor, additionalNavVendor } from "@/config/menuConfig"; // 메뉴 구성 임포트 +import { MobileMenu } from "./MobileMenu"; +import { CommandMenu } from "./command-menu"; +import { useSession, signOut } from "next-auth/react"; + + +export function Header() { + const params = useParams(); + const lng = params.lng as string; + const pathname = usePathname(); + const { data: session } = useSession(); + + const userName = session?.user?.name || ""; // 없을 수도 있으니 안전하게 처리 + const initials = userName + .split(" ") + .map((word) => word[0]?.toUpperCase()) + .join(""); + + + const [isMobileMenuOpen, setIsMobileMenuOpen] = React.useState(false); // 모바일 메뉴 상태 + + const toggleMobileMenu = () => { + setIsMobileMenuOpen(!isMobileMenuOpen); + }; + + const isPartnerRoute = pathname.includes("/partners"); + + const main = isPartnerRoute ? mainNavVendor : mainNav; + const additional = isPartnerRoute ? additionalNavVendor : additionalNav; + + const basePath = `/${lng}${isPartnerRoute ? "/partners" : "/evcp"}`; + + return ( + <> + <header className="border-grid sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"> + <div className="container-wrapper"> + <div className="container flex h-14 items-center"> + + <Button + onClick={toggleMobileMenu} + variant="ghost" + className="-ml-2 mr-2 h-8 w-8 px-0 text-base hover:bg-transparent focus-visible:bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 md:hidden" + > + <svg + xmlns="http://www.w3.org/2000/svg" + fill="none" + viewBox="0 0 24 24" + strokeWidth="1.5" + stroke="currentColor" + className="!size-6" + > + <path + strokeLinecap="round" + strokeLinejoin="round" + d="M3.75 9h16.5m-16.5 6.75h16.5" + /> + </svg> + <span className="sr-only">Toggle Menu</span> + </Button> + + + <div className="mr-4 hidden md:flex"> + + {/* 로고 영역 */} + <div className="mr-4 flex items-center gap-2 lg:mr-6"> + <Link href={`/${lng}/evcp`} className="flex items-center gap-2"> + <Image + className="dark:invert" + src="/images/vercel.svg" + alt="EVCP Logo" + width={20} + height={20} + /> + <span className="hidden font-bold lg:inline-block">eVCP</span> + </Link> + </div> + {/* 데스크탑 네비게이션 메뉴 */} + <NavigationMenu className="flex items-center gap-4 text-sm xl:gap-6"> + <NavigationMenuList> + {main.map((section: MenuSection) => ( + <NavigationMenuItem key={section.title}> + <NavigationMenuTrigger>{section.title}</NavigationMenuTrigger> + <NavigationMenuContent> + <ul className="grid w-[400px] gap-3 p-4 md:w-[500px] md:grid-cols-2 lg:w-[600px] "> + {section.items.map((item) => ( + <ListItem + key={item.title} + title={item.title} + href={`/${lng}${item.href}`} + > + {item.description} + </ListItem> + ))} + </ul> + </NavigationMenuContent> + </NavigationMenuItem> + ))} + + + {/* 추가 네비게이션 항목 */} + {additional.map((item) => ( + <NavigationMenuItem key={item.title}> + <Link href={`/${lng}${item.href}`} legacyBehavior passHref> + <NavigationMenuLink className={navigationMenuTriggerStyle()}> + {item.title} + </NavigationMenuLink> + </Link> + </NavigationMenuItem> + ))} + </NavigationMenuList> + </NavigationMenu> + + + </div> + + + {/* 우측 영역 */} + <div className="flex flex-1 items-center justify-between gap-2 md:justify-end"> + + <CommandMenu /> + + + <div className="flex items-center space-x-4"> + {/* 알림 버튼 */} + <Button variant="ghost" className="relative p-2" aria-label="Notifications"> + <BellIcon className="h-5 w-5" /> + {/* 알림 뱃지 예시 */} + <span className="absolute -top-1 -right-1 inline-flex h-2 w-2 rounded-full bg-red-500"></span> + </Button> + + {/* 사용자 메뉴 (DropdownMenu) */} + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Avatar className="cursor-pointer"> + <AvatarImage src={`/profiles/${session?.user?.image}`||"/user-avatar.jpg"} alt="User Avatar" /> + <AvatarFallback> + {initials || "?"} + </AvatarFallback> + </Avatar> + </DropdownMenuTrigger> + <DropdownMenuContent className="w-48" align="end"> + <DropdownMenuLabel>My Account</DropdownMenuLabel> + <DropdownMenuSeparator /> + {/* <DropdownMenuItem asChild> + <Link href={`${basePath}/profile`}>Profile</Link> + </DropdownMenuItem> */} + <DropdownMenuItem asChild> + <Link href={`${basePath}/settings`}>Settings</Link> + </DropdownMenuItem> + <DropdownMenuSeparator /> + <DropdownMenuItem onSelect={() => signOut({ callbackUrl: `/${lng}/login` })}> + Logout + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + + {/* 모바일 햄버거 메뉴 버튼 */} + + </div> + </div> + </div> + </div> + + {/* 모바일 메뉴 */} + {isMobileMenuOpen && <MobileMenu lng={lng} onClose={toggleMobileMenu} />} + </header> + </> + ); +} + +const ListItem = React.forwardRef< + React.ElementRef<"a">, + React.ComponentPropsWithoutRef<"a"> +>(({ className, title, children, ...props }, ref) => { + return ( + <li> + <NavigationMenuLink asChild> + <a + ref={ref} + className={cn( + "block select-none space-y-1 rounded-md p-3 leading-none no-underline outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground", + className + )} + {...props} + > + <div className="text-sm font-medium leading-none">{title}</div> + {children && ( + <p className="line-clamp-2 text-sm leading-snug text-muted-foreground"> + {children} + </p> + )} + </a> + </NavigationMenuLink> + </li> + ); +}); +ListItem.displayName = "ListItem";
\ No newline at end of file diff --git a/components/layout/MobileMenu.tsx b/components/layout/MobileMenu.tsx new file mode 100644 index 00000000..d2e6b927 --- /dev/null +++ b/components/layout/MobileMenu.tsx @@ -0,0 +1,88 @@ +// components/MobileMenu.tsx + +"use client"; + +import * as React from "react"; +import Link from "next/link"; +import { useRouter,usePathname } from "next/navigation"; +import { MenuSection, mainNav, additionalNav, MenuItem, mainNavVendor, additionalNavVendor } from "@/config/menuConfig"; +import { cn } from "@/lib/utils"; +import { Drawer, DrawerContent,DrawerTitle,DrawerTrigger } from "@/components/ui/drawer"; +import { Button } from "@/components/ui/button"; + +interface MobileMenuProps { + lng: string; + onClose: () => void; +} + +export function MobileMenu({ lng, onClose }: MobileMenuProps) { + const router = useRouter(); + + + const handleLinkClick = (href: string) => { + router.push(href); + onClose(); + }; + const pathname = usePathname(); + const isPartnerRoute = pathname.includes("/partners"); + + const main = isPartnerRoute ? mainNavVendor : mainNav; + const additional = isPartnerRoute ? additionalNavVendor : additionalNav; + + return ( + <Drawer open={true} onOpenChange={onClose}> + <DrawerTrigger asChild> + </DrawerTrigger> + <DrawerTitle /> + <DrawerContent className="max-h-[60vh] p-0"> + <div className="overflow-auto p-6"> + + <nav> + <ul className="space-y-4"> + {/* 메인 네비게이션 섹션 */} + {main.map((section: MenuSection) => ( + <li key={section.title}> + <h3 className="text-md font-medium">{section.title}</h3> + <ul className="mt-2 space-y-2"> + {section.items.map((item: MenuItem) => ( + <li key={item.title}> + <Link + href={`/${lng}${item.href}`} + className="text-indigo-600" + onClick={() => handleLinkClick(item.href)} + > + {item.title} + {item.label && ( + <span className="ml-2 rounded-md bg-[#adfa1d] px-1.5 py-0.5 text-xs text-[#000000]"> + {item.label} + </span> + )} + </Link> + {item.description && ( + <p className="text-xs text-gray-500">{item.description}</p> + )} + </li> + ))} + </ul> + </li> + ))} + + {/* 추가 네비게이션 항목 */} + {additional.map((item: MenuItem) => ( + <li key={item.title}> + <Link + href={item.href} + className="block text-sm text-indigo-600" + onClick={() => handleLinkClick(`/${lng}${item.href}`)} + > + {item.title} + </Link> + </li> + ))} + </ul> + </nav> + </div> + </DrawerContent> + </Drawer> + ); +}
\ No newline at end of file diff --git a/components/layout/command-menu.tsx b/components/layout/command-menu.tsx new file mode 100644 index 00000000..5537a042 --- /dev/null +++ b/components/layout/command-menu.tsx @@ -0,0 +1,139 @@ +"use client" + +import * as React from "react" +import { useRouter,usePathname } from "next/navigation" +import { type DialogProps } from "@radix-ui/react-dialog" +import { Circle, File, Laptop, Moon, Sun } from "lucide-react" +import { useTheme } from "next-themes" + +import { MenuSection, mainNav, additionalNav, MenuItem, mainNavVendor, additionalNavVendor } from "@/config/menuConfig"; +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from "@/components/ui/command" +import { DialogTitle } from "@/components/ui/dialog" + +export function CommandMenu({ ...props }: DialogProps) { + const router = useRouter() + const [open, setOpen] = React.useState(false) + const { setTheme } = useTheme() + + React.useEffect(() => { + const down = (e: KeyboardEvent) => { + if ((e.key === "k" && (e.metaKey || e.ctrlKey)) || e.key === "/") { + if ( + (e.target instanceof HTMLElement && e.target.isContentEditable) || + e.target instanceof HTMLInputElement || + e.target instanceof HTMLTextAreaElement || + e.target instanceof HTMLSelectElement + ) { + return + } + + e.preventDefault() + setOpen((open) => !open) + } + } + + document.addEventListener("keydown", down) + return () => document.removeEventListener("keydown", down) + }, []) + + const runCommand = React.useCallback((command: () => unknown) => { + setOpen(false) + command() + }, []) + + +const pathname = usePathname(); +const isPartnerRoute = pathname.includes("/partners"); + + const main = isPartnerRoute ? mainNavVendor : mainNav; + const additional = isPartnerRoute ? additionalNavVendor : additionalNav; + + + return ( + <> + <Button + variant="outline" + className={cn( + "relative h-8 w-full justify-start rounded-[0.5rem] bg-muted/50 text-sm font-normal text-muted-foreground shadow-none sm:pr-12 md:w-40 lg:w-56 xl:w-64" + )} + onClick={() => setOpen(true)} + {...props} + > + <span className="hidden lg:inline-flex">Search Menu...</span> + <span className="inline-flex lg:hidden">Search...</span> + <kbd className="pointer-events-none absolute right-[0.3rem] top-[0.3rem] hidden h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium opacity-100 sm:flex"> + <span className="text-xs">⌘</span>K + </kbd> + </Button> + <CommandDialog open={open} onOpenChange={setOpen}> + <DialogTitle className="sr-only">Search Menu</DialogTitle> + <CommandInput placeholder="Type a command or search..." /> + <CommandList> + <CommandEmpty>No results found.</CommandEmpty> + + {main.map((group) => ( + <CommandGroup key={group.title} heading={group.title}> + {group.items.map((navItem) => ( + <CommandItem + key={navItem.title} + value={navItem.title} + onSelect={() => { + runCommand(() => router.push(navItem.href as string)) + }} + > + <div className="mr-2 flex h-4 w-4 items-center justify-center"> + <Circle className="h-3 w-3" /> + </div> + {navItem.title} + </CommandItem> + ))} + </CommandGroup> + ))} + <CommandGroup heading=""> + {additional + // .filter((navitem) => !navitem.external) + .map((navItem) => ( + <CommandItem + key={navItem.title} + value={navItem.title} + onSelect={() => { + runCommand(() => router.push(navItem.href as string)) + }} + > + <File /> + {navItem.title} + </CommandItem> + ))} + </CommandGroup> + <CommandSeparator /> + + + <CommandGroup heading="Theme"> + <CommandItem onSelect={() => runCommand(() => setTheme("light"))}> + <Sun /> + Light + </CommandItem> + <CommandItem onSelect={() => runCommand(() => setTheme("dark"))}> + <Moon /> + Dark + </CommandItem> + <CommandItem onSelect={() => runCommand(() => setTheme("system"))}> + <Laptop /> + System + </CommandItem> + </CommandGroup> + </CommandList> + </CommandDialog> + </> + ) +} diff --git a/components/layout/createEmotionCashe.ts b/components/layout/createEmotionCashe.ts new file mode 100644 index 00000000..ae8bc3b5 --- /dev/null +++ b/components/layout/createEmotionCashe.ts @@ -0,0 +1,5 @@ +import createCache from '@emotion/cache'; + +export default function createEmotionCache() { + return createCache({ key: 'css' }); +}
\ No newline at end of file diff --git a/components/layout/mode-switcher.tsx b/components/layout/mode-switcher.tsx new file mode 100644 index 00000000..d27b6a73 --- /dev/null +++ b/components/layout/mode-switcher.tsx @@ -0,0 +1,35 @@ +"use client" + +import * as React from "react" +import { MoonIcon, SunIcon } from "lucide-react" +import { useTheme } from "next-themes" + +import { META_THEME_COLORS } from "@/config/site" +import { useMetaColor } from "@/hooks/use-meta-color" +import { Button } from "@/components/ui/button" + +export function ModeSwitcher() { + const { setTheme, resolvedTheme } = useTheme() + const { setMetaColor } = useMetaColor() + + const toggleTheme = React.useCallback(() => { + setTheme(resolvedTheme === "dark" ? "light" : "dark") + setMetaColor( + resolvedTheme === "dark" + ? META_THEME_COLORS.light + : META_THEME_COLORS.dark + ) + }, [resolvedTheme, setTheme, setMetaColor]) + + return ( + <Button + variant="ghost" + className="group/toggle h-8 w-8 px-0" + onClick={toggleTheme} + > + <SunIcon className="hidden [html.dark_&]:block" /> + <MoonIcon className="hidden [html.light_&]:block" /> + <span className="sr-only">Toggle theme</span> + </Button> + ) +} diff --git a/components/layout/providers.tsx b/components/layout/providers.tsx new file mode 100644 index 00000000..1c645531 --- /dev/null +++ b/components/layout/providers.tsx @@ -0,0 +1,38 @@ +"use client" + +import * as React from "react" +import { Provider as JotaiProvider } from "jotai" +import { ThemeProvider as NextThemesProvider } from "next-themes" +import { NuqsAdapter } from "nuqs/adapters/next/app" +import { SessionProvider } from "next-auth/react"; +import { CacheProvider } from '@emotion/react'; + +import { TooltipProvider } from "@/components/ui/tooltip" +import createEmotionCache from './createEmotionCashe'; + + +const cache = createEmotionCache(); + + +export function ThemeProvider({ + children, + ...props +}: React.ComponentProps<typeof NextThemesProvider>) { + return ( + <JotaiProvider> + <CacheProvider value={cache}> + + <NextThemesProvider {...props}> + <TooltipProvider delayDuration={0}> + <NuqsAdapter> + <SessionProvider> + {children} + </SessionProvider> + </NuqsAdapter> + </TooltipProvider> + </NextThemesProvider> + </CacheProvider> + + </JotaiProvider> + ) +} diff --git a/components/layout/sidebar-nav.tsx b/components/layout/sidebar-nav.tsx new file mode 100644 index 00000000..addcfefd --- /dev/null +++ b/components/layout/sidebar-nav.tsx @@ -0,0 +1,44 @@ +"use client" + +import Link from "next/link" +import { usePathname } from "next/navigation" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +interface SidebarNavProps extends React.HTMLAttributes<HTMLElement> { + items: { + href: string + title: string + }[] +} + +export function SidebarNav({ className, items, ...props }: SidebarNavProps) { + const pathname = usePathname() + + return ( + <nav + className={cn( + "flex space-x-2 lg:flex-col lg:space-x-0 lg:space-y-1", + className + )} + {...props} + > + {items.map((item) => ( + <Link + key={item.href} + href={item.href} + className={cn( + buttonVariants({ variant: "ghost" }), + pathname === item.href + ? "bg-muted hover:bg-muted" + : "hover:bg-transparent hover:underline", + "justify-start" + )} + > + {item.title} + </Link> + ))} + </nav> + ) +} diff --git a/components/login/login-form-skeleton.tsx b/components/login/login-form-skeleton.tsx new file mode 100644 index 00000000..c434c4b7 --- /dev/null +++ b/components/login/login-form-skeleton.tsx @@ -0,0 +1,71 @@ +import { Skeleton } from "@/components/ui/skeleton" +import { Button } from "@/components/ui/button" +import { Ship, Loader2, GlobeIcon, ChevronDownIcon } from "lucide-react" +import Link from "next/link" +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +export function LoginFormSkeleton() { + return ( + <div className="container relative flex h-screen flex-col items-center justify-center md:grid lg:max-w-none lg:grid-cols-2 lg:px-0"> + {/* Left Content */} + <div className="flex flex-col w-full h-screen lg:p-2"> + {/* Top bar with Logo + eVCP (left) and "Request Vendor Repository" (right) */} + <div className="flex items-center justify-between"> + <div className="flex items-center space-x-2"> + <Ship className="w-4 h-4" /> + <span className="text-md font-bold">eVCP</span> + </div> + <Link + href="/partners/repository" + className={cn(buttonVariants({ variant: "ghost" }))} + > + Request Vendor Repository + </Link> + </div> + + {/* Content section that occupies remaining space, centered vertically */} + <div className="flex-1 flex items-center justify-center"> + {/* Form container */} + <div className="mx-auto w-full flex flex-col space-y-6 sm:w-[350px]"> + <div className="p-6 md:p-8"> + <div className="flex flex-col gap-6"> + <div className="flex flex-col items-center text-center"> + <Skeleton className="h-8 w-48 mb-2" /> + </div> + <div className="grid gap-2"> + <Skeleton className="h-10 w-full" /> + </div> + <Skeleton className="h-10 w-full" /> + <div className="text-center text-sm mx-auto"> + <Button variant="ghost" className="flex items-center gap-2" disabled> + <GlobeIcon className="h-4 w-4" /> + <Skeleton className="h-4 w-16" /> + <ChevronDownIcon className="h-4 w-4" /> + </Button> + </div> + </div> + </div> + + <div className="text-balance text-center"> + <Skeleton className="h-4 w-[280px] mx-auto" /> + </div> + </div> + </div> + </div> + + {/* Right BG 이미지 영역 */} + <div className="relative hidden h-full flex-col p-10 text-white dark:border-r md:flex"> + <div className="absolute inset-0 bg-zinc-100 animate-pulse" /> + <div className="relative z-20 flex items-center text-lg font-medium"> + {/* Optional top-right content on the image side */} + </div> + <div className="relative z-20 mt-auto"> + <blockquote className="space-y-2"> + <Skeleton className="h-4 w-[250px] bg-white/50" /> + </blockquote> + </div> + </div> + </div> + ) +}
\ No newline at end of file diff --git a/components/login/login-form.tsx b/components/login/login-form.tsx new file mode 100644 index 00000000..2a51f2e2 --- /dev/null +++ b/components/login/login-form.tsx @@ -0,0 +1,322 @@ +'use client'; + +import { useState, useEffect } from "react"; +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Card, CardContent } from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { SendIcon, Loader2, GlobeIcon, ChevronDownIcon, Ship } from "lucide-react"; +import { useToast } from "@/hooks/use-toast"; +import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuRadioGroup, DropdownMenuRadioItem } from "@/components/ui/dropdown-menu" +import { useTranslation } from '@/i18n/client' +import { useRouter, useParams, usePathname, useSearchParams } from 'next/navigation'; +import { + InputOTP, + InputOTPGroup, + InputOTPSlot, +} from "@/components/ui/input-otp" +import { signIn } from 'next-auth/react'; +import { sendOtpAction } from "@/lib/users/send-otp"; +import { verifyTokenAction } from "@/lib/users/verifyToken"; +import { buttonVariants } from "@/components/ui/button" +import Link from "next/link" +import Image from 'next/image'; // 추가: Image 컴포넌트 import + +export function LoginForm({ + className, + ...props +}: React.ComponentProps<"div">) { + + const params = useParams(); + const pathname = usePathname(); + const router = useRouter(); + const searchParams = useSearchParams(); + const token = searchParams.get('token'); + + const lng = params.lng as string; + const { t, i18n } = useTranslation(lng, 'login'); + + const { toast } = useToast(); + + const handleChangeLanguage = (lang: string) => { + const segments = pathname.split('/'); + segments[1] = lang; + router.push(segments.join('/')); + }; + + const currentLanguageText = i18n.language === 'ko' ? t('languages.korean') : t('languages.english'); + + const [email, setEmail] = useState(''); + const [otpSent, setOtpSent] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [otp, setOtp] = useState(''); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsLoading(true); + try { + const result = await sendOtpAction(email, lng); + + if (result?.success) { + setOtpSent(true); + toast({ + title: t('otpSentTitle'), + description: t('otpSentMessage'), + }); + } + } catch (error) { + toast({ + title: t('errorTitle'), + description: t('defaultErrorMessage'), + variant: 'destructive', + }); + } finally { + setIsLoading(false); + } + }; + + + async function handleOtpSubmit(e: React.FormEvent) { + e.preventDefault(); + setIsLoading(true); + + try { + // next-auth의 Credentials Provider로 로그인 시도 + const result = await signIn('credentials', { + email, + code: otp, + redirect: false, // 커스텀 처리 위해 redirect: false + }); + + if (result?.ok) { + // 토스트 메시지 표시 + toast({ + title: t('loginSuccess'), + description: t('youAreLoggedIn'), + }); + + // NextAuth에서 유저 정보 API 호출 (최신 상태 보장) + const response = await fetch('/api/auth/session'); + const session = await response.json(); + + // domain 값에 따라 동적으로 리다이렉션 + const userDomain = session?.user?.domain; + console.log(session) + + if (userDomain === 'evcp') { + router.push(`/${lng}/evcp`); + } else if (userDomain === 'partners') { + router.push(`/${lng}/partners`); + } else { + // 기본 리다이렉션 경로 + router.push(`/${lng}/dashboard`); + } + } else { + toast({ + title: t('errorTitle'), + description: t('defaultErrorMessage'), + variant: 'destructive', + }); + } + } catch (error) { + console.error('Login error:', error); + toast({ + title: t('errorTitle'), + description: t('defaultErrorMessage'), + variant: 'destructive', + }); + } finally { + setIsLoading(false); + } + } + + useEffect(() => { + const verifyToken = async () => { + if (!token) return; + setIsLoading(true); + + try { + const data = await verifyTokenAction(token); + + if (data.valid) { + setOtpSent(true); + setEmail(data.email ?? ''); + } else { + toast({ + title: t('errorTitle'), + description: t('invalidToken'), + variant: 'destructive', + }); + } + } catch (error) { + toast({ + title: t('errorTitle'), + description: t('defaultErrorMessage'), + variant: 'destructive', + }); + } finally { + setIsLoading(false); + } + }; + verifyToken(); + }, [token, toast, t]); + + return ( + <div className="container relative flex h-screen flex-col items-center justify-center md:grid lg:max-w-none lg:grid-cols-2 lg:px-0"> + {/* Left Content */} + <div className="flex flex-col w-full h-screen lg:p-2"> + {/* Top bar with Logo + eVCP (left) and "Request Vendor Repository" (right) */} + <div className="flex items-center justify-between"> + <div className="flex items-center space-x-2"> + {/* <img + src="/images/logo.png" + alt="logo" + className="h-8 w-auto" + /> */} + <Ship className="w-4 h-4" /> + <span className="text-md font-bold">eVCP</span> + </div> + <Link + href="/partners/repository" + className={cn(buttonVariants({ variant: "ghost" }))} + > + Request Vendor Repository + </Link> + </div> + + {/* Content section that occupies remaining space, centered vertically */} + <div className="flex-1 flex items-center justify-center"> + {/* Your form container */} + <div className="mx-auto w-full flex flex-col space-y-6 sm:w-[350px]"> + + {/* Here's your existing login/OTP forms: */} + {!otpSent ? ( + <form onSubmit={handleSubmit} className="p-6 md:p-8"> + <div className="flex flex-col gap-6"> + <div className="flex flex-col items-center text-center"> + <h1 className="text-2xl font-bold">{t('loginMessage')}</h1> + </div> + <div className="grid gap-2"> + <Input + id="email" + type="email" + placeholder={t('email')} + required + className="h-10" + value={email} + onChange={(e) => setEmail(e.target.value)} + /> + </div> + <Button type="submit" className="w-full" variant="samsung" disabled={isLoading}> + {isLoading ? t('sending') : t('ContinueWithEmail')} + </Button> + <div className="text-center text-sm mx-auto"> + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="ghost" className="flex items-center gap-2"> + <GlobeIcon className="h-4 w-4" /> + <span>{currentLanguageText}</span> + <ChevronDownIcon className="h-4 w-4" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuRadioGroup + value={i18n.language} + onValueChange={(value) => handleChangeLanguage(value)} + > + <DropdownMenuRadioItem value="en"> + {t('languages.english')} + </DropdownMenuRadioItem> + <DropdownMenuRadioItem value="ko"> + {t('languages.korean')} + </DropdownMenuRadioItem> + </DropdownMenuRadioGroup> + </DropdownMenuContent> + </DropdownMenu> + </div> + </div> + </form> + ) : ( + <form onSubmit={handleOtpSubmit} className="flex flex-col gap-4 p-6 md:p-8"> + <div className="flex flex-col gap-6"> + <div className="flex flex-col items-center text-center"> + <h1 className="text-2xl font-bold">{t('loginMessage')}</h1> + </div> + <div className="grid gap-2 justify-center"> + <InputOTP + maxLength={6} + value={otp} + onChange={(value) => setOtp(value)} + > + <InputOTPGroup> + <InputOTPSlot index={0} /> + <InputOTPSlot index={1} /> + <InputOTPSlot index={2} /> + <InputOTPSlot index={3} /> + <InputOTPSlot index={4} /> + <InputOTPSlot index={5} /> + </InputOTPGroup> + </InputOTP> + </div> + <Button type="submit" className="w-full" disabled={isLoading}> + {isLoading ? t('verifying') : t('verifyOtp')} + </Button> + <div className="mx-auto"> + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="ghost" className="flex items-center gap-2"> + <GlobeIcon className="h-4 w-4" /> + <span>{currentLanguageText}</span> + <ChevronDownIcon className="h-4 w-4" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuRadioGroup + value={i18n.language} + onValueChange={(value) => handleChangeLanguage(value)} + > + <DropdownMenuRadioItem value="en"> + {t('languages.english')} + </DropdownMenuRadioItem> + <DropdownMenuRadioItem value="ko"> + {t('languages.korean')} + </DropdownMenuRadioItem> + </DropdownMenuRadioGroup> + </DropdownMenuContent> + </DropdownMenu> + </div> + </div> + </form> + )} + + <div className="text-balance text-center text-xs text-muted-foreground [&_a]:underline [&_a]:underline-offset-4 hover:[&_a]:text-primary"> + {t('termsMessage')} <a href="#">{t('termsOfService')}</a> {t('and')} + <a href="#">{t('privacyPolicy')}</a>. + </div> + </div> + </div> + </div> + + {/* Right BG 이미지 영역 - Image 컴포넌트로 수정 */} + <div className="relative hidden h-full flex-col p-10 text-white dark:border-r md:flex"> + {/* Image 컴포넌트로 대체 */} + <div className="absolute inset-0"> + <Image + src="/images/02.jpg" + alt="Background image" + fill + priority + sizes="(max-width: 1024px) 100vw, 50vw" + className="object-cover" + /> + </div> + <div className="relative z-10 mt-auto"> + <blockquote className="space-y-2"> + <p className="text-sm">“{t("blockquote")}”</p> + {/* <footer className="text-sm">SHI</footer> */} + </blockquote> + </div> + </div> + </div> + ) +}
\ No newline at end of file diff --git a/components/login/partner-auth-form.tsx b/components/login/partner-auth-form.tsx new file mode 100644 index 00000000..effd7bd3 --- /dev/null +++ b/components/login/partner-auth-form.tsx @@ -0,0 +1,241 @@ +"use client" + +import * as React from "react" +import { useToast } from "@/hooks/use-toast" +import { useRouter, useParams, usePathname } from "next/navigation" +import { useTranslation } from "@/i18n/client" +import Link from "next/link" + +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { GlobeIcon, ChevronDownIcon, Loader, Ship } from "lucide-react" +import { languages } from "@/config/language" +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" +import { siteConfig } from "@/config/site" + +import { checkJoinPortal } from "@/lib/vendors/service" +import Image from "next/image" +// ↑ 실제 경로 맞춤 수정 (ex: "@/app/[lng]/actions/joinPortal" 등) + +interface UserAuthFormProps extends React.HTMLAttributes<HTMLDivElement> { } + +export function CompanyAuthForm({ className, ...props }: UserAuthFormProps) { + const [isLoading, setIsLoading] = React.useState<boolean>(false) + const router = useRouter() + const { toast } = useToast() + const params = useParams() + const pathname = usePathname() + + const lng = params.lng as string + const { t, i18n } = useTranslation(lng, "login") + + const handleChangeLanguage = (lang: string) => { + const segments = pathname.split("/") + segments[1] = lang + router.push(segments.join("/")) + } + + const currentLanguageText = + i18n.language === "ko" + ? t("languages.korean") + : i18n.language === "ja" + ? t("languages.japanese") + : t("languages.english") + + // --------------------------- + // 1) onSubmit -> 서버 액션 호출 + // --------------------------- + async function onSubmit(event: React.FormEvent<HTMLFormElement>) { + event.preventDefault() + setIsLoading(true) + + const formData = new FormData(event.currentTarget) + const taxID = formData.get("taxid")?.toString().trim() + + if (!taxID) { + toast({ + variant: "destructive", + title: "오류", + description: "Tax ID를 입력해주세요.", + }) + setIsLoading(false) + return + } + + try { + // --------------------------- + // 2) 서버 액션 호출 + // --------------------------- + const result = await checkJoinPortal(taxID) + + if (result.success) { + toast({ + variant: "default", + title: "성공", + description: "가입 신청이 가능합니다", + }) + // 가입 가능 → signup 페이지 이동 + router.push(`/partners/signup?taxID=${taxID}`) + } else { + toast({ + variant: "destructive", + title: "가입이 진행 중이거나 완료된 회사", + description: `${result.data} 에 연락하여 계정 생성 요청을 하시기 바랍니다.`, + }) + } + } catch (error: any) { + console.error(error) + toast({ + variant: "destructive", + title: "오류", + description: "서버 액션 호출에 실패했습니다. 잠시 후 다시 시도해주세요.", + }) + } finally { + setIsLoading(false) + } + } + + return ( + <div className="container relative flex h-screen flex-col items-center justify-center md:grid lg:max-w-none lg:grid-cols-2 lg:px-0"> + + {/* Left BG 이미지 영역 */} + + <div className="flex flex-col w-full h-screen lg:p-2"> + {/* Top bar */} + <div className="flex items-center justify-between"> + <div className="flex items-center space-x-2"> + {/* <img + src="/images/logo.png" + alt="logo" + className="h-8 w-auto" + /> */} + <Ship className="w-4 h-4" /> + <span className="text-md font-bold">eVCP</span> + </div> + + {/* Remove 'absolute right-4 top-4 ...', just use buttonVariants */} + <Link + href="/login" + className={cn( + buttonVariants({ variant: "ghost" }) + )} + > + Login + </Link> + </div> + <div className="flex-1 flex items-center justify-center"> + <div className="mx-auto w-full flex flex-col space-y-6 sm:w-[350px]"> + <div className="flex flex-col space-y-2 text-center"> + <h1 className="text-2xl font-semibold tracking-tight"> + {t("heading")} + </h1> + <p className="text-sm text-muted-foreground">{t("subheading")}</p> + </div> + + <div className={cn("grid gap-6", className)} {...props}> + <form onSubmit={onSubmit}> + <div className="grid gap-2"> + <div className="grid gap-1"> + <label className="sr-only" htmlFor="taxid"> + Business Number / Tax ID + </label> + <input + id="taxid" + name="taxid" + placeholder="880-81-01710" + type="text" + autoCapitalize="none" + autoComplete="off" + autoCorrect="off" + disabled={isLoading} + className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50" + /> + </div> + <Button type="submit" disabled={isLoading} variant="samsung"> + {isLoading && <Loader className="mr-2 h-4 w-4 animate-spin" />} + {t("joinButton")} + </Button> + + {/* 언어 선택 Dropdown */} + <div className="mx-auto"> + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="ghost" className="flex items-center gap-2"> + <GlobeIcon className="h-4 w-4" /> + <span>{currentLanguageText}</span> + <ChevronDownIcon className="h-4 w-4" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuRadioGroup + value={i18n.language} + onValueChange={(value) => handleChangeLanguage(value)} + > + {languages.map((v) => ( + <DropdownMenuRadioItem key={v.value} value={v.value}> + {t(v.labelKey)} + </DropdownMenuRadioItem> + ))} + </DropdownMenuRadioGroup> + </DropdownMenuContent> + </DropdownMenu> + </div> + </div> + </form> + </div> + <p className="px-8 text-center text-sm text-muted-foreground"> + {t("agreement")}{" "} + <Link + href="/terms" + className="underline underline-offset-4 hover:text-primary" + > + {t("termsOfService")} + </Link>{" "} + {t("and")}{" "} + <Link + href="/privacy" + className="underline underline-offset-4 hover:text-primary" + > + {t("privacyPolicy")} + </Link> + . + </p> + </div> + </div> + + </div> + + + {/* Right Content */} + <div className="relative hidden h-full flex-col p-10 text-white dark:border-r md:flex"> + {/* Image 컴포넌트로 대체 */} + <div className="absolute inset-0"> + <Image + src="/images/02.jpg" + alt="Background image" + fill + priority + sizes="(max-width: 1024px) 100vw, 50vw" + className="object-cover" + /> + </div> + <div className="relative z-10 mt-auto"> + <blockquote className="space-y-2"> + <p className="text-sm">“{t("blockquote")}”</p> + {/* <footer className="text-sm">SHI</footer> */} + </blockquote> + </div> + </div> + + </div> + ) +}
\ No newline at end of file diff --git a/components/pq/pq-input-tabs.tsx b/components/pq/pq-input-tabs.tsx new file mode 100644 index 00000000..743e1729 --- /dev/null +++ b/components/pq/pq-input-tabs.tsx @@ -0,0 +1,780 @@ +"use client" + +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { z } from "zod" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, +} from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Textarea } from "@/components/ui/textarea" +import { X, Save, CheckCircle2, AlertTriangle, ChevronsUpDown } from "lucide-react" +import prettyBytes from "pretty-bytes" +import { useToast } from "@/hooks/use-toast" +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible" + +// Form components +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + FormDescription, +} from "@/components/ui/form" + +// Custom Dropzone, FileList components +import { + Dropzone, + DropzoneDescription, + DropzoneInput, + DropzoneTitle, + DropzoneUploadIcon, + DropzoneZone, +} from "@/components/ui/dropzone" +import { + FileList, + FileListAction, + FileListDescription, + FileListHeader, + FileListIcon, + FileListInfo, + FileListItem, + FileListName, +} from "@/components/ui/file-list" + +// Dialog components from shadcn/ui +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog" + +// Additional UI +import { Separator } from "../ui/separator" + +// Server actions (adjust to your actual code) +import { + uploadFileAction, + savePQAnswersAction, + submitPQAction, +} from "@/lib/pq/service" +import { PQGroupData } from "@/lib/pq/service" + +// ---------------------------------------------------------------------- +// 1) Define client-side file shapes +// ---------------------------------------------------------------------- +interface UploadedFileState { + fileName: string + url: string + size?: number +} + +interface LocalFileState { + fileObj: File + uploaded: boolean +} + +// ---------------------------------------------------------------------- +// 2) Zod schema for the entire form +// ---------------------------------------------------------------------- +const pqFormSchema = z.object({ + answers: z.array( + z.object({ + criteriaId: z.number(), + // Must have at least 1 char + answer: z.string().min(1, "Answer is required"), + + // Existing, uploaded files + uploadedFiles: z + .array( + z.object({ + fileName: z.string(), + url: z.string(), + size: z.number().optional(), + }) + ) + .min(1, "At least one file attachment is required"), + + // Local (not-yet-uploaded) files + newUploads: z.array( + z.object({ + fileObj: z.any(), + uploaded: z.boolean().default(false), + }) + ), + + // track saved state + saved: z.boolean().default(false), + }) + ), +}) + +type PQFormValues = z.infer<typeof pqFormSchema> + +// ---------------------------------------------------------------------- +// 3) Main Component: PQInputTabs +// ---------------------------------------------------------------------- +export function PQInputTabs({ + data, + vendorId, +}: { + data: PQGroupData[] + vendorId: number +}) { + const [isSaving, setIsSaving] = React.useState(false) + const [isSubmitting, setIsSubmitting] = React.useState(false) + const [allSaved, setAllSaved] = React.useState(false) + const [showConfirmDialog, setShowConfirmDialog] = React.useState(false) + + const { toast } = useToast() + + // ---------------------------------------------------------------------- + // A) Create initial form values + // Mark items as "saved" if they have existing answer or attachments + // ---------------------------------------------------------------------- + function createInitialFormValues(): PQFormValues { + const answers: PQFormValues["answers"] = [] + + data.forEach((group) => { + group.items.forEach((item) => { + // Check if the server item is already “complete” + const hasExistingAnswer = item.answer && item.answer.trim().length > 0 + const hasExistingAttachments = item.attachments && item.attachments.length > 0 + + // If either is present, we consider it "saved" initially + const isAlreadySaved = hasExistingAnswer || hasExistingAttachments + + answers.push({ + criteriaId: item.criteriaId, + answer: item.answer || "", + uploadedFiles: item.attachments.map((attach) => ({ + fileName: attach.fileName, + url: attach.filePath, + size: attach.fileSize, + })), + newUploads: [], + saved: isAlreadySaved, + }) + }) + }) + + return { answers } + } + + // ---------------------------------------------------------------------- + // B) Set up react-hook-form + // ---------------------------------------------------------------------- + const form = useForm<PQFormValues>({ + resolver: zodResolver(pqFormSchema), + defaultValues: createInitialFormValues(), + mode: "onChange", + }) + + // ---------------------------------------------------------------------- + // C) Track if all items are saved => controls Submit PQ button + // ---------------------------------------------------------------------- + React.useEffect(() => { + const values = form.getValues() + // We consider items “saved” if `saved===true` AND they have an answer or attachments + const allItemsSaved = values.answers.every( + (answer) => answer.saved && (answer.answer || answer.uploadedFiles.length > 0) + ) + setAllSaved(allItemsSaved) + }, [form.watch()]) + + // Helper to find the array index by criteriaId + const getAnswerIndex = (criteriaId: number): number => { + return form.getValues().answers.findIndex((a) => a.criteriaId === criteriaId) + } + + // ---------------------------------------------------------------------- + // D) Handling File Drops, Removal + // ---------------------------------------------------------------------- + const handleDropAccepted = (criteriaId: number, files: File[]) => { + const answerIndex = getAnswerIndex(criteriaId) + if (answerIndex === -1) return + + // Convert each dropped file into a LocalFileState + const newLocalFiles: LocalFileState[] = files.map((f) => ({ + fileObj: f, + uploaded: false, + })) + + const current = form.getValues(`answers.${answerIndex}.newUploads`) + form.setValue(`answers.${answerIndex}.newUploads`, [...current, ...newLocalFiles], { + shouldDirty: true, + }) + + // Mark unsaved + form.setValue(`answers.${answerIndex}.saved`, false, { shouldDirty: true }) + } + + const handleDropRejected = () => { + toast({ + title: "File upload rejected", + description: "Please check file size and type.", + variant: "destructive", + }) + } + + const removeNewUpload = (answerIndex: number, fileIndex: number) => { + const current = [...form.getValues(`answers.${answerIndex}.newUploads`)] + current.splice(fileIndex, 1) + form.setValue(`answers.${answerIndex}.newUploads`, current, { shouldDirty: true }) + + form.setValue(`answers.${answerIndex}.saved`, false, { shouldDirty: true }) + } + + const removeUploadedFile = (answerIndex: number, fileIndex: number) => { + const current = [...form.getValues(`answers.${answerIndex}.uploadedFiles`)] + current.splice(fileIndex, 1) + form.setValue(`answers.${answerIndex}.uploadedFiles`, current, { shouldDirty: true }) + + form.setValue(`answers.${answerIndex}.saved`, false, { shouldDirty: true }) + } + + // ---------------------------------------------------------------------- + // E) Saving a Single Item + // ---------------------------------------------------------------------- + const handleSaveItem = async (answerIndex: number) => { + try { + const answerData = form.getValues(`answers.${answerIndex}`) + + // Validation + if (!answerData.answer) { + toast({ + title: "Validation Error", + description: "Answer is required", + variant: "destructive", + }) + return + } + + // Upload new files (if any) + if (answerData.newUploads.length > 0) { + setIsSaving(true) + + for (const localFile of answerData.newUploads) { + try { + const uploadResult = await uploadFileAction(localFile.fileObj) + const currentUploaded = form.getValues(`answers.${answerIndex}.uploadedFiles`) + currentUploaded.push({ + fileName: uploadResult.fileName, + url: uploadResult.url, + size: uploadResult.size, + }) + form.setValue(`answers.${answerIndex}.uploadedFiles`, currentUploaded, { + shouldDirty: true, + }) + } catch (error) { + console.error("File upload error:", error) + toast({ + title: "Upload Error", + description: "Failed to upload file", + variant: "destructive", + }) + } + } + + // Clear newUploads + form.setValue(`answers.${answerIndex}.newUploads`, [], { shouldDirty: true }) + } + + // Save to DB + const updatedAnswer = form.getValues(`answers.${answerIndex}`) + const saveResult = await savePQAnswersAction({ + vendorId, + answers: [ + { + criteriaId: updatedAnswer.criteriaId, + answer: updatedAnswer.answer, + attachments: updatedAnswer.uploadedFiles.map((f) => ({ + fileName: f.fileName, + url: f.url, + size: f.size, + })), + }, + ], + }) + + if (saveResult.ok) { + // Mark as saved + form.setValue(`answers.${answerIndex}.saved`, true, { shouldDirty: false }) + toast({ + title: "Saved", + description: "Item saved successfully", + }) + } + } catch (error) { + console.error("Save error:", error) + toast({ + title: "Save Error", + description: "Failed to save item", + variant: "destructive", + }) + } finally { + setIsSaving(false) + } + } + + // For convenience + const answers = form.getValues().answers + const dirtyFields = form.formState.dirtyFields.answers + + // Check if any item is dirty or has new uploads + const isAnyItemDirty = answers.some((answer, i) => { + const itemDirty = !!dirtyFields?.[i] + const hasNewUploads = answer.newUploads.length > 0 + return itemDirty || hasNewUploads + }) + + // ---------------------------------------------------------------------- + // F) Save All Items + // ---------------------------------------------------------------------- + const handleSaveAll = async () => { + try { + setIsSaving(true) + const answers = form.getValues().answers + + // Only save items that are dirty or have new uploads + for (let i = 0; i < answers.length; i++) { + const itemDirty = !!dirtyFields?.[i] + const hasNewUploads = answers[i].newUploads.length > 0 + if (!itemDirty && !hasNewUploads) continue + + await handleSaveItem(i) + } + + toast({ + title: "All Saved", + description: "All items saved successfully", + }) + } catch (error) { + console.error("Save all error:", error) + toast({ + title: "Save Error", + description: "Failed to save all items", + variant: "destructive", + }) + } finally { + setIsSaving(false) + } + } + + // ---------------------------------------------------------------------- + // G) Submission with Confirmation Dialog + // ---------------------------------------------------------------------- + const handleSubmitPQ = () => { + if (!allSaved) { + toast({ + title: "Cannot Submit", + description: "Please save all items before submitting", + variant: "destructive", + }) + return + } + setShowConfirmDialog(true) + } + + const handleConfirmSubmission = async () => { + try { + setIsSubmitting(true) + setShowConfirmDialog(false) + + const result = await submitPQAction(vendorId) + if (result.ok) { + toast({ + title: "PQ Submitted", + description: "Your PQ information has been submitted successfully", + }) + // Optionally redirect + } else { + toast({ + title: "Submit Error", + description: result.error || "Failed to submit PQ", + variant: "destructive", + }) + } + } catch (error) { + console.error("Submit error:", error) + toast({ + title: "Submit Error", + description: "Failed to submit PQ information", + variant: "destructive", + }) + } finally { + setIsSubmitting(false) + } + } + + // ---------------------------------------------------------------------- + // H) Render + // ---------------------------------------------------------------------- + return ( + <Form {...form}> + <form> + <Tabs defaultValue={data[0]?.groupName || ""} className="w-full"> + {/* Top Controls */} + <div className="flex justify-between items-center mb-4"> + <TabsList className="grid grid-cols-4"> + {data.map((group) => ( + <TabsTrigger + key={group.groupName} + value={group.groupName} + className="truncate" + > + <div className="flex items-center gap-2"> + {/* Mobile: truncated version */} + <span className="block sm:hidden"> + {group.groupName.length > 5 + ? group.groupName.slice(0, 5) + "..." + : group.groupName} + </span> + {/* Desktop: full text */} + <span className="hidden sm:block">{group.groupName}</span> + <span className="inline-flex items-center justify-center h-5 min-w-5 px-1 rounded-full bg-muted text-xs font-medium"> + {group.items.length} + </span> + </div> + </TabsTrigger> + ))} + </TabsList> + + <div className="flex gap-2"> + {/* Save All button */} + <Button + type="button" + variant="outline" + disabled={isSaving || !isAnyItemDirty} + onClick={handleSaveAll} + > + {isSaving ? "Saving..." : "Save All"} + <Save className="ml-2 h-4 w-4" /> + </Button> + + {/* Submit PQ button */} + <Button + type="button" + disabled={!allSaved || isSubmitting} + onClick={handleSubmitPQ} + > + {isSubmitting ? "Submitting..." : "Submit PQ"} + <CheckCircle2 className="ml-2 h-4 w-4" /> + </Button> + </div> + </div> + + {/* Render each group */} + {data.map((group) => ( + <TabsContent key={group.groupName} value={group.groupName}> + {/* 2-column grid */} + <div className="grid grid-cols-1 md:grid-cols-2 gap-4 pb-4"> + {group.items.map((item) => { + const { criteriaId, code, checkPoint, description } = item + const answerIndex = getAnswerIndex(criteriaId) + if (answerIndex === -1) return null + + const isSaved = form.watch(`answers.${answerIndex}.saved`) + const hasAnswer = form.watch(`answers.${answerIndex}.answer`) + const newUploads = form.watch(`answers.${answerIndex}.newUploads`) + const dirtyFieldsItem = form.formState.dirtyFields.answers?.[answerIndex] + + const isItemDirty = !!dirtyFieldsItem + const hasNewUploads = newUploads.length > 0 + const canSave = isItemDirty || hasNewUploads + + // For “Not Saved” vs. “Saved” status label + const hasUploads = + form.watch(`answers.${answerIndex}.uploadedFiles`).length > 0 || + newUploads.length > 0 + const isValid = !!hasAnswer || hasUploads + + return ( + <Collapsible key={criteriaId} defaultOpen={!isSaved} className="w-full"> + <Card className={isSaved ? "border-green-200" : ""}> + <CardHeader className="pb-1"> + <div className="flex justify-between"> + <div className="flex-1"> + <div className="flex items-center gap-2"> + <CollapsibleTrigger asChild> + <Button variant="ghost" size="sm" className="p-0 h-7 w-7"> + <ChevronsUpDown className="h-4 w-4" /> + <span className="sr-only">Toggle</span> + </Button> + </CollapsibleTrigger> + <CardTitle className="text-md"> + {code} - {checkPoint} + </CardTitle> + </div> + {description && ( + <CardDescription className="mt-1 whitespace-pre-wrap"> + {description} + </CardDescription> + )} + </div> + + {/* Save Status & Button */} + <div className="flex items-center gap-2"> + {!isSaved && canSave && ( + <span className="text-amber-600 text-xs flex items-center"> + <AlertTriangle className="h-4 w-4 mr-1" /> + Not Saved + </span> + )} + {isSaved && ( + <span className="text-green-600 text-xs flex items-center"> + <CheckCircle2 className="h-4 w-4 mr-1" /> + Saved + </span> + )} + + <Button + size="sm" + variant="outline" + disabled={isSaving || !canSave} + onClick={() => handleSaveItem(answerIndex)} + > + Save + </Button> + </div> + </div> + </CardHeader> + + <CollapsibleContent> + {/* Answer Field */} + <CardHeader className="pt-0 pb-3"> + <FormField + control={form.control} + name={`answers.${answerIndex}.answer`} + render={({ field }) => ( + <FormItem className="mt-3"> + <FormLabel>Answer</FormLabel> + <FormControl> + <Textarea + {...field} + className="min-h-24" + placeholder="Enter your answer here" + onChange={(e) => { + field.onChange(e) + form.setValue( + `answers.${answerIndex}.saved`, + false, + { shouldDirty: true } + ) + }} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </CardHeader> + + {/* Attachments / Dropzone */} + <CardContent> + <div className="grid gap-2"> + <FormLabel>Attachments</FormLabel> + <Dropzone + maxSize={6e8} // 600MB + onDropAccepted={(files) => + handleDropAccepted(criteriaId, files) + } + onDropRejected={handleDropRejected} + > + {() => ( + <FormItem> + <DropzoneZone className="flex justify-center h-24"> + <FormControl> + <DropzoneInput /> + </FormControl> + <div className="flex items-center gap-6"> + <DropzoneUploadIcon /> + <div className="grid gap-0.5"> + <DropzoneTitle>Drop files here</DropzoneTitle> + <DropzoneDescription> + Max size: 600MB + </DropzoneDescription> + </div> + </div> + </DropzoneZone> + <FormDescription> + Or click to browse files + </FormDescription> + <FormMessage /> + </FormItem> + )} + </Dropzone> + </div> + + {/* Existing + Pending Files */} + <div className="mt-4 space-y-4"> + {/* 1) Not-yet-uploaded files */} + {newUploads.length > 0 && ( + <div className="grid gap-2"> + <h6 className="text-sm font-medium"> + Pending Files ({newUploads.length}) + </h6> + <FileList> + {newUploads.map((f, fileIndex) => { + const fileObj = f.fileObj + if (!fileObj) return null + + return ( + <FileListItem key={fileIndex}> + <FileListHeader> + <FileListIcon /> + <FileListInfo> + <FileListName>{fileObj.name}</FileListName> + <FileListDescription> + {prettyBytes(fileObj.size)} + </FileListDescription> + </FileListInfo> + <FileListAction + onClick={() => + removeNewUpload(answerIndex, fileIndex) + } + > + <X className="h-4 w-4" /> + <span className="sr-only">Remove</span> + </FileListAction> + </FileListHeader> + </FileListItem> + ) + })} + </FileList> + </div> + )} + + {/* 2) Already uploaded files */} + {form + .watch(`answers.${answerIndex}.uploadedFiles`) + .map((file, fileIndex) => ( + <FileListItem key={fileIndex}> + <FileListHeader> + <FileListIcon /> + <FileListInfo> + <FileListName>{file.fileName}</FileListName> + {/* If you want to display the path: + <FileListDescription>{file.url}</FileListDescription> + */} + </FileListInfo> + {file.size && ( + <span className="text-xs text-muted-foreground"> + {prettyBytes(file.size)} + </span> + )} + <FileListAction + onClick={() => + removeUploadedFile(answerIndex, fileIndex) + } + > + <X className="h-4 w-4" /> + <span className="sr-only">Remove</span> + </FileListAction> + </FileListHeader> + </FileListItem> + ))} + </div> + </CardContent> + </CollapsibleContent> + </Card> + </Collapsible> + ) + })} + </div> + </TabsContent> + ))} + </Tabs> + </form> + + {/* Confirmation Dialog */} + <Dialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}> + <DialogContent> + <DialogHeader> + <DialogTitle>Confirm Submission</DialogTitle> + <DialogDescription> + Review your answers before final submission. + </DialogDescription> + </DialogHeader> + + <div className="space-y-4 max-h-[600px] overflow-y-auto "> + {data.map((group) => ( + <Collapsible key={group.groupName} defaultOpen> + <CollapsibleTrigger asChild> + <div className="flex justify-between items-center p-2 mb-1 cursor-pointer "> + <p className="font-semibold">{group.groupName}</p> + <ChevronsUpDown className="h-4 w-4 ml-2" /> + </div> + </CollapsibleTrigger> + + <CollapsibleContent> + {group.items.map((item) => { + const answerObj = form + .getValues() + .answers.find((a) => a.criteriaId === item.criteriaId) + + if (!answerObj) return null + + return ( + <div key={item.criteriaId} className="mb-2 p-2 ml-2 border rounded-md text-sm"> + {/* code & checkPoint */} + <p className="font-semibold"> + {item.code} - {item.checkPoint} + </p> + + {/* user's typed answer */} + <p className="text-sm font-medium mt-2">Answer:</p> + <p className="whitespace-pre-wrap text-sm"> + {answerObj.answer || "(no answer)"} + </p> + {/* attachments */} + <p>Attachments:</p> + {answerObj.uploadedFiles.length > 0 ? ( + <ul className="list-disc list-inside ml-4 text-xs"> + {answerObj.uploadedFiles.map((file, idx) => ( + <li key={idx}>{file.fileName}</li> + ))} + </ul> + ) : ( + <p className="text-xs text-muted-foreground">(none)</p> + )} + </div> + ) + })} + </CollapsibleContent> + </Collapsible> + ))} + </div> + + <DialogFooter> + <Button + variant="outline" + onClick={() => setShowConfirmDialog(false)} + disabled={isSubmitting} + > + Cancel + </Button> + <Button onClick={handleConfirmSubmission} disabled={isSubmitting}> + {isSubmitting ? "Submitting..." : "Confirm Submit"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + </Form> + ) +}
\ No newline at end of file diff --git a/components/pq/pq-review-detail.tsx b/components/pq/pq-review-detail.tsx new file mode 100644 index 00000000..e5cd080e --- /dev/null +++ b/components/pq/pq-review-detail.tsx @@ -0,0 +1,712 @@ +"use client" + +import React from "react" +import { Button } from "@/components/ui/button" +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog" +import { Textarea } from "@/components/ui/textarea" +import { useToast } from "@/hooks/use-toast" +import { PQGroupData, requestPqChangesAction, updateVendorStatusAction, getItemReviewLogsAction } from "@/lib/pq/service" +import { Vendor } from "@/db/schema/vendors" +import { Separator } from "@/components/ui/separator" +import { ChevronsUpDown, MessagesSquare, Download, Loader2, X } from "lucide-react" +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { Card } from "@/components/ui/card" +import { formatDate } from "@/lib/utils" +import { downloadFileAction } from "@/lib/downloadFile" + +// 코멘트 상태를 위한 인터페이스 정의 +interface PendingComment { + answerId: number; + checkPoint: string; + code: string; + comment: string; + createdAt: Date; +} + +interface ReviewLog { + id: number + reviewerComment: string + reviewerName: string | null + createdAt: Date +} + +export default function VendorPQAdminReview({ + data, + vendor, +}: { + data: PQGroupData[] + vendor: Vendor +}) { + const { toast } = useToast() + + // 다이얼로그 상태들 + const [showRequestDialog, setShowRequestDialog] = React.useState(false) + const [showApproveDialog, setShowApproveDialog] = React.useState(false) + const [showRejectDialog, setShowRejectDialog] = React.useState(false) + + // 코멘트 상태들 + const [requestComment, setRequestComment] = React.useState("") + const [approveComment, setApproveComment] = React.useState("") + const [rejectComment, setRejectComment] = React.useState("") + const [isLoading, setIsLoading] = React.useState(false) + + // 항목별 코멘트 상태 추적 (메모리에만 저장) + const [pendingComments, setPendingComments] = React.useState<PendingComment[]>([]) + + // 코멘트 추가 핸들러 - 실제 서버 저장이 아닌 메모리에 저장 + const handleCommentAdded = (newComment: PendingComment) => { + setPendingComments(prev => [...prev, newComment]); + toast({ + title: "Comment Added", + description: `Comment added for ${newComment.code}. Please "Request Changes" to save.` + }); + } + + // 코멘트 삭제 핸들러 + const handleRemoveComment = (index: number) => { + setPendingComments(prev => prev.filter((_, i) => i !== index)); + } + + // 1) 승인 다이얼로그 표시 + const handleApprove = () => { + // 코멘트가 있는데 승인하려고 하면 경고 + if (pendingComments.length > 0) { + if (!confirm('You have unsaved comments. Are you sure you want to approve without requesting changes?')) { + return; + } + } + setShowApproveDialog(true) + } + + // 실제 승인 처리 + const handleSubmitApprove = async () => { + try { + setIsLoading(true) + setShowApproveDialog(false) + + const res = await updateVendorStatusAction(vendor.id, "APPROVED") + if (res.ok) { + toast({ title: "Approved", description: "Vendor PQ has been approved." }) + // 코멘트 초기화 + setPendingComments([]); + } else { + toast({ title: "Error", description: res.error, variant: "destructive" }) + } + } catch (error) { + toast({ title: "Error", description: String(error), variant: "destructive" }) + } finally { + setIsLoading(false) + setApproveComment("") + } + } + + // 2) 거부 다이얼로그 표시 + const handleReject = () => { + // 코멘트가 있는데 거부하려고 하면 경고 + if (pendingComments.length > 0) { + if (!confirm('You have unsaved comments. Are you sure you want to reject without requesting changes?')) { + return; + } + } + setShowRejectDialog(true) + } + + // 실제 거부 처리 + const handleSubmitReject = async () => { + try { + setIsLoading(true) + setShowRejectDialog(false) + + const res = await updateVendorStatusAction(vendor.id, "REJECTED") + if (res.ok) { + toast({ title: "Rejected", description: "Vendor PQ has been rejected." }) + // 코멘트 초기화 + setPendingComments([]); + } else { + toast({ title: "Error", description: res.error, variant: "destructive" }) + } + } catch (error) { + toast({ title: "Error", description: String(error), variant: "destructive" }) + } finally { + setIsLoading(false) + setRejectComment("") + } + } + + // 3) 변경 요청 다이얼로그 표시 + const handleRequestChanges = () => { + setShowRequestDialog(true) + } + + // 4) 변경 요청 처리 - 이제 모든 코멘트를 한 번에 저장 +// 4) 변경 요청 처리 - 이제 모든 코멘트를 한 번에 저장 +const handleSubmitRequestChanges = async () => { + try { + setIsLoading(true); + setShowRequestDialog(false); + + // 항목별 코멘트 준비 - answerId와 함께 checkPoint와 code도 전송 + const itemComments = pendingComments.map(pc => ({ + answerId: pc.answerId, + checkPoint: pc.checkPoint, // 추가: 체크포인트 정보 전송 + code: pc.code, // 추가: 코드 정보 전송 + comment: pc.comment + })); + + // 서버 액션 호출 + const res = await requestPqChangesAction({ + vendorId: vendor.id, + comment: itemComments, + generalComment: requestComment || undefined + }); + + if (res.ok) { + toast({ + title: "Changes Requested", + description: "Vendor was notified of your comments.", + }); + // 코멘트 초기화 + setPendingComments([]); + } else { + toast({ title: "Error", description: res.error, variant: "destructive" }); + } + } catch (error) { + toast({ title: "Error", description: String(error), variant: "destructive" }); + } finally { + setIsLoading(false); + setRequestComment(""); + } +}; + + return ( + <div className="space-y-4"> + {/* Top header */} + <div className="flex items-center justify-between"> + <h2 className="text-2xl font-bold"> + {vendor.vendorCode} - {vendor.vendorName} PQ Review + </h2> + <div className="flex gap-2"> + <Button + variant="outline" + disabled={isLoading} + onClick={handleReject} + > + Reject + </Button> + <Button + variant={pendingComments.length > 0 ? "default" : "outline"} + disabled={isLoading} + onClick={handleRequestChanges} + > + Request Changes + {pendingComments.length > 0 && ( + <span className="ml-2 bg-white text-primary rounded-full h-5 min-w-5 inline-flex items-center justify-center text-xs px-1"> + {pendingComments.length} + </span> + )} + </Button> + <Button + disabled={isLoading} + onClick={handleApprove} + > + Approve + </Button> + </div> + </div> + + <p className="text-sm text-muted-foreground"> + Review the submitted PQ items below, then approve, reject, or request more info. + </p> + + {/* 코멘트가 있을 때 알림 표시 */} + {pendingComments.length > 0 && ( + <div className="mt-4 p-3 bg-yellow-50 border border-yellow-200 rounded-md text-yellow-800"> + <p className="text-sm font-medium flex items-center"> + <span className="mr-2">⚠️</span> + You have {pendingComments.length} pending comments. Click "Request Changes" to save them. + </p> + </div> + )} + + <Separator /> + + {/* VendorPQReviewPage 컴포넌트 대신 직접 구현 */} + <VendorPQReviewPageIntegrated + data={data} + onCommentAdded={handleCommentAdded} + /> + + {/* 변경 요청 다이얼로그 */} + <Dialog open={showRequestDialog} onOpenChange={setShowRequestDialog}> + <DialogContent className="max-w-3xl"> + <DialogHeader> + <DialogTitle>Request PQ Changes</DialogTitle> + <DialogDescription> + Review your comments and add any additional notes. The vendor will receive these changes. + </DialogDescription> + </DialogHeader> + + {/* 항목별 코멘트 목록 */} + {pendingComments.length > 0 && ( + <div className="border rounded-md p-2 space-y-2 max-h-[300px] overflow-y-auto"> + <h3 className="font-medium text-sm">Item Comments:</h3> + {pendingComments.map((comment, index) => ( + <div key={index} className="flex items-start gap-2 p-2 border rounded-md bg-muted/50"> + <div className="flex-1"> + <div className="flex items-center gap-2"> + <span className="text-sm font-medium">{comment.code}</span> + <span className="text-sm">{comment.checkPoint}</span> + </div> + <p className="text-sm mt-1">{comment.comment}</p> + <p className="text-xs text-muted-foreground mt-1"> + {formatDate(comment.createdAt)} + </p> + </div> + <Button + variant="ghost" + size="sm" + className="p-0 h-8 w-8" + onClick={() => handleRemoveComment(index)} + > + <X className="h-4 w-4" /> + </Button> + </div> + ))} + </div> + )} + + {/* 추가 코멘트 입력 */} + <div className="space-y-2 mt-2"> + <label className="text-sm font-medium"> + {pendingComments.length > 0 + ? "Additional comments (optional):" + : "Enter details about what should be modified:"} + </label> + <Textarea + value={requestComment} + onChange={(e) => setRequestComment(e.target.value)} + placeholder={pendingComments.length > 0 + ? "Add any additional notes..." + : "Please correct item #1, etc..."} + className="min-h-[100px]" + /> + </div> + + <DialogFooter> + <Button + variant="outline" + onClick={() => setShowRequestDialog(false)} + disabled={isLoading} + > + Cancel + </Button> + <Button + onClick={handleSubmitRequestChanges} + disabled={isLoading || (pendingComments.length === 0 && !requestComment.trim())} + > + Submit Changes + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + + {/* 승인 확인 다이얼로그 */} + <Dialog open={showApproveDialog} onOpenChange={setShowApproveDialog}> + <DialogContent> + <DialogHeader> + <DialogTitle>Confirm Approval</DialogTitle> + <DialogDescription> + Are you sure you want to approve this vendor PQ? You can add a comment if needed. + </DialogDescription> + </DialogHeader> + + <div className="space-y-2"> + <Textarea + value={approveComment} + onChange={(e) => setApproveComment(e.target.value)} + placeholder="Optional: Add any comments about this approval" + className="min-h-[100px]" + /> + </div> + + <DialogFooter> + <Button + variant="outline" + onClick={() => setShowApproveDialog(false)} + disabled={isLoading} + > + Cancel + </Button> + <Button + onClick={handleSubmitApprove} + disabled={isLoading} + > + Confirm Approval + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + + {/* 거부 확인 다이얼로그 */} + <Dialog open={showRejectDialog} onOpenChange={setShowRejectDialog}> + <DialogContent> + <DialogHeader> + <DialogTitle>Confirm Rejection</DialogTitle> + <DialogDescription> + Are you sure you want to reject this vendor PQ? Please provide a reason. + </DialogDescription> + </DialogHeader> + + <div className="space-y-2"> + <Textarea + value={rejectComment} + onChange={(e) => setRejectComment(e.target.value)} + placeholder="Required: Provide reason for rejection" + className="min-h-[150px]" + /> + </div> + + <DialogFooter> + <Button + variant="outline" + onClick={() => setShowRejectDialog(false)} + disabled={isLoading} + > + Cancel + </Button> + <Button + onClick={handleSubmitReject} + disabled={isLoading || !rejectComment.trim()} + variant="destructive" + > + Confirm Rejection + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + </div> + ) +} + +// 코멘트 추가 함수 인터페이스 +interface VendorPQReviewPageIntegratedProps { + data: PQGroupData[]; + onCommentAdded: (comment: PendingComment) => void; +} + +// 통합된 VendorPQReviewPage 컴포넌트 +function VendorPQReviewPageIntegrated({ data, onCommentAdded }: VendorPQReviewPageIntegratedProps) { + const { toast } = useToast(); + + // 파일 다운로드 함수 - 서버 액션 사용 + const handleFileDownload = async (filePath: string, fileName: string) => { + try { + toast({ + title: "Download Started", + description: `Preparing ${fileName} for download...`, + }); + + // 서버 액션 호출 + const result = await downloadFileAction(filePath); + + if (!result.ok || !result.data) { + throw new Error(result.error || 'Failed to download file'); + } + + // Base64 디코딩하여 Blob 생성 + const binaryString = atob(result.data.content); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + + // Blob 생성 및 다운로드 + const blob = new Blob([bytes.buffer], { type: result.data.mimeType }); + const url = URL.createObjectURL(blob); + + // 다운로드 링크 생성 및 클릭 + const a = document.createElement('a'); + a.href = url; + a.download = fileName; + document.body.appendChild(a); + a.click(); + + // 정리 + URL.revokeObjectURL(url); + document.body.removeChild(a); + + toast({ + title: "Download Complete", + description: `${fileName} downloaded successfully`, + }); + } catch (error) { + console.error('Download error:', error); + toast({ + title: "Download Error", + description: error instanceof Error ? error.message : "Failed to download file", + variant: "destructive" + }); + } + }; + + return ( + <div className="space-y-4"> + {data.map((group) => ( + <Collapsible key={group.groupName} defaultOpen> + <CollapsibleTrigger asChild> + <div className="flex items-center justify-between cursor-pointer p-3 bg-muted rounded"> + <h2 className="font-semibold text-lg">{group.groupName}</h2> + <Button variant="ghost" size="sm" className="p-0 h-7 w-7"> + <ChevronsUpDown className="h-4 w-4" /> + </Button> + </div> + </CollapsibleTrigger> + + <CollapsibleContent> + <Card className="mt-2 p-4"> + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-[60px]">Code</TableHead> + <TableHead>Check Point</TableHead> + <TableHead>Answer</TableHead> + <TableHead className="w-[180px]">Attachments</TableHead> + <TableHead className="w-[60px] text-center">Comments</TableHead> + </TableRow> + </TableHeader> + + <TableBody> + {group.items.map((item) => ( + <TableRow key={item.criteriaId}> + <TableCell className="font-medium">{item.code}</TableCell> + <TableCell>{item.checkPoint}</TableCell> + + <TableCell> + {item.answer ? ( + <p className="whitespace-pre-wrap text-sm"> + {item.answer} + </p> + ) : ( + <p className="text-sm text-muted-foreground">(no answer)</p> + )} + </TableCell> + + <TableCell> + {item.attachments.length > 0 ? ( + <ul className="list-none space-y-1"> + {item.attachments.map((file) => ( + <li key={file.attachId} className="text-sm flex items-center"> + <button + className="text-blue-600 hover:text-blue-800 hover:underline flex items-center truncate max-w-[160px]" + onClick={() => handleFileDownload(file.filePath, file.fileName)} + > + <Download className="h-3 w-3 mr-1 flex-shrink-0" /> + <span className="truncate">{file.fileName}</span> + </button> + </li> + ))} + </ul> + ) : ( + <p className="text-sm text-muted-foreground">(none)</p> + )} + </TableCell> + + <TableCell className="text-center"> + <ItemCommentButton + item={item} + onCommentAdded={onCommentAdded} + /> + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </Card> + </CollapsibleContent> + </Collapsible> + ))} + </div> + ); +} + +// 항목 코멘트 버튼 컴포넌트 props +interface ItemCommentButtonProps { + item: any; // 항목 데이터 + onCommentAdded: (comment: PendingComment) => void; +} + +// 항목별 코멘트 버튼 컴포넌트 (기존 로그 표시 + 메모리에 새 코멘트 저장) +function ItemCommentButton({ item, onCommentAdded }: ItemCommentButtonProps) { + const { toast } = useToast(); + const [open, setOpen] = React.useState(false); + const [logs, setLogs] = React.useState<ReviewLog[]>([]); + const [newComment, setNewComment] = React.useState(""); + const [isLoading, setIsLoading] = React.useState(false); + const [hasComments, setHasComments] = React.useState(false); + + // If there's no answerId, item wasn't answered + if (!item.answerId) { + return <p className="text-xs text-muted-foreground">N/A</p>; + } + + // 기존 로그 가져오기 + const fetchLogs = React.useCallback(async () => { + try { + setIsLoading(true); + const res = await getItemReviewLogsAction({ answerId: item.answerId }); + + if (res.ok && res.data) { + setLogs(res.data); + // 코멘트 존재 여부 설정 + setHasComments(res.data.length > 0); + } else { + console.error("Error response:", res.error); + toast({ title: "Error", description: res.error, variant: "destructive" }); + } + } catch (error) { + console.error("Fetch error:", error); + toast({ title: "Error", description: String(error), variant: "destructive" }); + } finally { + setIsLoading(false); + } + }, [item.answerId, toast]); + + // 초기 로드 시 코멘트 존재 여부 확인 (아이콘 색상용) + React.useEffect(() => { + const checkComments = async () => { + try { + const res = await getItemReviewLogsAction({ answerId: item.answerId }); + if (res.ok && res.data) { + setHasComments(res.data.length > 0); + } + } catch (error) { + console.error("Error checking comments:", error); + } + }; + + checkComments(); + }, [item.answerId]); + + // open 상태가 변경될 때 로그 가져오기 + React.useEffect(() => { + if (open) { + fetchLogs(); + } + }, [open, fetchLogs]); + + // 다이얼로그 열기 + const handleButtonClick = React.useCallback(() => { + setOpen(true); + }, []); + + // 다이얼로그 상태 변경 + const handleOpenChange = React.useCallback((nextOpen: boolean) => { + setOpen(nextOpen); + }, []); + + // 코멘트 추가 처리 (메모리에만 저장) + const handleAddComment = React.useCallback(() => { + if (!newComment.trim()) return; + + setIsLoading(true); + + // 새 코멘트 생성 + const pendingComment: PendingComment = { + answerId: item.answerId, + checkPoint: item.checkPoint, + code: item.code, + comment: newComment.trim(), + createdAt: new Date() + }; + + // 부모 컴포넌트에 전달 + onCommentAdded(pendingComment); + + // 상태 초기화 + setNewComment(""); + setOpen(false); + setIsLoading(false); + }, [item, newComment, onCommentAdded]); + + return ( + <> + <Button variant="ghost" size="sm" onClick={handleButtonClick}> + <MessagesSquare + className={`h-4 w-4 ${hasComments ? 'text-blue-600' : ''}`} + /> + </Button> + + <Dialog open={open} onOpenChange={handleOpenChange}> + <DialogContent> + <DialogHeader> + <DialogTitle>{item.checkPoint}</DialogTitle> + <DialogDescription> + Review existing comments and add new ones + </DialogDescription> + </DialogHeader> + + {/* 기존 로그 섹션 */} + <div className="max-h-[200px] overflow-y-auto space-y-2"> + {isLoading ? ( + <div className="flex justify-center p-4"> + <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /> + </div> + ) : logs.length > 0 ? ( + <div className="space-y-2"> + <h3 className="text-sm font-medium">Previous Comments:</h3> + {logs.map((log) => ( + <div key={log.id} className="p-2 border rounded text-sm"> + <p className="font-medium">{log.reviewerName}</p> + <p>{log.reviewerComment}</p> + <p className="text-xs text-muted-foreground"> + {formatDate(log.createdAt)} + </p> + </div> + ))} + </div> + ) : ( + <p className="text-sm text-muted-foreground">No previous comments yet.</p> + )} + </div> + + {/* 구분선 */} + {/* <Separator /> */} + + {/* 새 코멘트 추가 섹션 */} + <div className="space-y-2 mt-2"> + <div className="flex items-center justify-between"> + {/* <h3 className="text-sm font-medium">Add New Comment:</h3> */} + {/* <p className="text-xs text-muted-foreground"> + Comments will be saved when you click "Request Changes" + </p> */} + </div> + <Textarea + placeholder="Add your comment..." + value={newComment} + onChange={(e) => setNewComment(e.target.value)} + className="min-h-[100px]" + /> + <Button + onClick={handleAddComment} + disabled={isLoading || !newComment.trim()} + > + Add Comment + </Button> + </div> + </DialogContent> + </Dialog> + </> + ); +}
\ No newline at end of file diff --git a/components/pq/pq-review-table.tsx b/components/pq/pq-review-table.tsx new file mode 100644 index 00000000..e778cf91 --- /dev/null +++ b/components/pq/pq-review-table.tsx @@ -0,0 +1,340 @@ +"use client" + +import * as React from "react" +import { ChevronsUpDown, MessagesSquare, Download, Loader2 } from "lucide-react" + +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { Card } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { PQGroupData } from "@/lib/pq/service" +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { Textarea } from "@/components/ui/textarea" +import { addReviewCommentAction, getItemReviewLogsAction } from "@/lib/pq/service" +import { useToast } from "@/hooks/use-toast" +import { formatDate } from "@/lib/utils" +import { downloadFileAction } from "@/lib/downloadFile" + +interface ReviewLog { + id: number + reviewerComment: string + reviewerName: string | null + createdAt: Date +} + +interface VendorPQReviewPageProps { + data: PQGroupData[]; + onCommentAdded?: () => void; // 코멘트 추가 콜백 +} + +export default function VendorPQReviewPage({ data, onCommentAdded }: VendorPQReviewPageProps) { + const { toast } = useToast() + + // 파일 다운로드 함수 - 서버 액션 사용 + const handleFileDownload = async (filePath: string, fileName: string) => { + try { + toast({ + title: "Download Started", + description: `Preparing ${fileName} for download...`, + }); + + // 서버 액션 호출 + const result = await downloadFileAction(filePath); + + if (!result.ok || !result.data) { + throw new Error(result.error || 'Failed to download file'); + } + + // Base64 디코딩하여 Blob 생성 + const binaryString = atob(result.data.content); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + + // Blob 생성 및 다운로드 + const blob = new Blob([bytes.buffer], { type: result.data.mimeType }); + const url = URL.createObjectURL(blob); + + // 다운로드 링크 생성 및 클릭 + const a = document.createElement('a'); + a.href = url; + a.download = fileName; + document.body.appendChild(a); + a.click(); + + // 정리 + URL.revokeObjectURL(url); + document.body.removeChild(a); + + toast({ + title: "Download Complete", + description: `${fileName} downloaded successfully`, + }); + } catch (error) { + console.error('Download error:', error); + toast({ + title: "Download Error", + description: error instanceof Error ? error.message : "Failed to download file", + variant: "destructive" + }); + } + }; + + return ( + <div className="space-y-4"> + {data.map((group) => ( + <Collapsible key={group.groupName} defaultOpen> + <CollapsibleTrigger asChild> + <div className="flex items-center justify-between cursor-pointer p-3 bg-muted rounded"> + <h2 className="font-semibold text-lg">{group.groupName}</h2> + <Button variant="ghost" size="sm" className="p-0 h-7 w-7"> + <ChevronsUpDown className="h-4 w-4" /> + </Button> + </div> + </CollapsibleTrigger> + + <CollapsibleContent> + <Card className="mt-2 p-4"> + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-[60px]">Code</TableHead> + <TableHead>Check Point</TableHead> + <TableHead>Answer</TableHead> + <TableHead className="w-[180px]">Attachments</TableHead> + <TableHead className="w-[60px] text-center">Comments</TableHead> + </TableRow> + </TableHeader> + + <TableBody> + {group.items.map((item) => ( + <TableRow key={item.criteriaId}> + <TableCell className="font-medium">{item.code}</TableCell> + <TableCell>{item.checkPoint}</TableCell> + + <TableCell> + {item.answer ? ( + <p className="whitespace-pre-wrap text-sm"> + {item.answer} + </p> + ) : ( + <p className="text-sm text-muted-foreground">(no answer)</p> + )} + </TableCell> + + <TableCell> + {item.attachments.length > 0 ? ( + <ul className="list-none space-y-1"> + {item.attachments.map((file) => ( + <li key={file.attachId} className="text-sm flex items-center"> + <button + className="text-blue-600 hover:text-blue-800 hover:underline flex items-center truncate max-w-[160px]" + onClick={() => handleFileDownload(file.filePath, file.fileName)} + > + <Download className="h-3 w-3 mr-1 flex-shrink-0" /> + <span className="truncate">{file.fileName}</span> + </button> + </li> + ))} + </ul> + ) : ( + <p className="text-sm text-muted-foreground">(none)</p> + )} + </TableCell> + + <TableCell className="text-center"> + <ItemReviewButton + answerId={item.answerId ?? undefined} + checkPoint={item.checkPoint} + onCommentAdded={onCommentAdded} + /> + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </Card> + </CollapsibleContent> + </Collapsible> + ))} + </div> + ) +} + +interface ItemReviewButtonProps { + answerId?: number; + checkPoint: string; // Check Point 추가 + onCommentAdded?: () => void; +} + +/** + * A button that opens a dialog to show logs + add new comment for a single item (vendorPqCriteriaAnswers). + */ +function ItemReviewButton({ answerId, checkPoint, onCommentAdded }: ItemReviewButtonProps) { + const { toast } = useToast(); + const [open, setOpen] = React.useState(false); + const [logs, setLogs] = React.useState<ReviewLog[]>([]); + const [newComment, setNewComment] = React.useState(""); + const [isLoading, setIsLoading] = React.useState(false); + const [hasComments, setHasComments] = React.useState(false); + + // If there's no answerId, item wasn't answered + if (!answerId) { + return <p className="text-xs text-muted-foreground">N/A</p>; + } + + // fetchLogs 함수를 useCallback으로 메모이제이션 + const fetchLogs = React.useCallback(async () => { + try { + setIsLoading(true); + const res = await getItemReviewLogsAction({ answerId }); + + if (res.ok && res.data) { + setLogs(res.data); + // 코멘트 존재 여부 설정 + setHasComments(res.data.length > 0); + } else { + console.error("Error response:", res.error); + toast({ title: "Error", description: res.error, variant: "destructive" }); + } + } catch (error) { + console.error("Fetch error:", error); + toast({ title: "Error", description: String(error), variant: "destructive" }); + } finally { + setIsLoading(false); + } + }, [answerId, toast]); + + // 초기 로드 시 코멘트 존재 여부 확인 (아이콘 색상용) + React.useEffect(() => { + const checkComments = async () => { + try { + const res = await getItemReviewLogsAction({ answerId }); + if (res.ok && res.data) { + setHasComments(res.data.length > 0); + } + } catch (error) { + console.error("Error checking comments:", error); + } + }; + + checkComments(); + }, [answerId]); + + // open 상태가 변경될 때 로그 가져오기 + React.useEffect(() => { + if (open) { + fetchLogs(); + } + }, [open, fetchLogs]); + + // 버튼 클릭 핸들러 - 다이얼로그 열기 + const handleButtonClick = React.useCallback(() => { + setOpen(true); + }, []); + + // 다이얼로그 상태 변경 핸들러 + const handleOpenChange = React.useCallback((nextOpen: boolean) => { + setOpen(nextOpen); + }, []); + + // 코멘트 추가 핸들러 + const handleAddComment = React.useCallback(async () => { + try { + setIsLoading(true); + + const res = await addReviewCommentAction({ + answerId, + comment: newComment, + reviewerName: "AdminUser", + }); + + if (res.ok) { + toast({ title: "Comment added", description: "New review comment saved" }); + setNewComment(""); + setHasComments(true); // 코멘트 추가 성공 시 상태 업데이트 + + // 코멘트가 추가되었음을 부모 컴포넌트에 알림 + if (onCommentAdded) { + onCommentAdded(); + } + + // 로그 다시 가져오기 + fetchLogs(); + } else { + toast({ title: "Error", description: res.error, variant: "destructive" }); + } + } catch (error) { + toast({ title: "Error", description: String(error), variant: "destructive" }); + } finally { + setIsLoading(false); + } + }, [answerId, newComment, onCommentAdded, fetchLogs, toast]); + + return ( + <> + <Button variant="ghost" size="sm" onClick={handleButtonClick}> + <MessagesSquare + className={`h-4 w-4 ${hasComments ? 'text-blue-600' : ''}`} + /> + </Button> + + <Dialog open={open} onOpenChange={handleOpenChange}> + <DialogContent> + <DialogHeader> + <DialogTitle>{checkPoint} Comments</DialogTitle> + </DialogHeader> + + {/* Logs section */} + <div className="max-h-[200px] overflow-y-auto space-y-2"> + {isLoading ? ( + <div className="flex justify-center p-4"> + <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /> + </div> + ) : logs.length > 0 ? ( + logs.map((log) => ( + <div key={log.id} className="p-2 border rounded text-sm"> + <p className="font-medium">{log.reviewerName}</p> + <p>{log.reviewerComment}</p> + <p className="text-xs text-muted-foreground"> + {formatDate(log.createdAt)} + </p> + </div> + )) + ) : ( + <p className="text-sm text-muted-foreground">No comments yet.</p> + )} + </div> + + {/* Add new comment */} + <div className="space-y-2"> + <Textarea + placeholder="Add a new comment..." + value={newComment} + onChange={(e) => setNewComment(e.target.value)} + /> + <Button + size="sm" + onClick={handleAddComment} + disabled={isLoading || !newComment.trim()} + > + Add Comment + </Button> + </div> + </DialogContent> + </Dialog> + </> + ); +}
\ No newline at end of file diff --git a/components/settings/account-form.tsx b/components/settings/account-form.tsx new file mode 100644 index 00000000..97cad9e5 --- /dev/null +++ b/components/settings/account-form.tsx @@ -0,0 +1,263 @@ +"use client" + +import * as React from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm } from "react-hook-form" +import { z } from "zod" + +import { toast } from "@/hooks/use-toast" +import { Button } from "@/components/ui/button" + +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" + +import { findUserById } from "@/lib/admin-users/service" +import { useSession } from "next-auth/react"; + +import { updateUserProfileImage } from "@/lib/users/service" + + + +const accountFormSchema = z.object({ + name: z + .string() + .min(2, { + message: "Name must be at least 2 characters.", + }) + .max(30, { + message: "Name must not be longer than 30 characters.", + }), + email: z.string().email(), + company: z + .string() + .min(2, { + message: "Name must be at least 2 characters.", + }) + .max(30, { + message: "Name must not be longer than 30 characters.", + }), + + imageFile: z.any().optional(), + +}) + +type AccountFormValues = z.infer<typeof accountFormSchema> + + + +export function AccountForm() { + + const { data: session } = useSession(); + const userId = session?.user.id || "" + + + const [previewUrl, setPreviewUrl] = React.useState<string | null>(null) + + const form = useForm<AccountFormValues>({ + resolver: zodResolver(accountFormSchema), + defaultValues: { + name: "", + company: "", + email: "", + imageFile: null, + }, + }) + + // Fetch data in useEffect + React.useEffect(() => { + console.log("Form state changed: ", form.getValues()); + + async function fetchUser() { + try { + const data = await findUserById(Number(userId)) + if (data) { + // Also reset the form's default values + form.reset({ + name: data.user_name || "", + company: data.company_name || "", + email: data.user_email || "", + imageFile: data.user_image, // no file to begin with + }) + } + } catch (error) { + console.error("Failed to fetch user data:", error) + } + } + + if (userId) { + fetchUser() + } + }, [userId, form]) + + + async function onSubmit(data: AccountFormValues) { + // RHF가 추적한 dirtyFields를 가져옵니다. + const { dirtyFields } = form.formState + + // 변경된 필드가 전혀 없다면 => 업데이트 스킵 + if (Object.keys(dirtyFields).length === 0) { + toast({ + title: "No changes", + description: "Nothing to update", + }) + return + } + + // 바뀐 파일만 업로드 + let imageFile: File | null = null + if (dirtyFields.imageFile && data.imageFile && data.imageFile.length > 0) { + // 새로 업로드한 파일 + imageFile = data.imageFile[0] + } + + // FormData 생성 + const formData = new FormData() + formData.append("userId", userId) + formData.append("name", data.name) + formData.append("company", data.company) + formData.append("email", data.email) + + if (imageFile) { + formData.append("file", imageFile) + } + + try { + // 서버 액션(또는 API) 호출 + await updateUserProfileImage(formData) + + toast({ + title: "Account updated", + description: "User updated successfully!", + }) + + } catch (error: any) { + toast({ + title: "Error", + description: `Error: ${error.message ?? error}`, + variant: "destructive", + }) + } + } + + + return ( + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8"> + <FormField + control={form.control} + name="name" + render={({ field }) => ( + <FormItem> + <FormLabel>Name</FormLabel> + <FormControl> + <Input placeholder="Your name" {...field} /> + </FormControl> + <FormDescription> + This is the name that will be displayed on your profile and in + emails. + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="email" + render={({ field }) => ( + <FormItem> + <FormLabel>Email</FormLabel> + <FormControl> + <Input placeholder="Your Email" {...field} /> + </FormControl> + <FormDescription> + This is the email that will be used on login. If you want change it, please be careful. + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="company" + render={({ field }) => ( + <FormItem> + <FormLabel>Company</FormLabel> + <FormControl> + <Input + placeholder="Your Company name" + {...field} + readOnly + className="cursor-not-allowed bg-slate-50" + /> + </FormControl> + <FormDescription> + This is the name that will be displayed on your profile and in + emails. + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + + + {/* 이미지 업로드 */} + <FormField + control={form.control} + name="imageFile" + render={({ field }) => ( + <FormItem> + <FormLabel>Profile Image</FormLabel> + <FormControl> + <div className="space-y-2"> + <Input + type="file" + accept="image/*" + onChange={(e) => { + field.onChange(e.target.files) + if (e.target.files && e.target.files.length > 0) { + // 로컬 미리보기 URL + const file = e.target.files[0] + const url = URL.createObjectURL(file) + setPreviewUrl(url) + } + }} + /> + + {previewUrl ? ( + <img src={previewUrl} alt="Local Preview" width={200}/> + ) : ( + typeof field.value === "string" && + field.value && ( + <img + src={`/profiles/${field.value}`} + alt="Server Image" + width={200} + /> + ) + )} + </div> + </FormControl> + <FormDescription> + Upload your profile image. + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + <Button type="submit">Update account</Button> + </form> + </Form> + ) +} diff --git a/components/settings/appearance-form.tsx b/components/settings/appearance-form.tsx new file mode 100644 index 00000000..8f843fd6 --- /dev/null +++ b/components/settings/appearance-form.tsx @@ -0,0 +1,244 @@ +"use client" + +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm } from "react-hook-form" +import { z } from "zod" +import { useTheme } from "next-themes" +import { toast } from "@/hooks/use-toast" +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group" +import { useMetaColor } from "@/hooks/use-meta-color" +import { META_THEME_COLORS } from "@/config/site" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { Check, ChevronsUpDown } from "lucide-react" +import { cn } from "@/lib/utils" +import { useTranslation } from '@/i18n/client' +import { useRouter, useParams, usePathname } from 'next/navigation'; + +const appearanceFormSchema = z.object({ + theme: z.enum(["light", "dark"], { + required_error: "Please select a theme.", + }), + language: z.string({ + required_error: "Please select a language.", + }), + +}) + +type AppearanceFormValues = z.infer<typeof appearanceFormSchema> + +// This can come from your database or API. +const defaultValues: Partial<AppearanceFormValues> = { + theme: "light", + language:'ko' +} +const languages = [ + { label: "English", value: "en" }, + { label: "한국어", value: "ko" }, +] as const + +export function AppearanceForm() { + const { setTheme, resolvedTheme } = useTheme() + const { setMetaColor } = useMetaColor() + + const pathname = usePathname(); + const router = useRouter(); + + const params = useParams(); + const lng = params.lng as string; + const { t, i18n } = useTranslation(lng, 'translation'); + + const form = useForm<AppearanceFormValues>({ + resolver: zodResolver(appearanceFormSchema), + defaultValues, + }) + + function onSubmit(data: AppearanceFormValues) { + setTheme(data.theme) + setMetaColor( + resolvedTheme === "dark" + ? META_THEME_COLORS.light + : META_THEME_COLORS.dark + ) + + const segments = pathname.split('/'); + segments[1] = data.language; + router.push(segments.join('/')); + + toast({ + title: "Updated Successfully", + // description: ( + // <div className="mt-2 w-[340px] rounded-md bg-slate-950 p-4"> + // <div className="text-white">{JSON.stringify(data, null, 2)}</div> + // </div> + // ), + }) + } + + return ( + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8"> + + <FormField + control={form.control} + name="theme" + render={({ field }) => ( + <FormItem className="space-y-1"> + <FormLabel>Theme</FormLabel> + <FormDescription> + Customize the appearance of the app. Automatically switch between day + and night themes. + </FormDescription> + <FormMessage /> + <RadioGroup + onValueChange={field.onChange} + defaultValue={field.value} + className="grid max-w-md grid-cols-2 gap-8 pt-2" + > + <FormItem> + <FormLabel className="[&:has([data-state=checked])>div]:border-primary"> + <FormControl> + <RadioGroupItem value="light" className="sr-only" /> + </FormControl> + <div className="items-center rounded-md border-2 border-muted p-1 hover:border-accent"> + <div className="space-y-2 rounded-sm bg-[#ecedef] p-2"> + <div className="space-y-2 rounded-md bg-white p-2 shadow-sm"> + <div className="h-2 w-[80px] rounded-lg bg-[#ecedef]" /> + <div className="h-2 w-[100px] rounded-lg bg-[#ecedef]" /> + </div> + <div className="flex items-center space-x-2 rounded-md bg-white p-2 shadow-sm"> + <div className="h-4 w-4 rounded-full bg-[#ecedef]" /> + <div className="h-2 w-[100px] rounded-lg bg-[#ecedef]" /> + </div> + <div className="flex items-center space-x-2 rounded-md bg-white p-2 shadow-sm"> + <div className="h-4 w-4 rounded-full bg-[#ecedef]" /> + <div className="h-2 w-[100px] rounded-lg bg-[#ecedef]" /> + </div> + </div> + </div> + <span className="block w-full p-2 text-center font-normal"> + Light + </span> + </FormLabel> + </FormItem> + <FormItem> + <FormLabel className="[&:has([data-state=checked])>div]:border-primary"> + <FormControl> + <RadioGroupItem value="dark" className="sr-only" /> + </FormControl> + <div className="items-center rounded-md border-2 border-muted bg-popover p-1 hover:bg-accent hover:text-accent-foreground"> + <div className="space-y-2 rounded-sm bg-slate-950 p-2"> + <div className="space-y-2 rounded-md bg-slate-800 p-2 shadow-sm"> + <div className="h-2 w-[80px] rounded-lg bg-slate-400" /> + <div className="h-2 w-[100px] rounded-lg bg-slate-400" /> + </div> + <div className="flex items-center space-x-2 rounded-md bg-slate-800 p-2 shadow-sm"> + <div className="h-4 w-4 rounded-full bg-slate-400" /> + <div className="h-2 w-[100px] rounded-lg bg-slate-400" /> + </div> + <div className="flex items-center space-x-2 rounded-md bg-slate-800 p-2 shadow-sm"> + <div className="h-4 w-4 rounded-full bg-slate-400" /> + <div className="h-2 w-[100px] rounded-lg bg-slate-400" /> + </div> + </div> + </div> + <span className="block w-full p-2 text-center font-normal"> + Dark + </span> + </FormLabel> + </FormItem> + </RadioGroup> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="language" + render={({ field }) => ( + <FormItem className="flex flex-col"> + <FormLabel>Language</FormLabel> + <Popover> + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + role="combobox" + className={cn( + "w-[200px] justify-between", + !field.value && "text-muted-foreground" + )} + > + {field.value + ? languages.find( + (language) => language.value === field.value + )?.label + : "Select language"} + <ChevronsUpDown className="opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-[200px] p-0"> + <Command> + <CommandInput placeholder="Search language..." /> + <CommandList> + <CommandEmpty>No language found.</CommandEmpty> + <CommandGroup> + {languages.map((language) => ( + <CommandItem + value={language.label} + key={language.value} + onSelect={() => { + form.setValue("language", language.value) + }} + > + <Check + className={cn( + "mr-2", + language.value === field.value + ? "opacity-100" + : "opacity-0" + )} + /> + {language.label} + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + <FormDescription> + This is the language that will be used in the system. + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + <Button type="submit">Update preferences</Button> + </form> + </Form> + ) +} diff --git a/components/shell.tsx b/components/shell.tsx new file mode 100644 index 00000000..8082109b --- /dev/null +++ b/components/shell.tsx @@ -0,0 +1,37 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const shellVariants = cva("grid items-center gap-8 pb-8 pt-6 md:py-8", { + variants: { + variant: { + default: "container", + sidebar: "", + centered: "container flex h-dvh max-w-2xl flex-col justify-center py-16", + markdown: "container max-w-3xl py-8 md:py-10 lg:py-10", + }, + }, + defaultVariants: { + variant: "default", + }, +}) + +interface ShellProps + extends React.HTMLAttributes<HTMLDivElement>, + VariantProps<typeof shellVariants> { + as?: React.ElementType +} + +function Shell({ + className, + as: Comp = "section", + variant, + ...props +}: ShellProps) { + return ( + <Comp className={cn(shellVariants({ variant }), className)} {...props} /> + ) +} + +export { Shell, shellVariants } diff --git a/components/signup/join-form-skeleton.tsx b/components/signup/join-form-skeleton.tsx new file mode 100644 index 00000000..04622433 --- /dev/null +++ b/components/signup/join-form-skeleton.tsx @@ -0,0 +1,75 @@ +import { Skeleton } from "@/components/ui/skeleton" +import { Button } from "@/components/ui/button" +import { Separator } from "@/components/ui/separator" +import { Loader2 } from "lucide-react" + +export function JoinFormSkeleton() { + return ( + <div className="container py-6"> + <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow"> + <div className="hidden space-y-6 p-10 pb-16 md:block"> + <div className="space-y-6"> + <div> + <Skeleton className="h-6 w-64 mb-2" /> + <Skeleton className="h-4 w-full max-w-lg" /> + </div> + + <Separator /> + + <div className="space-y-8"> + {/* VendorName */} + <div className="space-y-2"> + <Skeleton className="h-4 w-24" /> + <Skeleton className="h-10 w-full" /> + <Skeleton className="h-4 w-3/4" /> + </div> + + {/* Address */} + <div className="space-y-2"> + <Skeleton className="h-4 w-16" /> + <Skeleton className="h-10 w-full" /> + <Skeleton className="h-4 w-2/3" /> + </div> + + {/* Email */} + <div className="space-y-2"> + <Skeleton className="h-4 w-12" /> + <Skeleton className="h-10 w-full" /> + <Skeleton className="h-4 w-3/4" /> + </div> + + {/* Phone */} + <div className="space-y-2"> + <Skeleton className="h-4 w-14" /> + <Skeleton className="h-10 w-full" /> + <Skeleton className="h-4 w-1/2" /> + </div> + + {/* Country */} + <div className="space-y-2"> + <Skeleton className="h-4 w-16" /> + <div className="relative"> + <Skeleton className="h-10 w-full" /> + </div> + <Skeleton className="h-4 w-1/3" /> + </div> + + {/* Attachments */} + <div className="space-y-2"> + <Skeleton className="h-4 w-20" /> + <Skeleton className="h-10 w-full" /> + <Skeleton className="h-4 w-3/4" /> + </div> + + {/* Submit button */} + <Button disabled className="cursor-wait"> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + 로딩 중... + </Button> + </div> + </div> + </div> + </section> + </div> + ) +}
\ No newline at end of file diff --git a/components/signup/join-form.tsx b/components/signup/join-form.tsx new file mode 100644 index 00000000..06aee3b5 --- /dev/null +++ b/components/signup/join-form.tsx @@ -0,0 +1,1010 @@ +"use client" + +import * as React from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm, useFieldArray } from "react-hook-form" +import { useRouter, useSearchParams, useParams } from "next/navigation" + +import i18nIsoCountries from "i18n-iso-countries" +import enLocale from "i18n-iso-countries/langs/en.json" +import koLocale from "i18n-iso-countries/langs/ko.json" + +import { Button } from "@/components/ui/button" +import { Separator } from "@/components/ui/separator" +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { toast } from "@/hooks/use-toast" +import { + Popover, + PopoverTrigger, + PopoverContent, +} from "@/components/ui/popover" +import { + Command, + CommandList, + CommandInput, + CommandEmpty, + CommandGroup, + CommandItem, +} from "@/components/ui/command" +import { Check, ChevronsUpDown, Loader2, Plus, X } from "lucide-react" +import { cn } from "@/lib/utils" +import { useTranslation } from "@/i18n/client" + +import { createVendor } from "@/lib/vendors/service" +import { createVendorSchema, CreateVendorSchema } from "@/lib/vendors/validations" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" + +import { + Dropzone, + DropzoneZone, + DropzoneInput, + DropzoneUploadIcon, + DropzoneTitle, + DropzoneDescription, +} from "@/components/ui/dropzone" +import { + FileList, + FileListItem, + FileListHeader, + FileListIcon, + FileListInfo, + FileListName, + FileListDescription, + FileListAction, +} from "@/components/ui/file-list" +import { Badge } from "@/components/ui/badge" +import { ScrollArea } from "@/components/ui/scroll-area" +import prettyBytes from "pretty-bytes" + +i18nIsoCountries.registerLocale(enLocale) +i18nIsoCountries.registerLocale(koLocale) + +const locale = "ko" +const countryMap = i18nIsoCountries.getNames(locale, { select: "official" }) +const countryArray = Object.entries(countryMap).map(([code, label]) => ({ + code, + label, +})) + +// Example agencies + rating scales +const creditAgencies = [ + { value: "NICE", label: "NICE평가정보" }, + { value: "KIS", label: "KIS (한국신용평가)" }, + { value: "KED", label: "KED (한국기업데이터)" }, + { value: "SCI", label: "SCI평가정보" }, +] +const creditRatingScaleMap: Record<string, string[]> = { + NICE: ["AAA", "AA", "A", "BBB", "BB", "B", "C", "D"], + KIS: ["AAA", "AA+", "AA", "A+", "A", "BBB+", "BBB", "BB", "B", "C"], + KED: ["AAA", "AA", "A", "BBB", "BB", "B", "CCC", "CC", "C", "D"], + SCI: ["AAA", "AA+", "AA", "AA-", "A+", "A", "A-", "BBB+", "BBB-", "B"], +} + +const MAX_FILE_SIZE = 3e9 + +export function JoinForm() { + const params = useParams() + const lng = (params.lng as string) || "ko" + const { t } = useTranslation(lng, "translation") + + const router = useRouter() + const searchParams = useSearchParams() + const defaultTaxId = searchParams.get("taxID") ?? "" + + // File states + const [selectedFiles, setSelectedFiles] = React.useState<File[]>([]) + const [creditRatingFile, setCreditRatingFile] = React.useState<File[]>([]) + const [cashFlowRatingFile, setCashFlowRatingFile] = React.useState<File[]>([]) + + const [isSubmitting, setIsSubmitting] = React.useState(false) + + // React Hook Form + const form = useForm<CreateVendorSchema>({ + resolver: zodResolver(createVendorSchema), + defaultValues: { + vendorName: "", + taxId: defaultTaxId, + address: "", + email: "", + phone: "", + country: "", + representativeName: "", + representativeBirth: "", + representativeEmail: "", + representativePhone: "", + corporateRegistrationNumber: "", + creditAgency: "", + creditRating: "", + cashFlowRating: "", + attachedFiles: undefined, + creditRatingAttachment: undefined, + cashFlowRatingAttachment: undefined, + // contacts (no isPrimary) + contacts: [ + { + contactName: "", + contactPosition: "", + contactEmail: "", + contactPhone: "", + }, + ], + }, + mode: "onChange", + }) + const isFormValid = form.formState.isValid + + + + // Field array for contacts + const { fields: contactFields, append: addContact, remove: removeContact } = + useFieldArray({ + control: form.control, + name: "contacts", + }) + + // Dropzone handlers (same as before)... + const handleDropAccepted = (acceptedFiles: File[]) => { + const newFiles = [...selectedFiles, ...acceptedFiles] + setSelectedFiles(newFiles) + form.setValue("attachedFiles", newFiles, { shouldValidate: true }) + } + const handleDropRejected = (fileRejections: any[]) => { + fileRejections.forEach((rej) => { + toast({ + variant: "destructive", + title: "File Error", + description: `${rej.file.name}: ${rej.errors[0]?.message || "Upload failed"}`, + }) + }) + } + const removeFile = (index: number) => { + const updated = [...selectedFiles] + updated.splice(index, 1) + setSelectedFiles(updated) + form.setValue("attachedFiles", updated, { shouldValidate: true }) + } + + const handleCreditDropAccepted = (acceptedFiles: File[]) => { + const newFiles = [...creditRatingFile, ...acceptedFiles] + setCreditRatingFile(newFiles) + form.setValue("creditRatingAttachment", newFiles, { shouldValidate: true }) + } + const handleCreditDropRejected = (fileRejections: any[]) => { + fileRejections.forEach((rej) => { + toast({ + variant: "destructive", + title: "File Error", + description: `${rej.file.name}: ${rej.errors[0]?.message || "Upload failed"}`, + }) + }) + } + const removeCreditFile = (index: number) => { + const updated = [...creditRatingFile] + updated.splice(index, 1) + setCreditRatingFile(updated) + form.setValue("creditRatingAttachment", updated, { shouldValidate: true }) + } + + const handleCashFlowDropAccepted = (acceptedFiles: File[]) => { + const newFiles = [...cashFlowRatingFile, ...acceptedFiles] + setCashFlowRatingFile(newFiles) + form.setValue("cashFlowRatingAttachment", newFiles, { shouldValidate: true }) + } + const handleCashFlowDropRejected = (fileRejections: any[]) => { + fileRejections.forEach((rej) => { + toast({ + variant: "destructive", + title: "File Error", + description: `${rej.file.name}: ${rej.errors[0]?.message || "Upload failed"}`, + }) + }) + } + const removeCashFlowFile = (index: number) => { + const updated = [...cashFlowRatingFile] + updated.splice(index, 1) + setCashFlowRatingFile(updated) + form.setValue("cashFlowRatingAttachment", updated, { shouldValidate: true }) + } + + // Submit + async function onSubmit(values: CreateVendorSchema) { + setIsSubmitting(true) + try { + const mainFiles = values.attachedFiles + ? Array.from(values.attachedFiles as FileList) + : [] + const creditRatingFiles = values.creditRatingAttachment + ? Array.from(values.creditRatingAttachment as FileList) + : [] + const cashFlowRatingFiles = values.cashFlowRatingAttachment + ? Array.from(values.cashFlowRatingAttachment as FileList) + : [] + + const vendorData = { + vendorName: values.vendorName, + vendorCode: values.vendorCode, + website: values.website, + taxId: values.taxId, + address: values.address, + email: values.email, + phone: values.phone, + country: values.country, + status: "PENDING_REVIEW" as const, + representativeName: values.representativeName || "", + representativeBirth: values.representativeBirth || "", + representativeEmail: values.representativeEmail || "", + representativePhone: values.representativePhone || "", + corporateRegistrationNumber: values.corporateRegistrationNumber || "", + creditAgency: values.creditAgency || "", + creditRating: values.creditRating || "", + cashFlowRating: values.cashFlowRating || "", + } + + const result = await createVendor({ + vendorData, + files: mainFiles, + creditRatingFiles, + cashFlowRatingFiles, + contacts: values.contacts, + }) + + if (!result.error) { + toast({ + title: "등록 완료", + description: "회사 등록이 완료되었습니다. (status=PENDING_REVIEW)", + }) + router.push("/") + } else { + toast({ + variant: "destructive", + title: "오류", + description: result.error || "등록에 실패했습니다.", + }) + } + } catch (error: any) { + console.error(error) + toast({ + variant: "destructive", + title: "서버 에러", + description: error.message || "에러가 발생했습니다.", + }) + } finally { + setIsSubmitting(false) + } + } + + // Render + return ( + <div className="container py-6"> + <section className="overflow-hidden rounded-md border bg-background shadow-sm"> + <div className="p-6 md:p-10 space-y-6"> + <div className="space-y-2"> + <h3 className="text-xl font-semibold"> + {defaultTaxId}{" "} + {t("joinForm.title", { + defaultValue: "Vendor Administrator Creation", + })} + </h3> + <p className="text-sm text-muted-foreground"> + {t("joinForm.description", { + defaultValue: + "Please provide basic company information and attach any required documents (e.g., business registration). We will review and approve as soon as possible.", + })} + </p> + </div> + + <Separator /> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8"> + {/* ───────────────────────────────────────── + Basic Info + ───────────────────────────────────────── */} + <div className="rounded-md border p-4 space-y-4"> + <h4 className="text-md font-semibold">기본 정보</h4> + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> + {/* vendorName is required in the schema → show * */} + <FormField + control={form.control} + name="vendorName" + render={({ field }) => ( + <FormItem> + <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500"> + 업체명 + </FormLabel> + <FormControl> + <Input {...field} disabled={isSubmitting} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Address (optional, no * here) */} + <FormField + control={form.control} + name="address" + render={({ field }) => ( + <FormItem> + <FormLabel>주소</FormLabel> + <FormControl> + <Input {...field} disabled={isSubmitting} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="phone" + render={({ field }) => ( + <FormItem> + <FormLabel>대표 전화</FormLabel> + <FormControl> + <Input {...field} disabled={isSubmitting} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* email is required → show * */} + <FormField + control={form.control} + name="email" + render={({ field }) => ( + <FormItem> + <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500"> + 대표 이메일 + </FormLabel> + <FormControl> + <Input {...field} disabled={isSubmitting} /> + </FormControl> + <FormDescription> + 회사 대표 이메일(관리자 로그인에 사용될 수 있음) + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + {/* website optional */} + <FormField + control={form.control} + name="website" + render={({ field }) => ( + <FormItem> + <FormLabel>웹사이트</FormLabel> + <FormControl> + <Input {...field} disabled={isSubmitting} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="country" + render={({ field }) => { + const selectedCountry = countryArray.find( + (c) => c.code === field.value + ) + return ( + <FormItem> + <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500"> + Country + </FormLabel> + <Popover> + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + role="combobox" + className={cn( + "w-full justify-between", + !field.value && "text-muted-foreground" + )} + disabled={isSubmitting} + > + {selectedCountry + ? selectedCountry.label + : "Select a country"} + <ChevronsUpDown className="ml-2 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-full p-0"> + <Command> + <CommandInput placeholder="Search country..." /> + <CommandList> + <CommandEmpty>No country found.</CommandEmpty> + <CommandGroup> + {countryArray.map((country) => ( + <CommandItem + key={country.code} + value={country.label} + onSelect={() => + field.onChange(country.code) + } + > + <Check + className={cn( + "mr-2", + country.code === field.value + ? "opacity-100" + : "opacity-0" + )} + /> + {country.label} + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + <FormMessage /> + </FormItem> + ) + }} + /> + </div> + </div> + + {/* ───────────────────────────────────────── + 담당자 정보 (contacts) + ───────────────────────────────────────── */} + <div className="rounded-md border p-4 space-y-4"> + <div className="flex items-center justify-between"> + <h4 className="text-md font-semibold">담당자 정보 (최소 1명)</h4> + <Button + type="button" + variant="outline" + onClick={() => + addContact({ + contactName: "", + contactPosition: "", + contactEmail: "", + contactPhone: "", + }) + } + disabled={isSubmitting} + > + <Plus className="mr-1 h-4 w-4" /> + Add Contact + </Button> + </div> + + <div className="space-y-2"> + {contactFields.map((contact, index) => ( + <div + key={contact.id} + className="bg-muted/10 rounded-md p-4 space-y-4" + > + <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"> + {/* contactName → required */} + <FormField + control={form.control} + name={`contacts.${index}.contactName`} + render={({ field }) => ( + <FormItem> + <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500"> + 담당자명 + </FormLabel> + <FormControl> + <Input {...field} disabled={isSubmitting} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* contactPosition → optional */} + <FormField + control={form.control} + name={`contacts.${index}.contactPosition`} + render={({ field }) => ( + <FormItem> + <FormLabel>직급 / 부서</FormLabel> + <FormControl> + <Input {...field} disabled={isSubmitting} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* contactEmail → required */} + <FormField + control={form.control} + name={`contacts.${index}.contactEmail`} + render={({ field }) => ( + <FormItem> + <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500"> + 이메일 + </FormLabel> + <FormControl> + <Input {...field} disabled={isSubmitting} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* contactPhone → optional */} + <FormField + control={form.control} + name={`contacts.${index}.contactPhone`} + render={({ field }) => ( + <FormItem> + <FormLabel>전화번호</FormLabel> + <FormControl> + <Input {...field} disabled={isSubmitting} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + + {/* Remove contact button row */} + {contactFields.length > 1 && ( + <div className="flex justify-end"> + <Button + variant="destructive" + onClick={() => removeContact(index)} + disabled={isSubmitting} + > + <X className="mr-1 h-4 w-4" /> + Remove + </Button> + </div> + )} + </div> + ))} + </div> + </div> + + {/* ───────────────────────────────────────── + 한국 사업자 (country === "KR") + ───────────────────────────────────────── */} + {form.watch("country") === "KR" && ( + <div className="rounded-md border p-4 space-y-4"> + <h4 className="text-md font-semibold">한국 사업자 정보</h4> + + {/* 대표자 등... all optional or whichever you want * for */} + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> + <FormField + control={form.control} + name="representativeName" + render={({ field }) => ( + <FormItem> + <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500"> + 대표자 이름 + </FormLabel> + <FormControl> + <Input {...field} disabled={isSubmitting} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="representativeBirth" + render={({ field }) => ( + <FormItem> + <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500"> + 대표자 생년월일 + </FormLabel> + <FormControl> + <Input + placeholder="YYYY-MM-DD" + {...field} + disabled={isSubmitting} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="representativeEmail" + render={({ field }) => ( + <FormItem> + <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500"> + 대표자 이메일 + </FormLabel> + <FormControl> + <Input {...field} disabled={isSubmitting} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="representativePhone" + render={({ field }) => ( + <FormItem> + <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500"> + 대표자 전화번호 + </FormLabel> + <FormControl> + <Input {...field} disabled={isSubmitting} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="corporateRegistrationNumber" + render={({ field }) => ( + <FormItem> + <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500"> + 법인등록번호 + </FormLabel> + <FormControl> + <Input {...field} disabled={isSubmitting} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + + <Separator /> + + {/* 신용/현금 흐름 */} + <div className="space-y-2"> + <FormField + control={form.control} + name="creditAgency" + render={({ field }) => { + const agencyValue = field.value + return ( + <FormItem> + <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500"> + 평가사 + </FormLabel> + <Select + onValueChange={field.onChange} + defaultValue={agencyValue} + > + <FormControl> + <SelectTrigger disabled={isSubmitting}> + <SelectValue placeholder="평가사 선택" /> + </SelectTrigger> + </FormControl> + <SelectContent> + {creditAgencies.map((agency) => ( + <SelectItem + key={agency.value} + value={agency.value} + > + {agency.label} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormDescription> + 신용평가 및 현금흐름등급에 사용할 평가사 + </FormDescription> + <FormMessage /> + </FormItem> + ) + }} + /> + {form.watch("creditAgency") && ( + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> + {/* 신용평가등급 */} + <FormField + control={form.control} + name="creditRating" + render={({ field }) => { + const selectedAgency = form.watch("creditAgency") + const ratingScale = + creditRatingScaleMap[ + selectedAgency as keyof typeof creditRatingScaleMap + ] || [] + return ( + <FormItem> + <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500"> + 신용평가등급 + </FormLabel> + <Select + onValueChange={field.onChange} + defaultValue={field.value} + > + <FormControl> + <SelectTrigger disabled={isSubmitting}> + <SelectValue placeholder="등급 선택" /> + </SelectTrigger> + </FormControl> + <SelectContent> + {ratingScale.map((r) => ( + <SelectItem key={r} value={r}> + {r} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + ) + }} + /> + {/* 현금흐름등급 */} + <FormField + control={form.control} + name="cashFlowRating" + render={({ field }) => { + const selectedAgency = form.watch("creditAgency") + const ratingScale = + creditRatingScaleMap[ + selectedAgency as keyof typeof creditRatingScaleMap + ] || [] + return ( + <FormItem> + <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500"> + 현금흐름등급 + </FormLabel> + <Select + onValueChange={field.onChange} + defaultValue={field.value} + > + <FormControl> + <SelectTrigger disabled={isSubmitting}> + <SelectValue placeholder="등급 선택" /> + </SelectTrigger> + </FormControl> + <SelectContent> + {ratingScale.map((r) => ( + <SelectItem key={r} value={r}> + {r} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + ) + }} + /> + </div> + )} + </div> + + {/* Credit/CashFlow Attachments */} + {form.watch("creditAgency") && ( + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> + <FormField + control={form.control} + name="creditRatingAttachment" + render={() => ( + <FormItem> + <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500"> + 신용평가등급 첨부</FormLabel> + <Dropzone + maxSize={MAX_FILE_SIZE} + multiple + onDropAccepted={handleCreditDropAccepted} + onDropRejected={handleCreditDropRejected} + disabled={isSubmitting} + > + {({ maxSize }) => ( + <DropzoneZone className="flex justify-center"> + <DropzoneInput /> + <div className="flex items-center gap-4"> + <DropzoneUploadIcon /> + <div className="grid gap-1"> + <DropzoneTitle>드래그 또는 클릭</DropzoneTitle> + <DropzoneDescription> + 최대: {maxSize ? prettyBytes(maxSize) : "무제한"} + </DropzoneDescription> + </div> + </div> + </DropzoneZone> + )} + </Dropzone> + {creditRatingFile.length > 0 && ( + <div className="mt-2"> + <ScrollArea className="max-h-32"> + <FileList className="gap-2"> + {creditRatingFile.map((file, i) => ( + <FileListItem key={file.name + i}> + <FileListHeader> + <FileListIcon /> + <FileListInfo> + <FileListName>{file.name}</FileListName> + <FileListDescription> + {prettyBytes(file.size)} + </FileListDescription> + </FileListInfo> + <FileListAction + onClick={() => removeCreditFile(i)} + > + <X className="h-4 w-4" /> + </FileListAction> + </FileListHeader> + </FileListItem> + ))} + </FileList> + </ScrollArea> + </div> + )} + </FormItem> + )} + /> + {/* Cash Flow Attachment */} + <FormField + control={form.control} + name="cashFlowRatingAttachment" + render={() => ( + <FormItem> + <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500"> + 현금흐름등급 첨부</FormLabel> + <Dropzone + maxSize={MAX_FILE_SIZE} + multiple + onDropAccepted={handleCashFlowDropAccepted} + onDropRejected={handleCashFlowDropRejected} + disabled={isSubmitting} + > + {({ maxSize }) => ( + <DropzoneZone className="flex justify-center"> + <DropzoneInput /> + <div className="flex items-center gap-4"> + <DropzoneUploadIcon /> + <div className="grid gap-1"> + <DropzoneTitle>드래그 또는 클릭</DropzoneTitle> + <DropzoneDescription> + 최대: {maxSize ? prettyBytes(maxSize) : "무제한"} + </DropzoneDescription> + </div> + </div> + </DropzoneZone> + )} + </Dropzone> + {cashFlowRatingFile.length > 0 && ( + <div className="mt-2"> + <ScrollArea className="max-h-32"> + <FileList className="gap-2"> + {cashFlowRatingFile.map((file, i) => ( + <FileListItem key={file.name + i}> + <FileListHeader> + <FileListIcon /> + <FileListInfo> + <FileListName>{file.name}</FileListName> + <FileListDescription> + {prettyBytes(file.size)} + </FileListDescription> + </FileListInfo> + <FileListAction + onClick={() => removeCashFlowFile(i)} + > + <X className="h-4 w-4" /> + </FileListAction> + </FileListHeader> + </FileListItem> + ))} + </FileList> + </ScrollArea> + </div> + )} + </FormItem> + )} + /> + </div> + )} + </div> + )} + + {/* ───────────────────────────────────────── + 첨부파일 (사업자등록증 등) + ───────────────────────────────────────── */} + <div className="rounded-md border p-4 space-y-4"> + <h4 className="text-md font-semibold">기타 첨부파일</h4> + <FormField + control={form.control} + name="attachedFiles" + render={() => ( + <FormItem> + <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500"> + 첨부 파일 + </FormLabel> + <Dropzone + maxSize={MAX_FILE_SIZE} + multiple + onDropAccepted={handleDropAccepted} + onDropRejected={handleDropRejected} + disabled={isSubmitting} + > + {({ maxSize }) => ( + <DropzoneZone className="flex justify-center"> + <DropzoneInput /> + <div className="flex items-center gap-4"> + <DropzoneUploadIcon /> + <div className="grid gap-1"> + <DropzoneTitle>파일 업로드</DropzoneTitle> + <DropzoneDescription> + 드래그 또는 클릭 + {maxSize + ? ` (최대: ${prettyBytes(maxSize)})` + : null} + </DropzoneDescription> + </div> + </div> + </DropzoneZone> + )} + </Dropzone> + {selectedFiles.length > 0 && ( + <div className="mt-2"> + <ScrollArea className="max-h-32"> + <FileList className="gap-2"> + {selectedFiles.map((file, i) => ( + <FileListItem key={file.name + i}> + <FileListHeader> + <FileListIcon /> + <FileListInfo> + <FileListName>{file.name}</FileListName> + <FileListDescription> + {prettyBytes(file.size)} + </FileListDescription> + </FileListInfo> + <FileListAction onClick={() => removeFile(i)}> + <X className="h-4 w-4" /> + </FileListAction> + </FileListHeader> + </FileListItem> + ))} + </FileList> + </ScrollArea> + </div> + )} + </FormItem> + )} + /> + </div> + + {/* ───────────────────────────────────────── + Submit + ───────────────────────────────────────── */} + <div className="flex justify-end"> + <Button type="submit" disabled={!isFormValid || isSubmitting}> + {isSubmitting ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + 등록 중... + </> + ) : ( + "Submit" + )} + </Button> + </div> + </form> + </Form> + </div> + </section> + </div> + ) +}
\ No newline at end of file diff --git a/components/system/permissionDialog.tsx b/components/system/permissionDialog.tsx new file mode 100644 index 00000000..f7247672 --- /dev/null +++ b/components/system/permissionDialog.tsx @@ -0,0 +1,301 @@ +"use client" + +import * as React from "react" +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog" +import { Checkbox } from "@/components/ui/checkbox" +import { Button } from "@/components/ui/button" +import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip" +import { RoleView } from "@/db/schema/users" +import { + getAllRoleView, + getMenuPermissions, + upsertPermissions +} from "@/lib/roles/services" +import { useToast } from "@/hooks/use-toast" +import { Loader } from "lucide-react" +import { permissionLabelMap } from "@/config/permissionsConfig" + +interface PermissionDialogProps { + open: boolean + onOpenChange: (val: boolean) => void + itemKey?: string + itemTitle?: string +} + +export function PermissionDialog({ + open, + onOpenChange, + itemKey, + itemTitle, +}: PermissionDialogProps) { + // **(A)**: 체크박스에 의해 새로 추가할 권한(perms) + const [permissions, setPermissions] = React.useState<string[]>([]) + + // **(B)**: 체크된 Roles(새로 부여할 대상) + const [selectedRoles, setSelectedRoles] = React.useState<number[]>([]) + + // **(C)**: 전체 Role 목록 + const [roles, setRoles] = React.useState<RoleView[]>([]) + + // **(D)**: Role별 이미 존재하는 권한들 → UI 표시용 + const [rolePermsMap, setRolePermsMap] = React.useState<Record<number, string[]>>({}) + + const { toast } = useToast() + const [isPending, startTransition] = React.useTransition() + + // 1) Role 목록 로드 + React.useEffect(() => { + getAllRoleView("evcp").then((res) => { + setRoles(res) + }) + }, []) + + // 2) Dialog 열릴 때 → DB에서 “이미 부여된 권한” 로드 + React.useEffect(() => { + if (open && itemKey) { + // 기존에 어떤 Role들이 itemKey 퍼미션을 가지고 있는지 + getMenuPermissions(itemKey).then((rows) => { + // rows: { roleId, permKey: "itemKey.xxx" } + // rolePermsMap[r.roleId] = ["create","viewAll",...] + const rMap: Record<number, string[]> = {} + for (const row of rows) { + const splitted = row.permKey.split(".") + const shortPerm = splitted[1] + if (!rMap[row.roleId]) { + rMap[row.roleId] = [] + } + rMap[row.roleId].push(shortPerm) + } + setRolePermsMap(rMap) + + // 권한 체크박스(permissions)와 selectedRoles는 + // "항상 비어있는 상태"로 시작 (새로 추가할 용도) + setPermissions([]) + setSelectedRoles([]) + }) + } else if (!open) { + // Dialog가 닫힐 때 리셋 + setPermissions([]) + setSelectedRoles([]) + setRolePermsMap({}) + } + }, [open, itemKey]) + + // Checkbox toggle: 권한 + function togglePermission(perm: string) { + setPermissions((prev) => + prev.includes(perm) ? prev.filter((p) => p !== perm) : [...prev, perm] + ) + } + + // Checkbox toggle: Role + function toggleRole(roleId: number) { + setSelectedRoles((prev) => + prev.includes(roleId) ? prev.filter((p) => p !== roleId) : [...prev, roleId] + ) + } + + async function handleSave() { + if (!itemKey) { + toast({ + variant: "destructive", + title: "오류", + description: "선택한 메뉴가 없어 권한을 생성할 수 없습니다.", + }) + onOpenChange(false) + return + } + + // permission_key = itemKey.perm + const permissionKeys = permissions.map((perm) => `${itemKey}.${perm}`) + + startTransition(async () => { + try { + await upsertPermissions({ + roleIds: selectedRoles, + permissionKeys, + itemTitle, + }) + + toast({ + variant: "default", + title: "권한 설정 완료", + description: "새 권한이 정상적으로 설정되었습니다.", + }) + setPermissions([]) + setSelectedRoles([]) + setRolePermsMap({}) + onOpenChange(false) + } catch (err) { + toast({ + variant: "destructive", + title: "오류", + description: "권한 설정에 실패했습니다. 다시 시도해주세요.", + }) + } + }) + } + + function handleCancel() { + setPermissions([]) + setSelectedRoles([]) + setRolePermsMap({}) + onOpenChange(false) + } + + const isDisabled = isPending + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-3xl"> + <DialogHeader> + <DialogTitle> + 권한 설정 - <span className="text-primary">{itemTitle}</span> + </DialogTitle> + </DialogHeader> + + <div className="flex flex-col gap-6"> + {/* 1) Role 표시: 이미 가진 권한은 slash 구분 */} + <section> + <h2 className="font-bold mb-2">Role & 이미 부여된 권한</h2> + <div className="max-h-[200px] overflow-y-auto border p-2 rounded space-y-2"> + {roles.map((r) => { + const existPerms = rolePermsMap[r.id] || [] + const permsText = existPerms + .map((perm) => permissionLabelMap[perm] ?? perm) + // ↑ 매핑에 없는 키일 경우 대비해 ?? perm 로 처리 + .join(" / ") + + return ( + <div key={r.id} className="flex items-center gap-2"> + <Checkbox + checked={selectedRoles.includes(r.id)} + onCheckedChange={() => toggleRole(r.id)} + disabled={isDisabled} + /> + <Tooltip> + <TooltipTrigger asChild> + <span className="cursor-help text-sm font-medium"> + {r.name} + </span> + </TooltipTrigger> + <TooltipContent> + {r.description ?? "No description"} + </TooltipContent> + </Tooltip> + {/* 이미 가진 권한 텍스트 */} + {permsText && ( + <span className="ml-2 text-xs text-muted-foreground"> + {permsText} + </span> + )} + </div> + ) + })} + </div> + </section> + + {/* 2) 새 권한 체크박스 */} + <section> + <h2 className="font-bold mb-2">새로 부여할 권한</h2> + <div className="grid grid-cols-2 gap-6 border p-2 rounded"> + {/* 왼쪽 */} + <div className="space-y-4"> + {/* 생성 */} + <div> + <h3 className="mb-2 font-semibold">생성</h3> + <div className="flex items-center gap-2"> + <Checkbox + checked={permissions.includes("create")} + onCheckedChange={() => togglePermission("create")} + disabled={isDisabled} + /> + <span className="text-sm">{permissionLabelMap["create"]}</span> + </div> + </div> + + {/* 보기 */} + <div> + <h3 className="mb-2 font-semibold">보기</h3> + <div className="flex items-center gap-2"> + <Checkbox + checked={permissions.includes("viewAll")} + onCheckedChange={() => togglePermission("viewAll")} + disabled={isDisabled} + /> + <span className="text-sm">{permissionLabelMap["viewAll"]}</span> + </div> + <div className="flex items-center gap-2"> + <Checkbox + checked={permissions.includes("viewOwn")} + onCheckedChange={() => togglePermission("viewOwn")} + disabled={isDisabled} + /> + <span className="text-sm">{permissionLabelMap["viewOwn"]}</span> + </div> + </div> + </div> + + {/* 오른쪽 */} + <div className="space-y-4"> + {/* 편집 */} + <div> + <h3 className="mb-2 font-semibold">편집</h3> + <div className="flex items-center gap-2"> + <Checkbox + checked={permissions.includes("editAll")} + onCheckedChange={() => togglePermission("editAll")} + disabled={isDisabled} + /> + <span className="text-sm">{permissionLabelMap["editAll"]}</span> + </div> + <div className="flex items-center gap-2"> + <Checkbox + checked={permissions.includes("editOwn")} + onCheckedChange={() => togglePermission("editOwn")} + disabled={isDisabled} + /> + <span className="text-sm">{permissionLabelMap["editOwn"]}</span> + </div> + </div> + + {/* 삭제 */} + <div> + <h3 className="mb-2 font-semibold">삭제</h3> + <div className="flex items-center gap-2"> + <Checkbox + checked={permissions.includes("deleteAll")} + onCheckedChange={() => togglePermission("deleteAll")} + disabled={isDisabled} + /> + <span className="text-sm">{permissionLabelMap["deleteAll"]}</span> + </div> + <div className="flex items-center gap-2"> + <Checkbox + checked={permissions.includes("deleteOwn")} + onCheckedChange={() => togglePermission("deleteOwn")} + disabled={isDisabled} + /> + <span className="text-sm">{permissionLabelMap["deleteOwn"]}</span> + </div> + </div> + </div> + </div> + </section> + </div> + + <DialogFooter> + <Button variant="secondary" onClick={handleCancel} disabled={isDisabled}> + 취소 + </Button> + <Button variant="default" onClick={handleSave} disabled={isDisabled}> + {isPending && ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + )} + 저장 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/components/system/permissionsTree.tsx b/components/system/permissionsTree.tsx new file mode 100644 index 00000000..8f6adfb0 --- /dev/null +++ b/components/system/permissionsTree.tsx @@ -0,0 +1,167 @@ +"use client" + +import * as React from 'react'; +import Box from '@mui/material/Box'; +import Stack from '@mui/material/Stack'; +import SvgIcon, { SvgIconProps } from '@mui/material/SvgIcon'; +import { styled } from '@mui/material/styles'; +import { SimpleTreeView } from '@mui/x-tree-view/SimpleTreeView'; +import { TreeItem, treeItemClasses } from '@mui/x-tree-view/TreeItem'; +import { Minus, MinusSquare, Plus, SquarePlus } from 'lucide-react'; +import { Button } from "@/components/ui/button"; +import { mainNav, additionalNav, MenuSection } from "@/config/menuConfig"; +import { PermissionDialog } from './permissionDialog'; + +// ------------------- Custom TreeItem Style ------------------- +const CustomTreeItem = styled(TreeItem)({ + [`& .${treeItemClasses.iconContainer}`]: { + '& .close': { + opacity: 0.3, + }, + }, +}); + +function CloseSquare(props: SvgIconProps) { + return ( + <SvgIcon + className="close" + fontSize="inherit" + style={{ width: 14, height: 14 }} + {...props} + > + {/* tslint:disable-next-line: max-line-length */} + <path d="M17.485 17.512q-.281.281-.682.281t-.696-.268l-4.12-4.147-4.12 4.147q-.294.268-.696.268t-.682-.281-.281-.682.294-.669l4.12-4.147-4.12-4.147q-.294-.268-.294-.669t.281-.682.682-.281.696.268l4.12 4.147 4.12-4.147q.294-.268.696-.268t.682.281 .281.669-.294.682l-4.12 4.147 4.12 4.147q.294.268 .294.669t-.281.682zM22.047 22.074v0 0-20.147 0h-20.12v0 20.147 0h20.12zM22.047 24h-20.12q-.803 0-1.365-.562t-.562-1.365v-20.147q0-.776.562-1.351t1.365-.575h20.147q.776 0 1.351.575t.575 1.351v20.147q0 .803-.575 1.365t-1.378.562v0z" /> + </SvgIcon> + ); +} + + +interface SelectedKey { + key: string; + title: string; +} + +export default function PermissionsTree() { + const [expandedItems, setExpandedItems] = React.useState<string[]>([]); + const [dialogOpen, setDialogOpen] = React.useState(false); + const [selectedKey, setSelectedKey] = React.useState<SelectedKey | null>(null); + + const handleExpandedItemsChange = ( + event: React.SyntheticEvent, + itemIds: string[], + ) => { + setExpandedItems(itemIds); + }; + + const handleExpandClick = () => { + if (expandedItems.length === 0) { + // 모든 노드를 펼치기 + // 실제로는 mainNav와 additionalNav를 순회해 itemId를 전부 수집하는 방식 + setExpandedItems([...collectAllIds()]); + } else { + setExpandedItems([]); + } + }; + + // (4) 수동으로 "모든 TreeItem의 itemId"를 수집하는 함수 + const collectAllIds = React.useCallback(() => { + const ids: string[] = []; + + // mainNav: 상위 = section.title, 하위 = item.title + mainNav.forEach((section) => { + ids.push(section.title); // 상위 + section.items.forEach((itm) => ids.push(itm.title)); + }); + + // additionalNav를 "기타메뉴" 아래에 넣을 경우, "기타메뉴" 라는 itemId + each item + additionalNav.forEach((itm) => ids.push(itm.title)); + return ids; + }, []); + + + function handleItemClick(key: SelectedKey) { + // 1) Dialog 열기 + setSelectedKey(key); // 이 값은 Dialog에서 어떤 메뉴인지 식별에 사용 + setDialogOpen(true); + } + + // (5) 실제 렌더 + return ( + <div className='lg:max-w-2xl'> + <Stack spacing={2}> + <div> + <Button onClick={handleExpandClick} type='button'> + {expandedItems.length === 0 ? ( + <> + <Plus /> + Expand All + </> + ) : ( + <> + <Minus /> + Collapse All + </> + )} + </Button> + </div> + + <Box sx={{ minHeight: 352, minWidth: 250 }}> + <SimpleTreeView + // 아래 props로 아이콘 지정 + slots={{ + expandIcon: SquarePlus, + collapseIcon: MinusSquare, + endIcon: CloseSquare, + }} + expansionTrigger="iconContainer" + onExpandedItemsChange={handleExpandedItemsChange} + expandedItems={expandedItems} + > + {/* (A) mainNav를 트리로 렌더 */} + {mainNav.map((section) => ( + <CustomTreeItem + key={section.title} + itemId={section.title} + label={section.title} + > + {section.items.map((itm) => { + const lastSegment = itm.href.split("/").pop() || itm.title; + const key = { key: lastSegment, title: itm.title } + return ( + <CustomTreeItem + key={lastSegment} + itemId={lastSegment} + label={itm.title} + onClick={() => handleItemClick(key)} + /> + ); + })} + </CustomTreeItem> + ))} + + + {additionalNav.map((itm) => { + const lastSegment = itm.href.split("/").pop() || itm.title; + const key = { key: lastSegment, title: itm.title } + return ( + <CustomTreeItem + key={lastSegment} + itemId={lastSegment} + label={itm.title} + onClick={() => handleItemClick(key)} + /> + ); + })} + </SimpleTreeView> + </Box> + </Stack> + + <PermissionDialog + open={dialogOpen} + onOpenChange={setDialogOpen} + itemKey={selectedKey?.key} + itemTitle={selectedKey?.title} + /> + </div> + ); +}
\ No newline at end of file diff --git a/components/ui/accordion.tsx b/components/ui/accordion.tsx new file mode 100644 index 00000000..2f55a32f --- /dev/null +++ b/components/ui/accordion.tsx @@ -0,0 +1,57 @@ +"use client" + +import * as React from "react" +import * as AccordionPrimitive from "@radix-ui/react-accordion" +import { ChevronDown } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Accordion = AccordionPrimitive.Root + +const AccordionItem = React.forwardRef< + React.ElementRef<typeof AccordionPrimitive.Item>, + React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item> +>(({ className, ...props }, ref) => ( + <AccordionPrimitive.Item + ref={ref} + className={cn("border-b", className)} + {...props} + /> +)) +AccordionItem.displayName = "AccordionItem" + +const AccordionTrigger = React.forwardRef< + React.ElementRef<typeof AccordionPrimitive.Trigger>, + React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger> +>(({ className, children, ...props }, ref) => ( + <AccordionPrimitive.Header className="flex"> + <AccordionPrimitive.Trigger + ref={ref} + className={cn( + "flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline text-left [&[data-state=open]>svg]:rotate-180", + className + )} + {...props} + > + {children} + <ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200" /> + </AccordionPrimitive.Trigger> + </AccordionPrimitive.Header> +)) +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName + +const AccordionContent = React.forwardRef< + React.ElementRef<typeof AccordionPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content> +>(({ className, children, ...props }, ref) => ( + <AccordionPrimitive.Content + ref={ref} + className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down" + {...props} + > + <div className={cn("pb-4 pt-0", className)}>{children}</div> + </AccordionPrimitive.Content> +)) +AccordionContent.displayName = AccordionPrimitive.Content.displayName + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/components/ui/action-dialog.tsx b/components/ui/action-dialog.tsx new file mode 100644 index 00000000..9927bcc5 --- /dev/null +++ b/components/ui/action-dialog.tsx @@ -0,0 +1,54 @@ +"use client" + +import * as React from "react" +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogClose } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Loader } from "lucide-react" + +interface ActionConfirmDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + title: string + description?: string + confirmLabel?: string + confirmVariant?: "default" | "destructive" | "outline" | "secondary" | "ghost" + onConfirm: () => Promise<void> | void + isLoading?: boolean +} + +export function ActionConfirmDialog({ + open, + onOpenChange, + title, + description, + confirmLabel = "Confirm", + confirmVariant = "destructive", + onConfirm, + isLoading, +}: ActionConfirmDialogProps) { + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent> + <DialogHeader> + <DialogTitle>{title}</DialogTitle> + {description ? <DialogDescription>{description}</DialogDescription> : null} + </DialogHeader> + <DialogFooter className="gap-2"> + <DialogClose asChild> + <Button variant="outline">Cancel</Button> + </DialogClose> + <Button + variant={confirmVariant} + onClick={onConfirm} + disabled={isLoading} + > + {isLoading && ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + )} + {confirmLabel} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/components/ui/alert-dialog.tsx b/components/ui/alert-dialog.tsx new file mode 100644 index 00000000..57760f2e --- /dev/null +++ b/components/ui/alert-dialog.tsx @@ -0,0 +1,141 @@ +"use client" + +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +const AlertDialog = AlertDialogPrimitive.Root + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger + +const AlertDialogPortal = AlertDialogPrimitive.Portal + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef<typeof AlertDialogPrimitive.Overlay>, + React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay> +>(({ className, ...props }, ref) => ( + <AlertDialogPrimitive.Overlay + className={cn( + "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", + className + )} + {...props} + ref={ref} + /> +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef< + React.ElementRef<typeof AlertDialogPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content> +>(({ className, ...props }, ref) => ( + <AlertDialogPortal> + <AlertDialogOverlay /> + <AlertDialogPrimitive.Content + ref={ref} + className={cn( + "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg", + className + )} + {...props} + /> + </AlertDialogPortal> +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes<HTMLDivElement>) => ( + <div + className={cn( + "flex flex-col space-y-2 text-center sm:text-left", + className + )} + {...props} + /> +) +AlertDialogHeader.displayName = "AlertDialogHeader" + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes<HTMLDivElement>) => ( + <div + className={cn( + "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", + className + )} + {...props} + /> +) +AlertDialogFooter.displayName = "AlertDialogFooter" + +const AlertDialogTitle = React.forwardRef< + React.ElementRef<typeof AlertDialogPrimitive.Title>, + React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title> +>(({ className, ...props }, ref) => ( + <AlertDialogPrimitive.Title + ref={ref} + className={cn("text-lg font-semibold", className)} + {...props} + /> +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef< + React.ElementRef<typeof AlertDialogPrimitive.Description>, + React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description> +>(({ className, ...props }, ref) => ( + <AlertDialogPrimitive.Description + ref={ref} + className={cn("text-sm text-muted-foreground", className)} + {...props} + /> +)) +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef< + React.ElementRef<typeof AlertDialogPrimitive.Action>, + React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action> +>(({ className, ...props }, ref) => ( + <AlertDialogPrimitive.Action + ref={ref} + className={cn(buttonVariants(), className)} + {...props} + /> +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef< + React.ElementRef<typeof AlertDialogPrimitive.Cancel>, + React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel> +>(({ className, ...props }, ref) => ( + <AlertDialogPrimitive.Cancel + ref={ref} + className={cn( + buttonVariants({ variant: "outline" }), + "mt-2 sm:mt-0", + className + )} + {...props} + /> +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/components/ui/alert.tsx b/components/ui/alert.tsx new file mode 100644 index 00000000..5afd41d1 --- /dev/null +++ b/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants> +>(({ className, variant, ...props }, ref) => ( + <div + ref={ref} + role="alert" + className={cn(alertVariants({ variant }), className)} + {...props} + /> +)) +Alert.displayName = "Alert" + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes<HTMLHeadingElement> +>(({ className, ...props }, ref) => ( + <h5 + ref={ref} + className={cn("mb-1 font-medium leading-none tracking-tight", className)} + {...props} + /> +)) +AlertTitle.displayName = "AlertTitle" + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes<HTMLParagraphElement> +>(({ className, ...props }, ref) => ( + <div + ref={ref} + className={cn("text-sm [&_p]:leading-relaxed", className)} + {...props} + /> +)) +AlertDescription.displayName = "AlertDescription" + +export { Alert, AlertTitle, AlertDescription } diff --git a/components/ui/aspect-ratio.tsx b/components/ui/aspect-ratio.tsx new file mode 100644 index 00000000..d6a5226f --- /dev/null +++ b/components/ui/aspect-ratio.tsx @@ -0,0 +1,7 @@ +"use client" + +import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio" + +const AspectRatio = AspectRatioPrimitive.Root + +export { AspectRatio } diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx new file mode 100644 index 00000000..51e507ba --- /dev/null +++ b/components/ui/avatar.tsx @@ -0,0 +1,50 @@ +"use client" + +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/lib/utils" + +const Avatar = React.forwardRef< + React.ElementRef<typeof AvatarPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root> +>(({ className, ...props }, ref) => ( + <AvatarPrimitive.Root + ref={ref} + className={cn( + "relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", + className + )} + {...props} + /> +)) +Avatar.displayName = AvatarPrimitive.Root.displayName + +const AvatarImage = React.forwardRef< + React.ElementRef<typeof AvatarPrimitive.Image>, + React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image> +>(({ className, ...props }, ref) => ( + <AvatarPrimitive.Image + ref={ref} + className={cn("aspect-square h-full w-full", className)} + {...props} + /> +)) +AvatarImage.displayName = AvatarPrimitive.Image.displayName + +const AvatarFallback = React.forwardRef< + React.ElementRef<typeof AvatarPrimitive.Fallback>, + React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback> +>(({ className, ...props }, ref) => ( + <AvatarPrimitive.Fallback + ref={ref} + className={cn( + "flex h-full w-full items-center justify-center rounded-full bg-muted", + className + )} + {...props} + /> +)) +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx new file mode 100644 index 00000000..e87d62bf --- /dev/null +++ b/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +export interface BadgeProps + extends React.HTMLAttributes<HTMLDivElement>, + VariantProps<typeof badgeVariants> {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( + <div className={cn(badgeVariants({ variant }), className)} {...props} /> + ) +} + +export { Badge, badgeVariants } diff --git a/components/ui/breadcrumb.tsx b/components/ui/breadcrumb.tsx new file mode 100644 index 00000000..60e6c96f --- /dev/null +++ b/components/ui/breadcrumb.tsx @@ -0,0 +1,115 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { ChevronRight, MoreHorizontal } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Breadcrumb = React.forwardRef< + HTMLElement, + React.ComponentPropsWithoutRef<"nav"> & { + separator?: React.ReactNode + } +>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />) +Breadcrumb.displayName = "Breadcrumb" + +const BreadcrumbList = React.forwardRef< + HTMLOListElement, + React.ComponentPropsWithoutRef<"ol"> +>(({ className, ...props }, ref) => ( + <ol + ref={ref} + className={cn( + "flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5", + className + )} + {...props} + /> +)) +BreadcrumbList.displayName = "BreadcrumbList" + +const BreadcrumbItem = React.forwardRef< + HTMLLIElement, + React.ComponentPropsWithoutRef<"li"> +>(({ className, ...props }, ref) => ( + <li + ref={ref} + className={cn("inline-flex items-center gap-1.5", className)} + {...props} + /> +)) +BreadcrumbItem.displayName = "BreadcrumbItem" + +const BreadcrumbLink = React.forwardRef< + HTMLAnchorElement, + React.ComponentPropsWithoutRef<"a"> & { + asChild?: boolean + } +>(({ asChild, className, ...props }, ref) => { + const Comp = asChild ? Slot : "a" + + return ( + <Comp + ref={ref} + className={cn("transition-colors hover:text-foreground", className)} + {...props} + /> + ) +}) +BreadcrumbLink.displayName = "BreadcrumbLink" + +const BreadcrumbPage = React.forwardRef< + HTMLSpanElement, + React.ComponentPropsWithoutRef<"span"> +>(({ className, ...props }, ref) => ( + <span + ref={ref} + role="link" + aria-disabled="true" + aria-current="page" + className={cn("font-normal text-foreground", className)} + {...props} + /> +)) +BreadcrumbPage.displayName = "BreadcrumbPage" + +const BreadcrumbSeparator = ({ + children, + className, + ...props +}: React.ComponentProps<"li">) => ( + <li + role="presentation" + aria-hidden="true" + className={cn("[&>svg]:w-3.5 [&>svg]:h-3.5", className)} + {...props} + > + {children ?? <ChevronRight />} + </li> +) +BreadcrumbSeparator.displayName = "BreadcrumbSeparator" + +const BreadcrumbEllipsis = ({ + className, + ...props +}: React.ComponentProps<"span">) => ( + <span + role="presentation" + aria-hidden="true" + className={cn("flex h-9 w-9 items-center justify-center", className)} + {...props} + > + <MoreHorizontal className="h-4 w-4" /> + <span className="sr-only">More</span> + </span> +) +BreadcrumbEllipsis.displayName = "BreadcrumbElipssis" + +export { + Breadcrumb, + BreadcrumbList, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbPage, + BreadcrumbSeparator, + BreadcrumbEllipsis, +} diff --git a/components/ui/button.tsx b/components/ui/button.tsx new file mode 100644 index 00000000..6473751a --- /dev/null +++ b/components/ui/button.tsx @@ -0,0 +1,60 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + { + variants: { + variant: { + default: + "bg-primary text-primary-foreground shadow hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", + outline: + "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + samsung: + "bg-[hsl(222,80%,40%)] text-white shadow-sm hover:bg-[hsl(222,80%,40%)]/80", + }, + size: { + samsung:"h-9 px-4 py-2", + default: "h-9 px-4 py-2", + sm: "h-8 rounded-md px-3 text-xs", + lg: "h-10 rounded-md px-8", + icon: "h-9 w-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes<HTMLButtonElement>, + VariantProps<typeof buttonVariants> { + asChild?: boolean +} + +const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + return ( + <Comp + className={cn(buttonVariants({ variant, size, className }))} + ref={ref} + {...props} + /> + ) + } +) +Button.displayName = "Button" + +export { Button, buttonVariants } diff --git a/components/ui/calendar.tsx b/components/ui/calendar.tsx new file mode 100644 index 00000000..115cff90 --- /dev/null +++ b/components/ui/calendar.tsx @@ -0,0 +1,76 @@ +"use client" + +import * as React from "react" +import { ChevronLeft, ChevronRight } from "lucide-react" +import { DayPicker } from "react-day-picker" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +export type CalendarProps = React.ComponentProps<typeof DayPicker> + +function Calendar({ + className, + classNames, + showOutsideDays = true, + ...props +}: CalendarProps) { + return ( + <DayPicker + showOutsideDays={showOutsideDays} + className={cn("p-3", className)} + classNames={{ + months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0", + month: "space-y-4", + caption: "flex justify-center pt-1 relative items-center", + caption_label: "text-sm font-medium", + nav: "space-x-1 flex items-center", + nav_button: cn( + buttonVariants({ variant: "outline" }), + "h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100" + ), + nav_button_previous: "absolute left-1", + nav_button_next: "absolute right-1", + table: "w-full border-collapse space-y-1", + head_row: "flex", + head_cell: + "text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]", + row: "flex w-full mt-2", + cell: cn( + "relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected].day-range-end)]:rounded-r-md", + props.mode === "range" + ? "[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md" + : "[&:has([aria-selected])]:rounded-md" + ), + day: cn( + buttonVariants({ variant: "ghost" }), + "h-8 w-8 p-0 font-normal aria-selected:opacity-100" + ), + day_range_start: "day-range-start", + day_range_end: "day-range-end", + day_selected: + "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground", + day_today: "bg-accent text-accent-foreground", + day_outside: + "day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground", + day_disabled: "text-muted-foreground opacity-50", + day_range_middle: + "aria-selected:bg-accent aria-selected:text-accent-foreground", + day_hidden: "invisible", + ...classNames, + }} + components={{ + IconLeft: ({ className, ...props }) => ( + <ChevronLeft className={cn("h-4 w-4", className)} {...props} /> + ), + IconRight: ({ className, ...props }) => ( + <ChevronRight className={cn("h-4 w-4", className)} {...props} /> + ), + }} + {...props} + /> + ) +} +Calendar.displayName = "Calendar" + +export { Calendar } diff --git a/components/ui/card.tsx b/components/ui/card.tsx new file mode 100644 index 00000000..cabfbfc5 --- /dev/null +++ b/components/ui/card.tsx @@ -0,0 +1,76 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> +>(({ className, ...props }, ref) => ( + <div + ref={ref} + className={cn( + "rounded-xl border bg-card text-card-foreground shadow", + className + )} + {...props} + /> +)) +Card.displayName = "Card" + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> +>(({ className, ...props }, ref) => ( + <div + ref={ref} + className={cn("flex flex-col space-y-1.5 p-6", className)} + {...props} + /> +)) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> +>(({ className, ...props }, ref) => ( + <div + ref={ref} + className={cn("font-semibold leading-none tracking-tight", className)} + {...props} + /> +)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> +>(({ className, ...props }, ref) => ( + <div + ref={ref} + className={cn("text-sm text-muted-foreground", className)} + {...props} + /> +)) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> +>(({ className, ...props }, ref) => ( + <div ref={ref} className={cn("p-6 pt-0", className)} {...props} /> +)) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> +>(({ className, ...props }, ref) => ( + <div + ref={ref} + className={cn("flex items-center p-6 pt-0", className)} + {...props} + /> +)) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/components/ui/carousel.tsx b/components/ui/carousel.tsx new file mode 100644 index 00000000..ec505d00 --- /dev/null +++ b/components/ui/carousel.tsx @@ -0,0 +1,262 @@ +"use client" + +import * as React from "react" +import useEmblaCarousel, { + type UseEmblaCarouselType, +} from "embla-carousel-react" +import { ArrowLeft, ArrowRight } from "lucide-react" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" + +type CarouselApi = UseEmblaCarouselType[1] +type UseCarouselParameters = Parameters<typeof useEmblaCarousel> +type CarouselOptions = UseCarouselParameters[0] +type CarouselPlugin = UseCarouselParameters[1] + +type CarouselProps = { + opts?: CarouselOptions + plugins?: CarouselPlugin + orientation?: "horizontal" | "vertical" + setApi?: (api: CarouselApi) => void +} + +type CarouselContextProps = { + carouselRef: ReturnType<typeof useEmblaCarousel>[0] + api: ReturnType<typeof useEmblaCarousel>[1] + scrollPrev: () => void + scrollNext: () => void + canScrollPrev: boolean + canScrollNext: boolean +} & CarouselProps + +const CarouselContext = React.createContext<CarouselContextProps | null>(null) + +function useCarousel() { + const context = React.useContext(CarouselContext) + + if (!context) { + throw new Error("useCarousel must be used within a <Carousel />") + } + + return context +} + +const Carousel = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> & CarouselProps +>( + ( + { + orientation = "horizontal", + opts, + setApi, + plugins, + className, + children, + ...props + }, + ref + ) => { + const [carouselRef, api] = useEmblaCarousel( + { + ...opts, + axis: orientation === "horizontal" ? "x" : "y", + }, + plugins + ) + const [canScrollPrev, setCanScrollPrev] = React.useState(false) + const [canScrollNext, setCanScrollNext] = React.useState(false) + + const onSelect = React.useCallback((api: CarouselApi) => { + if (!api) { + return + } + + setCanScrollPrev(api.canScrollPrev()) + setCanScrollNext(api.canScrollNext()) + }, []) + + const scrollPrev = React.useCallback(() => { + api?.scrollPrev() + }, [api]) + + const scrollNext = React.useCallback(() => { + api?.scrollNext() + }, [api]) + + const handleKeyDown = React.useCallback( + (event: React.KeyboardEvent<HTMLDivElement>) => { + if (event.key === "ArrowLeft") { + event.preventDefault() + scrollPrev() + } else if (event.key === "ArrowRight") { + event.preventDefault() + scrollNext() + } + }, + [scrollPrev, scrollNext] + ) + + React.useEffect(() => { + if (!api || !setApi) { + return + } + + setApi(api) + }, [api, setApi]) + + React.useEffect(() => { + if (!api) { + return + } + + onSelect(api) + api.on("reInit", onSelect) + api.on("select", onSelect) + + return () => { + api?.off("select", onSelect) + } + }, [api, onSelect]) + + return ( + <CarouselContext.Provider + value={{ + carouselRef, + api: api, + opts, + orientation: + orientation || (opts?.axis === "y" ? "vertical" : "horizontal"), + scrollPrev, + scrollNext, + canScrollPrev, + canScrollNext, + }} + > + <div + ref={ref} + onKeyDownCapture={handleKeyDown} + className={cn("relative", className)} + role="region" + aria-roledescription="carousel" + {...props} + > + {children} + </div> + </CarouselContext.Provider> + ) + } +) +Carousel.displayName = "Carousel" + +const CarouselContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> +>(({ className, ...props }, ref) => { + const { carouselRef, orientation } = useCarousel() + + return ( + <div ref={carouselRef} className="overflow-hidden"> + <div + ref={ref} + className={cn( + "flex", + orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col", + className + )} + {...props} + /> + </div> + ) +}) +CarouselContent.displayName = "CarouselContent" + +const CarouselItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> +>(({ className, ...props }, ref) => { + const { orientation } = useCarousel() + + return ( + <div + ref={ref} + role="group" + aria-roledescription="slide" + className={cn( + "min-w-0 shrink-0 grow-0 basis-full", + orientation === "horizontal" ? "pl-4" : "pt-4", + className + )} + {...props} + /> + ) +}) +CarouselItem.displayName = "CarouselItem" + +const CarouselPrevious = React.forwardRef< + HTMLButtonElement, + React.ComponentProps<typeof Button> +>(({ className, variant = "outline", size = "icon", ...props }, ref) => { + const { orientation, scrollPrev, canScrollPrev } = useCarousel() + + return ( + <Button + ref={ref} + variant={variant} + size={size} + className={cn( + "absolute h-8 w-8 rounded-full", + orientation === "horizontal" + ? "-left-12 top-1/2 -translate-y-1/2" + : "-top-12 left-1/2 -translate-x-1/2 rotate-90", + className + )} + disabled={!canScrollPrev} + onClick={scrollPrev} + {...props} + > + <ArrowLeft className="h-4 w-4" /> + <span className="sr-only">Previous slide</span> + </Button> + ) +}) +CarouselPrevious.displayName = "CarouselPrevious" + +const CarouselNext = React.forwardRef< + HTMLButtonElement, + React.ComponentProps<typeof Button> +>(({ className, variant = "outline", size = "icon", ...props }, ref) => { + const { orientation, scrollNext, canScrollNext } = useCarousel() + + return ( + <Button + ref={ref} + variant={variant} + size={size} + className={cn( + "absolute h-8 w-8 rounded-full", + orientation === "horizontal" + ? "-right-12 top-1/2 -translate-y-1/2" + : "-bottom-12 left-1/2 -translate-x-1/2 rotate-90", + className + )} + disabled={!canScrollNext} + onClick={scrollNext} + {...props} + > + <ArrowRight className="h-4 w-4" /> + <span className="sr-only">Next slide</span> + </Button> + ) +}) +CarouselNext.displayName = "CarouselNext" + +export { + type CarouselApi, + Carousel, + CarouselContent, + CarouselItem, + CarouselPrevious, + CarouselNext, +} diff --git a/components/ui/chart.tsx b/components/ui/chart.tsx new file mode 100644 index 00000000..32dc873f --- /dev/null +++ b/components/ui/chart.tsx @@ -0,0 +1,365 @@ +"use client" + +import * as React from "react" +import * as RechartsPrimitive from "recharts" + +import { cn } from "@/lib/utils" + +// Format: { THEME_NAME: CSS_SELECTOR } +const THEMES = { light: "", dark: ".dark" } as const + +export type ChartConfig = { + [k in string]: { + label?: React.ReactNode + icon?: React.ComponentType + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record<keyof typeof THEMES, string> } + ) +} + +type ChartContextProps = { + config: ChartConfig +} + +const ChartContext = React.createContext<ChartContextProps | null>(null) + +function useChart() { + const context = React.useContext(ChartContext) + + if (!context) { + throw new Error("useChart must be used within a <ChartContainer />") + } + + return context +} + +const ChartContainer = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + config: ChartConfig + children: React.ComponentProps< + typeof RechartsPrimitive.ResponsiveContainer + >["children"] + } +>(({ id, className, children, config, ...props }, ref) => { + const uniqueId = React.useId() + const chartId = `chart-${id || uniqueId.replace(/:/g, "")}` + + return ( + <ChartContext.Provider value={{ config }}> + <div + data-chart={chartId} + ref={ref} + className={cn( + "flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none", + className + )} + {...props} + > + <ChartStyle id={chartId} config={config} /> + <RechartsPrimitive.ResponsiveContainer> + {children} + </RechartsPrimitive.ResponsiveContainer> + </div> + </ChartContext.Provider> + ) +}) +ChartContainer.displayName = "Chart" + +const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { + const colorConfig = Object.entries(config).filter( + ([, config]) => config.theme || config.color + ) + + if (!colorConfig.length) { + return null + } + + return ( + <style + dangerouslySetInnerHTML={{ + __html: Object.entries(THEMES) + .map( + ([theme, prefix]) => ` +${prefix} [data-chart=${id}] { +${colorConfig + .map(([key, itemConfig]) => { + const color = + itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || + itemConfig.color + return color ? ` --color-${key}: ${color};` : null + }) + .join("\n")} +} +` + ) + .join("\n"), + }} + /> + ) +} + +const ChartTooltip = RechartsPrimitive.Tooltip + +const ChartTooltipContent = React.forwardRef< + HTMLDivElement, + React.ComponentProps<typeof RechartsPrimitive.Tooltip> & + React.ComponentProps<"div"> & { + hideLabel?: boolean + hideIndicator?: boolean + indicator?: "line" | "dot" | "dashed" + nameKey?: string + labelKey?: string + } +>( + ( + { + active, + payload, + className, + indicator = "dot", + hideLabel = false, + hideIndicator = false, + label, + labelFormatter, + labelClassName, + formatter, + color, + nameKey, + labelKey, + }, + ref + ) => { + const { config } = useChart() + + const tooltipLabel = React.useMemo(() => { + if (hideLabel || !payload?.length) { + return null + } + + const [item] = payload + const key = `${labelKey || item.dataKey || item.name || "value"}` + const itemConfig = getPayloadConfigFromPayload(config, item, key) + const value = + !labelKey && typeof label === "string" + ? config[label as keyof typeof config]?.label || label + : itemConfig?.label + + if (labelFormatter) { + return ( + <div className={cn("font-medium", labelClassName)}> + {labelFormatter(value, payload)} + </div> + ) + } + + if (!value) { + return null + } + + return <div className={cn("font-medium", labelClassName)}>{value}</div> + }, [ + label, + labelFormatter, + payload, + hideLabel, + labelClassName, + config, + labelKey, + ]) + + if (!active || !payload?.length) { + return null + } + + const nestLabel = payload.length === 1 && indicator !== "dot" + + return ( + <div + ref={ref} + className={cn( + "grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl", + className + )} + > + {!nestLabel ? tooltipLabel : null} + <div className="grid gap-1.5"> + {payload.map((item, index) => { + const key = `${nameKey || item.name || item.dataKey || "value"}` + const itemConfig = getPayloadConfigFromPayload(config, item, key) + const indicatorColor = color || item.payload.fill || item.color + + return ( + <div + key={item.dataKey} + className={cn( + "flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground", + indicator === "dot" && "items-center" + )} + > + {formatter && item?.value !== undefined && item.name ? ( + formatter(item.value, item.name, item, index, item.payload) + ) : ( + <> + {itemConfig?.icon ? ( + <itemConfig.icon /> + ) : ( + !hideIndicator && ( + <div + className={cn( + "shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]", + { + "h-2.5 w-2.5": indicator === "dot", + "w-1": indicator === "line", + "w-0 border-[1.5px] border-dashed bg-transparent": + indicator === "dashed", + "my-0.5": nestLabel && indicator === "dashed", + } + )} + style={ + { + "--color-bg": indicatorColor, + "--color-border": indicatorColor, + } as React.CSSProperties + } + /> + ) + )} + <div + className={cn( + "flex flex-1 justify-between leading-none", + nestLabel ? "items-end" : "items-center" + )} + > + <div className="grid gap-1.5"> + {nestLabel ? tooltipLabel : null} + <span className="text-muted-foreground"> + {itemConfig?.label || item.name} + </span> + </div> + {item.value && ( + <span className="font-mono font-medium tabular-nums text-foreground"> + {item.value.toLocaleString()} + </span> + )} + </div> + </> + )} + </div> + ) + })} + </div> + </div> + ) + } +) +ChartTooltipContent.displayName = "ChartTooltip" + +const ChartLegend = RechartsPrimitive.Legend + +const ChartLegendContent = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & + Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & { + hideIcon?: boolean + nameKey?: string + } +>( + ( + { className, hideIcon = false, payload, verticalAlign = "bottom", nameKey }, + ref + ) => { + const { config } = useChart() + + if (!payload?.length) { + return null + } + + return ( + <div + ref={ref} + className={cn( + "flex items-center justify-center gap-4", + verticalAlign === "top" ? "pb-3" : "pt-3", + className + )} + > + {payload.map((item) => { + const key = `${nameKey || item.dataKey || "value"}` + const itemConfig = getPayloadConfigFromPayload(config, item, key) + + return ( + <div + key={item.value} + className={cn( + "flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground" + )} + > + {itemConfig?.icon && !hideIcon ? ( + <itemConfig.icon /> + ) : ( + <div + className="h-2 w-2 shrink-0 rounded-[2px]" + style={{ + backgroundColor: item.color, + }} + /> + )} + {itemConfig?.label} + </div> + ) + })} + </div> + ) + } +) +ChartLegendContent.displayName = "ChartLegend" + +// Helper to extract item config from a payload. +function getPayloadConfigFromPayload( + config: ChartConfig, + payload: unknown, + key: string +) { + if (typeof payload !== "object" || payload === null) { + return undefined + } + + const payloadPayload = + "payload" in payload && + typeof payload.payload === "object" && + payload.payload !== null + ? payload.payload + : undefined + + let configLabelKey: string = key + + if ( + key in payload && + typeof payload[key as keyof typeof payload] === "string" + ) { + configLabelKey = payload[key as keyof typeof payload] as string + } else if ( + payloadPayload && + key in payloadPayload && + typeof payloadPayload[key as keyof typeof payloadPayload] === "string" + ) { + configLabelKey = payloadPayload[ + key as keyof typeof payloadPayload + ] as string + } + + return configLabelKey in config + ? config[configLabelKey] + : config[key as keyof typeof config] +} + +export { + ChartContainer, + ChartTooltip, + ChartTooltipContent, + ChartLegend, + ChartLegendContent, + ChartStyle, +} diff --git a/components/ui/checkbox.tsx b/components/ui/checkbox.tsx new file mode 100644 index 00000000..c6fdd071 --- /dev/null +++ b/components/ui/checkbox.tsx @@ -0,0 +1,30 @@ +"use client" + +import * as React from "react" +import * as CheckboxPrimitive from "@radix-ui/react-checkbox" +import { Check } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Checkbox = React.forwardRef< + React.ElementRef<typeof CheckboxPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root> +>(({ className, ...props }, ref) => ( + <CheckboxPrimitive.Root + ref={ref} + className={cn( + "peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground", + className + )} + {...props} + > + <CheckboxPrimitive.Indicator + className={cn("flex items-center justify-center text-current")} + > + <Check className="h-4 w-4" /> + </CheckboxPrimitive.Indicator> + </CheckboxPrimitive.Root> +)) +Checkbox.displayName = CheckboxPrimitive.Root.displayName + +export { Checkbox } diff --git a/components/ui/collapsible.tsx b/components/ui/collapsible.tsx new file mode 100644 index 00000000..9fa48946 --- /dev/null +++ b/components/ui/collapsible.tsx @@ -0,0 +1,11 @@ +"use client" + +import * as CollapsiblePrimitive from "@radix-ui/react-collapsible" + +const Collapsible = CollapsiblePrimitive.Root + +const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger + +const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent + +export { Collapsible, CollapsibleTrigger, CollapsibleContent } diff --git a/components/ui/command.tsx b/components/ui/command.tsx new file mode 100644 index 00000000..2cecd910 --- /dev/null +++ b/components/ui/command.tsx @@ -0,0 +1,153 @@ +"use client" + +import * as React from "react" +import { type DialogProps } from "@radix-ui/react-dialog" +import { Command as CommandPrimitive } from "cmdk" +import { Search } from "lucide-react" + +import { cn } from "@/lib/utils" +import { Dialog, DialogContent } from "@/components/ui/dialog" + +const Command = React.forwardRef< + React.ElementRef<typeof CommandPrimitive>, + React.ComponentPropsWithoutRef<typeof CommandPrimitive> +>(({ className, ...props }, ref) => ( + <CommandPrimitive + ref={ref} + className={cn( + "flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground", + className + )} + {...props} + /> +)) +Command.displayName = CommandPrimitive.displayName + +const CommandDialog = ({ children, ...props }: DialogProps) => { + return ( + <Dialog {...props}> + <DialogContent className="overflow-hidden p-0"> + <Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"> + {children} + </Command> + </DialogContent> + </Dialog> + ) +} + +const CommandInput = React.forwardRef< + React.ElementRef<typeof CommandPrimitive.Input>, + React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input> +>(({ className, ...props }, ref) => ( + <div className="flex items-center border-b px-3" cmdk-input-wrapper=""> + <Search className="mr-2 h-4 w-4 shrink-0 opacity-50" /> + <CommandPrimitive.Input + ref={ref} + className={cn( + "flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50", + className + )} + {...props} + /> + </div> +)) + +CommandInput.displayName = CommandPrimitive.Input.displayName + +const CommandList = React.forwardRef< + React.ElementRef<typeof CommandPrimitive.List>, + React.ComponentPropsWithoutRef<typeof CommandPrimitive.List> +>(({ className, ...props }, ref) => ( + <CommandPrimitive.List + ref={ref} + className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)} + {...props} + /> +)) + +CommandList.displayName = CommandPrimitive.List.displayName + +const CommandEmpty = React.forwardRef< + React.ElementRef<typeof CommandPrimitive.Empty>, + React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty> +>((props, ref) => ( + <CommandPrimitive.Empty + ref={ref} + className="py-6 text-center text-sm" + {...props} + /> +)) + +CommandEmpty.displayName = CommandPrimitive.Empty.displayName + +const CommandGroup = React.forwardRef< + React.ElementRef<typeof CommandPrimitive.Group>, + React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group> +>(({ className, ...props }, ref) => ( + <CommandPrimitive.Group + ref={ref} + className={cn( + "overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground", + className + )} + {...props} + /> +)) + +CommandGroup.displayName = CommandPrimitive.Group.displayName + +const CommandSeparator = React.forwardRef< + React.ElementRef<typeof CommandPrimitive.Separator>, + React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator> +>(({ className, ...props }, ref) => ( + <CommandPrimitive.Separator + ref={ref} + className={cn("-mx-1 h-px bg-border", className)} + {...props} + /> +)) +CommandSeparator.displayName = CommandPrimitive.Separator.displayName + +const CommandItem = React.forwardRef< + React.ElementRef<typeof CommandPrimitive.Item>, + React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item> +>(({ className, ...props }, ref) => ( + <CommandPrimitive.Item + ref={ref} + className={cn( + "relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + className + )} + {...props} + /> +)) + +CommandItem.displayName = CommandPrimitive.Item.displayName + +const CommandShortcut = ({ + className, + ...props +}: React.HTMLAttributes<HTMLSpanElement>) => { + return ( + <span + className={cn( + "ml-auto text-xs tracking-widest text-muted-foreground", + className + )} + {...props} + /> + ) +} +CommandShortcut.displayName = "CommandShortcut" + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +} diff --git a/components/ui/context-menu.tsx b/components/ui/context-menu.tsx new file mode 100644 index 00000000..f7257a6d --- /dev/null +++ b/components/ui/context-menu.tsx @@ -0,0 +1,200 @@ +"use client" + +import * as React from "react" +import * as ContextMenuPrimitive from "@radix-ui/react-context-menu" +import { Check, ChevronRight, Circle } from "lucide-react" + +import { cn } from "@/lib/utils" + +const ContextMenu = ContextMenuPrimitive.Root + +const ContextMenuTrigger = ContextMenuPrimitive.Trigger + +const ContextMenuGroup = ContextMenuPrimitive.Group + +const ContextMenuPortal = ContextMenuPrimitive.Portal + +const ContextMenuSub = ContextMenuPrimitive.Sub + +const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup + +const ContextMenuSubTrigger = React.forwardRef< + React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>, + React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & { + inset?: boolean + } +>(({ className, inset, children, ...props }, ref) => ( + <ContextMenuPrimitive.SubTrigger + ref={ref} + className={cn( + "flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground", + inset && "pl-8", + className + )} + {...props} + > + {children} + <ChevronRight className="ml-auto h-4 w-4" /> + </ContextMenuPrimitive.SubTrigger> +)) +ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName + +const ContextMenuSubContent = React.forwardRef< + React.ElementRef<typeof ContextMenuPrimitive.SubContent>, + React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent> +>(({ className, ...props }, ref) => ( + <ContextMenuPrimitive.SubContent + ref={ref} + className={cn( + "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + className + )} + {...props} + /> +)) +ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName + +const ContextMenuContent = React.forwardRef< + React.ElementRef<typeof ContextMenuPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content> +>(({ className, ...props }, ref) => ( + <ContextMenuPrimitive.Portal> + <ContextMenuPrimitive.Content + ref={ref} + className={cn( + "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + className + )} + {...props} + /> + </ContextMenuPrimitive.Portal> +)) +ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName + +const ContextMenuItem = React.forwardRef< + React.ElementRef<typeof ContextMenuPrimitive.Item>, + React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + <ContextMenuPrimitive.Item + ref={ref} + className={cn( + "relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", + inset && "pl-8", + className + )} + {...props} + /> +)) +ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName + +const ContextMenuCheckboxItem = React.forwardRef< + React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>, + React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem> +>(({ className, children, checked, ...props }, ref) => ( + <ContextMenuPrimitive.CheckboxItem + ref={ref} + className={cn( + "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", + className + )} + checked={checked} + {...props} + > + <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> + <ContextMenuPrimitive.ItemIndicator> + <Check className="h-4 w-4" /> + </ContextMenuPrimitive.ItemIndicator> + </span> + {children} + </ContextMenuPrimitive.CheckboxItem> +)) +ContextMenuCheckboxItem.displayName = + ContextMenuPrimitive.CheckboxItem.displayName + +const ContextMenuRadioItem = React.forwardRef< + React.ElementRef<typeof ContextMenuPrimitive.RadioItem>, + React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem> +>(({ className, children, ...props }, ref) => ( + <ContextMenuPrimitive.RadioItem + ref={ref} + className={cn( + "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", + className + )} + {...props} + > + <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> + <ContextMenuPrimitive.ItemIndicator> + <Circle className="h-4 w-4 fill-current" /> + </ContextMenuPrimitive.ItemIndicator> + </span> + {children} + </ContextMenuPrimitive.RadioItem> +)) +ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName + +const ContextMenuLabel = React.forwardRef< + React.ElementRef<typeof ContextMenuPrimitive.Label>, + React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + <ContextMenuPrimitive.Label + ref={ref} + className={cn( + "px-2 py-1.5 text-sm font-semibold text-foreground", + inset && "pl-8", + className + )} + {...props} + /> +)) +ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName + +const ContextMenuSeparator = React.forwardRef< + React.ElementRef<typeof ContextMenuPrimitive.Separator>, + React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator> +>(({ className, ...props }, ref) => ( + <ContextMenuPrimitive.Separator + ref={ref} + className={cn("-mx-1 my-1 h-px bg-border", className)} + {...props} + /> +)) +ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName + +const ContextMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes<HTMLSpanElement>) => { + return ( + <span + className={cn( + "ml-auto text-xs tracking-widest text-muted-foreground", + className + )} + {...props} + /> + ) +} +ContextMenuShortcut.displayName = "ContextMenuShortcut" + +export { + ContextMenu, + ContextMenuTrigger, + ContextMenuContent, + ContextMenuItem, + ContextMenuCheckboxItem, + ContextMenuRadioItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuGroup, + ContextMenuPortal, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuRadioGroup, +} diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx new file mode 100644 index 00000000..1647513e --- /dev/null +++ b/components/ui/dialog.tsx @@ -0,0 +1,122 @@ +"use client" + +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { X } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Dialog = DialogPrimitive.Root + +const DialogTrigger = DialogPrimitive.Trigger + +const DialogPortal = DialogPrimitive.Portal + +const DialogClose = DialogPrimitive.Close + +const DialogOverlay = React.forwardRef< + React.ElementRef<typeof DialogPrimitive.Overlay>, + React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> +>(({ className, ...props }, ref) => ( + <DialogPrimitive.Overlay + ref={ref} + className={cn( + "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", + className + )} + {...props} + /> +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef< + React.ElementRef<typeof DialogPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> +>(({ className, children, ...props }, ref) => ( + <DialogPortal> + <DialogOverlay /> + <DialogPrimitive.Content + ref={ref} + className={cn( + "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg", + className + )} + {...props} + > + {children} + <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"> + <X className="h-4 w-4" /> + <span className="sr-only">Close</span> + </DialogPrimitive.Close> + </DialogPrimitive.Content> + </DialogPortal> +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes<HTMLDivElement>) => ( + <div + className={cn( + "flex flex-col space-y-1.5 text-center sm:text-left", + className + )} + {...props} + /> +) +DialogHeader.displayName = "DialogHeader" + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes<HTMLDivElement>) => ( + <div + className={cn( + "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", + className + )} + {...props} + /> +) +DialogFooter.displayName = "DialogFooter" + +const DialogTitle = React.forwardRef< + React.ElementRef<typeof DialogPrimitive.Title>, + React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title> +>(({ className, ...props }, ref) => ( + <DialogPrimitive.Title + ref={ref} + className={cn( + "text-lg font-semibold leading-none tracking-tight", + className + )} + {...props} + /> +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +const DialogDescription = React.forwardRef< + React.ElementRef<typeof DialogPrimitive.Description>, + React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description> +>(({ className, ...props }, ref) => ( + <DialogPrimitive.Description + ref={ref} + className={cn("text-sm text-muted-foreground", className)} + {...props} + /> +)) +DialogDescription.displayName = DialogPrimitive.Description.displayName + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogTrigger, + DialogClose, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} diff --git a/components/ui/drawer.tsx b/components/ui/drawer.tsx new file mode 100644 index 00000000..6a0ef53d --- /dev/null +++ b/components/ui/drawer.tsx @@ -0,0 +1,118 @@ +"use client" + +import * as React from "react" +import { Drawer as DrawerPrimitive } from "vaul" + +import { cn } from "@/lib/utils" + +const Drawer = ({ + shouldScaleBackground = true, + ...props +}: React.ComponentProps<typeof DrawerPrimitive.Root>) => ( + <DrawerPrimitive.Root + shouldScaleBackground={shouldScaleBackground} + {...props} + /> +) +Drawer.displayName = "Drawer" + +const DrawerTrigger = DrawerPrimitive.Trigger + +const DrawerPortal = DrawerPrimitive.Portal + +const DrawerClose = DrawerPrimitive.Close + +const DrawerOverlay = React.forwardRef< + React.ElementRef<typeof DrawerPrimitive.Overlay>, + React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay> +>(({ className, ...props }, ref) => ( + <DrawerPrimitive.Overlay + ref={ref} + className={cn("fixed inset-0 z-50 bg-black/80", className)} + {...props} + /> +)) +DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName + +const DrawerContent = React.forwardRef< + React.ElementRef<typeof DrawerPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content> +>(({ className, children, ...props }, ref) => ( + <DrawerPortal> + <DrawerOverlay /> + <DrawerPrimitive.Content + ref={ref} + className={cn( + "fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background", + className + )} + {...props} + > + <div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" /> + {children} + </DrawerPrimitive.Content> + </DrawerPortal> +)) +DrawerContent.displayName = "DrawerContent" + +const DrawerHeader = ({ + className, + ...props +}: React.HTMLAttributes<HTMLDivElement>) => ( + <div + className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)} + {...props} + /> +) +DrawerHeader.displayName = "DrawerHeader" + +const DrawerFooter = ({ + className, + ...props +}: React.HTMLAttributes<HTMLDivElement>) => ( + <div + className={cn("mt-auto flex flex-col gap-2 p-4", className)} + {...props} + /> +) +DrawerFooter.displayName = "DrawerFooter" + +const DrawerTitle = React.forwardRef< + React.ElementRef<typeof DrawerPrimitive.Title>, + React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title> +>(({ className, ...props }, ref) => ( + <DrawerPrimitive.Title + ref={ref} + className={cn( + "text-lg font-semibold leading-none tracking-tight", + className + )} + {...props} + /> +)) +DrawerTitle.displayName = DrawerPrimitive.Title.displayName + +const DrawerDescription = React.forwardRef< + React.ElementRef<typeof DrawerPrimitive.Description>, + React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description> +>(({ className, ...props }, ref) => ( + <DrawerPrimitive.Description + ref={ref} + className={cn("text-sm text-muted-foreground", className)} + {...props} + /> +)) +DrawerDescription.displayName = DrawerPrimitive.Description.displayName + +export { + Drawer, + DrawerPortal, + DrawerOverlay, + DrawerTrigger, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerFooter, + DrawerTitle, + DrawerDescription, +} diff --git a/components/ui/dropdown-menu.tsx b/components/ui/dropdown-menu.tsx new file mode 100644 index 00000000..082639fb --- /dev/null +++ b/components/ui/dropdown-menu.tsx @@ -0,0 +1,201 @@ +"use client" + +import * as React from "react" +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" +import { Check, ChevronRight, Circle } from "lucide-react" + +import { cn } from "@/lib/utils" + +const DropdownMenu = DropdownMenuPrimitive.Root + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger + +const DropdownMenuGroup = DropdownMenuPrimitive.Group + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal + +const DropdownMenuSub = DropdownMenuPrimitive.Sub + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & { + inset?: boolean + } +>(({ className, inset, children, ...props }, ref) => ( + <DropdownMenuPrimitive.SubTrigger + ref={ref} + className={cn( + "flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + inset && "pl-8", + className + )} + {...props} + > + {children} + <ChevronRight className="ml-auto" /> + </DropdownMenuPrimitive.SubTrigger> +)) +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef<typeof DropdownMenuPrimitive.SubContent>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent> +>(({ className, ...props }, ref) => ( + <DropdownMenuPrimitive.SubContent + ref={ref} + className={cn( + "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + className + )} + {...props} + /> +)) +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName + +const DropdownMenuContent = React.forwardRef< + React.ElementRef<typeof DropdownMenuPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> +>(({ className, sideOffset = 4, ...props }, ref) => ( + <DropdownMenuPrimitive.Portal> + <DropdownMenuPrimitive.Content + ref={ref} + sideOffset={sideOffset} + className={cn( + "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md", + "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + className + )} + {...props} + /> + </DropdownMenuPrimitive.Portal> +)) +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName + +const DropdownMenuItem = React.forwardRef< + React.ElementRef<typeof DropdownMenuPrimitive.Item>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + <DropdownMenuPrimitive.Item + ref={ref} + className={cn( + "relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0", + inset && "pl-8", + className + )} + {...props} + /> +)) +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> +>(({ className, children, checked, ...props }, ref) => ( + <DropdownMenuPrimitive.CheckboxItem + ref={ref} + className={cn( + "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", + className + )} + checked={checked} + {...props} + > + <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> + <DropdownMenuPrimitive.ItemIndicator> + <Check className="h-4 w-4" /> + </DropdownMenuPrimitive.ItemIndicator> + </span> + {children} + </DropdownMenuPrimitive.CheckboxItem> +)) +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> +>(({ className, children, ...props }, ref) => ( + <DropdownMenuPrimitive.RadioItem + ref={ref} + className={cn( + "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", + className + )} + {...props} + > + <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> + <DropdownMenuPrimitive.ItemIndicator> + <Circle className="h-2 w-2 fill-current" /> + </DropdownMenuPrimitive.ItemIndicator> + </span> + {children} + </DropdownMenuPrimitive.RadioItem> +)) +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef<typeof DropdownMenuPrimitive.Label>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + <DropdownMenuPrimitive.Label + ref={ref} + className={cn( + "px-2 py-1.5 text-sm font-semibold", + inset && "pl-8", + className + )} + {...props} + /> +)) +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef<typeof DropdownMenuPrimitive.Separator>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator> +>(({ className, ...props }, ref) => ( + <DropdownMenuPrimitive.Separator + ref={ref} + className={cn("-mx-1 my-1 h-px bg-muted", className)} + {...props} + /> +)) +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes<HTMLSpanElement>) => { + return ( + <span + className={cn("ml-auto text-xs tracking-widest opacity-60", className)} + {...props} + /> + ) +} +DropdownMenuShortcut.displayName = "DropdownMenuShortcut" + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +} diff --git a/components/ui/dropzone-primitive.tsx b/components/ui/dropzone-primitive.tsx new file mode 100644 index 00000000..d006d031 --- /dev/null +++ b/components/ui/dropzone-primitive.tsx @@ -0,0 +1,192 @@ +"use client" + +import * as React from "react" +import { composeEventHandlers } from "@radix-ui/primitive" +import { Primitive } from "@radix-ui/react-primitive" +import { + FileRejection, + FileWithPath, + useDropzone, + type DropzoneOptions, + type DropzoneState, +} from "react-dropzone" + +export type DropzoneContextProps = DropzoneState & DropzoneOptions + +const DropzoneContext = React.createContext<DropzoneContextProps>( + {} as DropzoneContextProps +) + +export const useDropzoneContext = () => React.useContext(DropzoneContext) + +export interface DropzoneProps extends DropzoneOptions { + children: React.ReactNode | ((state: DropzoneContextProps) => React.ReactNode) +} + +export const Dropzone = ({ children, ...props }: DropzoneProps) => { + const state = useDropzone(props) + + const context = { ...state, ...props } + + return ( + <DropzoneContext.Provider value={context}> + {typeof children === "function" ? children(context) : children} + </DropzoneContext.Provider> + ) +} +Dropzone.displayName = "Dropzone" + +export const DropzoneInput = React.forwardRef< + React.ElementRef<typeof Primitive.input>, + React.ComponentPropsWithoutRef<typeof Primitive.input> +>((props, ref) => { + const { getInputProps, disabled } = useDropzoneContext() + + return ( + <Primitive.input ref={ref} {...getInputProps({ disabled, ...props })} /> + ) +}) +DropzoneInput.displayName = "DropzoneInput" + +export const DropzoneZone = React.forwardRef< + React.ElementRef<typeof Primitive.div>, + React.ComponentPropsWithoutRef<typeof Primitive.div> +>((props, ref) => { + const { + getRootProps, + isFocused, + isDragActive, + isDragAccept, + isDragReject, + isFileDialogActive, + preventDropOnDocument, + noClick, + noKeyboard, + noDrag, + noDragEventsBubbling, + disabled, + } = useDropzoneContext() + + return ( + <Primitive.div + ref={ref} + data-prevent-drop-on-document={preventDropOnDocument ? true : undefined} + data-no-click={noClick ? true : undefined} + data-no-keyboard={noKeyboard ? true : undefined} + data-no-drag={noDrag ? true : undefined} + data-no-drag-events-bubbling={noDragEventsBubbling ? true : undefined} + data-disabled={disabled ? true : undefined} + data-focused={isFocused ? true : undefined} + data-drag-active={isDragActive ? true : undefined} + data-drag-accept={isDragAccept ? true : undefined} + data-drag-reject={isDragReject ? true : undefined} + data-file-dialog-active={isFileDialogActive ? true : undefined} + {...getRootProps(props)} + /> + ) +}) +DropzoneZone.displayName = "DropzoneZone" + +export const DropzoneTrigger = React.forwardRef< + React.ElementRef<typeof Primitive.button>, + React.ComponentPropsWithoutRef<typeof Primitive.button> +>(({ onClick, ...props }, ref) => { + const { open } = useDropzoneContext() + + return ( + <Primitive.button + ref={ref} + onClick={composeEventHandlers(onClick, open)} + {...props} + /> + ) +}) +DropzoneTrigger.displayName = "DropzoneTrigger" + +export interface DropzoneDragAcceptedProps { + children?: React.ReactNode +} + +export const DropzoneDragAccepted = ({ + children, +}: DropzoneDragAcceptedProps) => { + const { isDragAccept } = useDropzoneContext() + + if (!isDragAccept) { + return null + } + + return children +} + +export interface DropzoneDragRejectedProps { + children?: React.ReactNode +} + +export const DropzoneDragRejected = ({ + children, +}: DropzoneDragRejectedProps) => { + const { isDragReject } = useDropzoneContext() + + if (!isDragReject) { + return null + } + + return children +} + +export interface DropzoneDragDefaultProps { + children?: React.ReactNode +} + +export const DropzoneDragDefault = ({ children }: DropzoneDragDefaultProps) => { + const { isDragActive } = useDropzoneContext() + + if (isDragActive) { + return null + } + + return children +} + +export interface DropzoneAcceptedProps { + children: (acceptedFiles: Readonly<FileWithPath[]>) => React.ReactNode +} + +export const DropzoneAccepted = ({ children }: DropzoneAcceptedProps) => { + const { acceptedFiles } = useDropzoneContext() + + return children(acceptedFiles) +} + +export interface DropzoneRejectedProps { + children: (fileRejections: Readonly<FileRejection[]>) => React.ReactNode +} + +export const DropzoneRejected = ({ children }: DropzoneRejectedProps) => { + const { fileRejections } = useDropzoneContext() + + return children(fileRejections) +} + +const Root = Dropzone +const Input = DropzoneInput +const Zone = DropzoneZone +const Trigger = DropzoneTrigger +const DragAccepted = DropzoneDragAccepted +const DragRejected = DropzoneDragRejected +const DragDefault = DropzoneDragDefault +const Accepted = DropzoneAccepted +const Rejected = DropzoneRejected + +export { + Root, + Input, + Zone, + Trigger, + DragAccepted, + DragRejected, + DragDefault, + Accepted, + Rejected, +} diff --git a/components/ui/dropzone.tsx b/components/ui/dropzone.tsx new file mode 100644 index 00000000..a72826d9 --- /dev/null +++ b/components/ui/dropzone.tsx @@ -0,0 +1,87 @@ +"use client" + +import * as React from "react" +import { Primitive } from "@radix-ui/react-primitive" +import { Ban, CheckCircle2, Upload } from "lucide-react" + +import { cn } from "@/lib/utils" +import * as DropzonePrimitive from "@/components/ui/dropzone-primitive" + +export const Dropzone = DropzonePrimitive.Dropzone + +export const DropzoneInput = DropzonePrimitive.Input + +export const DropzoneZone = React.forwardRef< + React.ElementRef<typeof DropzonePrimitive.Zone>, + React.ComponentPropsWithoutRef<typeof DropzonePrimitive.Zone> +>(({ className, ...props }, ref) => ( + <DropzonePrimitive.Zone + ref={ref} + className={cn( + "cursor-pointer rounded-md border-2 border-dashed border-input p-6 shadow-sm transition-colors hover:border-accent-foreground/50 hover:bg-accent focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring data-[disabled]:cursor-not-allowed data-[drag-reject]:cursor-no-drop data-[no-click]:cursor-default data-[disabled]:border-inherit data-[drag-active]:border-accent-foreground/50 data-[drag-reject]:border-destructive data-[disabled]:bg-inherit data-[drag-active]:bg-accent data-[drag-reject]:bg-destructive/30 data-[disabled]:opacity-50", + className + )} + {...props} + /> +)) +DropzoneZone.displayName = "DropzoneZone" + +export const DropzoneUploadIcon = React.forwardRef< + React.ElementRef<typeof Upload>, + React.ComponentPropsWithoutRef<typeof Upload> +>(({ className, ...props }, ref) => ( + <> + <DropzonePrimitive.DragAccepted> + <CheckCircle2 ref={ref} className={cn("size-8", className)} {...props} /> + </DropzonePrimitive.DragAccepted> + <DropzonePrimitive.DragRejected> + <Ban ref={ref} className={cn("size-8", className)} {...props} /> + </DropzonePrimitive.DragRejected> + <DropzonePrimitive.DragDefault> + <Upload ref={ref} className={cn("size-8", className)} {...props} /> + </DropzonePrimitive.DragDefault> + </> +)) +DropzoneUploadIcon.displayName = "DropzoneUploadIcon" + +export const DropzoneGroup = React.forwardRef< + React.ElementRef<typeof Primitive.div>, + React.ComponentPropsWithoutRef<typeof Primitive.div> +>(({ className, ...props }, ref) => ( + <Primitive.div + ref={ref} + className={cn("grid place-items-center gap-1.5", className)} + {...props} + /> +)) +DropzoneGroup.displayName = "DropzoneGroup" + +export const DropzoneTitle = React.forwardRef< + React.ElementRef<typeof Primitive.h3>, + React.ComponentPropsWithoutRef<typeof Primitive.h3> +>(({ className, ...props }, ref) => ( + <Primitive.h3 + ref={ref} + className={cn("font-medium leading-none tracking-tight", className)} + {...props} + /> +)) +DropzoneTitle.displayName = "DropzoneTitle" + +export const DropzoneDescription = React.forwardRef< + React.ElementRef<typeof Primitive.p>, + React.ComponentPropsWithoutRef<typeof Primitive.p> +>(({ className, ...props }, ref) => ( + <Primitive.p + ref={ref} + className={cn("text-sm text-muted-foreground", className)} + {...props} + /> +)) +DropzoneDescription.displayName = "DropzoneDescription" + +export const DropzoneTrigger = DropzonePrimitive.Trigger + +export const DropzoneAccepted = DropzonePrimitive.Accepted + +export const DropzoneRejected = DropzonePrimitive.Rejected diff --git a/components/ui/faceted-filter.tsx b/components/ui/faceted-filter.tsx new file mode 100644 index 00000000..34320cbc --- /dev/null +++ b/components/ui/faceted-filter.tsx @@ -0,0 +1,106 @@ +"use client" + +import * as React from "react" +import { Check } from "lucide-react" + +import { cn } from "@/lib/utils" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, + CommandShortcut, +} from "@/components/ui/command" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" + +const FacetedFilter = Popover + +const FacetedFilterTrigger = React.forwardRef< + React.ComponentRef<typeof PopoverTrigger>, + React.ComponentPropsWithoutRef<typeof PopoverTrigger> +>(({ className, children, ...props }, ref) => ( + <PopoverTrigger ref={ref} className={cn(className)} {...props}> + {children} + </PopoverTrigger> +)) +FacetedFilterTrigger.displayName = "FacetedFilterTrigger" + +const FacetedFilterContent = React.forwardRef< + React.ComponentRef<typeof PopoverContent>, + React.ComponentPropsWithoutRef<typeof PopoverContent> +>(({ className, children, ...props }, ref) => ( + <PopoverContent + ref={ref} + className={cn("w-[12.5rem] p-0", className)} + align="start" + {...props} + > + <Command>{children}</Command> + </PopoverContent> +)) +FacetedFilterContent.displayName = "FacetedFilterContent" + +const FacetedFilterInput = CommandInput + +const FacetedFilterList = CommandList + +const FacetedFilterEmpty = CommandEmpty + +const FacetedFilterGroup = CommandGroup + +interface FacetedFilterItemProps + extends React.ComponentPropsWithoutRef<typeof CommandItem> { + selected: boolean +} + +const FacetedFilterItem = React.forwardRef< + React.ComponentRef<typeof CommandItem>, + FacetedFilterItemProps +>(({ className, children, selected, ...props }, ref) => { + return ( + <CommandItem + ref={ref} + aria-selected={selected} + data-selected={selected} + className={cn(className)} + {...props} + > + <span + className={cn( + "mr-2 flex size-4 items-center justify-center rounded-sm border border-primary", + selected + ? "bg-primary text-primary-foreground" + : "opacity-50 [&_svg]:invisible" + )} + > + <Check className="size-4" /> + </span> + {children} + </CommandItem> + ) +}) +FacetedFilterItem.displayName = "FacetedFilterItem" + +const FacetedFilterSeparator = CommandSeparator + +const FacetedFilterShortcut = CommandShortcut + +export { + FacetedFilter, + FacetedFilterTrigger, + FacetedFilterContent, + FacetedFilterInput, + FacetedFilterList, + FacetedFilterEmpty, + FacetedFilterGroup, + FacetedFilterItem, + FacetedFilterSeparator, + FacetedFilterShortcut, +} diff --git a/components/ui/file-list.tsx b/components/ui/file-list.tsx new file mode 100644 index 00000000..b6ade7f5 --- /dev/null +++ b/components/ui/file-list.tsx @@ -0,0 +1,173 @@ +"use client" + +import * as React from "react" +import { FileText } from "lucide-react" +import prettyBytes from "pretty-bytes" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Progress } from "@/components/ui/progress" + +export const FileList = React.forwardRef< + React.ElementRef<"div">, + React.ComponentPropsWithoutRef<"div"> +>(({ className, ...props }, ref) => ( + <div ref={ref} className={cn("grid gap-4", className)} {...props} /> +)) +FileList.displayName = "FileList" + +export const FileListItem = React.forwardRef< + React.ElementRef<"div">, + React.ComponentPropsWithoutRef<"div"> +>(({ className, ...props }, ref) => ( + <div + ref={ref} + className={cn( + "grid gap-4 rounded-xl border bg-card p-4 text-card-foreground shadow", + className + )} + {...props} + /> +)) +FileListItem.displayName = "FileListItem" + +export const FileListHeader = React.forwardRef< + React.ElementRef<"div">, + React.ComponentPropsWithoutRef<"div"> +>(({ className, ...props }, ref) => ( + <div + ref={ref} + className={cn("flex items-center gap-4", className)} + {...props} + /> +)) +FileListHeader.displayName = "FileListHeader" + +export const FileListIcon = React.forwardRef< + React.ElementRef<"div">, + React.ComponentPropsWithoutRef<"div"> +>(({ className, children, ...props }, ref) => ( + <div + ref={ref} + className={cn( + "flex size-10 items-center justify-center rounded-lg border bg-muted text-muted-foreground [&>svg]:size-5", + className + )} + {...props} + > + {children ?? <FileText />} + </div> +)) +FileListIcon.displayName = "FileListIcon" + +export const FileListInfo = React.forwardRef< + React.ElementRef<"div">, + React.ComponentPropsWithoutRef<"div"> +>(({ className, ...props }, ref) => ( + <div ref={ref} className={cn("grid flex-1 gap-1", className)} {...props} /> +)) +FileListInfo.displayName = "FileListInfo" + +export const FileListName = React.forwardRef< + React.ElementRef<"p">, + React.ComponentPropsWithoutRef<"p"> +>(({ className, ...props }, ref) => ( + <p + ref={ref} + className={cn("text-sm font-medium leading-none tracking-tight", className)} + {...props} + /> +)) +FileListName.displayName = "FileListName" + +export const FileListDescription = React.forwardRef< + React.ElementRef<"div">, + React.ComponentPropsWithoutRef<"div"> +>(({ className, ...props }, ref) => ( + <div + ref={ref} + className={cn( + "flex items-center gap-2 text-xs text-muted-foreground", + className + )} + {...props} + /> +)) +FileListDescription.displayName = "FileListDescription" + +export const FileListDescriptionSeparator = React.forwardRef< + React.ElementRef<"span">, + React.ComponentPropsWithoutRef<"span"> +>(({ children, ...props }, ref) => ( + <span ref={ref} {...props}> + {children ?? "•"} + </span> +)) +FileListDescriptionSeparator.displayName = "FileListDescriptionSeparator" + +export interface FileListSizeProps + extends React.ComponentPropsWithoutRef<"span"> { + children: number +} + +export const FileListSize = React.forwardRef< + React.ElementRef<"span">, + FileListSizeProps +>(({ children, ...props }, ref) => ( + <span ref={ref} {...props}> + {prettyBytes(children)} + </span> +)) +FileListSize.displayName = "FileListSize" + +export const FileListProgress = React.forwardRef< + React.ElementRef<typeof Progress>, + React.ComponentPropsWithoutRef<typeof Progress> +>(({ className, ...props }, ref) => ( + <Progress ref={ref} className={cn("h-1", className)} {...props} /> +)) +FileListProgress.displayName = "FileListProgress" + +export const FileListDescriptionText = React.forwardRef< + React.ElementRef<"span">, + React.ComponentPropsWithoutRef<"span"> +>(({ className, ...props }, ref) => ( + <span + ref={ref} + className={cn("flex items-center gap-1.5 [&>svg]:size-3", className)} + {...props} + /> +)) +FileListDescriptionText.displayName = "FileListDescriptionText" + +export const FileListContent = React.forwardRef< + React.ElementRef<"div">, + React.ComponentPropsWithoutRef<"div"> +>((props, ref) => <div ref={ref} {...props} />) +FileListContent.displayName = "FileListContent" + +export const FileListActions = React.forwardRef< + React.ElementRef<"div">, + React.ComponentPropsWithoutRef<"div"> +>(({ className, ...props }, ref) => ( + <div + ref={ref} + className={cn("flex items-center gap-2", className)} + {...props} + /> +)) +FileListActions.displayName = "FileListActions" + +export const FileListAction = React.forwardRef< + React.ElementRef<typeof Button>, + React.ComponentPropsWithoutRef<typeof Button> +>(({ className, variant = "outline", size = "icon", ...props }, ref) => ( + <Button + ref={ref} + variant={variant} + size={size} + className={cn("size-7 [&_svg]:size-3.5", className)} + {...props} + /> +)) +FileListAction.displayName = "FileListAction" diff --git a/components/ui/form.tsx b/components/ui/form.tsx new file mode 100644 index 00000000..b6daa654 --- /dev/null +++ b/components/ui/form.tsx @@ -0,0 +1,178 @@ +"use client" + +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { Slot } from "@radix-ui/react-slot" +import { + Controller, + ControllerProps, + FieldPath, + FieldValues, + FormProvider, + useFormContext, +} from "react-hook-form" + +import { cn } from "@/lib/utils" +import { Label } from "@/components/ui/label" + +const Form = FormProvider + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues> +> = { + name: TName +} + +const FormFieldContext = React.createContext<FormFieldContextValue>( + {} as FormFieldContextValue +) + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues> +>({ + ...props +}: ControllerProps<TFieldValues, TName>) => { + return ( + <FormFieldContext.Provider value={{ name: props.name }}> + <Controller {...props} /> + </FormFieldContext.Provider> + ) +} + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext) + const itemContext = React.useContext(FormItemContext) + const { getFieldState, formState } = useFormContext() + + const fieldState = getFieldState(fieldContext.name, formState) + + if (!fieldContext) { + throw new Error("useFormField should be used within <FormField>") + } + + const { id } = itemContext + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + } +} + +type FormItemContextValue = { + id: string +} + +const FormItemContext = React.createContext<FormItemContextValue>( + {} as FormItemContextValue +) + +const FormItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> +>(({ className, ...props }, ref) => { + const id = React.useId() + + return ( + <FormItemContext.Provider value={{ id }}> + <div ref={ref} className={cn("space-y-2", className)} {...props} /> + </FormItemContext.Provider> + ) +}) +FormItem.displayName = "FormItem" + +const FormLabel = React.forwardRef< + React.ElementRef<typeof LabelPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField() + + return ( + <Label + ref={ref} + className={cn(error && "text-destructive", className)} + htmlFor={formItemId} + {...props} + /> + ) +}) +FormLabel.displayName = "FormLabel" + +const FormControl = React.forwardRef< + React.ElementRef<typeof Slot>, + React.ComponentPropsWithoutRef<typeof Slot> +>(({ ...props }, ref) => { + const { error, formItemId, formDescriptionId, formMessageId } = useFormField() + + return ( + <Slot + ref={ref} + id={formItemId} + aria-describedby={ + !error + ? `${formDescriptionId}` + : `${formDescriptionId} ${formMessageId}` + } + aria-invalid={!!error} + {...props} + /> + ) +}) +FormControl.displayName = "FormControl" + +const FormDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes<HTMLParagraphElement> +>(({ className, ...props }, ref) => { + const { formDescriptionId } = useFormField() + + return ( + <p + ref={ref} + id={formDescriptionId} + className={cn("text-[0.8rem] text-muted-foreground", className)} + {...props} + /> + ) +}) +FormDescription.displayName = "FormDescription" + +const FormMessage = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes<HTMLParagraphElement> +>(({ className, children, ...props }, ref) => { + const { error, formMessageId } = useFormField() + const body = error ? String(error?.message) : children + + if (!body) { + return null + } + + return ( + <p + ref={ref} + id={formMessageId} + className={cn("text-[0.8rem] font-medium text-destructive", className)} + {...props} + > + {body} + </p> + ) +}) +FormMessage.displayName = "FormMessage" + +export { + useFormField, + Form, + FormItem, + FormLabel, + FormControl, + FormDescription, + FormMessage, + FormField, +} diff --git a/components/ui/hover-card.tsx b/components/ui/hover-card.tsx new file mode 100644 index 00000000..e54d91cf --- /dev/null +++ b/components/ui/hover-card.tsx @@ -0,0 +1,29 @@ +"use client" + +import * as React from "react" +import * as HoverCardPrimitive from "@radix-ui/react-hover-card" + +import { cn } from "@/lib/utils" + +const HoverCard = HoverCardPrimitive.Root + +const HoverCardTrigger = HoverCardPrimitive.Trigger + +const HoverCardContent = React.forwardRef< + React.ElementRef<typeof HoverCardPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content> +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + <HoverCardPrimitive.Content + ref={ref} + align={align} + sideOffset={sideOffset} + className={cn( + "z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + className + )} + {...props} + /> +)) +HoverCardContent.displayName = HoverCardPrimitive.Content.displayName + +export { HoverCard, HoverCardTrigger, HoverCardContent } diff --git a/components/ui/input-otp.tsx b/components/ui/input-otp.tsx new file mode 100644 index 00000000..eb2a1bae --- /dev/null +++ b/components/ui/input-otp.tsx @@ -0,0 +1,71 @@ +"use client" + +import * as React from "react" +import { OTPInput, OTPInputContext } from "input-otp" +import { Minus } from "lucide-react" + +import { cn } from "@/lib/utils" + +const InputOTP = React.forwardRef< + React.ElementRef<typeof OTPInput>, + React.ComponentPropsWithoutRef<typeof OTPInput> +>(({ className, containerClassName, ...props }, ref) => ( + <OTPInput + ref={ref} + containerClassName={cn( + "flex items-center gap-2 has-[:disabled]:opacity-50", + containerClassName + )} + className={cn("disabled:cursor-not-allowed", className)} + {...props} + /> +)) +InputOTP.displayName = "InputOTP" + +const InputOTPGroup = React.forwardRef< + React.ElementRef<"div">, + React.ComponentPropsWithoutRef<"div"> +>(({ className, ...props }, ref) => ( + <div ref={ref} className={cn("flex items-center", className)} {...props} /> +)) +InputOTPGroup.displayName = "InputOTPGroup" + +const InputOTPSlot = React.forwardRef< + React.ElementRef<"div">, + React.ComponentPropsWithoutRef<"div"> & { index: number } +>(({ index, className, ...props }, ref) => { + const inputOTPContext = React.useContext(OTPInputContext) + const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index] + + return ( + <div + ref={ref} + className={cn( + "relative flex h-9 w-9 items-center justify-center border-y border-r border-input text-sm shadow-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md", + isActive && "z-10 ring-1 ring-ring", + className + )} + {...props} + > + {char} + {hasFakeCaret && ( + <div className="pointer-events-none absolute inset-0 flex items-center justify-center"> + <div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" /> + </div> + )} + </div> + ) +}) +InputOTPSlot.displayName = "InputOTPSlot" + +const InputOTPSeparator = React.forwardRef< + React.ElementRef<"div">, + React.ComponentPropsWithoutRef<"div"> +>(({ ...props }, ref) => ( + <div ref={ref} role="separator" {...props}> + <Minus /> + </div> +)) +InputOTPSeparator.displayName = "InputOTPSeparator" + +export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator } diff --git a/components/ui/input.tsx b/components/ui/input.tsx new file mode 100644 index 00000000..69b64fb2 --- /dev/null +++ b/components/ui/input.tsx @@ -0,0 +1,22 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>( + ({ className, type, ...props }, ref) => { + return ( + <input + type={type} + className={cn( + "flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", + className + )} + ref={ref} + {...props} + /> + ) + } +) +Input.displayName = "Input" + +export { Input } diff --git a/components/ui/label.tsx b/components/ui/label.tsx new file mode 100644 index 00000000..53418217 --- /dev/null +++ b/components/ui/label.tsx @@ -0,0 +1,26 @@ +"use client" + +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const labelVariants = cva( + "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" +) + +const Label = React.forwardRef< + React.ElementRef<typeof LabelPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & + VariantProps<typeof labelVariants> +>(({ className, ...props }, ref) => ( + <LabelPrimitive.Root + ref={ref} + className={cn(labelVariants(), className)} + {...props} + /> +)) +Label.displayName = LabelPrimitive.Root.displayName + +export { Label } diff --git a/components/ui/menubar.tsx b/components/ui/menubar.tsx new file mode 100644 index 00000000..126091b3 --- /dev/null +++ b/components/ui/menubar.tsx @@ -0,0 +1,236 @@ +"use client" + +import * as React from "react" +import * as MenubarPrimitive from "@radix-ui/react-menubar" +import { Check, ChevronRight, Circle } from "lucide-react" + +import { cn } from "@/lib/utils" + +const MenubarMenu = MenubarPrimitive.Menu + +const MenubarGroup = MenubarPrimitive.Group + +const MenubarPortal = MenubarPrimitive.Portal + +const MenubarSub = MenubarPrimitive.Sub + +const MenubarRadioGroup = MenubarPrimitive.RadioGroup + +const Menubar = React.forwardRef< + React.ElementRef<typeof MenubarPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root> +>(({ className, ...props }, ref) => ( + <MenubarPrimitive.Root + ref={ref} + className={cn( + "flex h-9 items-center space-x-1 rounded-md border bg-background p-1 shadow-sm", + className + )} + {...props} + /> +)) +Menubar.displayName = MenubarPrimitive.Root.displayName + +const MenubarTrigger = React.forwardRef< + React.ElementRef<typeof MenubarPrimitive.Trigger>, + React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger> +>(({ className, ...props }, ref) => ( + <MenubarPrimitive.Trigger + ref={ref} + className={cn( + "flex cursor-default select-none items-center rounded-sm px-3 py-1 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground", + className + )} + {...props} + /> +)) +MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName + +const MenubarSubTrigger = React.forwardRef< + React.ElementRef<typeof MenubarPrimitive.SubTrigger>, + React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & { + inset?: boolean + } +>(({ className, inset, children, ...props }, ref) => ( + <MenubarPrimitive.SubTrigger + ref={ref} + className={cn( + "flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground", + inset && "pl-8", + className + )} + {...props} + > + {children} + <ChevronRight className="ml-auto h-4 w-4" /> + </MenubarPrimitive.SubTrigger> +)) +MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName + +const MenubarSubContent = React.forwardRef< + React.ElementRef<typeof MenubarPrimitive.SubContent>, + React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent> +>(({ className, ...props }, ref) => ( + <MenubarPrimitive.SubContent + ref={ref} + className={cn( + "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + className + )} + {...props} + /> +)) +MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName + +const MenubarContent = React.forwardRef< + React.ElementRef<typeof MenubarPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content> +>( + ( + { className, align = "start", alignOffset = -4, sideOffset = 8, ...props }, + ref + ) => ( + <MenubarPrimitive.Portal> + <MenubarPrimitive.Content + ref={ref} + align={align} + alignOffset={alignOffset} + sideOffset={sideOffset} + className={cn( + "z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + className + )} + {...props} + /> + </MenubarPrimitive.Portal> + ) +) +MenubarContent.displayName = MenubarPrimitive.Content.displayName + +const MenubarItem = React.forwardRef< + React.ElementRef<typeof MenubarPrimitive.Item>, + React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + <MenubarPrimitive.Item + ref={ref} + className={cn( + "relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", + inset && "pl-8", + className + )} + {...props} + /> +)) +MenubarItem.displayName = MenubarPrimitive.Item.displayName + +const MenubarCheckboxItem = React.forwardRef< + React.ElementRef<typeof MenubarPrimitive.CheckboxItem>, + React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem> +>(({ className, children, checked, ...props }, ref) => ( + <MenubarPrimitive.CheckboxItem + ref={ref} + className={cn( + "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", + className + )} + checked={checked} + {...props} + > + <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> + <MenubarPrimitive.ItemIndicator> + <Check className="h-4 w-4" /> + </MenubarPrimitive.ItemIndicator> + </span> + {children} + </MenubarPrimitive.CheckboxItem> +)) +MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName + +const MenubarRadioItem = React.forwardRef< + React.ElementRef<typeof MenubarPrimitive.RadioItem>, + React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem> +>(({ className, children, ...props }, ref) => ( + <MenubarPrimitive.RadioItem + ref={ref} + className={cn( + "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", + className + )} + {...props} + > + <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> + <MenubarPrimitive.ItemIndicator> + <Circle className="h-4 w-4 fill-current" /> + </MenubarPrimitive.ItemIndicator> + </span> + {children} + </MenubarPrimitive.RadioItem> +)) +MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName + +const MenubarLabel = React.forwardRef< + React.ElementRef<typeof MenubarPrimitive.Label>, + React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + <MenubarPrimitive.Label + ref={ref} + className={cn( + "px-2 py-1.5 text-sm font-semibold", + inset && "pl-8", + className + )} + {...props} + /> +)) +MenubarLabel.displayName = MenubarPrimitive.Label.displayName + +const MenubarSeparator = React.forwardRef< + React.ElementRef<typeof MenubarPrimitive.Separator>, + React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator> +>(({ className, ...props }, ref) => ( + <MenubarPrimitive.Separator + ref={ref} + className={cn("-mx-1 my-1 h-px bg-muted", className)} + {...props} + /> +)) +MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName + +const MenubarShortcut = ({ + className, + ...props +}: React.HTMLAttributes<HTMLSpanElement>) => { + return ( + <span + className={cn( + "ml-auto text-xs tracking-widest text-muted-foreground", + className + )} + {...props} + /> + ) +} +MenubarShortcut.displayname = "MenubarShortcut" + +export { + Menubar, + MenubarMenu, + MenubarTrigger, + MenubarContent, + MenubarItem, + MenubarSeparator, + MenubarLabel, + MenubarCheckboxItem, + MenubarRadioGroup, + MenubarRadioItem, + MenubarPortal, + MenubarSubContent, + MenubarSubTrigger, + MenubarGroup, + MenubarSub, + MenubarShortcut, +} diff --git a/components/ui/multi-select.tsx b/components/ui/multi-select.tsx new file mode 100644 index 00000000..96aa3bd0 --- /dev/null +++ b/components/ui/multi-select.tsx @@ -0,0 +1,379 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; +import { + CheckIcon, + XCircle, + ChevronDown, + XIcon, + WandSparkles, +} from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { Separator } from "@/components/ui/separator"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from "@/components/ui/command"; + +/** + * Variants for the multi-select component to handle different styles. + * Uses class-variance-authority (cva) to define different styles based on "variant" prop. + */ +const multiSelectVariants = cva( + "m-1 transition ease-in-out delay-150 hover:-translate-y-1 hover:scale-110 duration-300", + { + variants: { + variant: { + default: + "border-foreground/10 text-foreground bg-card hover:bg-card/80", + secondary: + "border-foreground/10 bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + inverted: "inverted", + }, + }, + defaultVariants: { + variant: "default", + }, + } +); + +/** + * Props for MultiSelect component + */ +interface MultiSelectProps + extends React.ButtonHTMLAttributes<HTMLButtonElement>, + VariantProps<typeof multiSelectVariants> { + /** + * An array of option objects to be displayed in the multi-select component. + * Each option object has a label, value, and an optional icon. + */ + options: { + /** The text to display for the option. */ + label: string; + /** The unique value associated with the option. */ + value: string; + /** Optional icon component to display alongside the option. */ + icon?: React.ComponentType<{ className?: string }>; + }[]; + + /** + * Callback function triggered when the selected values change. + * Receives an array of the new selected values. + */ + onValueChange: (value: string[]) => void; + + /** The default selected values when the component mounts. */ + defaultValue?: string[]; + + /** + * Placeholder text to be displayed when no values are selected. + * Optional, defaults to "Select options". + */ + placeholder?: string; + + /** + * Animation duration in seconds for the visual effects (e.g., bouncing badges). + * Optional, defaults to 0 (no animation). + */ + animation?: number; + + /** + * Maximum number of items to display. Extra selected items will be summarized. + * Optional, defaults to 3. + */ + maxCount?: number; + + /** + * The modality of the popover. When set to true, interaction with outside elements + * will be disabled and only popover content will be visible to screen readers. + * Optional, defaults to false. + */ + modalPopover?: boolean; + + /** + * If true, renders the multi-select component as a child of another component. + * Optional, defaults to false. + */ + asChild?: boolean; + + /** + * Additional class names to apply custom styles to the multi-select component. + * Optional, can be used to add custom styles. + */ + className?: string; +} + +export const MultiSelect = React.forwardRef< + HTMLButtonElement, + MultiSelectProps +>( + ( + { + options, + onValueChange, + variant, + defaultValue = [], + placeholder = "Select options", + animation = 0, + maxCount = 3, + modalPopover = false, + asChild = false, + className, + ...props + }, + ref + ) => { + const [selectedValues, setSelectedValues] = + React.useState<string[]>(defaultValue); + const [isPopoverOpen, setIsPopoverOpen] = React.useState(false); + const [isAnimating, setIsAnimating] = React.useState(false); + + const handleInputKeyDown = ( + event: React.KeyboardEvent<HTMLInputElement> + ) => { + if (event.key === "Enter") { + setIsPopoverOpen(true); + } else if (event.key === "Backspace" && !event.currentTarget.value) { + const newSelectedValues = [...selectedValues]; + newSelectedValues.pop(); + setSelectedValues(newSelectedValues); + onValueChange(newSelectedValues); + } + }; + + const toggleOption = (option: string) => { + const newSelectedValues = selectedValues.includes(option) + ? selectedValues.filter((value) => value !== option) + : [...selectedValues, option]; + setSelectedValues(newSelectedValues); + onValueChange(newSelectedValues); + }; + + const handleClear = () => { + setSelectedValues([]); + onValueChange([]); + }; + + const handleTogglePopover = () => { + setIsPopoverOpen((prev) => !prev); + }; + + const clearExtraOptions = () => { + const newSelectedValues = selectedValues.slice(0, maxCount); + setSelectedValues(newSelectedValues); + onValueChange(newSelectedValues); + }; + + const toggleAll = () => { + if (selectedValues.length === options.length) { + handleClear(); + } else { + const allValues = options.map((option) => option.value); + setSelectedValues(allValues); + onValueChange(allValues); + } + }; + + return ( + <Popover + open={isPopoverOpen} + onOpenChange={setIsPopoverOpen} + modal={modalPopover} + > + <PopoverTrigger asChild> + <Button + ref={ref} + {...props} + onClick={handleTogglePopover} + className={cn( + "flex w-full p-1 rounded-md border min-h-10 h-auto items-center justify-between bg-inherit hover:bg-inherit [&_svg]:pointer-events-auto", + className + )} + > + {selectedValues.length > 0 ? ( + <div className="flex justify-between items-center w-full"> + <div className="flex flex-wrap items-center"> + {selectedValues.slice(0, maxCount).map((value) => { + const option = options.find((o) => o.value === value); + const IconComponent = option?.icon; + return ( + <Badge + key={value} + className={cn( + isAnimating ? "animate-bounce" : "", + multiSelectVariants({ variant }) + )} + style={{ animationDuration: `${animation}s` }} + > + {IconComponent && ( + <IconComponent className="h-4 w-4 mr-2" /> + )} + {option?.label} + <XCircle + className="ml-2 h-4 w-4 cursor-pointer" + onClick={(event) => { + event.stopPropagation(); + toggleOption(value); + }} + /> + </Badge> + ); + })} + {selectedValues.length > maxCount && ( + <Badge + className={cn( + "bg-transparent text-foreground border-foreground/1 hover:bg-transparent", + isAnimating ? "animate-bounce" : "", + multiSelectVariants({ variant }) + )} + style={{ animationDuration: `${animation}s` }} + > + {`+ ${selectedValues.length - maxCount} more`} + <XCircle + className="ml-2 h-4 w-4 cursor-pointer" + onClick={(event) => { + event.stopPropagation(); + clearExtraOptions(); + }} + /> + </Badge> + )} + </div> + <div className="flex items-center justify-between"> + <XIcon + className="h-4 mx-2 cursor-pointer text-muted-foreground" + onClick={(event) => { + event.stopPropagation(); + handleClear(); + }} + /> + <Separator + orientation="vertical" + className="flex min-h-6 h-full" + /> + <ChevronDown className="h-4 mx-2 cursor-pointer text-muted-foreground" /> + </div> + </div> + ) : ( + <div className="flex items-center justify-between w-full mx-auto"> + <span className="text-sm text-muted-foreground mx-3"> + {placeholder} + </span> + <ChevronDown className="h-4 cursor-pointer text-muted-foreground mx-2" /> + </div> + )} + </Button> + </PopoverTrigger> + <PopoverContent + className="w-auto p-0" + align="start" + onEscapeKeyDown={() => setIsPopoverOpen(false)} + > + <Command> + <CommandInput + placeholder="Search..." + onKeyDown={handleInputKeyDown} + /> + <CommandList> + <CommandEmpty>No results found.</CommandEmpty> + <CommandGroup> + <CommandItem + key="all" + onSelect={toggleAll} + className="cursor-pointer" + > + <div + className={cn( + "mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary", + selectedValues.length === options.length + ? "bg-primary text-primary-foreground" + : "opacity-50 [&_svg]:invisible" + )} + > + <CheckIcon className="h-4 w-4" /> + </div> + <span>(Select All)</span> + </CommandItem> + {options.map((option) => { + const isSelected = selectedValues.includes(option.value); + return ( + <CommandItem + key={option.value} + onSelect={() => toggleOption(option.value)} + className="cursor-pointer" + > + <div + className={cn( + "mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary", + isSelected + ? "bg-primary text-primary-foreground" + : "opacity-50 [&_svg]:invisible" + )} + > + <CheckIcon className="h-4 w-4" /> + </div> + {option.icon && ( + <option.icon className="mr-2 h-4 w-4 text-muted-foreground" /> + )} + <span>{option.label}</span> + </CommandItem> + ); + })} + </CommandGroup> + <CommandSeparator /> + <CommandGroup> + <div className="flex items-center justify-between"> + {selectedValues.length > 0 && ( + <> + <CommandItem + onSelect={handleClear} + className="flex-1 justify-center cursor-pointer" + > + Clear + </CommandItem> + <Separator + orientation="vertical" + className="flex min-h-6 h-full" + /> + </> + )} + <CommandItem + onSelect={() => setIsPopoverOpen(false)} + className="flex-1 justify-center cursor-pointer max-w-full" + > + Close + </CommandItem> + </div> + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + {animation > 0 && selectedValues.length > 0 && ( + <WandSparkles + className={cn( + "cursor-pointer my-2 text-foreground bg-background w-3 h-3", + isAnimating ? "" : "text-muted-foreground" + )} + onClick={() => setIsAnimating(!isAnimating)} + /> + )} + </Popover> + ); + } +); + +MultiSelect.displayName = "MultiSelect";
\ No newline at end of file diff --git a/components/ui/navigation-menu.tsx b/components/ui/navigation-menu.tsx new file mode 100644 index 00000000..a5d4d27d --- /dev/null +++ b/components/ui/navigation-menu.tsx @@ -0,0 +1,128 @@ +import * as React from "react" +import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu" +import { cva } from "class-variance-authority" +import { ChevronDown } from "lucide-react" + +import { cn } from "@/lib/utils" + +const NavigationMenu = React.forwardRef< + React.ElementRef<typeof NavigationMenuPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root> +>(({ className, children, ...props }, ref) => ( + <NavigationMenuPrimitive.Root + ref={ref} + className={cn( + "relative z-10 flex max-w-max flex-1 items-center justify-center", + className + )} + {...props} + > + {children} + <NavigationMenuViewport /> + </NavigationMenuPrimitive.Root> +)) +NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName + +const NavigationMenuList = React.forwardRef< + React.ElementRef<typeof NavigationMenuPrimitive.List>, + React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List> +>(({ className, ...props }, ref) => ( + <NavigationMenuPrimitive.List + ref={ref} + className={cn( + "group flex flex-1 list-none items-center justify-center space-x-1", + className + )} + {...props} + /> +)) +NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName + +const NavigationMenuItem = NavigationMenuPrimitive.Item + +const navigationMenuTriggerStyle = cva( + "group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50" +) + +const NavigationMenuTrigger = React.forwardRef< + React.ElementRef<typeof NavigationMenuPrimitive.Trigger>, + React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger> +>(({ className, children, ...props }, ref) => ( + <NavigationMenuPrimitive.Trigger + ref={ref} + className={cn(navigationMenuTriggerStyle(), "group", className)} + {...props} + > + {children}{" "} + <ChevronDown + className="relative top-[1px] ml-1 h-3 w-3 transition duration-300 group-data-[state=open]:rotate-180" + aria-hidden="true" + /> + </NavigationMenuPrimitive.Trigger> +)) +NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName + +const NavigationMenuContent = React.forwardRef< + React.ElementRef<typeof NavigationMenuPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content> +>(({ className, ...props }, ref) => ( + <NavigationMenuPrimitive.Content + ref={ref} + className={cn( + "left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ", + className + )} + {...props} + /> +)) +NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName + +const NavigationMenuLink = NavigationMenuPrimitive.Link + +const NavigationMenuViewport = React.forwardRef< + React.ElementRef<typeof NavigationMenuPrimitive.Viewport>, + React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport> +>(({ className, ...props }, ref) => ( + <div className={cn("absolute left-0 top-full flex justify-center")}> + <NavigationMenuPrimitive.Viewport + className={cn( + "origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]", + className + )} + ref={ref} + {...props} + /> + </div> +)) +NavigationMenuViewport.displayName = + NavigationMenuPrimitive.Viewport.displayName + +const NavigationMenuIndicator = React.forwardRef< + React.ElementRef<typeof NavigationMenuPrimitive.Indicator>, + React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator> +>(({ className, ...props }, ref) => ( + <NavigationMenuPrimitive.Indicator + ref={ref} + className={cn( + "top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in", + className + )} + {...props} + > + <div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" /> + </NavigationMenuPrimitive.Indicator> +)) +NavigationMenuIndicator.displayName = + NavigationMenuPrimitive.Indicator.displayName + +export { + navigationMenuTriggerStyle, + NavigationMenu, + NavigationMenuList, + NavigationMenuItem, + NavigationMenuContent, + NavigationMenuTrigger, + NavigationMenuLink, + NavigationMenuIndicator, + NavigationMenuViewport, +} diff --git a/components/ui/pagination.tsx b/components/ui/pagination.tsx new file mode 100644 index 00000000..d3311054 --- /dev/null +++ b/components/ui/pagination.tsx @@ -0,0 +1,117 @@ +import * as React from "react" +import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react" + +import { cn } from "@/lib/utils" +import { ButtonProps, buttonVariants } from "@/components/ui/button" + +const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => ( + <nav + role="navigation" + aria-label="pagination" + className={cn("mx-auto flex w-full justify-center", className)} + {...props} + /> +) +Pagination.displayName = "Pagination" + +const PaginationContent = React.forwardRef< + HTMLUListElement, + React.ComponentProps<"ul"> +>(({ className, ...props }, ref) => ( + <ul + ref={ref} + className={cn("flex flex-row items-center gap-1", className)} + {...props} + /> +)) +PaginationContent.displayName = "PaginationContent" + +const PaginationItem = React.forwardRef< + HTMLLIElement, + React.ComponentProps<"li"> +>(({ className, ...props }, ref) => ( + <li ref={ref} className={cn("", className)} {...props} /> +)) +PaginationItem.displayName = "PaginationItem" + +type PaginationLinkProps = { + isActive?: boolean +} & Pick<ButtonProps, "size"> & + React.ComponentProps<"a"> + +const PaginationLink = ({ + className, + isActive, + size = "icon", + ...props +}: PaginationLinkProps) => ( + <a + aria-current={isActive ? "page" : undefined} + className={cn( + buttonVariants({ + variant: isActive ? "outline" : "ghost", + size, + }), + className + )} + {...props} + /> +) +PaginationLink.displayName = "PaginationLink" + +const PaginationPrevious = ({ + className, + ...props +}: React.ComponentProps<typeof PaginationLink>) => ( + <PaginationLink + aria-label="Go to previous page" + size="default" + className={cn("gap-1 pl-2.5", className)} + {...props} + > + <ChevronLeft className="h-4 w-4" /> + <span>Previous</span> + </PaginationLink> +) +PaginationPrevious.displayName = "PaginationPrevious" + +const PaginationNext = ({ + className, + ...props +}: React.ComponentProps<typeof PaginationLink>) => ( + <PaginationLink + aria-label="Go to next page" + size="default" + className={cn("gap-1 pr-2.5", className)} + {...props} + > + <span>Next</span> + <ChevronRight className="h-4 w-4" /> + </PaginationLink> +) +PaginationNext.displayName = "PaginationNext" + +const PaginationEllipsis = ({ + className, + ...props +}: React.ComponentProps<"span">) => ( + <span + aria-hidden + className={cn("flex h-9 w-9 items-center justify-center", className)} + {...props} + > + <MoreHorizontal className="h-4 w-4" /> + <span className="sr-only">More pages</span> + </span> +) +PaginationEllipsis.displayName = "PaginationEllipsis" + +export { + Pagination, + PaginationContent, + PaginationLink, + PaginationItem, + PaginationPrevious, + PaginationNext, + PaginationEllipsis, +} diff --git a/components/ui/popover.tsx b/components/ui/popover.tsx new file mode 100644 index 00000000..29c7bd2a --- /dev/null +++ b/components/ui/popover.tsx @@ -0,0 +1,33 @@ +"use client" + +import * as React from "react" +import * as PopoverPrimitive from "@radix-ui/react-popover" + +import { cn } from "@/lib/utils" + +const Popover = PopoverPrimitive.Root + +const PopoverTrigger = PopoverPrimitive.Trigger + +const PopoverAnchor = PopoverPrimitive.Anchor + +const PopoverContent = React.forwardRef< + React.ElementRef<typeof PopoverPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + <PopoverPrimitive.Portal> + <PopoverPrimitive.Content + ref={ref} + align={align} + sideOffset={sideOffset} + className={cn( + "z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + className + )} + {...props} + /> + </PopoverPrimitive.Portal> +)) +PopoverContent.displayName = PopoverPrimitive.Content.displayName + +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } diff --git a/components/ui/portal.tsx b/components/ui/portal.tsx new file mode 100644 index 00000000..0b765f45 --- /dev/null +++ b/components/ui/portal.tsx @@ -0,0 +1,7 @@ +"use client" + +import * as PortalPrimitive from "@radix-ui/react-portal" + +const Portal = PortalPrimitive.Root + +export { Portal } diff --git a/components/ui/progress.tsx b/components/ui/progress.tsx new file mode 100644 index 00000000..4fc3b473 --- /dev/null +++ b/components/ui/progress.tsx @@ -0,0 +1,28 @@ +"use client" + +import * as React from "react" +import * as ProgressPrimitive from "@radix-ui/react-progress" + +import { cn } from "@/lib/utils" + +const Progress = React.forwardRef< + React.ElementRef<typeof ProgressPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> +>(({ className, value, ...props }, ref) => ( + <ProgressPrimitive.Root + ref={ref} + className={cn( + "relative h-2 w-full overflow-hidden rounded-full bg-primary/20", + className + )} + {...props} + > + <ProgressPrimitive.Indicator + className="h-full w-full flex-1 bg-primary transition-all" + style={{ transform: `translateX(-${100 - (value || 0)}%)` }} + /> + </ProgressPrimitive.Root> +)) +Progress.displayName = ProgressPrimitive.Root.displayName + +export { Progress } diff --git a/components/ui/radio-group.tsx b/components/ui/radio-group.tsx new file mode 100644 index 00000000..0bdf6853 --- /dev/null +++ b/components/ui/radio-group.tsx @@ -0,0 +1,44 @@ +"use client" + +import * as React from "react" +import * as RadioGroupPrimitive from "@radix-ui/react-radio-group" +import { Circle } from "lucide-react" + +import { cn } from "@/lib/utils" + +const RadioGroup = React.forwardRef< + React.ElementRef<typeof RadioGroupPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root> +>(({ className, ...props }, ref) => { + return ( + <RadioGroupPrimitive.Root + className={cn("grid gap-2", className)} + {...props} + ref={ref} + /> + ) +}) +RadioGroup.displayName = RadioGroupPrimitive.Root.displayName + +const RadioGroupItem = React.forwardRef< + React.ElementRef<typeof RadioGroupPrimitive.Item>, + React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item> +>(({ className, ...props }, ref) => { + return ( + <RadioGroupPrimitive.Item + ref={ref} + className={cn( + "aspect-square h-4 w-4 rounded-full border border-primary text-primary shadow focus:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50", + className + )} + {...props} + > + <RadioGroupPrimitive.Indicator className="flex items-center justify-center"> + <Circle className="h-3.5 w-3.5 fill-primary" /> + </RadioGroupPrimitive.Indicator> + </RadioGroupPrimitive.Item> + ) +}) +RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName + +export { RadioGroup, RadioGroupItem } diff --git a/components/ui/resizable.tsx b/components/ui/resizable.tsx new file mode 100644 index 00000000..f4bc5586 --- /dev/null +++ b/components/ui/resizable.tsx @@ -0,0 +1,45 @@ +"use client" + +import { GripVertical } from "lucide-react" +import * as ResizablePrimitive from "react-resizable-panels" + +import { cn } from "@/lib/utils" + +const ResizablePanelGroup = ({ + className, + ...props +}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => ( + <ResizablePrimitive.PanelGroup + className={cn( + "flex h-full w-full data-[panel-group-direction=vertical]:flex-col", + className + )} + {...props} + /> +) + +const ResizablePanel = ResizablePrimitive.Panel + +const ResizableHandle = ({ + withHandle, + className, + ...props +}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & { + withHandle?: boolean +}) => ( + <ResizablePrimitive.PanelResizeHandle + className={cn( + "relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90", + className + )} + {...props} + > + {withHandle && ( + <div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border"> + <GripVertical className="h-2.5 w-2.5" /> + </div> + )} + </ResizablePrimitive.PanelResizeHandle> +) + +export { ResizablePanelGroup, ResizablePanel, ResizableHandle } diff --git a/components/ui/scroll-area.tsx b/components/ui/scroll-area.tsx new file mode 100644 index 00000000..0b4a48d8 --- /dev/null +++ b/components/ui/scroll-area.tsx @@ -0,0 +1,48 @@ +"use client" + +import * as React from "react" +import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" + +import { cn } from "@/lib/utils" + +const ScrollArea = React.forwardRef< + React.ElementRef<typeof ScrollAreaPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> +>(({ className, children, ...props }, ref) => ( + <ScrollAreaPrimitive.Root + ref={ref} + className={cn("relative overflow-hidden", className)} + {...props} + > + <ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]"> + {children} + </ScrollAreaPrimitive.Viewport> + <ScrollBar /> + <ScrollAreaPrimitive.Corner /> + </ScrollAreaPrimitive.Root> +)) +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName + +const ScrollBar = React.forwardRef< + React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>, + React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar> +>(({ className, orientation = "vertical", ...props }, ref) => ( + <ScrollAreaPrimitive.ScrollAreaScrollbar + ref={ref} + orientation={orientation} + className={cn( + "flex touch-none select-none transition-colors", + orientation === "vertical" && + "h-full w-2.5 border-l border-l-transparent p-[1px]", + orientation === "horizontal" && + "h-2.5 flex-col border-t border-t-transparent p-[1px]", + className + )} + {...props} + > + <ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" /> + </ScrollAreaPrimitive.ScrollAreaScrollbar> +)) +ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName + +export { ScrollArea, ScrollBar } diff --git a/components/ui/select.tsx b/components/ui/select.tsx new file mode 100644 index 00000000..0cbf77d1 --- /dev/null +++ b/components/ui/select.tsx @@ -0,0 +1,159 @@ +"use client" + +import * as React from "react" +import * as SelectPrimitive from "@radix-ui/react-select" +import { Check, ChevronDown, ChevronUp } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Select = SelectPrimitive.Root + +const SelectGroup = SelectPrimitive.Group + +const SelectValue = SelectPrimitive.Value + +const SelectTrigger = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.Trigger>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> +>(({ className, children, ...props }, ref) => ( + <SelectPrimitive.Trigger + ref={ref} + className={cn( + "flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", + className + )} + {...props} + > + {children} + <SelectPrimitive.Icon asChild> + <ChevronDown className="h-4 w-4 opacity-50" /> + </SelectPrimitive.Icon> + </SelectPrimitive.Trigger> +)) +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.ScrollUpButton>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton> +>(({ className, ...props }, ref) => ( + <SelectPrimitive.ScrollUpButton + ref={ref} + className={cn( + "flex cursor-default items-center justify-center py-1", + className + )} + {...props} + > + <ChevronUp className="h-4 w-4" /> + </SelectPrimitive.ScrollUpButton> +)) +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.ScrollDownButton>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton> +>(({ className, ...props }, ref) => ( + <SelectPrimitive.ScrollDownButton + ref={ref} + className={cn( + "flex cursor-default items-center justify-center py-1", + className + )} + {...props} + > + <ChevronDown className="h-4 w-4" /> + </SelectPrimitive.ScrollDownButton> +)) +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName + +const SelectContent = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content> +>(({ className, children, position = "popper", ...props }, ref) => ( + <SelectPrimitive.Portal> + <SelectPrimitive.Content + ref={ref} + className={cn( + "relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + position === "popper" && + "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", + className + )} + position={position} + {...props} + > + <SelectScrollUpButton /> + <SelectPrimitive.Viewport + className={cn( + "p-1", + position === "popper" && + "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]" + )} + > + {children} + </SelectPrimitive.Viewport> + <SelectScrollDownButton /> + </SelectPrimitive.Content> + </SelectPrimitive.Portal> +)) +SelectContent.displayName = SelectPrimitive.Content.displayName + +const SelectLabel = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.Label>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label> +>(({ className, ...props }, ref) => ( + <SelectPrimitive.Label + ref={ref} + className={cn("px-2 py-1.5 text-sm font-semibold", className)} + {...props} + /> +)) +SelectLabel.displayName = SelectPrimitive.Label.displayName + +const SelectItem = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.Item>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> +>(({ className, children, ...props }, ref) => ( + <SelectPrimitive.Item + ref={ref} + className={cn( + "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", + className + )} + {...props} + > + <span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center"> + <SelectPrimitive.ItemIndicator> + <Check className="h-4 w-4" /> + </SelectPrimitive.ItemIndicator> + </span> + <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> + </SelectPrimitive.Item> +)) +SelectItem.displayName = SelectPrimitive.Item.displayName + +const SelectSeparator = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.Separator>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator> +>(({ className, ...props }, ref) => ( + <SelectPrimitive.Separator + ref={ref} + className={cn("-mx-1 my-1 h-px bg-muted", className)} + {...props} + /> +)) +SelectSeparator.displayName = SelectPrimitive.Separator.displayName + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +} diff --git a/components/ui/separator.tsx b/components/ui/separator.tsx new file mode 100644 index 00000000..12d81c4a --- /dev/null +++ b/components/ui/separator.tsx @@ -0,0 +1,31 @@ +"use client" + +import * as React from "react" +import * as SeparatorPrimitive from "@radix-ui/react-separator" + +import { cn } from "@/lib/utils" + +const Separator = React.forwardRef< + React.ElementRef<typeof SeparatorPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root> +>( + ( + { className, orientation = "horizontal", decorative = true, ...props }, + ref + ) => ( + <SeparatorPrimitive.Root + ref={ref} + decorative={decorative} + orientation={orientation} + className={cn( + "shrink-0 bg-border", + orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]", + className + )} + {...props} + /> + ) +) +Separator.displayName = SeparatorPrimitive.Root.displayName + +export { Separator } diff --git a/components/ui/sheet.tsx b/components/ui/sheet.tsx new file mode 100644 index 00000000..272cb721 --- /dev/null +++ b/components/ui/sheet.tsx @@ -0,0 +1,140 @@ +"use client" + +import * as React from "react" +import * as SheetPrimitive from "@radix-ui/react-dialog" +import { cva, type VariantProps } from "class-variance-authority" +import { X } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Sheet = SheetPrimitive.Root + +const SheetTrigger = SheetPrimitive.Trigger + +const SheetClose = SheetPrimitive.Close + +const SheetPortal = SheetPrimitive.Portal + +const SheetOverlay = React.forwardRef< + React.ElementRef<typeof SheetPrimitive.Overlay>, + React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay> +>(({ className, ...props }, ref) => ( + <SheetPrimitive.Overlay + className={cn( + "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", + className + )} + {...props} + ref={ref} + /> +)) +SheetOverlay.displayName = SheetPrimitive.Overlay.displayName + +const sheetVariants = cva( + "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out", + { + variants: { + side: { + top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top", + bottom: + "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom", + left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm", + right: + "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm", + }, + }, + defaultVariants: { + side: "right", + }, + } +) + +interface SheetContentProps + extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>, + VariantProps<typeof sheetVariants> {} + +const SheetContent = React.forwardRef< + React.ElementRef<typeof SheetPrimitive.Content>, + SheetContentProps +>(({ side = "right", className, children, ...props }, ref) => ( + <SheetPortal> + <SheetOverlay /> + <SheetPrimitive.Content + ref={ref} + className={cn(sheetVariants({ side }), className)} + {...props} + > + <SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary"> + <X className="h-4 w-4" /> + <span className="sr-only">Close</span> + </SheetPrimitive.Close> + {children} + </SheetPrimitive.Content> + </SheetPortal> +)) +SheetContent.displayName = SheetPrimitive.Content.displayName + +const SheetHeader = ({ + className, + ...props +}: React.HTMLAttributes<HTMLDivElement>) => ( + <div + className={cn( + "flex flex-col space-y-2 text-center sm:text-left", + className + )} + {...props} + /> +) +SheetHeader.displayName = "SheetHeader" + +const SheetFooter = ({ + className, + ...props +}: React.HTMLAttributes<HTMLDivElement>) => ( + <div + className={cn( + "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", + className + )} + {...props} + /> +) +SheetFooter.displayName = "SheetFooter" + +const SheetTitle = React.forwardRef< + React.ElementRef<typeof SheetPrimitive.Title>, + React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title> +>(({ className, ...props }, ref) => ( + <SheetPrimitive.Title + ref={ref} + className={cn("text-lg font-semibold text-foreground", className)} + {...props} + /> +)) +SheetTitle.displayName = SheetPrimitive.Title.displayName + +const SheetDescription = React.forwardRef< + React.ElementRef<typeof SheetPrimitive.Description>, + React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description> +>(({ className, ...props }, ref) => ( + <SheetPrimitive.Description + ref={ref} + className={cn("text-sm text-muted-foreground", className)} + {...props} + /> +)) +SheetDescription.displayName = SheetPrimitive.Description.displayName + +export { + Sheet, + SheetPortal, + SheetOverlay, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +} diff --git a/components/ui/sidebar.tsx b/components/ui/sidebar.tsx new file mode 100644 index 00000000..eeb2d7ae --- /dev/null +++ b/components/ui/sidebar.tsx @@ -0,0 +1,763 @@ +"use client" + +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { VariantProps, cva } from "class-variance-authority" +import { PanelLeft } from "lucide-react" + +import { useIsMobile } from "@/hooks/use-mobile" +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Separator } from "@/components/ui/separator" +import { Sheet, SheetContent } from "@/components/ui/sheet" +import { Skeleton } from "@/components/ui/skeleton" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip" + +const SIDEBAR_COOKIE_NAME = "sidebar:state" +const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 +const SIDEBAR_WIDTH = "16rem" +const SIDEBAR_WIDTH_MOBILE = "18rem" +const SIDEBAR_WIDTH_ICON = "3rem" +const SIDEBAR_KEYBOARD_SHORTCUT = "b" + +type SidebarContext = { + state: "expanded" | "collapsed" + open: boolean + setOpen: (open: boolean) => void + openMobile: boolean + setOpenMobile: (open: boolean) => void + isMobile: boolean + toggleSidebar: () => void +} + +const SidebarContext = React.createContext<SidebarContext | null>(null) + +function useSidebar() { + const context = React.useContext(SidebarContext) + if (!context) { + throw new Error("useSidebar must be used within a SidebarProvider.") + } + + return context +} + +const SidebarProvider = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + defaultOpen?: boolean + open?: boolean + onOpenChange?: (open: boolean) => void + } +>( + ( + { + defaultOpen = true, + open: openProp, + onOpenChange: setOpenProp, + className, + style, + children, + ...props + }, + ref + ) => { + const isMobile = useIsMobile() + const [openMobile, setOpenMobile] = React.useState(false) + + // This is the internal state of the sidebar. + // We use openProp and setOpenProp for control from outside the component. + const [_open, _setOpen] = React.useState(defaultOpen) + const open = openProp ?? _open + const setOpen = React.useCallback( + (value: boolean | ((value: boolean) => boolean)) => { + const openState = typeof value === "function" ? value(open) : value + if (setOpenProp) { + setOpenProp(openState) + } else { + _setOpen(openState) + } + + // This sets the cookie to keep the sidebar state. + document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` + }, + [setOpenProp, open] + ) + + // Helper to toggle the sidebar. + const toggleSidebar = React.useCallback(() => { + return isMobile + ? setOpenMobile((open) => !open) + : setOpen((open) => !open) + }, [isMobile, setOpen, setOpenMobile]) + + // Adds a keyboard shortcut to toggle the sidebar. + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if ( + event.key === SIDEBAR_KEYBOARD_SHORTCUT && + (event.metaKey || event.ctrlKey) + ) { + event.preventDefault() + toggleSidebar() + } + } + + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [toggleSidebar]) + + // We add a state so that we can do data-state="expanded" or "collapsed". + // This makes it easier to style the sidebar with Tailwind classes. + const state = open ? "expanded" : "collapsed" + + const contextValue = React.useMemo<SidebarContext>( + () => ({ + state, + open, + setOpen, + isMobile, + openMobile, + setOpenMobile, + toggleSidebar, + }), + [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] + ) + + return ( + <SidebarContext.Provider value={contextValue}> + <TooltipProvider delayDuration={0}> + <div + style={ + { + "--sidebar-width": SIDEBAR_WIDTH, + "--sidebar-width-icon": SIDEBAR_WIDTH_ICON, + ...style, + } as React.CSSProperties + } + className={cn( + "group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar", + className + )} + ref={ref} + {...props} + > + {children} + </div> + </TooltipProvider> + </SidebarContext.Provider> + ) + } +) +SidebarProvider.displayName = "SidebarProvider" + +const Sidebar = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + side?: "left" | "right" + variant?: "sidebar" | "floating" | "inset" + collapsible?: "offcanvas" | "icon" | "none" + } +>( + ( + { + side = "left", + variant = "sidebar", + collapsible = "offcanvas", + className, + children, + ...props + }, + ref + ) => { + const { isMobile, state, openMobile, setOpenMobile } = useSidebar() + + if (collapsible === "none") { + return ( + <div + className={cn( + "flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground", + className + )} + ref={ref} + {...props} + > + {children} + </div> + ) + } + + if (isMobile) { + return ( + <Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}> + <SheetContent + data-sidebar="sidebar" + data-mobile="true" + className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden" + style={ + { + "--sidebar-width": SIDEBAR_WIDTH_MOBILE, + } as React.CSSProperties + } + side={side} + > + <div className="flex h-full w-full flex-col">{children}</div> + </SheetContent> + </Sheet> + ) + } + + return ( + <div + ref={ref} + className="group peer hidden md:block text-sidebar-foreground" + data-state={state} + data-collapsible={state === "collapsed" ? collapsible : ""} + data-variant={variant} + data-side={side} + > + {/* This is what handles the sidebar gap on desktop */} + <div + className={cn( + "duration-200 relative h-svh w-[--sidebar-width] bg-transparent transition-[width] ease-linear", + "group-data-[collapsible=offcanvas]:w-0", + "group-data-[side=right]:rotate-180", + variant === "floating" || variant === "inset" + ? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]" + : "group-data-[collapsible=icon]:w-[--sidebar-width-icon]" + )} + /> + <div + className={cn( + "duration-200 fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] ease-linear md:flex", + side === "left" + ? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]" + : "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]", + // Adjust the padding for floating and inset variants. + variant === "floating" || variant === "inset" + ? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]" + : "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l", + className + )} + {...props} + > + <div + data-sidebar="sidebar" + className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow" + > + {children} + </div> + </div> + </div> + ) + } +) +Sidebar.displayName = "Sidebar" + +const SidebarTrigger = React.forwardRef< + React.ElementRef<typeof Button>, + React.ComponentProps<typeof Button> +>(({ className, onClick, ...props }, ref) => { + const { toggleSidebar } = useSidebar() + + return ( + <Button + ref={ref} + data-sidebar="trigger" + variant="ghost" + size="icon" + className={cn("h-7 w-7", className)} + onClick={(event) => { + onClick?.(event) + toggleSidebar() + }} + {...props} + > + <PanelLeft /> + <span className="sr-only">Toggle Sidebar</span> + </Button> + ) +}) +SidebarTrigger.displayName = "SidebarTrigger" + +const SidebarRail = React.forwardRef< + HTMLButtonElement, + React.ComponentProps<"button"> +>(({ className, ...props }, ref) => { + const { toggleSidebar } = useSidebar() + + return ( + <button + ref={ref} + data-sidebar="rail" + aria-label="Toggle Sidebar" + tabIndex={-1} + onClick={toggleSidebar} + title="Toggle Sidebar" + className={cn( + "absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex", + "[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize", + "[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize", + "group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar", + "[[data-side=left][data-collapsible=offcanvas]_&]:-right-2", + "[[data-side=right][data-collapsible=offcanvas]_&]:-left-2", + className + )} + {...props} + /> + ) +}) +SidebarRail.displayName = "SidebarRail" + +const SidebarInset = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"main"> +>(({ className, ...props }, ref) => { + return ( + <main + ref={ref} + className={cn( + "relative flex min-h-svh flex-1 flex-col bg-background", + "peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow", + className + )} + {...props} + /> + ) +}) +SidebarInset.displayName = "SidebarInset" + +const SidebarInput = React.forwardRef< + React.ElementRef<typeof Input>, + React.ComponentProps<typeof Input> +>(({ className, ...props }, ref) => { + return ( + <Input + ref={ref} + data-sidebar="input" + className={cn( + "h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring", + className + )} + {...props} + /> + ) +}) +SidebarInput.displayName = "SidebarInput" + +const SidebarHeader = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> +>(({ className, ...props }, ref) => { + return ( + <div + ref={ref} + data-sidebar="header" + className={cn("flex flex-col gap-2 p-2", className)} + {...props} + /> + ) +}) +SidebarHeader.displayName = "SidebarHeader" + +const SidebarFooter = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> +>(({ className, ...props }, ref) => { + return ( + <div + ref={ref} + data-sidebar="footer" + className={cn("flex flex-col gap-2 p-2", className)} + {...props} + /> + ) +}) +SidebarFooter.displayName = "SidebarFooter" + +const SidebarSeparator = React.forwardRef< + React.ElementRef<typeof Separator>, + React.ComponentProps<typeof Separator> +>(({ className, ...props }, ref) => { + return ( + <Separator + ref={ref} + data-sidebar="separator" + className={cn("mx-2 w-auto bg-sidebar-border", className)} + {...props} + /> + ) +}) +SidebarSeparator.displayName = "SidebarSeparator" + +const SidebarContent = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> +>(({ className, ...props }, ref) => { + return ( + <div + ref={ref} + data-sidebar="content" + className={cn( + "flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden", + className + )} + {...props} + /> + ) +}) +SidebarContent.displayName = "SidebarContent" + +const SidebarGroup = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> +>(({ className, ...props }, ref) => { + return ( + <div + ref={ref} + data-sidebar="group" + className={cn("relative flex w-full min-w-0 flex-col p-2", className)} + {...props} + /> + ) +}) +SidebarGroup.displayName = "SidebarGroup" + +const SidebarGroupLabel = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { asChild?: boolean } +>(({ className, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "div" + + return ( + <Comp + ref={ref} + data-sidebar="group-label" + className={cn( + "duration-200 flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opa] ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", + "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0", + className + )} + {...props} + /> + ) +}) +SidebarGroupLabel.displayName = "SidebarGroupLabel" + +const SidebarGroupAction = React.forwardRef< + HTMLButtonElement, + React.ComponentProps<"button"> & { asChild?: boolean } +>(({ className, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + + return ( + <Comp + ref={ref} + data-sidebar="group-action" + className={cn( + "absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", + // Increases the hit area of the button on mobile. + "after:absolute after:-inset-2 after:md:hidden", + "group-data-[collapsible=icon]:hidden", + className + )} + {...props} + /> + ) +}) +SidebarGroupAction.displayName = "SidebarGroupAction" + +const SidebarGroupContent = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> +>(({ className, ...props }, ref) => ( + <div + ref={ref} + data-sidebar="group-content" + className={cn("w-full text-sm", className)} + {...props} + /> +)) +SidebarGroupContent.displayName = "SidebarGroupContent" + +const SidebarMenu = React.forwardRef< + HTMLUListElement, + React.ComponentProps<"ul"> +>(({ className, ...props }, ref) => ( + <ul + ref={ref} + data-sidebar="menu" + className={cn("flex w-full min-w-0 flex-col gap-1", className)} + {...props} + /> +)) +SidebarMenu.displayName = "SidebarMenu" + +const SidebarMenuItem = React.forwardRef< + HTMLLIElement, + React.ComponentProps<"li"> +>(({ className, ...props }, ref) => ( + <li + ref={ref} + data-sidebar="menu-item" + className={cn("group/menu-item relative", className)} + {...props} + /> +)) +SidebarMenuItem.displayName = "SidebarMenuItem" + +const sidebarMenuButtonVariants = cva( + "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0", + { + variants: { + variant: { + default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground", + outline: + "bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]", + }, + size: { + default: "h-8 text-sm", + sm: "h-7 text-xs", + lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +const SidebarMenuButton = React.forwardRef< + HTMLButtonElement, + React.ComponentProps<"button"> & { + asChild?: boolean + isActive?: boolean + tooltip?: string | React.ComponentProps<typeof TooltipContent> + } & VariantProps<typeof sidebarMenuButtonVariants> +>( + ( + { + asChild = false, + isActive = false, + variant = "default", + size = "default", + tooltip, + className, + ...props + }, + ref + ) => { + const Comp = asChild ? Slot : "button" + const { isMobile, state } = useSidebar() + + const button = ( + <Comp + ref={ref} + data-sidebar="menu-button" + data-size={size} + data-active={isActive} + className={cn(sidebarMenuButtonVariants({ variant, size }), className)} + {...props} + /> + ) + + if (!tooltip) { + return button + } + + if (typeof tooltip === "string") { + tooltip = { + children: tooltip, + } + } + + return ( + <Tooltip> + <TooltipTrigger asChild>{button}</TooltipTrigger> + <TooltipContent + side="right" + align="center" + hidden={state !== "collapsed" || isMobile} + {...tooltip} + /> + </Tooltip> + ) + } +) +SidebarMenuButton.displayName = "SidebarMenuButton" + +const SidebarMenuAction = React.forwardRef< + HTMLButtonElement, + React.ComponentProps<"button"> & { + asChild?: boolean + showOnHover?: boolean + } +>(({ className, asChild = false, showOnHover = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + + return ( + <Comp + ref={ref} + data-sidebar="menu-action" + className={cn( + "absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0", + // Increases the hit area of the button on mobile. + "after:absolute after:-inset-2 after:md:hidden", + "peer-data-[size=sm]/menu-button:top-1", + "peer-data-[size=default]/menu-button:top-1.5", + "peer-data-[size=lg]/menu-button:top-2.5", + "group-data-[collapsible=icon]:hidden", + showOnHover && + "group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0", + className + )} + {...props} + /> + ) +}) +SidebarMenuAction.displayName = "SidebarMenuAction" + +const SidebarMenuBadge = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> +>(({ className, ...props }, ref) => ( + <div + ref={ref} + data-sidebar="menu-badge" + className={cn( + "absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground select-none pointer-events-none", + "peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground", + "peer-data-[size=sm]/menu-button:top-1", + "peer-data-[size=default]/menu-button:top-1.5", + "peer-data-[size=lg]/menu-button:top-2.5", + "group-data-[collapsible=icon]:hidden", + className + )} + {...props} + /> +)) +SidebarMenuBadge.displayName = "SidebarMenuBadge" + +const SidebarMenuSkeleton = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + showIcon?: boolean + } +>(({ className, showIcon = false, ...props }, ref) => { + // Random width between 50 to 90%. + const width = React.useMemo(() => { + return `${Math.floor(Math.random() * 40) + 50}%` + }, []) + + return ( + <div + ref={ref} + data-sidebar="menu-skeleton" + className={cn("rounded-md h-8 flex gap-2 px-2 items-center", className)} + {...props} + > + {showIcon && ( + <Skeleton + className="size-4 rounded-md" + data-sidebar="menu-skeleton-icon" + /> + )} + <Skeleton + className="h-4 flex-1 max-w-[--skeleton-width]" + data-sidebar="menu-skeleton-text" + style={ + { + "--skeleton-width": width, + } as React.CSSProperties + } + /> + </div> + ) +}) +SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton" + +const SidebarMenuSub = React.forwardRef< + HTMLUListElement, + React.ComponentProps<"ul"> +>(({ className, ...props }, ref) => ( + <ul + ref={ref} + data-sidebar="menu-sub" + className={cn( + "mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5", + "group-data-[collapsible=icon]:hidden", + className + )} + {...props} + /> +)) +SidebarMenuSub.displayName = "SidebarMenuSub" + +const SidebarMenuSubItem = React.forwardRef< + HTMLLIElement, + React.ComponentProps<"li"> +>(({ ...props }, ref) => <li ref={ref} {...props} />) +SidebarMenuSubItem.displayName = "SidebarMenuSubItem" + +const SidebarMenuSubButton = React.forwardRef< + HTMLAnchorElement, + React.ComponentProps<"a"> & { + asChild?: boolean + size?: "sm" | "md" + isActive?: boolean + } +>(({ asChild = false, size = "md", isActive, className, ...props }, ref) => { + const Comp = asChild ? Slot : "a" + + return ( + <Comp + ref={ref} + data-sidebar="menu-sub-button" + data-size={size} + data-active={isActive} + className={cn( + "flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground", + "data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground", + size === "sm" && "text-xs", + size === "md" && "text-sm", + "group-data-[collapsible=icon]:hidden", + className + )} + {...props} + /> + ) +}) +SidebarMenuSubButton.displayName = "SidebarMenuSubButton" + +export { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupAction, + SidebarGroupContent, + SidebarGroupLabel, + SidebarHeader, + SidebarInput, + SidebarInset, + SidebarMenu, + SidebarMenuAction, + SidebarMenuBadge, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSkeleton, + SidebarMenuSub, + SidebarMenuSubButton, + SidebarMenuSubItem, + SidebarProvider, + SidebarRail, + SidebarSeparator, + SidebarTrigger, + useSidebar, +} diff --git a/components/ui/skeleton.tsx b/components/ui/skeleton.tsx new file mode 100644 index 00000000..d7e45f7b --- /dev/null +++ b/components/ui/skeleton.tsx @@ -0,0 +1,15 @@ +import { cn } from "@/lib/utils" + +function Skeleton({ + className, + ...props +}: React.HTMLAttributes<HTMLDivElement>) { + return ( + <div + className={cn("animate-pulse rounded-md bg-primary/10", className)} + {...props} + /> + ) +} + +export { Skeleton } diff --git a/components/ui/slider.tsx b/components/ui/slider.tsx new file mode 100644 index 00000000..ab19d576 --- /dev/null +++ b/components/ui/slider.tsx @@ -0,0 +1,28 @@ +"use client" + +import * as React from "react" +import * as SliderPrimitive from "@radix-ui/react-slider" + +import { cn } from "@/lib/utils" + +const Slider = React.forwardRef< + React.ElementRef<typeof SliderPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> +>(({ className, ...props }, ref) => ( + <SliderPrimitive.Root + ref={ref} + className={cn( + "relative flex w-full touch-none select-none items-center", + className + )} + {...props} + > + <SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20"> + <SliderPrimitive.Range className="absolute h-full bg-primary" /> + </SliderPrimitive.Track> + <SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" /> + </SliderPrimitive.Root> +)) +Slider.displayName = SliderPrimitive.Root.displayName + +export { Slider } diff --git a/components/ui/sonner.tsx b/components/ui/sonner.tsx new file mode 100644 index 00000000..452f4d9f --- /dev/null +++ b/components/ui/sonner.tsx @@ -0,0 +1,31 @@ +"use client" + +import { useTheme } from "next-themes" +import { Toaster as Sonner } from "sonner" + +type ToasterProps = React.ComponentProps<typeof Sonner> + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = "system" } = useTheme() + + return ( + <Sonner + theme={theme as ToasterProps["theme"]} + className="toaster group" + toastOptions={{ + classNames: { + toast: + "group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg", + description: "group-[.toast]:text-muted-foreground", + actionButton: + "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground", + cancelButton: + "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground", + }, + }} + {...props} + /> + ) +} + +export { Toaster } diff --git a/components/ui/sortable.tsx b/components/ui/sortable.tsx new file mode 100644 index 00000000..fd3dbd92 --- /dev/null +++ b/components/ui/sortable.tsx @@ -0,0 +1,336 @@ +"use client" + +import * as React from "react" +import type { + DndContextProps, + DraggableSyntheticListeners, + DropAnimation, + UniqueIdentifier, +} from "@dnd-kit/core" +import { + closestCenter, + defaultDropAnimationSideEffects, + DndContext, + DragOverlay, + KeyboardSensor, + MouseSensor, + TouchSensor, + useSensor, + useSensors, +} from "@dnd-kit/core" +import { + restrictToHorizontalAxis, + restrictToParentElement, + restrictToVerticalAxis, +} from "@dnd-kit/modifiers" +import { + arrayMove, + horizontalListSortingStrategy, + SortableContext, + useSortable, + verticalListSortingStrategy, + type SortableContextProps, +} from "@dnd-kit/sortable" +import { CSS } from "@dnd-kit/utilities" +import { Slot, type SlotProps } from "@radix-ui/react-slot" + +import { composeRefs } from "@/lib/compose-refs" +import { cn } from "@/lib/utils" +import { Button, type ButtonProps } from "@/components/ui/button" + +const orientationConfig = { + vertical: { + modifiers: [restrictToVerticalAxis, restrictToParentElement], + strategy: verticalListSortingStrategy, + }, + horizontal: { + modifiers: [restrictToHorizontalAxis, restrictToParentElement], + strategy: horizontalListSortingStrategy, + }, + mixed: { + modifiers: [restrictToParentElement], + strategy: undefined, + }, +} + +interface SortableProps<TData extends { id: UniqueIdentifier }> + extends DndContextProps { + /** + * An array of data items that the sortable component will render. + * @example + * value={[ + * { id: 1, name: 'Item 1' }, + * { id: 2, name: 'Item 2' }, + * ]} + */ + value: TData[] + + /** + * An optional callback function that is called when the order of the data items changes. + * It receives the new array of items as its argument. + * @example + * onValueChange={(items) => console.log(items)} + */ + onValueChange?: (items: TData[]) => void + + /** + * An optional callback function that is called when an item is moved. + * It receives an event object with `activeIndex` and `overIndex` properties, representing the original and new positions of the moved item. + * This will override the default behavior of updating the order of the data items. + * @type (event: { activeIndex: number; overIndex: number }) => void + * @example + * onMove={(event) => console.log(`Item moved from index ${event.activeIndex} to index ${event.overIndex}`)} + */ + onMove?: (event: { activeIndex: number; overIndex: number }) => void + + /** + * A collision detection strategy that will be used to determine the closest sortable item. + * @default closestCenter + * @type DndContextProps["collisionDetection"] + */ + collisionDetection?: DndContextProps["collisionDetection"] + + /** + * An array of modifiers that will be used to modify the behavior of the sortable component. + * @default + * [restrictToVerticalAxis, restrictToParentElement] + * @type Modifier[] + */ + modifiers?: DndContextProps["modifiers"] + + /** + * A sorting strategy that will be used to determine the new order of the data items. + * @default verticalListSortingStrategy + * @type SortableContextProps["strategy"] + */ + strategy?: SortableContextProps["strategy"] + + /** + * Specifies the axis for the drag-and-drop operation. It can be "vertical", "horizontal", or "both". + * @default "vertical" + * @type "vertical" | "horizontal" | "mixed" + */ + orientation?: "vertical" | "horizontal" | "mixed" + + /** + * An optional React node that is rendered on top of the sortable component. + * It can be used to display additional information or controls. + * @default null + * @type React.ReactNode | null + * @example + * overlay={<Skeleton className="w-full h-8" />} + */ + overlay?: React.ReactNode | null +} + +function Sortable<TData extends { id: UniqueIdentifier }>({ + value, + onValueChange, + collisionDetection = closestCenter, + modifiers, + strategy, + onMove, + orientation = "vertical", + overlay, + children, + ...props +}: SortableProps<TData>) { + const [activeId, setActiveId] = React.useState<UniqueIdentifier | null>(null) + const sensors = useSensors( + useSensor(MouseSensor), + useSensor(TouchSensor), + useSensor(KeyboardSensor) + ) + + const config = orientationConfig[orientation] + + return ( + <DndContext + modifiers={modifiers ?? config.modifiers} + sensors={sensors} + onDragStart={({ active }) => setActiveId(active.id)} + onDragEnd={({ active, over }) => { + if (over && active.id !== over?.id) { + const activeIndex = value.findIndex((item) => item.id === active.id) + const overIndex = value.findIndex((item) => item.id === over.id) + + if (onMove) { + onMove({ activeIndex, overIndex }) + } else { + onValueChange?.(arrayMove(value, activeIndex, overIndex)) + } + } + setActiveId(null) + }} + onDragCancel={() => setActiveId(null)} + collisionDetection={collisionDetection} + {...props} + > + <SortableContext items={value} strategy={strategy ?? config.strategy}> + {children} + </SortableContext> + {overlay ? ( + <SortableOverlay activeId={activeId}>{overlay}</SortableOverlay> + ) : null} + </DndContext> + ) +} + +const dropAnimationOpts: DropAnimation = { + sideEffects: defaultDropAnimationSideEffects({ + styles: { + active: { + opacity: "0.4", + }, + }, + }), +} + +interface SortableOverlayProps + extends React.ComponentPropsWithRef<typeof DragOverlay> { + activeId?: UniqueIdentifier | null +} + +const SortableOverlay = React.forwardRef<HTMLDivElement, SortableOverlayProps>( + ( + { activeId, dropAnimation = dropAnimationOpts, children, ...props }, + ref + ) => { + return ( + <DragOverlay dropAnimation={dropAnimation} {...props}> + {activeId ? ( + <SortableItem + ref={ref} + value={activeId} + className="cursor-grabbing" + asChild + > + {children} + </SortableItem> + ) : null} + </DragOverlay> + ) + } +) +SortableOverlay.displayName = "SortableOverlay" + +interface SortableItemContextProps { + attributes: React.HTMLAttributes<HTMLElement> + listeners: DraggableSyntheticListeners | undefined + isDragging?: boolean +} + +const SortableItemContext = React.createContext<SortableItemContextProps>({ + attributes: {}, + listeners: undefined, + isDragging: false, +}) + +function useSortableItem() { + const context = React.useContext(SortableItemContext) + + if (!context) { + throw new Error("useSortableItem must be used within a SortableItem") + } + + return context +} + +interface SortableItemProps extends SlotProps { + /** + * The unique identifier of the item. + * @example "item-1" + * @type UniqueIdentifier + */ + value: UniqueIdentifier + + /** + * Specifies whether the item should act as a trigger for the drag-and-drop action. + * @default false + * @type boolean | undefined + */ + asTrigger?: boolean + + /** + * Merges the item's props into its immediate child. + * @default false + * @type boolean | undefined + */ + asChild?: boolean +} + +const SortableItem = React.forwardRef<HTMLDivElement, SortableItemProps>( + ({ value, asTrigger, asChild, className, ...props }, ref) => { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: value }) + + const context = React.useMemo<SortableItemContextProps>( + () => ({ + attributes, + listeners, + isDragging, + }), + [attributes, listeners, isDragging] + ) + const style: React.CSSProperties = { + opacity: isDragging ? 0.5 : 1, + transform: CSS.Translate.toString(transform), + transition, + } + + const Comp = asChild ? Slot : "div" + + return ( + <SortableItemContext.Provider value={context}> + <Comp + data-state={isDragging ? "dragging" : undefined} + className={cn( + "data-[state=dragging]:cursor-grabbing", + { "cursor-grab": !isDragging && asTrigger }, + className + )} + ref={composeRefs(ref, setNodeRef as React.Ref<HTMLDivElement>)} + style={style} + {...(asTrigger ? attributes : {})} + {...(asTrigger ? listeners : {})} + {...props} + /> + </SortableItemContext.Provider> + ) + } +) +SortableItem.displayName = "SortableItem" + +interface SortableDragHandleProps extends ButtonProps { + withHandle?: boolean +} + +const SortableDragHandle = React.forwardRef< + HTMLButtonElement, + SortableDragHandleProps +>(({ className, ...props }, ref) => { + const { attributes, listeners, isDragging } = useSortableItem() + + return ( + <Button + ref={composeRefs(ref)} + data-state={isDragging ? "dragging" : undefined} + className={cn( + "cursor-grab data-[state=dragging]:cursor-grabbing", + className + )} + {...attributes} + {...listeners} + {...props} + /> + ) +}) +SortableDragHandle.displayName = "SortableDragHandle" + +export { Sortable, SortableDragHandle, SortableItem, SortableOverlay } diff --git a/components/ui/switch.tsx b/components/ui/switch.tsx new file mode 100644 index 00000000..5f4117f0 --- /dev/null +++ b/components/ui/switch.tsx @@ -0,0 +1,29 @@ +"use client" + +import * as React from "react" +import * as SwitchPrimitives from "@radix-ui/react-switch" + +import { cn } from "@/lib/utils" + +const Switch = React.forwardRef< + React.ElementRef<typeof SwitchPrimitives.Root>, + React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root> +>(({ className, ...props }, ref) => ( + <SwitchPrimitives.Root + className={cn( + "peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input", + className + )} + {...props} + ref={ref} + > + <SwitchPrimitives.Thumb + className={cn( + "pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0" + )} + /> + </SwitchPrimitives.Root> +)) +Switch.displayName = SwitchPrimitives.Root.displayName + +export { Switch } diff --git a/components/ui/table.tsx b/components/ui/table.tsx new file mode 100644 index 00000000..2fa0032d --- /dev/null +++ b/components/ui/table.tsx @@ -0,0 +1,120 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Table = React.forwardRef< + HTMLTableElement, + React.HTMLAttributes<HTMLTableElement> +>(({ className, ...props }, ref) => ( + // <div className="relative w-full overflow-auto"> + <table + ref={ref} + className={cn("w-full caption-bottom text-sm", className)} + {...props} + /> + // </div> +)) +Table.displayName = "Table" + +const TableHeader = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes<HTMLTableSectionElement> +>(({ className, ...props }, ref) => ( + <thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} /> +)) +TableHeader.displayName = "TableHeader" + +const TableBody = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes<HTMLTableSectionElement> +>(({ className, ...props }, ref) => ( + <tbody + ref={ref} + className={cn("[&_tr:last-child]:border-0", className)} + {...props} + /> +)) +TableBody.displayName = "TableBody" + +const TableFooter = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes<HTMLTableSectionElement> +>(({ className, ...props }, ref) => ( + <tfoot + ref={ref} + className={cn( + "border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", + className + )} + {...props} + /> +)) +TableFooter.displayName = "TableFooter" + +const TableRow = React.forwardRef< + HTMLTableRowElement, + React.HTMLAttributes<HTMLTableRowElement> +>(({ className, ...props }, ref) => ( + <tr + ref={ref} + className={cn( + "border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted", + className + )} + {...props} + /> +)) +TableRow.displayName = "TableRow" + +const TableHead = React.forwardRef< + HTMLTableCellElement, + React.ThHTMLAttributes<HTMLTableCellElement> +>(({ className, ...props }, ref) => ( + <th + ref={ref} + className={cn( + "h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", + className + )} + {...props} + /> +)) +TableHead.displayName = "TableHead" + +const TableCell = React.forwardRef< + HTMLTableCellElement, + React.TdHTMLAttributes<HTMLTableCellElement> +>(({ className, ...props }, ref) => ( + <td + ref={ref} + className={cn( + "p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", + className + )} + {...props} + /> +)) +TableCell.displayName = "TableCell" + +const TableCaption = React.forwardRef< + HTMLTableCaptionElement, + React.HTMLAttributes<HTMLTableCaptionElement> +>(({ className, ...props }, ref) => ( + <caption + ref={ref} + className={cn("mt-4 text-sm text-muted-foreground", className)} + {...props} + /> +)) +TableCaption.displayName = "TableCaption" + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +} diff --git a/components/ui/tabs.tsx b/components/ui/tabs.tsx new file mode 100644 index 00000000..0f4caebb --- /dev/null +++ b/components/ui/tabs.tsx @@ -0,0 +1,55 @@ +"use client" + +import * as React from "react" +import * as TabsPrimitive from "@radix-ui/react-tabs" + +import { cn } from "@/lib/utils" + +const Tabs = TabsPrimitive.Root + +const TabsList = React.forwardRef< + React.ElementRef<typeof TabsPrimitive.List>, + React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> +>(({ className, ...props }, ref) => ( + <TabsPrimitive.List + ref={ref} + className={cn( + "inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground", + className + )} + {...props} + /> +)) +TabsList.displayName = TabsPrimitive.List.displayName + +const TabsTrigger = React.forwardRef< + React.ElementRef<typeof TabsPrimitive.Trigger>, + React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger> +>(({ className, ...props }, ref) => ( + <TabsPrimitive.Trigger + ref={ref} + className={cn( + "inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow", + className + )} + {...props} + /> +)) +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName + +const TabsContent = React.forwardRef< + React.ElementRef<typeof TabsPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content> +>(({ className, ...props }, ref) => ( + <TabsPrimitive.Content + ref={ref} + className={cn( + "mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", + className + )} + {...props} + /> +)) +TabsContent.displayName = TabsPrimitive.Content.displayName + +export { Tabs, TabsList, TabsTrigger, TabsContent } diff --git a/components/ui/textarea.tsx b/components/ui/textarea.tsx new file mode 100644 index 00000000..e56b0aff --- /dev/null +++ b/components/ui/textarea.tsx @@ -0,0 +1,22 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Textarea = React.forwardRef< + HTMLTextAreaElement, + React.ComponentProps<"textarea"> +>(({ className, ...props }, ref) => { + return ( + <textarea + className={cn( + "flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", + className + )} + ref={ref} + {...props} + /> + ) +}) +Textarea.displayName = "Textarea" + +export { Textarea } diff --git a/components/ui/toast.tsx b/components/ui/toast.tsx new file mode 100644 index 00000000..40ac9ddb --- /dev/null +++ b/components/ui/toast.tsx @@ -0,0 +1,129 @@ +"use client" + +import * as React from "react" +import * as ToastPrimitives from "@radix-ui/react-toast" +import { cva, type VariantProps } from "class-variance-authority" +import { X } from "lucide-react" + +import { cn } from "@/lib/utils" + +const ToastProvider = ToastPrimitives.Provider + +const ToastViewport = React.forwardRef< + React.ElementRef<typeof ToastPrimitives.Viewport>, + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport> +>(({ className, ...props }, ref) => ( + <ToastPrimitives.Viewport + ref={ref} + className={cn( + "fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]", + className + )} + {...props} + /> +)) +ToastViewport.displayName = ToastPrimitives.Viewport.displayName + +const toastVariants = cva( + "group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", + { + variants: { + variant: { + default: "border bg-background text-foreground", + destructive: + "destructive group border-destructive bg-destructive text-destructive-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Toast = React.forwardRef< + React.ElementRef<typeof ToastPrimitives.Root>, + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & + VariantProps<typeof toastVariants> +>(({ className, variant, ...props }, ref) => { + return ( + <ToastPrimitives.Root + ref={ref} + className={cn(toastVariants({ variant }), className)} + {...props} + /> + ) +}) +Toast.displayName = ToastPrimitives.Root.displayName + +const ToastAction = React.forwardRef< + React.ElementRef<typeof ToastPrimitives.Action>, + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action> +>(({ className, ...props }, ref) => ( + <ToastPrimitives.Action + ref={ref} + className={cn( + "inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive", + className + )} + {...props} + /> +)) +ToastAction.displayName = ToastPrimitives.Action.displayName + +const ToastClose = React.forwardRef< + React.ElementRef<typeof ToastPrimitives.Close>, + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close> +>(({ className, ...props }, ref) => ( + <ToastPrimitives.Close + ref={ref} + className={cn( + "absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600", + className + )} + toast-close="" + {...props} + > + <X className="h-4 w-4" /> + </ToastPrimitives.Close> +)) +ToastClose.displayName = ToastPrimitives.Close.displayName + +const ToastTitle = React.forwardRef< + React.ElementRef<typeof ToastPrimitives.Title>, + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title> +>(({ className, ...props }, ref) => ( + <ToastPrimitives.Title + ref={ref} + className={cn("text-sm font-semibold [&+div]:text-xs", className)} + {...props} + /> +)) +ToastTitle.displayName = ToastPrimitives.Title.displayName + +const ToastDescription = React.forwardRef< + React.ElementRef<typeof ToastPrimitives.Description>, + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description> +>(({ className, ...props }, ref) => ( + <ToastPrimitives.Description + ref={ref} + className={cn("text-sm opacity-90", className)} + {...props} + /> +)) +ToastDescription.displayName = ToastPrimitives.Description.displayName + +type ToastProps = React.ComponentPropsWithoutRef<typeof Toast> + +type ToastActionElement = React.ReactElement<typeof ToastAction> + +export { + type ToastProps, + type ToastActionElement, + ToastProvider, + ToastViewport, + Toast, + ToastTitle, + ToastDescription, + ToastClose, + ToastAction, +} diff --git a/components/ui/toaster.tsx b/components/ui/toaster.tsx new file mode 100644 index 00000000..171beb46 --- /dev/null +++ b/components/ui/toaster.tsx @@ -0,0 +1,35 @@ +"use client" + +import { useToast } from "@/hooks/use-toast" +import { + Toast, + ToastClose, + ToastDescription, + ToastProvider, + ToastTitle, + ToastViewport, +} from "@/components/ui/toast" + +export function Toaster() { + const { toasts } = useToast() + + return ( + <ToastProvider> + {toasts.map(function ({ id, title, description, action, ...props }) { + return ( + <Toast key={id} {...props}> + <div className="grid gap-1"> + {title && <ToastTitle>{title}</ToastTitle>} + {description && ( + <ToastDescription>{description}</ToastDescription> + )} + </div> + {action} + <ToastClose /> + </Toast> + ) + })} + <ToastViewport /> + </ToastProvider> + ) +} diff --git a/components/ui/toasterSonner.tsx b/components/ui/toasterSonner.tsx new file mode 100644 index 00000000..6028f0cd --- /dev/null +++ b/components/ui/toasterSonner.tsx @@ -0,0 +1,32 @@ +"use client" + +import { useTheme } from "next-themes" +import { Toaster as Sonner } from "sonner" + +type ToasterProps = React.ComponentProps<typeof Sonner> + +function ToasterSonner({ ...props }: ToasterProps) { + const { theme = "system" } = useTheme() + + return ( + <Sonner + theme={theme as ToasterProps["theme"]} + // eslint-disable-next-line tailwindcss/no-custom-classname + className="toaster group" + toastOptions={{ + classNames: { + toast: + "group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg", + description: "group-[.toast]:text-muted-foreground", + actionButton: + "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground", + cancelButton: + "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground", + }, + }} + {...props} + /> + ) +} + +export { ToasterSonner } diff --git a/components/ui/toggle-group.tsx b/components/ui/toggle-group.tsx new file mode 100644 index 00000000..1c876bbe --- /dev/null +++ b/components/ui/toggle-group.tsx @@ -0,0 +1,61 @@ +"use client" + +import * as React from "react" +import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group" +import { type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" +import { toggleVariants } from "@/components/ui/toggle" + +const ToggleGroupContext = React.createContext< + VariantProps<typeof toggleVariants> +>({ + size: "default", + variant: "default", +}) + +const ToggleGroup = React.forwardRef< + React.ElementRef<typeof ToggleGroupPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> & + VariantProps<typeof toggleVariants> +>(({ className, variant, size, children, ...props }, ref) => ( + <ToggleGroupPrimitive.Root + ref={ref} + className={cn("flex items-center justify-center gap-1", className)} + {...props} + > + <ToggleGroupContext.Provider value={{ variant, size }}> + {children} + </ToggleGroupContext.Provider> + </ToggleGroupPrimitive.Root> +)) + +ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName + +const ToggleGroupItem = React.forwardRef< + React.ElementRef<typeof ToggleGroupPrimitive.Item>, + React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> & + VariantProps<typeof toggleVariants> +>(({ className, children, variant, size, ...props }, ref) => { + const context = React.useContext(ToggleGroupContext) + + return ( + <ToggleGroupPrimitive.Item + ref={ref} + className={cn( + toggleVariants({ + variant: context.variant || variant, + size: context.size || size, + }), + className + )} + {...props} + > + {children} + </ToggleGroupPrimitive.Item> + ) +}) + +ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName + +export { ToggleGroup, ToggleGroupItem } diff --git a/components/ui/toggle.tsx b/components/ui/toggle.tsx new file mode 100644 index 00000000..db35d78b --- /dev/null +++ b/components/ui/toggle.tsx @@ -0,0 +1,45 @@ +"use client" + +import * as React from "react" +import * as TogglePrimitive from "@radix-ui/react-toggle" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const toggleVariants = cva( + "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + { + variants: { + variant: { + default: "bg-transparent", + outline: + "border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground", + }, + size: { + default: "h-9 px-2 min-w-9", + sm: "h-8 px-1.5 min-w-8", + lg: "h-10 px-2.5 min-w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +const Toggle = React.forwardRef< + React.ElementRef<typeof TogglePrimitive.Root>, + React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> & + VariantProps<typeof toggleVariants> +>(({ className, variant, size, ...props }, ref) => ( + <TogglePrimitive.Root + ref={ref} + className={cn(toggleVariants({ variant, size, className }))} + {...props} + /> +)) + +Toggle.displayName = TogglePrimitive.Root.displayName + +export { Toggle, toggleVariants } diff --git a/components/ui/tooltip.tsx b/components/ui/tooltip.tsx new file mode 100644 index 00000000..a66b3f22 --- /dev/null +++ b/components/ui/tooltip.tsx @@ -0,0 +1,32 @@ +"use client" + +import * as React from "react" +import * as TooltipPrimitive from "@radix-ui/react-tooltip" + +import { cn } from "@/lib/utils" + +const TooltipProvider = TooltipPrimitive.Provider + +const Tooltip = TooltipPrimitive.Root + +const TooltipTrigger = TooltipPrimitive.Trigger + +const TooltipContent = React.forwardRef< + React.ElementRef<typeof TooltipPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> +>(({ className, sideOffset = 4, ...props }, ref) => ( + <TooltipPrimitive.Portal> + <TooltipPrimitive.Content + ref={ref} + sideOffset={sideOffset} + className={cn( + "z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + className + )} + {...props} + /> + </TooltipPrimitive.Portal> +)) +TooltipContent.displayName = TooltipPrimitive.Content.displayName + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } diff --git a/components/vendor-data/project-swicher.tsx b/components/vendor-data/project-swicher.tsx new file mode 100644 index 00000000..609de5cc --- /dev/null +++ b/components/vendor-data/project-swicher.tsx @@ -0,0 +1,116 @@ +"use client" + +import * as React from "react" +import { cn } from "@/lib/utils" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" + +interface ContractInfo { + contractId: number + contractName: string +} + +interface ProjectInfo { + projectId: number + projectCode: string + projectName: string + contracts: ContractInfo[] +} + +interface ProjectSwitcherProps { + isCollapsed: boolean + projects: ProjectInfo[] + + // 상위가 관리하는 "현재 선택된 contractId" + selectedContractId: number | null + + // 콜백: 사용자가 "어떤 contract"를 골랐는지 + // => 우리가 projectId도 찾아서 상위 state를 같이 갱신해야 함 + onSelectContract: (projectId: number, contractId: number) => void +} + +export function ProjectSwitcher({ + isCollapsed, + projects, + selectedContractId, + onSelectContract, +}: ProjectSwitcherProps) { + // Select value = stringified contractId + const selectValue = selectedContractId ? String(selectedContractId) : "" + + // 현재 선택된 contract 객체 찾기 + const selectedContract = React.useMemo(() => { + if (!selectedContractId) return null + for (const proj of projects) { + const found = proj.contracts.find((c) => c.contractId === selectedContractId) + if (found) { + return { ...found, projectId: proj.projectId } + } + } + return null + }, [projects, selectedContractId]) + + // Trigger label => 계약 이름 or placeholder + const triggerLabel = selectedContract?.contractName ?? "Select a contract" + + // onValueChange: val = String(contractId) + // => 찾으면 projectId, contractId 모두 상위로 전달 + function handleValueChange(val: string) { + const contractId = Number(val) + // Find which project has this contract + let foundProjectId = 0 + let foundContractName = "" + + for (const proj of projects) { + const found = proj.contracts.find((c) => c.contractId === contractId) + if (found) { + foundProjectId = proj.projectId + foundContractName = found.contractName + break + } + } + // 상위로 알림 + onSelectContract(foundProjectId, contractId) + } + + return ( + <Select value={selectValue} onValueChange={handleValueChange}> + <SelectTrigger + className={cn( + "flex items-center gap-2", + isCollapsed && "flex h-9 w-9 shrink-0 items-center justify-center p-0" + )} + aria-label="Select Contract" + > + <SelectValue placeholder="Select a contract"> + <span className={cn("ml-2", isCollapsed && "hidden")}> + {triggerLabel} + </span> + </SelectValue> + </SelectTrigger> + + <SelectContent> + {projects.map((project) => ( + <SelectGroup key={project.projectCode}> + <SelectLabel>{project.projectName}</SelectLabel> + {project.contracts.map((contract) => ( + <SelectItem + key={contract.contractId} + value={String(contract.contractId)} + > + {contract.contractName} + </SelectItem> + ))} + </SelectGroup> + ))} + </SelectContent> + </Select> + ) +}
\ No newline at end of file diff --git a/components/vendor-data/sidebar.tsx b/components/vendor-data/sidebar.tsx new file mode 100644 index 00000000..b9e14b65 --- /dev/null +++ b/components/vendor-data/sidebar.tsx @@ -0,0 +1,235 @@ +"use client" + +import * as React from "react" +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Separator } from "@/components/ui/separator" +import { + Tooltip, + TooltipTrigger, + TooltipContent, +} from "@/components/ui/tooltip" +import { Package2, FormInput } from "lucide-react" +import { useRouter, usePathname } from "next/navigation" +import { Skeleton } from "@/components/ui/skeleton" +import { type FormInfo } from "@/lib/forms/services" + +interface PackageData { + itemId: number + itemName: string +} + +interface SidebarProps extends React.HTMLAttributes<HTMLDivElement> { + isCollapsed: boolean + packages: PackageData[] + selectedPackageId: number | null + onSelectPackage: (itemId: number) => void + forms: FormInfo[] + selectedForm: string | null + onSelectForm: (formName: string) => void + isLoadingForms?: boolean +} + +export function Sidebar({ + className, + isCollapsed, + packages, + selectedPackageId, + onSelectPackage, + forms, + selectedForm, + onSelectForm, + isLoadingForms = false, +}: SidebarProps) { + const router = useRouter() + const pathname = usePathname() + + /** + * --------------------------- + * 1) URL에서 현재 패키지 / 폼 코드 추출 + * --------------------------- + */ + const segments = pathname.split("/").filter(Boolean) + // 예) "/partners/vendor-data/tag/123" => ["partners","vendor-data","tag","123"] + + let currentItemId: number | null = null + let currentFormCode: string | null = null + + const tagIndex = segments.indexOf("tag") + if (tagIndex !== -1 && segments[tagIndex + 1]) { + // tag 뒤에 오는 값이 패키지 itemId + currentItemId = parseInt(segments[tagIndex + 1], 10) + } + + const formIndex = segments.indexOf("form") + if (formIndex !== -1) { + // form 뒤 첫 파라미터 => itemId, 그 다음 파라미터 => formCode + const itemSegment = segments[formIndex + 1] + const codeSegment = segments[formIndex + 2] + + if (itemSegment) { + currentItemId = parseInt(itemSegment, 10) + } + if (codeSegment) { + currentFormCode = codeSegment + } + } + + /** + * --------------------------- + * 2) 패키지 클릭 핸들러 + * --------------------------- + */ + const handlePackageClick = (itemId: number) => { + // 상위 컴포넌트 상태 업데이트 + onSelectPackage(itemId) + + // 해당 태그 페이지로 라우팅 + // 예: /vendor-data/tag/123 + const baseSegments = segments.slice(0, segments.indexOf("vendor-data") + 1).join("/") + router.push(`/${baseSegments}/tag/${itemId}`) + } + + /** + * --------------------------- + * 3) 폼 클릭 핸들러 + * --------------------------- + */ + const handleFormClick = (form: FormInfo) => { + // 패키지가 선택되어 있을 때만 동작 + if (selectedPackageId === null) return + + // 상위 컴포넌트 상태 업데이트 + onSelectForm(form.formName) + + // 해당 폼 페이지로 라우팅 + // 예: /vendor-data/form/[packageId]/[formCode] + + const baseSegments = segments.slice(0, segments.indexOf("vendor-data") + 1).join("/") + + router.push(`/${baseSegments}/form/${selectedPackageId}/${form.formCode}`) + } + + return ( + <div className={cn("pb-12", className)}> + <div className="space-y-4 py-4"> + {/* ---------- 패키지(Items) 목록 ---------- */} + <div className="py-1"> + <h2 className="relative px-7 text-lg font-semibold tracking-tight"> + {isCollapsed ? "P" : "Package Lists"} + </h2> + <ScrollArea className="h-[150px] px-1"> + <div className="space-y-1 p-2"> + {packages.map((pkg) => { + // URL 기준으로 active 여부 판단 + const isActive = pkg.itemId === currentItemId + + return ( + <div key={pkg.itemId}> + {isCollapsed ? ( + <Tooltip delayDuration={0}> + <TooltipTrigger asChild> + <Button + variant="ghost" + className={cn( + "w-full justify-start font-normal", + isActive && "bg-accent text-accent-foreground" + )} + onClick={() => handlePackageClick(pkg.itemId)} + > + <Package2 className="mr-2 h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent side="right"> + {pkg.itemName} + </TooltipContent> + </Tooltip> + ) : ( + <Button + variant="ghost" + className={cn( + "w-full justify-start font-normal", + isActive && "bg-accent text-accent-foreground" + )} + onClick={() => handlePackageClick(pkg.itemId)} + > + <Package2 className="mr-2 h-4 w-4" /> + {pkg.itemName} + </Button> + )} + </div> + ) + })} + </div> + </ScrollArea> + </div> + + <Separator /> + + {/* ---------- 폼 목록 ---------- */} + <div className="py-1"> + <h2 className="relative px-7 text-lg font-semibold tracking-tight"> + {isCollapsed ? "F" : "Form Lists"} + </h2> + <ScrollArea className="h-[300px] px-1"> + <div className="space-y-1 p-2"> + {isLoadingForms ? ( + // 로딩 중 스켈레톤 UI 표시 + Array.from({ length: 3 }).map((_, index) => ( + <div key={`form-skeleton-${index}`} className="px-2 py-1.5"> + <Skeleton className="h-8 w-full" /> + </div> + )) + ) : forms.length === 0 ? ( + <p className="text-sm text-muted-foreground px-2"> + (No forms loaded) + </p> + ) : ( + forms.map((form) => { + // URL 기준으로 active 여부 판단 + const isActive = form.formCode === currentFormCode + + return isCollapsed ? ( + <Tooltip key={form.formCode} delayDuration={0}> + <TooltipTrigger asChild> + <Button + variant="ghost" + className={cn( + "w-full justify-start font-normal", + isActive && "bg-accent text-accent-foreground" + )} + onClick={() => handleFormClick(form)} + disabled={currentItemId === null} + > + <FormInput className="mr-2 h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent side="right"> + {form.formName} + </TooltipContent> + </Tooltip> + ) : ( + <Button + key={form.formCode} + variant="ghost" + className={cn( + "w-full justify-start font-normal", + isActive && "bg-accent text-accent-foreground" + )} + onClick={() => handleFormClick(form)} + disabled={currentItemId === null} + > + <FormInput className="mr-2 h-4 w-4" /> + {form.formName} + </Button> + ) + }) + )} + </div> + </ScrollArea> + </div> + </div> + </div> + ) +}
\ No newline at end of file diff --git a/components/vendor-data/tag-table/add-tag-dialog.tsx b/components/vendor-data/tag-table/add-tag-dialog.tsx new file mode 100644 index 00000000..1321fc58 --- /dev/null +++ b/components/vendor-data/tag-table/add-tag-dialog.tsx @@ -0,0 +1,357 @@ +"use client" + +import * as React from "react" +import { useForm, useWatch } 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" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { createTagSchema, type CreateTagSchema } from "@/lib/tags/validations" +import { createTag } from "@/lib/tags/service" +import { toast } from "sonner" +import { Loader2 } from "lucide-react" +import { cn } from "@/lib/utils" +import { useRouter } from "next/navigation" + +// Popover + Command +import { + Popover, + PopoverTrigger, + PopoverContent, +} from "@/components/ui/popover" +import { + Command, + CommandInput, + CommandList, + CommandGroup, + CommandItem, + CommandEmpty, +} from "@/components/ui/command" +import { ChevronsUpDown, Check } from "lucide-react" + +// The dynamic Tag Type definitions +import { tagTypeDefinitions } from "./tag-type-definitions" + +// Add Select component for dropdown fields +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { ScrollArea } from "@/components/ui/scroll-area" + +interface AddTagDialogProps { + selectedPackageId: number | null +} + +export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) { + const [open, setOpen] = React.useState(false) + const [isPending, startTransition] = React.useTransition() + const router = useRouter() + + const form = useForm<CreateTagSchema>({ + resolver: zodResolver(createTagSchema), + defaultValues: { + tagType: "", // user picks + tagNo: "", // auto-generated + description: "", + functionCode: "", + seqNumber: "", + valveAcronym: "", + processUnit: "", + }, + }) + + const watchAll = useWatch({ control: form.control }) + + // 1) Find the selected tag type definition + const currentTagTypeDef = React.useMemo(() => { + return tagTypeDefinitions.find((def) => def.id === watchAll.tagType) || null + }, [watchAll.tagType]) + + // 2) Whenever the user changes sub-fields, re-generate `tagNo` + React.useEffect(() => { + if (!currentTagTypeDef) { + // if no type selected, no auto-generation + return + } + + // Prevent infinite loop by excluding tagNo from the watched dependencies + // This is crucial because setting tagNo would trigger another update + const { tagNo, ...fieldsToWatch } = watchAll + + const newTagNo = currentTagTypeDef.generateTagNo(fieldsToWatch as CreateTagSchema) + + // Only update if different to avoid unnecessary re-renders + if (form.getValues("tagNo") !== newTagNo) { + form.setValue("tagNo", newTagNo, { shouldValidate: false }) + } + }, [currentTagTypeDef, watchAll, form]) + + // Check if tag number is valid (doesn't contain '??' and is not empty) + const isTagNoValid = React.useMemo(() => { + const tagNo = form.getValues("tagNo"); + return tagNo && tagNo.trim() !== "" && !tagNo.includes("??"); + }, [form, watchAll.tagNo]); + + // onSubmit + async function onSubmit(data: CreateTagSchema) { + startTransition(async () => { + if (!selectedPackageId) { + toast.error("No selectedPackageId.") + return + } + + const result = await createTag(data, selectedPackageId) + if ("error" in result) { + toast.error(`Error: ${result.error}`) + return + } + + toast.success("Tag created successfully!") + form.reset() + setOpen(false) + router.refresh() + + }) + } + + function handleDialogOpenChange(nextOpen: boolean) { + if (!nextOpen) { + form.reset() + } + setOpen(nextOpen) + } + + // 3) TagType selection UI (like your Command menu) + function renderTagTypeSelector(field: any) { + const [popoverOpen, setPopoverOpen] = React.useState(false) + return ( + <FormItem> + <FormLabel>Tag Type</FormLabel> + <FormControl> + <Popover open={popoverOpen} onOpenChange={setPopoverOpen}> + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + aria-expanded={popoverOpen} + className="w-full justify-between" + > + {field.value + ? tagTypeDefinitions.find((d) => d.id === field.value)?.label + : "Select Tag Type..."} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-full p-0"> + <Command> + <CommandInput placeholder="Search Tag Type..." /> + <CommandList> + <CommandEmpty>No tag type found.</CommandEmpty> + <CommandGroup> + {tagTypeDefinitions.map((def,index) => ( + <CommandItem + key={index} + onSelect={() => { + field.onChange(def.id) // store the 'id' + setPopoverOpen(false) + }} + value={def.id} + > + {def.label} + <Check + className={cn( + "ml-auto h-4 w-4", + field.value === def.id + ? "opacity-100" + : "opacity-0" + )} + /> + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + </FormControl> + <FormMessage /> + </FormItem> + ) + } + + // 4) Render sub-fields based on currentTagTypeDef + // Updated to handle different field types (text, select) + function renderSubFields() { + if (!currentTagTypeDef) return null + + return currentTagTypeDef.subFields.map((subField, index) => ( + + <FormField + key={`${subField.name}-${index}`} + control={form.control} + name={subField.name as keyof CreateTagSchema} + render={({ field }) => ( + <FormItem> + <FormLabel>{subField.label}</FormLabel> + <FormControl> + {subField.type === "select" && subField.options ? ( + <Select + value={field.value || ""} + onValueChange={field.onChange} + > + <SelectTrigger className="w-full"> + <SelectValue placeholder={subField.placeholder || "Select an option"} /> + </SelectTrigger> + <SelectContent> + {subField.options.map((option, index) => ( + <SelectItem key={index} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + ) : ( + <Input + placeholder={subField.placeholder || ""} + value={field.value || ""} + onChange={field.onChange} + onBlur={field.onBlur} + name={field.name} + ref={field.ref} + /> + )} + </FormControl> + {subField.formatHint && ( + <p className="text-sm text-muted-foreground mt-1"> + {subField.formatHint} + </p> + )} + <FormMessage /> + </FormItem> + )} + /> + )) + } + + return ( + <Dialog open={open} onOpenChange={handleDialogOpenChange}> + <DialogTrigger asChild> + <Button variant="default" size="sm"> + Add Tag + </Button> + </DialogTrigger> + + <DialogContent className="max-w-md max-h-[90vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle>Add New Tag</DialogTitle> + <DialogDescription> + Select a Tag Type and fill in sub-fields. The Tag No will be generated automatically. + </DialogDescription> + </DialogHeader> + <ScrollArea className="flex-1"> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> + <div className="space-y-4"> + {/* Tag Type - Outside ScrollArea as it's always visible */} + <FormField + control={form.control} + name="tagType" + render={({ field }) => renderTagTypeSelector(field)} + /> + </div> + + {/* ScrollArea for dynamic fields */} + <ScrollArea className="h-[50vh] pr-4"> + <div className="space-y-4"> + {/* sub-fields from the selected tagType */} + {renderSubFields()} + + {/* Tag No (auto-generated) */} + <FormField + control={form.control} + name="tagNo" + render={({ field }) => ( + <FormItem> + <FormLabel>Tag No (auto-generated)</FormLabel> + <FormControl> + <Input + placeholder="Auto-generated..." + {...field} + readOnly + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Description (optional) */} + <FormField + control={form.control} + name="description" + render={({ field }) => ( + <FormItem> + <FormLabel>Description </FormLabel> + <FormControl> + <Input + placeholder="Optional desc..." + value={field.value ?? ""} + onChange={(e) => field.onChange(e.target.value)} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + </ScrollArea> + </form> + </Form> + </ScrollArea> + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => { + form.reset(); + setOpen(false); + }} + disabled={isPending} + > + Cancel + </Button> + <Button type="submit" disabled={isPending || !isTagNoValid}> + {isPending && ( + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + )} + Create + </Button> + </DialogFooter> + + + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/components/vendor-data/tag-table/tag-table-column.tsx b/components/vendor-data/tag-table/tag-table-column.tsx new file mode 100644 index 00000000..a22611cf --- /dev/null +++ b/components/vendor-data/tag-table/tag-table-column.tsx @@ -0,0 +1,196 @@ +"use client" + +import * as React from "react" +import { ColumnDef } from "@tanstack/react-table" +import type { Row } from "@tanstack/react-table" +import { numericFilter } from "@/lib/data-table" +import { ClientDataTableColumnHeaderSimple } from "@/components/client-data-table/data-table-column-simple-header" +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 { Ellipsis } from "lucide-react" +import { Tag } from "@/types/vendorData" + + +export interface DataTableRowAction<TData> { + row: Row<TData> + type: 'open' | "update" | "delete" +} + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<Tag> | null>> +} + +export function getColumns({ + setRowAction, + }: GetColumnsProps): ColumnDef<Tag>[] { + return [ + { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + enableSorting: false, + enableHiding: false, + size: 40, + }, + + { + accessorKey: "tagNo", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="Tag No." /> + ), + cell: ({ row }) => <div className="w-20">{row.getValue("tagNo")}</div>, + meta: { + excelHeader: "Tag No" + }, + }, + { + accessorKey: "description", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="Tag Description" /> + ), + cell: ({ row }) => <div className="w-120">{row.getValue("description")}</div>, + meta: { + excelHeader: "Tag Descripiton" + }, + }, + { + accessorKey: "tagType", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="Tag Type" /> + ), + cell: ({ row }) => <div className="w-40">{row.getValue("tagType")}</div>, + meta: { + excelHeader: "Tag Type" + }, + }, + { + id: "validation", + header: "Error", + cell: ({ row }) => <div className="w-100"></div>, + }, + + { + accessorKey: "createdAt", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="Created At" /> + ), + cell: ({ cell }) => formatDate(cell.getValue() as Date), + meta: { + excelHeader: "created At" + }, + }, + { + accessorKey: "updatedAt", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="Updated At" /> + ), + cell: ({ cell }) => formatDate(cell.getValue() as Date), + meta: { + excelHeader: "updated At" + }, + }, + + { + id: "actions", + 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( + updateTask({ + 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, + }, + ] + } +
\ No newline at end of file diff --git a/components/vendor-data/tag-table/tag-table.tsx b/components/vendor-data/tag-table/tag-table.tsx new file mode 100644 index 00000000..a449529f --- /dev/null +++ b/components/vendor-data/tag-table/tag-table.tsx @@ -0,0 +1,39 @@ +"use client" + +import * as React from "react" +import { ClientDataTable } from "@/components/client-data-table/data-table" +import { DataTableRowAction, getColumns } from "./tag-table-column" +import { Tag as TagData } from "@/types/vendorData" +import { DataTableAdvancedFilterField } from "@/types/table" +import { AddTagDialog } from "./add-tag-dialog" + +interface TagTableProps { + data: TagData[] +} + +/** + * TagTable: Tag 데이터를 표시하는 표 + */ +export function TagTable({ data }: TagTableProps) { + + const [rowAction, setRowAction] = + React.useState<DataTableRowAction<TagData> | null>(null) + + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + const advancedFilterFields: DataTableAdvancedFilterField<TagData>[] = [] + + return ( + <> + <ClientDataTable + data={data} + columns={columns} + advancedFilterFields={advancedFilterFields} + /> + + </> + ) +}
\ No newline at end of file diff --git a/components/vendor-data/tag-table/tag-type-definitions.ts b/components/vendor-data/tag-table/tag-type-definitions.ts new file mode 100644 index 00000000..e5d04eab --- /dev/null +++ b/components/vendor-data/tag-table/tag-type-definitions.ts @@ -0,0 +1,87 @@ +import { CreateTagSchema } from "@/lib/tags/validations" + +/** + * Each "Tag Type" has: + * - id, label + * - subFields[]: + * -- name (form field name) + * -- label (UI label) + * -- placeholder? + * -- type: "select" | "text" + * -- options?: { value: string; label: string; }[] (for dropdown) + * -- optional "regex" or "formatHint" for display + * - generateTagNo: function + */ +export const tagTypeDefinitions = [ + { + id: "EquipmentNumbering", + label: "Equipment Numbering", + subFields: [ + { + name: "functionCode", + label: "Function", + placeholder: "", + type: "select", + // Example options: + options: [ + { value: "PM", label: "Pump" }, + { value: "AA", label: "Pneumatic Motor" }, + ], + // or if you want a regex or format hint: + formatHint: "2 letters, e.g. PM", + }, + { + name: "seqNumber", + label: "Seq. Number", + placeholder: "001", + type: "text", + formatHint: "3 digits", + }, + ], + generateTagNo: (values: CreateTagSchema) => { + const fc = values.functionCode || "??" + const seq = values.seqNumber || "000" + return `${fc}-${seq}` + }, + }, + { + id: "Valve", + label: "Valve", + subFields: [ + { + name: "valveAcronym", + label: "Valve Acronym", + placeholder: "", + type: "select", + options: [ + { value: "VB", label: "Ball Valve" }, + { value: "VAR", label: "Auto Recirculation Valve" }, + ], + }, + { + name: "processUnit", + label: "Process Unit (2 digits)", + placeholder: "01", + type: "select", + options: [ + { value: "01", label: "Firewater System" }, + { value: "02", label: "Liquefaction Unit" }, + ], + }, + { + name: "seqNumber", + label: "Seq. Number", + placeholder: "001", + type: "text", + formatHint: "3 digits", + }, + ], + generateTagNo: (values: CreateTagSchema) => { + const va = values.valveAcronym || "??" + const pu = values.processUnit || "??" + const seq= values.seqNumber || "000" + return `${va}-${pu}${seq}` + }, + }, + // ... more types from your API ... +]
\ No newline at end of file diff --git a/components/vendor-data/vendor-data-container.tsx b/components/vendor-data/vendor-data-container.tsx new file mode 100644 index 00000000..69c22b79 --- /dev/null +++ b/components/vendor-data/vendor-data-container.tsx @@ -0,0 +1,231 @@ +"use client" + +import * as React from "react" +import { TooltipProvider } from "@/components/ui/tooltip" +import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable" +import { cn } from "@/lib/utils" +import { ProjectSwitcher } from "./project-swicher" +import { Sidebar } from "./sidebar" +import { useParams, usePathname, useRouter } from "next/navigation" +import { getFormsByContractItemId, type FormInfo } from "@/lib/forms/services" +import { Separator } from "@/components/ui/separator" + +interface PackageData { + itemId: number + itemName: string +} + +interface ContractData { + contractId: number + contractName: string + packages: PackageData[] +} + +interface ProjectData { + projectId: number + projectCode: string + projectName: string + contracts: ContractData[] +} + +interface VendorDataContainerProps { + projects: ProjectData[] + defaultLayout?: number[] + defaultCollapsed?: boolean + navCollapsedSize: number + children: React.ReactNode +} + +function getTagIdFromPathname(path: string): number | null { + // 태그 패턴 검사 (/tag/123) + const tagMatch = path.match(/\/tag\/(\d+)/) + if (tagMatch) return parseInt(tagMatch[1], 10) + + // 폼 패턴 검사 (/form/123/...) + const formMatch = path.match(/\/form\/(\d+)/) + if (formMatch) return parseInt(formMatch[1], 10) + + return null +} +export function VendorDataContainer({ + projects, + defaultLayout = [20, 80], + defaultCollapsed = false, + navCollapsedSize, + children +}: VendorDataContainerProps) { + const pathname = usePathname() + const router = useRouter() + const tagIdNumber = getTagIdFromPathname(pathname) + + const [isCollapsed, setIsCollapsed] = React.useState(defaultCollapsed) + // 폼 로드 요청 추적 + const lastRequestIdRef = React.useRef(0) + + // 기본 상태 + const [selectedProjectId, setSelectedProjectId] = React.useState(projects[0]?.projectId || 0) + const [selectedContractId, setSelectedContractId] = React.useState( + projects[0]?.contracts[0]?.contractId || 0 + ) + // URL에서 들어온 tagIdNumber를 우선으로 설정하기 위해 초기에 null로 두고, 뒤에서 useEffect로 세팅 + const [selectedPackageId, setSelectedPackageId] = React.useState<number | null>(null) + + const [formList, setFormList] = React.useState<FormInfo[]>([]) + const [selectedFormCode, setSelectedFormCode] = React.useState<string | null>(null) + const [isLoadingForms, setIsLoadingForms] = React.useState(false) + + // 현재 선택된 프로젝트/계약/패키지 + const currentProject = projects.find((p) => p.projectId === selectedProjectId) ?? projects[0] + const currentContract = currentProject?.contracts.find((c) => c.contractId === selectedContractId) + ?? currentProject?.contracts[0] + + const isTagOrFormRoute = pathname.includes("/tag/") || pathname.includes("/form/") + const currentPackageName = isTagOrFormRoute + ? currentContract?.packages.find((pkg) => pkg.itemId === selectedPackageId)?.itemName || "None" + : "None" + + // 폼 목록에서 고유한 폼 이름만 추출 + const formNames = React.useMemo(() => { + return [...new Set(formList.map((form) => form.formName))] + }, [formList]) + + // (1) 새로고침 시 URL 파라미터(tagIdNumber) → selectedPackageId 세팅 + // URL에 tagIdNumber가 있으면 그걸 우선으로, 아니면 기존 로직대로 '첫 번째 패키지' 사용 + React.useEffect(() => { + if (!currentContract) return + + if (tagIdNumber) { + setSelectedPackageId(tagIdNumber) + } else { + // tagIdNumber가 없으면, 현재 계약의 첫 번째 패키지로 + if (currentContract.packages?.length) { + setSelectedPackageId(currentContract.packages[0].itemId) + } else { + setSelectedPackageId(null) + } + } + }, [tagIdNumber, currentContract]) + + // (2) 프로젝트 변경 시 계약 초기화 + React.useEffect(() => { + if (currentProject?.contracts.length) { + setSelectedContractId(currentProject.contracts[0].contractId) + } else { + setSelectedContractId(0) + } + }, [currentProject]) + + // // (3) selectedPackageId 바뀔 때 URL도 같이 업데이트 + // React.useEffect(() => { + // if (!selectedPackageId) return + // const basePath = pathname.includes("/partners/") + // ? "/partners/vendor-data/tag/" + // : "/vendor-data/tag/" + + // router.push(`${basePath}${selectedPackageId}`) + // }, [selectedPackageId, router, pathname]) + + // (4) 패키지 ID가 정해질 때마다 폼 로딩 + React.useEffect(() => { + const packageId = getTagIdFromPathname(pathname) + + if (packageId) { + setSelectedPackageId(packageId) + + // URL에서 패키지 ID를 얻었을 때 즉시 폼 로드 + loadFormsList(packageId); + } else if (currentContract?.packages?.length) { + setSelectedPackageId(currentContract.packages[0].itemId) + } + }, [pathname, currentContract]) + + // 폼 로드 함수를 컴포넌트 내부에 정의하고 재사용 + const loadFormsList = async (packageId: number) => { + if (!packageId) return; + + setIsLoadingForms(true); + try { + const result = await getFormsByContractItemId(packageId); + setFormList(result.forms || []); + } catch (error) { + console.error("폼 로딩 오류:", error); + setFormList([]); + } finally { + setIsLoadingForms(false); + } + }; + // 핸들러들 + function handleSelectContract(projId: number, cId: number) { + setSelectedProjectId(projId) + setSelectedContractId(cId) + } + + function handleSelectPackage(itemId: number) { + setSelectedPackageId(itemId) + } + + function handleSelectForm(formName: string) { + const form = formList.find((f) => f.formName === formName) + if (form) { + setSelectedFormCode(form.formCode) + } + } + + return ( + <TooltipProvider delayDuration={0}> + <ResizablePanelGroup direction="horizontal" className="h-full"> + <ResizablePanel + defaultSize={defaultLayout[0]} + collapsedSize={navCollapsedSize} + collapsible + minSize={15} + maxSize={25} + onCollapse={() => setIsCollapsed(true)} + onResize={() => setIsCollapsed(false)} + className={cn(isCollapsed && "min-w-[50px] transition-all duration-300 ease-in-out")} + > + <div + className={cn( + "flex h-[52px] items-center justify-center gap-2", + isCollapsed ? "h-[52px]" : "px-2" + )} + > + <ProjectSwitcher + isCollapsed={isCollapsed} + projects={projects} + selectedContractId={selectedContractId} + onSelectContract={handleSelectContract} + /> + </div> + <Separator /> + <Sidebar + isCollapsed={isCollapsed} + packages={currentContract?.packages || []} + selectedPackageId={selectedPackageId} + onSelectPackage={handleSelectPackage} + forms={formList} + selectedForm={ + selectedFormCode + ? formList.find((f) => f.formCode === selectedFormCode)?.formName || null + : null + } + onSelectForm={handleSelectForm} + isLoadingForms={isLoadingForms} + className="hidden lg:block" + /> + </ResizablePanel> + + <ResizableHandle withHandle /> + + <ResizablePanel defaultSize={defaultLayout[1]} minSize={40}> + <div className="p-4 h-full overflow-auto flex flex-col"> + <div className="flex items-center justify-between mb-4"> + <h2 className="text-lg font-bold">Package: {currentPackageName}</h2> + </div> + {children} + </div> + </ResizablePanel> + </ResizablePanelGroup> + </TooltipProvider> + ) +}
\ No newline at end of file |
