최초커밋

This commit is contained in:
kjs
2025-08-21 09:41:46 +09:00
commit a0e5b57a24
2454 changed files with 1476904 additions and 0 deletions

View File

@@ -0,0 +1,280 @@
"use client";
import * as React from "react";
import {
ColumnDef,
flexRender,
getCoreRowModel,
getSortedRowModel,
getFilteredRowModel,
getPaginationRowModel,
SortingState,
ColumnFiltersState,
useReactTable,
} from "@tanstack/react-table";
import { ChevronDown, ChevronUp, Search, Download, Filter } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { cn } from "@/lib/utils";
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
searchable?: boolean;
searchPlaceholder?: string;
showExport?: boolean;
showFilter?: boolean;
onExport?: () => void;
onRowClick?: (row: TData) => void;
className?: string;
}
export function DataTable<TData, TValue>({
columns,
data,
searchable = true,
searchPlaceholder = "검색...",
showExport = false,
showFilter = false,
onExport,
onRowClick,
className,
}: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = React.useState<SortingState>([]);
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([]);
const [globalFilter, setGlobalFilter] = React.useState("");
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onGlobalFilterChange: setGlobalFilter,
state: {
sorting,
columnFilters,
globalFilter,
},
});
return (
<div className={cn("space-y-4", className)}>
{/* 테이블 상단 툴바 */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
{searchable && (
<div className="relative">
<Search className="absolute top-2.5 left-2.5 h-4 w-4 text-slate-500" />
<Input
placeholder={searchPlaceholder}
value={globalFilter ?? ""}
onChange={(event) => setGlobalFilter(event.target.value)}
className="w-64 pl-8"
/>
</div>
)}
{showFilter && (
<Button variant="outline" size="sm">
<Filter className="mr-2 h-4 w-4" />
</Button>
)}
</div>
<div className="flex items-center space-x-2">
{showExport && (
<Button variant="outline" size="sm" onClick={onExport}>
<Download className="mr-2 h-4 w-4" />
</Button>
)}
</div>
</div>
{/* 테이블 */}
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id} className="bg-slate-50">
{header.isPlaceholder ? null : (
<div
className={cn(
"flex items-center space-x-2",
header.column.getCanSort() ? "cursor-pointer select-none" : "",
)}
onClick={header.column.getToggleSortingHandler()}
>
<span>{flexRender(header.column.columnDef.header, header.getContext())}</span>
{header.column.getCanSort() && (
<div className="flex flex-col">
{header.column.getIsSorted() === "desc" ? (
<ChevronDown className="h-4 w-4" />
) : header.column.getIsSorted() === "asc" ? (
<ChevronUp className="h-4 w-4" />
) : (
<div className="h-4 w-4 opacity-50">
<ChevronDown className="h-3 w-3" />
</div>
)}
</div>
)}
</div>
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className={cn(onRowClick ? "cursor-pointer hover:bg-slate-50" : "")}
onClick={() => onRowClick?.(row.original)}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{/* 페이지네이션 */}
<div className="flex items-center justify-between px-2">
<div className="flex-1 text-sm text-slate-600">
{table.getFilteredSelectedRowModel().rows.length} {table.getFilteredRowModel().rows.length}
</div>
<div className="flex items-center space-x-6 lg:space-x-8">
<div className="flex items-center space-x-2">
<p className="text-sm font-medium"> </p>
<select
className="h-8 w-16 rounded border border-slate-300 text-sm"
value={table.getState().pagination.pageSize}
onChange={(e) => {
table.setPageSize(Number(e.target.value));
}}
>
{[10, 20, 30, 40, 50].map((pageSize) => (
<option key={pageSize} value={pageSize}>
{pageSize}
</option>
))}
</select>
</div>
<div className="flex w-24 items-center justify-center text-sm font-medium">
{table.getState().pagination.pageIndex + 1} / {table.getPageCount()}
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only"> </span>
{"<<"}
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only"> </span>
{"<"}
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className="sr-only"> </span>
{">"}
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<span className="sr-only"> </span>
{">>"}
</Button>
</div>
</div>
</div>
</div>
);
}
// 컬럼 정의 헬퍼 함수들
export const createTextColumn = (accessorKey: string, header: string) => ({
accessorKey,
header,
cell: ({ row }: any) => <div className="text-left">{row.getValue(accessorKey)}</div>,
});
export const createDateColumn = (accessorKey: string, header: string) => ({
accessorKey,
header,
cell: ({ row }: any) => {
const date = row.getValue(accessorKey);
return <div className="text-left">{date ? new Date(date).toLocaleDateString("ko-KR") : "-"}</div>;
},
});
export const createStatusColumn = (accessorKey: string, header: string) => ({
accessorKey,
header,
cell: ({ row }: any) => {
const status = row.getValue(accessorKey);
return (
<div className="flex w-16">
<span
className={cn(
"inline-flex items-center rounded-full px-2 py-1 text-xs font-medium",
status === "active" || status === "활성"
? "bg-green-50 text-green-700"
: status === "inactive" || status === "비활성"
? "bg-gray-50 text-gray-700"
: status === "pending" || status === "대기"
? "bg-yellow-50 text-yellow-700"
: "bg-red-50 text-red-700",
)}
>
{status || "-"}
</span>
</div>
);
},
});
export const createActionColumn = (actions: React.ReactNode) => ({
id: "actions",
header: "작업",
cell: () => actions,
});

View File

@@ -0,0 +1,43 @@
"use client";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useMultiLang } from "@/hooks/useMultiLang";
interface LanguageSelectorProps {
className?: string;
}
export function LanguageSelector({ className }: LanguageSelectorProps) {
const { userLang, changeLang } = useMultiLang();
const languages = [
{ code: "KR", name: "한국어", flag: "🇰🇷" },
{ code: "US", name: "English", flag: "🇺🇸" },
{ code: "JP", name: "日本語", flag: "🇯🇵" },
{ code: "CN", name: "中文", flag: "🇨🇳" },
];
const handleLanguageChange = (newLang: string) => {
changeLang(newLang);
// 언어 변경 시 메뉴 컨텍스트가 자동으로 새로고침됨
console.log("언어 변경됨:", newLang);
};
return (
<Select value={userLang} onValueChange={handleLanguageChange}>
<SelectTrigger className={`${className}`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{languages.map((lang) => (
<SelectItem key={lang.code} value={lang.code}>
<span className="flex items-center space-x-2">
<span>{lang.flag}</span>
<span>{lang.name}</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
);
}

View File

@@ -0,0 +1,54 @@
import { Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
interface LoadingSpinnerProps {
size?: "sm" | "md" | "lg";
className?: string;
text?: string;
}
export function LoadingSpinner({ size = "md", className, text = "로딩 중..." }: LoadingSpinnerProps) {
const sizeClasses = {
sm: "h-4 w-4",
md: "h-6 w-6",
lg: "h-8 w-8",
};
return (
<div className={cn("flex items-center justify-center", className)}>
<div className="flex items-center space-x-2">
<Loader2 className={cn("animate-spin", sizeClasses[size])} />
{text && <span className="text-sm text-slate-600">{text}</span>}
</div>
</div>
);
}
// 페이지 전체 로딩 오버레이
export function LoadingOverlay({
isLoading,
text = "로딩 중...",
children,
}: {
isLoading: boolean;
text?: string;
children: React.ReactNode;
}) {
if (!isLoading) {
return <>{children}</>;
}
return (
<div className="relative">
<div className="pointer-events-none opacity-50">{children}</div>
<div className="absolute inset-0 flex items-center justify-center bg-white/50 backdrop-blur-sm">
<LoadingSpinner size="lg" text={text} />
</div>
</div>
);
}
// 인라인 로딩 스피너 (버튼 내부 등에서 사용)
export function InlineSpinner({ className }: { className?: string }) {
return <Loader2 className={cn("h-4 w-4 animate-spin", className)} />;
}

View File

@@ -0,0 +1,58 @@
"use client";
import { ArrowLeft, HelpCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface PageHeaderProps {
title: string;
subtitle?: string;
showBackButton?: boolean;
onBack?: () => void;
showHelpButton?: boolean;
onHelp?: () => void;
children?: React.ReactNode;
className?: string;
}
export function PageHeader({
title,
subtitle,
showBackButton = false,
onBack,
showHelpButton = false,
onHelp,
children,
className,
}: PageHeaderProps) {
return (
<div className={cn("flex items-center justify-between border-b border-slate-200 pb-6", className)}>
<div className="flex items-center space-x-4">
{showBackButton && (
<Button variant="ghost" size="sm" onClick={onBack} className="h-8 w-8 p-0">
<ArrowLeft className="h-4 w-4" />
</Button>
)}
<div>
<h1 className="text-2xl font-bold text-slate-900">{title}</h1>
{subtitle && <p className="mt-1 text-sm text-slate-600">{subtitle}</p>}
</div>
</div>
<div className="flex items-center space-x-2">
{children}
{showHelpButton && (
<Button variant="outline" size="sm" onClick={onHelp} className="h-8 w-8 p-0">
<HelpCircle className="h-4 w-4" />
</Button>
)}
</div>
</div>
);
}
// 페이지 헤더 액션 버튼 컴포넌트
export function PageHeaderActions({ children, className }: { children: React.ReactNode; className?: string }) {
return <div className={cn("flex items-center space-x-2", className)}>{children}</div>;
}

View File

@@ -0,0 +1,226 @@
"use client";
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { cn } from "@/lib/utils";
export interface PaginationInfo {
currentPage: number;
totalPages: number;
totalItems: number;
itemsPerPage: number;
startItem: number;
endItem: number;
}
export interface PaginationProps {
paginationInfo: PaginationInfo;
onPageChange: (page: number) => void;
onPageSizeChange?: (pageSize: number) => void;
showPageSizeSelector?: boolean;
pageSizeOptions?: number[];
className?: string;
}
/**
* 재사용 가능한 페이지네이션 컴포넌트
*
* @example
* <Pagination
* paginationInfo={{
* currentPage: 1,
* totalPages: 10,
* totalItems: 200,
* itemsPerPage: 20,
* startItem: 1,
* endItem: 20
* }}
* onPageChange={(page) => console.log('Page changed:', page)}
* onPageSizeChange={(size) => console.log('Page size changed:', size)}
* showPageSizeSelector={true}
* pageSizeOptions={[10, 20, 50, 100]}
* />
*/
export function Pagination({
paginationInfo,
onPageChange,
onPageSizeChange,
showPageSizeSelector = false,
pageSizeOptions = [10, 20, 50, 100],
className,
}: PaginationProps) {
const { currentPage, totalPages, totalItems, itemsPerPage, startItem, endItem } = paginationInfo;
// 페이지 버튼 범위 계산 (현재 페이지 기준으로 앞뒤 2개씩)
const getPageNumbers = () => {
const delta = 2;
const range = [];
const rangeWithDots = [];
// 시작과 끝 계산
let start = Math.max(1, currentPage - delta);
let end = Math.min(totalPages, currentPage + delta);
// 범위 조정
if (end - start < delta * 2) {
if (start === 1) {
end = Math.min(totalPages, start + delta * 2);
} else if (end === totalPages) {
start = Math.max(1, end - delta * 2);
}
}
// 페이지 번호 배열 생성
for (let i = start; i <= end; i++) {
range.push(i);
}
// 첫 페이지와 점 추가
if (start > 1) {
rangeWithDots.push(1);
if (start > 2) {
rangeWithDots.push("...");
}
}
// 중간 범위 추가
rangeWithDots.push(...range);
// 마지막 페이지와 점 추가
if (end < totalPages) {
if (end < totalPages - 1) {
rangeWithDots.push("...");
}
rangeWithDots.push(totalPages);
}
return rangeWithDots;
};
const pageNumbers = getPageNumbers();
// 페이지 변경 핸들러
const handlePageChange = (page: number) => {
if (page >= 1 && page <= totalPages && page !== currentPage) {
onPageChange(page);
}
};
// 페이지 크기 변경 핸들러
const handlePageSizeChange = (newPageSize: string) => {
const size = parseInt(newPageSize, 10);
if (onPageSizeChange && size !== itemsPerPage) {
onPageSizeChange(size);
}
};
if (totalPages <= 1) {
return null; // 페이지가 1개 이하면 렌더링하지 않음
}
return (
<div className={cn("flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between", className)}>
{/* 페이지 정보 */}
<div className="text-muted-foreground text-sm">
<span className="font-medium">{startItem.toLocaleString()}</span>
{" - "}
<span className="font-medium">{endItem.toLocaleString()}</span>
{" / "}
<span className="font-medium">{totalItems.toLocaleString()}</span>
</div>
{/* 페이지네이션 컨트롤 */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
{/* 페이지 크기 선택 */}
{showPageSizeSelector && onPageSizeChange && (
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-sm"></span>
<Select value={itemsPerPage.toString()} onValueChange={handlePageSizeChange}>
<SelectTrigger className="w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>
{pageSizeOptions.map((size) => (
<SelectItem key={size} value={size.toString()}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-muted-foreground text-sm"></span>
</div>
)}
{/* 페이지 버튼들 */}
<div className="flex items-center gap-1">
{/* 첫 페이지로 */}
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(1)}
disabled={currentPage === 1}
className="h-9 w-9 p-0"
title="첫 페이지"
>
<ChevronsLeft className="h-4 w-4" />
</Button>
{/* 이전 페이지 */}
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1}
className="h-9 w-9 p-0"
title="이전 페이지"
>
<ChevronLeft className="h-4 w-4" />
</Button>
{/* 페이지 번호들 */}
{pageNumbers.map((page, index) => (
<div key={index}>
{page === "..." ? (
<span className="text-muted-foreground flex h-9 w-9 items-center justify-center text-sm">...</span>
) : (
<Button
variant={page === currentPage ? "default" : "outline"}
size="sm"
onClick={() => handlePageChange(page as number)}
className="h-9 w-9 p-0"
>
{page}
</Button>
)}
</div>
))}
{/* 다음 페이지 */}
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className="h-9 w-9 p-0"
title="다음 페이지"
>
<ChevronRight className="h-4 w-4" />
</Button>
{/* 마지막 페이지로 */}
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(totalPages)}
disabled={currentPage === totalPages}
className="h-9 w-9 p-0"
title="마지막 페이지"
>
<ChevronsRight className="h-4 w-4" />
</Button>
</div>
</div>
</div>
);
}