Merge remote-tracking branch 'origin/main' into iipbs/sn/dev

This commit is contained in:
yschoi
2025-04-24 11:18:55 +09:00
308 changed files with 12760 additions and 1886 deletions
@@ -13,9 +13,11 @@ export interface MenuItem {
urlPath: string;
menuLnkgTypeCd: string;
menuLnkgSn: number;
menuPath: string;
qrcd: string;
useYn: string;
otptYn: string;
newWndwYn: string;
requiredUrlPath: boolean;
}
+1 -1
View File
@@ -20,7 +20,7 @@ export interface SiteInfo {
mdfcnDt: string;
mdFr: string;
rgTrNm: string;
mdFrNm: string;
mdfrNm: string;
}
export const getSiteInfo = async (siteId: string) => {
+8 -10
View File
@@ -1,12 +1,10 @@
export const PORT = import.meta.env.VITE_PORT || "3000";
export const PROXY_HOST =
import.meta.env.VITE_PROXY_HOST || `http://localhost:${PORT}`;
export const PORT = process.env.PORT || "3000";
export const PROXY_HOST = process.env.PROXY_HOST || `http://localhost:${PORT}`;
export const SERVER_API_URL = import.meta.env.VITE_SERVER_API_URL;
export const SITE_ID = import.meta.env.VITE_SITE_ID || "CS";
export const SERVER_API_URL = process.env.SERVER_API_URL;
export const SITE_ID = process.env.SITE_ID || "CS";
export const CLAIM_NAME = import.meta.env.VITE_CLAIM_NAME || "Authorization";
export const ACCESS_TOKEN = import.meta.env.VITE_ACCESS_TOKEN || "access-token";
export const REFRESH_TOKEN =
import.meta.env.VITE_REFRESH_TOKEN || "refresh-token";
export const AUTH_USER_ID = import.meta.env.VITE_AUTH_USER_ID || "token-id";
export const CLAIM_NAME = process.env.CLAIM_NAME || "Authorization";
export const ACCESS_TOKEN = process.env.ACCESS_TOKEN || "access-token";
export const REFRESH_TOKEN = process.env.REFRESH_TOKEN || "refresh-token";
export const AUTH_USER_ID = process.env.AUTH_USER_ID || "token-id";
+2 -1
View File
@@ -1,2 +1,3 @@
export * from "./utils";
export * from "./cookie";
export * from "./utils";
export * from "./use-mobile";
@@ -2,13 +2,17 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
export function QueryProvider({
children,
client,
queryClient,
}: {
children: React.ReactNode;
client?: QueryClient;
queryClient?: QueryClient;
}) {
const queryClient = client ?? new QueryClient();
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
if (!queryClient) {
const queryClient = new QueryClient();
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
return <>{children}</>;
}
@@ -44,7 +44,7 @@ export function Calendar({
),
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",
"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",
@@ -0,0 +1,261 @@
"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 "../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 {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
};
@@ -1,9 +1,18 @@
import {
flexRender,
type RowData,
type Table as TableType,
type Table as TableInstance,
} from "@tanstack/react-table";
import {
ChevronLeftIcon,
ChevronRightIcon,
DoubleArrowLeftIcon,
DoubleArrowRightIcon,
} from "@radix-ui/react-icons";
import { cn } from "../../lib";
import { Button } from "../button";
import { Table, TableCell, TableHead, TableHeader, TableRow } from "../table";
import type { HTMLAttributes } from "react";
declare module "@tanstack/react-table" {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -12,7 +21,50 @@ declare module "@tanstack/react-table" {
}
}
function THead<T>({ table }: { table: TableType<T> }) {
type DataTableProps<T> = {
table: TableInstance<T>;
variant?: "default" | "secondary" | "line";
className?: HTMLAttributes<HTMLTableElement>["className"];
viewPagination?: boolean;
onRowClick?: (row: T) => void;
};
function DataTable<T>({
table,
variant = "default",
className,
viewPagination,
onRowClick,
}: DataTableProps<T>) {
return (
<>
<Table className={cn("w-full", className)} variant={variant}>
<THead table={table} />
<tbody>
{table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
onClick={() => onRowClick?.(row.original)}
className={cn(onRowClick && "cursor-pointer hover:bg-accent")}
>
{row.getVisibleCells().map((cell) => {
const { meta } = cell.column.columnDef;
return (
<TableCell key={cell.id} align="center" {...meta}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
);
})}
</TableRow>
))}
</tbody>
</Table>
{viewPagination && <Pagination table={table} />}
</>
);
}
function THead<T>({ table }: { table: TableInstance<T> }) {
return (
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
@@ -36,34 +88,81 @@ function THead<T>({ table }: { table: TableType<T> }) {
);
}
type TableProps<T> = {
table: TableType<T>;
onRowClick?: (row: T) => void;
};
function Pagination<T>({ table }: { table: TableInstance<T> }) {
const pageCount = table.getPageCount();
const currentPage = table.getState().pagination.pageIndex;
const maxVisiblePages = 10; // 한 번에 보여줄 최대 페이지 수
let startPage = Math.max(0, currentPage - maxVisiblePages / 2); // 시작 페이지
const endPage = Math.min(pageCount, startPage + maxVisiblePages); // 끝 페이지
if (endPage - startPage < maxVisiblePages) {
startPage = Math.max(0, endPage - maxVisiblePages);
}
const pageNumbers = Array(endPage - startPage)
.fill(0)
.map((_, i) => startPage + i);
function DataTable<T>({ table, onRowClick }: TableProps<T>) {
return (
<Table className="w-full">
<THead table={table} />
<tbody>
{table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
onClick={() => onRowClick?.(row.original)}
className={onRowClick ? "cursor-pointer hover:bg-muted" : ""}
<div className="mt-[24px] flex items-center justify-center space-x-2">
<Button
variant="ghost"
color="white"
className="hidden size-10 p-0 lg:flex"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">첫 페이지로</span>
<DoubleArrowLeftIcon className="size-4" />
</Button>
<Button
variant="ghost"
color="white"
className="size-10 p-0"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">이전 페이지로</span>
<ChevronLeftIcon className="size-4" />
</Button>
<div className="flex items-center justify-center text-sm font-medium">
{pageNumbers.map((pageNumber) => (
<Button
key={pageNumber}
variant="ghost"
className={cn(
"inline-block size-10 !bg-transparent p-0 text-foreground hover:!bg-accent",
currentPage === pageNumber &&
"!bg-primary !font-bold !text-primary-foreground hover:!bg-primary",
)}
onClick={() => table.setPageIndex(pageNumber)}
>
{row.getVisibleCells().map((cell) => {
const { meta } = cell.column.columnDef;
return (
<TableCell key={cell.id} align="center" {...meta}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
);
})}
</TableRow>
{pageNumber + 1}
</Button>
))}
</tbody>
</Table>
</div>
<Button
variant="ghost"
color="white"
className="size-10 p-0"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">다음 페이지로</span>
<ChevronRightIcon className="size-4" />
</Button>
<Button
variant="ghost"
color="white"
className="hidden size-10 p-0 lg:flex"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">마지막 페이지로</span>
<DoubleArrowRightIcon className="size-4" />
</Button>
</div>
);
}
@@ -2,6 +2,6 @@
@layer components {
.date-picker-trigger {
@apply h-[3rem] w-[12rem] justify-between border-input !bg-background px-4 py-2 text-left text-[1.3rem] font-normal leading-none text-muted-foreground hover:!bg-accent;
@apply h-[3rem] w-[12rem] justify-start border-input !bg-background px-4 py-2 text-left text-[1.3rem] font-normal text-muted-foreground hover:!bg-accent;
}
}
@@ -1,6 +1,6 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react";
import { DatePicker } from ".";
import { useState } from "react";
const meta: Meta<typeof DatePicker> = {
title: "Elements/DatePicker",
@@ -19,12 +19,12 @@ export function DatePicker({ className, ...props }: DatePickerProps) {
variant="outline"
className={cn("date-picker-trigger", className)}
>
<CalendarIcon className="!size-[1.6rem]" />
{props.selected ? (
format(props.selected, "yyyy-MM-dd")
) : (
<span>날짜 선택</span>
)}
<CalendarIcon className="!size-[1.6rem]" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
@@ -0,0 +1,71 @@
"use client";
import { useState } from "react";
import { format } from "date-fns";
import { CalendarIcon } from "lucide-react";
import type { DateRange } from "react-day-picker";
import { cn } from "../../lib/utils";
import { Button } from "../button";
import { Calendar } from "../calendar";
import { Popover, PopoverContent, PopoverTrigger } from "../popover";
type DateRangePickerProps = React.HTMLAttributes<HTMLDivElement> & {
disabled?: boolean;
handleDateChange?: (date: DateRange | undefined) => void;
};
export function DateRangePicker({
disabled,
className,
handleDateChange,
}: DateRangePickerProps) {
const [date, setDate] = useState<DateRange>();
const handleSelect = (date: DateRange | undefined) => {
setDate(date);
handleDateChange?.(date);
};
return (
<div className={cn("grid gap-2", className)}>
<Popover>
<PopoverTrigger asChild>
<Button
id="date"
variant="outline"
className={cn(
"h-[3.2rem] min-w-[12rem] justify-start border-input !bg-background px-4 py-2 text-left text-[1.3rem] font-normal text-muted-foreground hover:!bg-accent",
!date && "text-muted-foreground",
)}
disabled={disabled}
>
<CalendarIcon className="!size-[1.6rem]" />
{date?.from ? (
date.to ? (
<>
{format(date.from, "yyyy-MM-dd")} ~{" "}
{format(date.to, "yyyy-MM-dd")}
</>
) : (
format(date.from, "yyyy-MM-dd")
)
) : (
<span>기간 선택</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
initialFocus
mode="range"
defaultMonth={date?.from}
selected={date}
onSelect={handleSelect}
numberOfMonths={2}
/>
</PopoverContent>
</Popover>
</div>
);
}
@@ -1,13 +0,0 @@
@tailwind components;
@layer components {
.file-search-container {
@apply flex w-full max-w-sm items-center gap-2;
}
.file-search-preview {
@apply h-12 w-full min-w-56 overflow-x-auto whitespace-nowrap rounded-md border border-input bg-background px-3 py-2 text-[1.2rem];
}
.file-search-label {
@apply flex-shrink-0 cursor-pointer rounded-sm border border-input bg-muted px-4 py-2 text-[1.2rem] font-medium text-muted-foreground;
}
}
@@ -1,23 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react";
import { FileSearch } from ".";
const meta: Meta<typeof FileSearch> = {
title: "UI/FileSearch",
component: FileSearch,
parameters: {
docs: {
description: {
component: `
- [Docs](https://ui.shadcn.com/docs/components/input#file)
`,
},
},
},
};
export default meta;
type Story = StoryObj<typeof FileSearch>;
export const Default: Story = {
render: () => <FileSearch id="file-search" />,
};
@@ -1,29 +0,0 @@
import "./file-search.css";
import { Input } from "../input";
import { cn } from "../../lib/utils";
import { combineFileName, validFileType } from "./utils";
type FileSearchProps = React.InputHTMLAttributes<HTMLInputElement> & {
id: string;
files?: ReadonlyArray<File>;
};
export function FileSearch({
id,
className,
files,
...props
}: FileSearchProps) {
return (
<div className={cn("file-search-container", className)}>
<Input id={id} type="file" className="sr-only" {...props} />
<div className="file-search-preview">
{files?.filter(validFileType).map(combineFileName).join(", ")}
</div>
<label htmlFor={id} className="file-search-label">
파일 선택
</label>
</div>
);
}
@@ -0,0 +1,13 @@
@tailwind components;
@layer components {
.file-uploader-container {
@apply flex h-[3.2rem] w-full max-w-sm items-center gap-2;
}
.file-uploader-preview {
@apply h-full w-full min-w-56 overflow-x-auto whitespace-nowrap rounded-md border border-input bg-background px-3 py-2 text-[1.2rem];
}
.file-uploader-label {
@apply inline-flex h-full flex-shrink-0 cursor-pointer items-center rounded-sm border border-input bg-muted px-4 py-2 text-[1.2rem] font-medium text-muted-foreground;
}
}
@@ -0,0 +1,14 @@
import type { Meta, StoryObj } from "@storybook/react";
import { FileUploader } from ".";
const meta: Meta<typeof FileUploader> = {
title: "UI/FileUploader",
component: FileUploader,
};
export default meta;
type Story = StoryObj<typeof FileUploader>;
export const Default: Story = {
render: () => <FileUploader id="file-uploader" onUpload={() => {}} />,
};
@@ -0,0 +1,42 @@
import "./file-uploader.css";
import { cn } from "../../lib/utils";
import { Input } from "../input";
import { Label } from "../label";
import { combineFileName, validFileType } from "./utils";
type FileUploaderProps = React.InputHTMLAttributes<HTMLInputElement> & {
id: string;
files?: ReadonlyArray<File>;
customPreview?: React.ReactNode;
onUpload: (e: React.ChangeEvent<HTMLInputElement>) => void;
};
export function FileUploader({
id,
files,
customPreview,
className,
onUpload,
...props
}: FileUploaderProps) {
return (
<div className={cn("file-uploader-container", className)}>
<Input
id={id}
type="file"
className="sr-only"
onChange={onUpload}
{...props}
/>
{customPreview || (
<div className="file-uploader-preview">
{files?.filter(validFileType).map(combineFileName).join(", ")}
</div>
)}
<Label htmlFor={id} className="file-uploader-label">
파일 선택
</Label>
</div>
);
}
+7 -1
View File
@@ -2,21 +2,27 @@ export * from "./accordion";
export * from "./badge";
export * from "./button";
export * from "./calendar";
export * from "./carousel";
export * from "./checkbox";
export * from "./data-table";
export * from "./date-picker";
export * from "./date-range-picker";
export * from "./dialog";
export * from "./dropdown-menu";
export * from "./file-search";
export * from "./file-uploader";
export * from "./icon-finder";
export * from "./input";
export * from "./label";
export * from "./layout";
export * from "./loading";
export * from "./page-title";
export * from "./popover";
export * from "./progress";
export * from "./radio-group";
export * from "./select";
export * from "./slider";
export * from "./skeleton";
export * from "./switch";
export * from "./table";
export * from "./tabs";
export * from "./textarea";
@@ -1,26 +1,30 @@
import type { QueryClient } from "@tanstack/react-query";
import type { MenuItem } from "../../api";
import { QueryProvider } from "../../provider/query-provider";
import { SidebarProvider } from "../sidebar";
import { LNB } from "../lnb";
import { Header } from "../header";
import { Footer } from "./footer";
import { SidebarProvider } from "../sidebar";
import "./common.css";
import type { QueryClient } from "@tanstack/react-query";
export function BaseLayout({
children,
queryClient,
currentPath,
handleMenuClick,
}: {
children: React.ReactNode;
queryClient?: QueryClient;
currentPath?: string;
handleMenuClick?: (item: MenuItem) => void;
}) {
return (
<QueryProvider client={queryClient}>
<QueryProvider queryClient={queryClient}>
<SidebarProvider>
<div className="flex-1">
<Header />
<div className="flex h-[calc(100vh-50px)]">
<LNB />
<LNB currentPath={currentPath} handleMenuClick={handleMenuClick} />
<div className="flex flex-1 flex-col">
<div className="max-h-[calc(100%-50px)] flex-1 overflow-auto">
{children}
@@ -15,7 +15,7 @@ export function VBaseLayout({
queryClient?: QueryClient;
}) {
return (
<QueryProvider client={queryClient}>
<QueryProvider queryClient={queryClient}>
<SidebarProvider>
<div className="flex-1">
<Header viewOnly />
@@ -1,7 +1,7 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { ChevronRight } from "lucide-react";
import { cn } from "../../lib/utils";
import { cn, setCookie } from "../../lib";
import { ENDPOINT, SITE_ID } from "../../config";
import { getMenuList, type MenuItem } from "../../api";
import {
@@ -19,7 +19,15 @@ import {
} from "../collapsible";
import { getParentIds } from "./lib";
export function LNB({ viewOnly }: { viewOnly?: boolean }) {
export function LNB({
viewOnly,
currentPath,
handleMenuClick,
}: {
viewOnly?: boolean;
currentPath?: string;
handleMenuClick?: (item: MenuItem) => void;
}) {
const { data: menuList } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.MENU_ROLES],
queryFn: () => getMenuList(SITE_ID),
@@ -28,14 +36,32 @@ export function LNB({ viewOnly }: { viewOnly?: boolean }) {
const [openMenuIds, setOpenMenuIds] = useState<number[]>([]);
function isPathMatched(path: string) {
if (!path) return false;
return currentPath?.startsWith(path);
}
function MenuItem({ item }: { item: MenuItem }) {
const isMatched = isPathMatched(item.urlPath);
const handleClick = () => {
setCookie("menu-code-key", item.menuCd.toString());
handleMenuClick?.(item);
};
if (item.children) {
return <MenuGroup item={item} />;
}
return (
<SidebarMenuItem>
<SidebarMenuButton className="h-auto py-4 text-[1.4rem] text-label transition-colors hover:bg-accent">
<SidebarMenuButton
className={cn(
"h-auto py-4 text-[1.4rem] text-label transition-colors hover:bg-accent",
isMatched && "bg-accent !text-primary",
)}
onClick={handleClick}
>
<i className={cn("diveicon ml-2", `di-${item.iconNm}`)} />
{item.menuKornNm}
</SidebarMenuButton>
@@ -44,7 +70,11 @@ export function LNB({ viewOnly }: { viewOnly?: boolean }) {
}
function MenuGroup({ item }: { item: MenuItem }) {
const isOpen = openMenuIds.includes(item.menuCd);
const hasMatchedChild = item.children?.some((child) =>
isPathMatched(child.urlPath),
);
const isOpen = openMenuIds.includes(item.menuCd) || hasMatchedChild;
const handleOpenChange = (open: boolean) => {
if (open) {
@@ -61,15 +91,15 @@ export function LNB({ viewOnly }: { viewOnly?: boolean }) {
<Collapsible open={isOpen} onOpenChange={handleOpenChange}>
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<div className="group flex items-center justify-between rounded-sm py-4 pr-2 hover:bg-accent data-[state=open]:text-primary">
<div className="group flex items-center justify-between rounded-sm py-4 pr-2 hover:bg-accent">
<SidebarMenuButton className="group/menu-button text-[1.4rem] group-data-[state=open]:bg-transparent">
<i
className={cn(
"diveicon ml-2 text-label group-data-[state=open]:text-primary",
"diveicon ml-2 text-label",
`di-${item.iconNm}`,
)}
/>
<span className="text-label group-hover/menu-button:text-foreground group-data-[state=open]:text-primary">
<span className="text-label group-hover/menu-button:text-foreground">
{item.menuKornNm}
</span>
</SidebarMenuButton>
@@ -0,0 +1,30 @@
import React from "react";
const PageTitle = ({
title,
menuPaths,
}: {
title: string;
menuPaths: string[];
}) => {
return (
<div className="flex w-full items-center justify-between px-[1.8rem] pt-[2rem]">
<h2 className="text-[2.4rem] font-semibold text-title">{title}</h2>
{menuPaths.length > 0 && (
<div className="flex items-center gap-3">
<i className="diveicon di-home-fill text-foreground" />
{menuPaths?.map((path, i) => (
<React.Fragment key={`${i}-${path}`}>
{i !== 0 && (
<i className="diveicon di-arrow-forward text-foreground" />
)}
<span className="text-xl text-foreground">{path}</span>
</React.Fragment>
))}
</div>
)}
</div>
);
};
export { PageTitle };
@@ -0,0 +1,17 @@
import type { Meta, StoryObj } from "@storybook/react";
import { PageTitle } from ".";
const meta: Meta<typeof PageTitle> = {
title: "UI/PageTitle",
};
export default meta;
type Story = StoryObj<typeof PageTitle>;
export const Default: Story = {
render: () => (
<div className="w-[40rem]">
<PageTitle title="타이틀" menuPaths={["메뉴 1", "메뉴 2", "메뉴 3"]} />
</div>
),
};
@@ -0,0 +1,90 @@
// apps/cs/src/common/components/ui/pagination.tsx
import { Button } from "../button";
interface PaginationProps {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
}
function Pagination({ currentPage, totalPages, onPageChange }: PaginationProps) {
// 표시할 페이지 번호 범위 계산
const getPageRange = () => {
const range: number[] = [];
const maxButtons = 5; // 한 번에 보여줄 최대 페이지 버튼 수
const halfMaxButtons = Math.floor(maxButtons / 2);
let startPage = Math.max(currentPage - halfMaxButtons, 1);
let endPage = Math.min(startPage + maxButtons - 1, totalPages);
if (endPage - startPage + 1 < maxButtons) {
startPage = Math.max(endPage - maxButtons + 1, 1);
}
for (let i = startPage; i <= endPage; i++) {
range.push(i);
}
return range;
};
if (totalPages <= 1) return null;
return (
<div className="flex items-center gap-1">
{/* 첫 페이지 버튼 */}
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(1)}
disabled={currentPage === 1}
>
{"<<"}
</Button>
{/* 이전 페이지 버튼 */}
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
>
{"<"}
</Button>
{/* 페이지 번호 버튼들 */}
{getPageRange().map((pageNum) => (
<Button
key={pageNum}
variant={currentPage === pageNum ? "default" : "outline"}
size="sm"
onClick={() => onPageChange(pageNum)}
>
{pageNum}
</Button>
))}
{/* 다음 페이지 버튼 */}
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
>
{">"}
</Button>
{/* 마지막 페이지 버튼 */}
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(totalPages)}
disabled={currentPage === totalPages}
>
{">>"}
</Button>
</div>
);
}
export { Pagination };
@@ -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(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
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 };
@@ -3,8 +3,7 @@ import { Slot } from "@radix-ui/react-slot";
import { type VariantProps, cva } from "class-variance-authority";
import { PanelLeft } from "lucide-react";
import { useIsMobile } from "../../lib/use-mobile";
import { cn } from "../../lib/utils";
import { cn, useIsMobile } from "../../lib";
import { Button } from "../button";
import { Input } from "../input";
import { Separator } from "../separator";
@@ -6,7 +6,7 @@ function Skeleton({
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-muted", className)}
className={cn("animate-pulse rounded-md bg-[rgba(0,0,0,0.1)]", className)}
{...props}
/>
);
@@ -9,7 +9,7 @@ interface TableProps extends React.HTMLAttributes<HTMLTableElement> {
}
const Table = React.forwardRef<HTMLTableElement, TableProps>(
({ className, variant, ...props }, ref) => (
({ className, variant = "default", ...props }, ref) => (
<div className="relative w-full">
<table
ref={ref}
@@ -79,11 +79,7 @@ const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn("align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
<th ref={ref} className={cn("align-middle", className)} {...props} />
));
TableHead.displayName = "TableHead";
@@ -93,10 +89,7 @@ const TableCell = React.forwardRef<
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
"align-middle text-foreground [&:has([role=checkbox])]:pr-0",
className,
)}
className={cn("align-middle text-foreground", className)}
{...props}
/>
));
@@ -1,38 +1,50 @@
@tailwind components;
@layer components {
.table-ui tr {
@apply border-0;
}
.table-ui th {
@apply bg-[#5987bf] px-4 py-[1.2rem] text-[1.3rem] font-medium leading-[1.2] text-[#fff];
@apply px-4 py-[1.2rem] text-[1.3rem] font-medium leading-[1.2];
}
.table-ui td {
@apply border-b border-b-border px-2 py-[1.2rem] text-[1.3rem] font-normal leading-[1.2] text-label;
@apply px-2 py-[1.2rem] text-[1.3rem] font-normal leading-[1.2];
}
.table-ui.default th {
@apply bg-[#5987bf] text-[#fff];
}
.table-ui.default td {
@apply border-b border-b-border text-label;
}
.table-ui.secondary th {
@apply border border-border bg-[#eaf3fb] text-[#647784];
}
.table-ui.secondary td {
@apply border border-border;
}
.table-ui.line th {
@apply border-0 border-b-2 border-t border-b-[#d4e0ea] border-t-[#d4e0ea] bg-[#fff] text-[#555];
.table-ui.line tr {
@apply border-b border-t border-[#d4e0ea] bg-transparent;
}
.table-ui.line td {
@apply border-b border-b-border;
.table-ui.line th {
@apply border-b-2 border-[#d4e0ea] text-[#555];
}
[data-theme="black"] {
.table-ui th {
@apply bg-[#5e7b9c] text-[#fff];
.table-ui.default tr {
@apply bg-[#545556];
}
.table-ui.default th {
@apply bg-[#62686d];
}
.table-ui.secondary tr {
@apply bg-[#565b5f];
}
.table-ui.secondary th {
@apply bg-[#1a1c1d];
@apply bg-[#5e7b9c] text-[#fff];
}
.table-ui.line th {
@apply bg-[#0a0a0a];
@apply text-[#fff];
}
}
}
@@ -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-[6rem] w-full rounded-md border border-input bg-transparent p-3 text-base placeholder:text-placeholder 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 };