Merge remote-tracking branch 'origin/main' into iipbs/sn/dev
This commit is contained in:
@@ -58,6 +58,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"embla-carousel-autoplay": "^8.6.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lucide-react": "^0.474.0",
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { ENDPOINT } from "../config";
|
||||
import { getApiClient } from "./client";
|
||||
|
||||
export interface ConnectionLog {
|
||||
cntnSn: number;
|
||||
cntnDt: string;
|
||||
cntnId: string;
|
||||
cntnIp: string;
|
||||
cntnRgn: string;
|
||||
wbsrKnd: string;
|
||||
menuCd: number;
|
||||
siteId: string;
|
||||
urlPath: string;
|
||||
}
|
||||
|
||||
export interface ConnectionLogPayload {
|
||||
cntnId?: string;
|
||||
cntnIp?: string;
|
||||
cntnRgn?: string;
|
||||
wbsrKnd?: string;
|
||||
menuCd?: number | null;
|
||||
siteId?: string;
|
||||
urlPath: string;
|
||||
}
|
||||
|
||||
const client = getApiClient();
|
||||
|
||||
export const saveConnectionLog = async (payload: ConnectionLogPayload) => {
|
||||
const response = await client.post<ConnectionLog>(
|
||||
`${ENDPOINT.CMS_SERVICE.CONNECTION_LOG}`,
|
||||
payload,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from "./client";
|
||||
export * from "./connection-log";
|
||||
export * from "./menu";
|
||||
export * from "./popup";
|
||||
export * from "./site";
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface MenuItem {
|
||||
otptYn: string;
|
||||
newWndwYn: string;
|
||||
requiredUrlPath: boolean;
|
||||
menuHlpCn: string;
|
||||
}
|
||||
|
||||
export interface TreeMenu extends MenuItem {
|
||||
|
||||
@@ -36,5 +36,5 @@ export const getSiteInfo = async (siteId: string) => {
|
||||
},
|
||||
},
|
||||
);
|
||||
return response.json();
|
||||
return response.json() as Promise<SiteInfo>;
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ export const ENDPOINT = {
|
||||
SITES: "/cms-service/api/v1/sites",
|
||||
POPUPS: "/cms-service/api/v1/popups",
|
||||
IPS: "/cms-service/api/v1/site-ips",
|
||||
CONNECTION_LOG: "/cms-service/api/v1/cntnlog",
|
||||
},
|
||||
USER_SERVICE: {
|
||||
USERS: "/user-service/api/v1/users",
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import Autoplay from "embla-carousel-autoplay";
|
||||
import { getConfig } from "@common/config";
|
||||
import { ENDPOINT } from "../../config";
|
||||
import { getSiteInfo } from "../../api";
|
||||
import {
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselNext,
|
||||
CarouselPrevious,
|
||||
} from "../carousel";
|
||||
import { useRef } from "react";
|
||||
|
||||
const appConfig = getConfig();
|
||||
const siteId = appConfig.SITE_ID ?? "";
|
||||
|
||||
export function Footer() {
|
||||
const { data: siteInfo } = useQuery({
|
||||
queryKey: [ENDPOINT.CMS_SERVICE.SITES, siteId],
|
||||
queryFn: () => getSiteInfo(siteId),
|
||||
});
|
||||
|
||||
const plugin = useRef(Autoplay({ delay: 2000, stopOnInteraction: true }));
|
||||
|
||||
return (
|
||||
<footer className="flex w-full shrink-0 items-start border-t border-solid border-border bg-background px-6 py-8">
|
||||
<div className="w-1/2">
|
||||
<h6 className="mb-4 text-2xl font-bold">{siteInfo?.siteNm ?? ""}</h6>
|
||||
<p className="my-1 text-xl opacity-60">{siteInfo?.siteAddr ?? ""}</p>
|
||||
<p className="my-1 text-xl opacity-60">
|
||||
전화: {siteInfo?.telNo ?? ""} / 팩스: {siteInfo?.faxNumber ?? ""}
|
||||
</p>
|
||||
<p className="my-1 text-xl opacity-60">{siteInfo?.lwndCn ?? ""}</p>
|
||||
<p className="my-1 text-xl opacity-60">
|
||||
본 시스템은 Chrome, Firefox, Safari, Edge 브라우저에 최적화되어
|
||||
있습니다.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex w-1/2 flex-col">
|
||||
<div className="flex justify-end gap-4">
|
||||
<span className="text-xl">개인정보처리방침</span>
|
||||
<span className="text-xl">이용약관</span>
|
||||
<span className="text-xl">사이트맵</span>
|
||||
<span className="text-xl">고객센터</span>
|
||||
</div>
|
||||
<Carousel
|
||||
plugins={[plugin.current]}
|
||||
opts={{ loop: true }}
|
||||
className="relative my-8 h-32"
|
||||
>
|
||||
<CarouselContent>
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<CarouselItem key={`banner-${i}`} className="basis-1/3">
|
||||
<div className="relative flex h-32 items-center justify-center">
|
||||
<div className="flex h-full w-full items-center justify-center bg-accent">
|
||||
{`${i + 1}번째 배너`}
|
||||
</div>
|
||||
</div>
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
<CarouselPrevious className="absolute left-2 top-1/2 !bg-[rgba(255,255,255,0.8)] text-accent-foreground" />
|
||||
<CarouselNext className="absolute right-2 top-1/2 !bg-[rgba(255,255,255,0.8)] text-accent-foreground" />
|
||||
</Carousel>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import { Button } from "../button";
|
||||
import { SidebarTrigger } from "../sidebar";
|
||||
import { Skeleton } from "../skeleton";
|
||||
import { SearchForm } from "./search-form";
|
||||
|
||||
const appConfig = getConfig();
|
||||
const siteId = appConfig.SITE_ID ?? "";
|
||||
@@ -135,36 +136,39 @@ export function Header({ viewOnly }: { viewOnly?: boolean }) {
|
||||
</span>
|
||||
</h1>
|
||||
</div>
|
||||
{mounted ? (
|
||||
isLoggedIn && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
className="group m-0 flex h-[3rem] items-center !bg-background px-4 py-0 text-label"
|
||||
data-trigger="dropdown"
|
||||
>
|
||||
<i className="diveicon di-account-circle static translate-y-0.5 pr-2 text-3xl" />
|
||||
{userInfo?.userName}
|
||||
<i className="diveicon di-chevron-down static p-0 transition-transform group-data-[state=open]:rotate-180" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>프로필 설정</DropdownMenuItem>
|
||||
<div className="flex flex-col items-start gap-0 px-4 py-3">
|
||||
<p className="text-base text-label">최종접속일</p>
|
||||
<p>{userInfo?.lastLoginDate}</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-0 px-4 py-3">
|
||||
<p className="text-base text-label">접속 IP</p>
|
||||
<p></p>
|
||||
</div>
|
||||
<DropdownMenuItem onClick={logout}>로그아웃</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
) : (
|
||||
<Skeleton className="mx-4 h-[3rem] w-[15rem]" />
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<SearchForm />
|
||||
{mounted ? (
|
||||
isLoggedIn && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
className="group m-0 flex h-[3rem] items-center !bg-background px-4 py-0 text-label"
|
||||
data-trigger="dropdown"
|
||||
>
|
||||
<i className="diveicon di-account-circle static translate-y-0.5 pr-2 text-3xl" />
|
||||
{userInfo?.userName}
|
||||
<i className="diveicon di-chevron-down static p-0 transition-transform group-data-[state=open]:rotate-180" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>프로필 설정</DropdownMenuItem>
|
||||
<div className="flex flex-col items-start gap-0 px-4 py-3">
|
||||
<p className="text-base text-label">최종접속일</p>
|
||||
<p>{userInfo?.lastLoginDate}</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-0 px-4 py-3">
|
||||
<p className="text-base text-label">접속 IP</p>
|
||||
<p></p>
|
||||
</div>
|
||||
<DropdownMenuItem onClick={logout}>로그아웃</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
) : (
|
||||
<Skeleton className="mx-4 h-[3rem] w-[15rem]" />
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Button } from "../button";
|
||||
import { Input } from "../input";
|
||||
|
||||
export function SearchForm() {
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target as HTMLFormElement);
|
||||
const keyword = formData.get("keyword") as string;
|
||||
|
||||
if (keyword && keyword.trim().length > 0) {
|
||||
window.location.href = `/cms/search?keyword=${encodeURIComponent(keyword)}`;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="relative flex items-center gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
name="keyword"
|
||||
className="h-[3.6rem] w-96 rounded-[3.6rem] pl-4 pr-12"
|
||||
placeholder="검색어를 입력해주세요."
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
size="icon"
|
||||
className="absolute right-[0.4rem] top-[0.4rem] size-[2.8rem] rounded-full p-2"
|
||||
>
|
||||
<i className="diveicon di-search" />
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import type { MenuItem } from "../../api";
|
||||
import { SidebarProvider } from "../sidebar";
|
||||
import { LNB } from "../lnb";
|
||||
import { Header } from "../header";
|
||||
import { Footer } from "./footer";
|
||||
import { Footer } from "../footer";
|
||||
import "./common.css";
|
||||
|
||||
export function BaseLayout({
|
||||
@@ -27,15 +27,13 @@ export function BaseLayout({
|
||||
<SidebarProvider>
|
||||
<div className="flex-1">
|
||||
<Header />
|
||||
<div className="flex h-[calc(100vh-50px)]">
|
||||
<div className="flex">
|
||||
<LNB
|
||||
currentPath={currentPath}
|
||||
handleMenuClick={handleMenuClick}
|
||||
/>
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="max-h-[calc(100%-50px)] flex-1 overflow-auto">
|
||||
{children}
|
||||
</div>
|
||||
<div className="flex min-h-[calc(100vh-50px)] flex-1 flex-col overflow-auto">
|
||||
<div className="flex-1">{children}</div>
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="flex h-[5rem] w-full shrink-0 items-center border-t border-solid border-border bg-background p-4">
|
||||
<p className="text-foreground">
|
||||
© 2025 Ona And All. All Rights Reserved.
|
||||
</p>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { QueryProvider } from "../../provider/query-provider";
|
||||
import { SidebarProvider } from "../sidebar";
|
||||
import { LNB } from "../lnb";
|
||||
import { Header } from "../header";
|
||||
import { Footer } from "./footer";
|
||||
import { Footer } from "../footer";
|
||||
|
||||
import "./common.css";
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { ChevronRight, HelpCircle } from "lucide-react";
|
||||
import { useDetectDevice } from "@frontend-opensource/use-react-hooks";
|
||||
import { getConfig } from "@common/config";
|
||||
import { getParentIds } from "./lib";
|
||||
import {
|
||||
@@ -21,12 +22,20 @@ import {
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "../collapsible";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "../dialog";
|
||||
import { ENDPOINT } from "../../config";
|
||||
import { cn, getCookie, setCookie } from "../../lib";
|
||||
import {
|
||||
getAnonymouseMenuList,
|
||||
getMenuList,
|
||||
getPreviewTreeMenuList,
|
||||
getAnonymouseMenuList,
|
||||
saveConnectionLog,
|
||||
type MenuItem,
|
||||
} from "../../api";
|
||||
|
||||
@@ -54,7 +63,10 @@ export function LNB({
|
||||
}) {
|
||||
const [defaultOpenMenuIds, setDefaultOpenMenuIds] = useState<number[]>([]);
|
||||
const [currentMenuCode, setCurrentMenuCode] = useState<number | null>(null);
|
||||
const [currentMenuHelp, setCurrentMenuHelp] = useState<string>("");
|
||||
const [isHelpModalOpen, setIsHelpModalOpen] = useState(false);
|
||||
const prevPathRef = useRef(currentPath);
|
||||
const { browser } = useDetectDevice();
|
||||
|
||||
const siteId = propSiteId ?? appSiteId;
|
||||
|
||||
@@ -67,7 +79,7 @@ export function LNB({
|
||||
const { data: previewTreeMenuListData } = useQuery({
|
||||
queryKey: [ENDPOINT.CMS_SERVICE.MENUS, siteId, "preview"],
|
||||
queryFn: () => getPreviewTreeMenuList(siteId),
|
||||
enabled: isLoggedIn && previewMode,
|
||||
enabled: isLoggedIn && !!previewMode,
|
||||
});
|
||||
|
||||
const { data: anonymousMenuListData } = useQuery({
|
||||
@@ -92,6 +104,7 @@ export function LNB({
|
||||
|
||||
const matchedMenuIds: number[] = [];
|
||||
let matchedMenuCode: number | null = null;
|
||||
let matchedMenuHelp: string = "";
|
||||
|
||||
const findMatchedMenus = (items: MenuItem[]) => {
|
||||
items.forEach((item) => {
|
||||
@@ -106,7 +119,10 @@ export function LNB({
|
||||
const findMatchedMenuCode = (items: MenuItem[]) => {
|
||||
items.forEach((item) => {
|
||||
if (matchedMenuCode) return;
|
||||
if (currentPath === item.urlPath) matchedMenuCode = item.menuCd;
|
||||
if (currentPath === item.urlPath) {
|
||||
matchedMenuCode = item.menuCd;
|
||||
matchedMenuHelp = item.menuHlpCn || "";
|
||||
}
|
||||
if (item.children) findMatchedMenuCode(item.children);
|
||||
});
|
||||
};
|
||||
@@ -124,7 +140,20 @@ export function LNB({
|
||||
prevPathRef.current = currentPath;
|
||||
}
|
||||
|
||||
if (matchedMenuCode) setCurrentMenuCode(matchedMenuCode);
|
||||
if (matchedMenuCode) {
|
||||
setCurrentMenuCode(matchedMenuCode);
|
||||
setCurrentMenuHelp(matchedMenuHelp);
|
||||
}
|
||||
|
||||
// 접속 로그 저장
|
||||
saveConnectionLog({
|
||||
cntnId: userId,
|
||||
cntnRgn: navigator.language,
|
||||
wbsrKnd: browser,
|
||||
menuCd: matchedMenuCode,
|
||||
siteId,
|
||||
urlPath: currentPath,
|
||||
});
|
||||
}, [menuList, currentPath]);
|
||||
|
||||
function MenuItem({
|
||||
@@ -172,7 +201,7 @@ export function LNB({
|
||||
/>
|
||||
<span className={cn("whitespace-nowrap", !isSidebarOpen && "hidden")}>
|
||||
{item.menuKornNm}
|
||||
</span>
|
||||
</span>
|
||||
</MenuButton>
|
||||
</MenuItem>
|
||||
);
|
||||
@@ -247,7 +276,7 @@ export function LNB({
|
||||
<Sidebar
|
||||
collapsible="icon"
|
||||
className={cn(
|
||||
"border-border bg-background px-4 py-[6rem]",
|
||||
"border-border bg-background px-4 pt-[6rem]",
|
||||
classNames?.wrapper,
|
||||
)}
|
||||
>
|
||||
@@ -255,6 +284,41 @@ export function LNB({
|
||||
<SidebarMenu className="gap-0">
|
||||
{menuList?.map((item) => <MenuItem key={item.menuCd} item={item} />)}
|
||||
</SidebarMenu>
|
||||
|
||||
{/* 도움말 아이콘 */}
|
||||
<div className="mt-auto pb-4">
|
||||
<Dialog open={isHelpModalOpen} onOpenChange={setIsHelpModalOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
"flex h-12 w-full items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground",
|
||||
"focus:outline-none"
|
||||
)}
|
||||
title="도움말"
|
||||
>
|
||||
<HelpCircle className="size-8" />
|
||||
<span className="ml-2">도움말</span>
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[85rem]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>도움말</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
{currentMenuHelp ? (
|
||||
<div
|
||||
className="text-sm leading-relaxed text-muted-foreground"
|
||||
dangerouslySetInnerHTML={{ __html: currentMenuHelp }}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
현재 페이지에 대한 도움말이 없습니다.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user