[feat] negodata/front: 디자인 리뉴얼 1차 — 인디고 콘솔 토큰·⌘K 크롬·대시보드/로그인/알림함 개편, 툴바 통일, 시트 스크롤 수정

This commit is contained in:
Mina Choi 2026-07-16 16:24:40 +09:00
parent 3d515055fb
commit 25e13d7b01
20 changed files with 805 additions and 580 deletions

View File

@ -1,10 +1,11 @@
import { useState, type ReactNode, type ElementType } from 'react';
import { useEffect, useState, type ReactNode, type ElementType } from 'react';
import { PageType } from '@/types';
import { useAuth } from '@/features/auth/useAuth';
import { ProfileSheet } from '@/features/auth/components/ProfileSheet';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Typography } from '@/components/ui/typography';
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
import { cn } from '@/lib/utils';
import { NotificationBell } from './NotificationBell';
import {
@ -17,6 +18,7 @@ import {
FileSpreadsheet,
Layers,
LogOut,
Search,
Sun,
Moon,
Building,
@ -34,17 +36,34 @@ interface LayoutProps {
type SidebarUser = ReturnType<typeof useAuth>['user'];
type MenuItem = { type: PageType; label: string; icon: ElementType; id: string; ownerOnly?: boolean };
// ownerOnly 항목은 최고관리자에게만 노출된다(렌더 시 user.role 로 필터).
const menuItems: { type: PageType; label: string; icon: ElementType; id: string; ownerOnly?: boolean }[] = [
{ type: 'DASHBOARD', label: '대시보드', icon: LayoutDashboard, id: 'sidebar-dashboard' },
{ type: 'STATISTICS', label: '통계', icon: BarChart3, id: 'sidebar-statistics' },
{ type: 'PRODUCTS', label: '상품관리', icon: Briefcase, id: 'sidebar-products' },
{ type: 'PARTNERS', label: '협력사관리', icon: Users, id: 'sidebar-partners' },
{ type: 'QUOTATION', label: '견적관리', icon: FileSpreadsheet, id: 'sidebar-quotation' },
{ type: 'CARDS', label: '협상카드관리', icon: Layers, id: 'sidebar-cards' },
{ type: 'MEMBERS', label: '회원관리', icon: UserCog, id: 'sidebar-members', ownerOnly: true },
// 그룹 라벨은 사이드바 섹션 헤더로 노출(접힘 상태에선 숨김).
const menuGroups: { label?: string; items: MenuItem[] }[] = [
{
items: [
{ type: 'DASHBOARD', label: '대시보드', icon: LayoutDashboard, id: 'sidebar-dashboard' },
{ type: 'STATISTICS', label: '통계', icon: BarChart3, id: 'sidebar-statistics' },
],
},
{
label: '업무',
items: [
{ type: 'PRODUCTS', label: '상품관리', icon: Briefcase, id: 'sidebar-products' },
{ type: 'PARTNERS', label: '협력사관리', icon: Users, id: 'sidebar-partners' },
{ type: 'QUOTATION', label: '견적관리', icon: FileSpreadsheet, id: 'sidebar-quotation' },
{ type: 'CARDS', label: '협상카드관리', icon: Layers, id: 'sidebar-cards' },
],
},
{
label: '관리',
items: [{ type: 'MEMBERS', label: '회원관리', icon: UserCog, id: 'sidebar-members', ownerOnly: true }],
},
];
const menuItems: MenuItem[] = menuGroups.flatMap((g) => g.items);
const pageLabelMap: Record<PageType, string> = {
DASHBOARD: '대시보드',
STATISTICS: '통계',
@ -65,10 +84,25 @@ export default function Layout({ children, currentPage, setPage, onLogout }: Lay
const [isSidebarOpen, setIsSidebarOpen] = useState(true); // 데스크톱 접기 토글
const [isMobileOpen, setIsMobileOpen] = useState(false); // 모바일 드로어 열림
const [isProfileOpen, setIsProfileOpen] = useState(false); // 내 정보 수정 시트
const [isCmdOpen, setIsCmdOpen] = useState(false); // ⌘K 빠른 이동
// 사이드바 펼침 여부(라벨/프로필 노출 기준). 모바일 드로어는 항상 펼친 상태로 본다.
const expanded = isMobileOpen || isSidebarOpen;
const visibleItems = menuItems.filter((item) => !item.ownerOnly || user?.role === '최고관리자');
// ⌘K / Ctrl+K 로 빠른 이동 열기
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
setIsCmdOpen((v) => !v);
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
const toggleDarkMode = () => {
setIsDark(!isDark);
if (!isDark) {
@ -94,7 +128,7 @@ export default function Layout({ children, currentPage, setPage, onLogout }: Lay
{/* Main Sidebar */}
<aside
className={cn(
'bg-card border-r border-border flex flex-col justify-between transition-all duration-300 z-40 fixed inset-y-0 left-0',
'bg-sidebar border-r border-sidebar-border flex flex-col justify-between transition-all duration-300 z-40 fixed inset-y-0 left-0',
// 폭: 모바일은 항상 펼친 드로어(w-60), 데스크톱만 접기 토글
'w-60',
isSidebarOpen ? 'md:w-60' : 'md:w-16',
@ -103,11 +137,12 @@ export default function Layout({ children, currentPage, setPage, onLogout }: Lay
'md:translate-x-0'
)}
>
<div>
<div className="min-h-0 flex flex-col">
{/* Sidebar Brand Header */}
<div className="h-14 flex items-center justify-between px-4 border-b border-border">
<div className="h-14 shrink-0 flex items-center justify-between px-4 border-b border-sidebar-border">
{expanded && (
<Typography as="div" variant="body" className="text-primary font-extrabold tracking-tighter">
<Typography as="div" variant="body" className="flex items-center gap-2 font-extrabold tracking-tight text-foreground">
<span aria-hidden className="size-4 rounded-[5px] bg-primary" />
NegoData
</Typography>
)}
@ -117,8 +152,8 @@ export default function Layout({ children, currentPage, setPage, onLogout }: Lay
variant="ghost"
onClick={() => setIsSidebarOpen((v) => !v)}
className={cn(
'hidden md:inline-flex p-1 h-auto rounded text-muted-foreground',
!isSidebarOpen && 'mx-auto bg-muted/65'
'hidden md:inline-flex p-1 h-auto rounded text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground dark:hover:bg-sidebar-accent',
!isSidebarOpen && 'mx-auto bg-sidebar-accent'
)}
title={isSidebarOpen ? '메뉴 접기' : '메뉴 열기'}
>
@ -129,7 +164,7 @@ export default function Layout({ children, currentPage, setPage, onLogout }: Lay
<Button
variant="ghost"
onClick={() => setIsMobileOpen(false)}
className="md:hidden p-1 h-auto rounded text-muted-foreground"
className="md:hidden p-1 h-auto rounded text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground dark:hover:bg-sidebar-accent"
title="메뉴 닫기"
>
<X size={16} className="size-4" />
@ -140,32 +175,48 @@ export default function Layout({ children, currentPage, setPage, onLogout }: Lay
{expanded && <SidebarProfileCard user={user} />}
{/* Nav Items */}
<nav className="mt-2 px-3 space-y-1">
{menuItems
.filter((item) => !item.ownerOnly || user?.role === '최고관리자')
.map((item) => (
<NavItem
key={item.type}
item={item}
isActive={currentPage === item.type}
isOpen={expanded}
onClick={() => {
setPage(item.type);
setIsMobileOpen(false);
}}
/>
))}
<nav className="mt-1 px-3 overflow-y-auto">
{menuGroups.map((group, gi) => {
const items = group.items.filter((item) => !item.ownerOnly || user?.role === '최고관리자');
if (items.length === 0) return null;
return (
<div key={group.label ?? gi} className="space-y-0.5">
{group.label && expanded && (
<Typography
variant="caption"
className="block px-3 pt-4 pb-1 text-[10px] font-bold uppercase tracking-wider text-sidebar-foreground/70"
>
{group.label}
</Typography>
)}
{/* 접힘 상태에선 라벨 대신 얇은 구분선으로 그룹 경계를 남긴다 */}
{group.label && !expanded && <div className="my-2 border-t border-sidebar-border" />}
{items.map((item) => (
<NavItem
key={item.type}
item={item}
isActive={currentPage === item.type}
isOpen={expanded}
onClick={() => {
setPage(item.type);
setIsMobileOpen(false);
}}
/>
))}
</div>
);
})}
</nav>
</div>
{/* Sidebar Footer Controls */}
<div className="p-2 border-t border-border space-y-1">
<div className="p-2 border-t border-sidebar-border space-y-1">
{/* 내 정보 수정 */}
<Button
id="sidebar-profile-edit"
variant="ghost"
onClick={() => setIsProfileOpen(true)}
className="w-full justify-start gap-3 py-1.5 px-3 h-auto rounded text-[11px] font-semibold text-muted-foreground"
className="w-full justify-start gap-3 py-1.5 px-3 h-auto rounded text-[11px] font-semibold text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground dark:hover:bg-sidebar-accent"
>
<UserPen size={14} className="size-3.5" />
{expanded && <span> </span>}
@ -175,7 +226,7 @@ export default function Layout({ children, currentPage, setPage, onLogout }: Lay
<Button
variant="ghost"
onClick={toggleDarkMode}
className="w-full justify-start gap-3 py-1.5 px-3 h-auto rounded text-[11px] font-semibold text-muted-foreground"
className="w-full justify-start gap-3 py-1.5 px-3 h-auto rounded text-[11px] font-semibold text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground dark:hover:bg-sidebar-accent"
>
{isDark ? <Sun size={14} className="text-warning size-3.5" /> : <Moon size={14} className="size-3.5" />}
{expanded && <span>{isDark ? '라이트 모드' : '다크 모드'}</span>}
@ -186,7 +237,7 @@ export default function Layout({ children, currentPage, setPage, onLogout }: Lay
id="header-logout-button"
variant="ghost"
onClick={onLogout}
className="w-full justify-start gap-3 py-1.5 px-3 h-auto rounded text-[11px] font-semibold text-destructive hover:bg-destructive/10 hover:text-destructive"
className="w-full justify-start gap-3 py-1.5 px-3 h-auto rounded text-[11px] font-semibold text-destructive hover:bg-destructive/10 hover:text-destructive dark:hover:bg-destructive/10"
>
<LogOut size={14} className="size-3.5" />
{expanded && <span> </span>}
@ -202,9 +253,9 @@ export default function Layout({ children, currentPage, setPage, onLogout }: Lay
isSidebarOpen ? 'md:pl-60' : 'md:pl-16'
)}
>
{/* Global Header */}
<header className="h-14 bg-card border-b border-border flex items-center justify-between px-4 md:px-8 sticky top-0 z-20 shadow-xs backdrop-blur-md bg-opacity-95">
<div className="flex items-center gap-2 md:gap-4 min-w-0">
{/* Global Header — 좌: 페이지명 / 중: 빠른 이동(⌘K) / 우: 알림·계정 메타 */}
<header className="h-14 bg-card border-b border-border grid grid-cols-[1fr_auto] md:grid-cols-[1fr_auto_1fr] items-center gap-3 px-4 md:px-6 sticky top-0 z-20">
<div className="flex items-center gap-2 md:gap-3 min-w-0">
{/* 모바일 햄버거 */}
<Button
variant="ghost"
@ -220,7 +271,24 @@ export default function Layout({ children, currentPage, setPage, onLogout }: Lay
</Typography>
</div>
<div className="flex items-center gap-3 md:gap-4">
{/* 빠른 이동 트리거 */}
<button
type="button"
onClick={() => setIsCmdOpen(true)}
className="hidden md:flex w-56 lg:w-72 items-center justify-between gap-3 rounded-lg border border-border bg-muted/40 px-3 py-1.5 text-muted-foreground transition-colors hover:bg-muted/70 cursor-pointer"
>
<span className="flex items-center gap-2 min-w-0">
<Search size={13} className="shrink-0" />
<Typography as="span" variant="caption" className="truncate">
</Typography>
</span>
<Typography as="kbd" variant="caption" className="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px]">
K
</Typography>
</button>
<div className="flex items-center gap-3 md:gap-4 justify-self-end">
<NotificationBell />
<HeaderMeta user={user} today={today} />
</div>
@ -237,11 +305,100 @@ export default function Layout({ children, currentPage, setPage, onLogout }: Lay
</footer>
</div>
<CommandMenu
open={isCmdOpen}
onOpenChange={setIsCmdOpen}
items={visibleItems}
currentPage={currentPage}
onSelect={(type) => {
setPage(type);
setIsCmdOpen(false);
}}
/>
{isProfileOpen && <ProfileSheet open onClose={() => setIsProfileOpen(false)} />}
</div>
);
}
/** ⌘K 빠른 이동 — 메뉴 검색·이동. 검색 대상 확장(견적명 등)은 추후 단계 */
function CommandMenu({
open,
onOpenChange,
items,
currentPage,
onSelect,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
items: MenuItem[];
currentPage: PageType;
onSelect: (type: PageType) => void;
}) {
const [query, setQuery] = useState('');
useEffect(() => {
if (!open) setQuery('');
}, [open]);
const q = query.trim().toLowerCase();
const filtered = q ? items.filter((i) => i.label.toLowerCase().includes(q)) : items;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
showCloseButton={false}
className="top-[18%] translate-y-0 gap-0 overflow-hidden p-0 sm:max-w-md"
>
<DialogTitle className="sr-only"> </DialogTitle>
<div className="flex items-center gap-2.5 border-b border-border px-3.5">
<Search size={15} className="shrink-0 text-muted-foreground" />
<input
/* 커맨드 입력은 보더 없는 관례라 ui/Input 대신 소재 그대로 사용 */
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && filtered.length > 0) onSelect(filtered[0].type);
}}
placeholder="이동할 메뉴 검색…"
className="h-11 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
</div>
<div className="max-h-72 overflow-y-auto p-1.5">
{filtered.length === 0 ? (
<Typography variant="muted" className="px-3 py-4 text-xs">
</Typography>
) : (
filtered.map((item) => {
const Icon = item.icon;
return (
<button
key={item.type}
type="button"
onClick={() => onSelect(item.type)}
className="flex w-full cursor-pointer items-center gap-2.5 rounded-md px-3 py-2 text-left transition-colors hover:bg-accent"
>
<Icon size={15} className="shrink-0 text-muted-foreground" />
<Typography as="span" variant="small" className="flex-1 truncate">
{item.label}
</Typography>
{item.type === currentPage && (
<Typography as="span" variant="caption">
</Typography>
)}
</button>
);
})
)}
</div>
</DialogContent>
</Dialog>
);
}
/** 라벨:값 한 줄 (사이드바 프로필 카드용) */
function InfoRow({
label,
@ -256,8 +413,8 @@ function InfoRow({
}) {
return (
<div className="flex justify-between items-center">
<Typography variant="caption">{label}</Typography>
<Typography variant="caption" title={title} className={cn('font-bold text-foreground', valueClassName)}>
<Typography variant="caption" className="text-sidebar-foreground">{label}</Typography>
<Typography variant="caption" title={title} className={cn('font-bold text-foreground/90', valueClassName)}>
{value}
</Typography>
</div>
@ -267,9 +424,9 @@ function InfoRow({
/** 사이드바 사용자 프로필 카드 */
function SidebarProfileCard({ user }: { user: SidebarUser }) {
return (
<div className="p-3.5 mx-4 my-4 rounded-lg bg-muted/80 border border-border flex flex-col gap-1.5 shadow-2xs">
<div className="flex items-center gap-2 pb-1.5 border-b border-border/50">
<Building size={13} className="text-primary" />
<div className="p-3.5 mx-3 my-3 rounded-lg bg-card border border-sidebar-border flex flex-col gap-1.5">
<div className="flex items-center gap-2 pb-1.5 border-b border-sidebar-border/60">
<Building size={13} className="text-sidebar-primary" />
<Typography variant="caption" className="font-bold text-foreground truncate max-w-[140px]" title="소속 고객사">
{user?.company}
</Typography>
@ -281,11 +438,11 @@ function SidebarProfileCard({ user }: { user: SidebarUser }) {
label="이메일:"
value={user?.email}
title={user?.email}
valueClassName="font-mono text-foreground/90 truncate max-w-[80px]"
valueClassName="font-mono truncate max-w-[80px]"
/>
<InfoRow label="연락처:" value={user?.contact} valueClassName="font-mono" />
<div className="flex justify-between items-center pt-1 border-t border-border/20">
<Typography variant="caption">:</Typography>
<div className="flex justify-between items-center pt-1 border-t border-sidebar-border/60">
<Typography variant="caption" className="text-sidebar-foreground">:</Typography>
<Badge variant="secondary" className="text-[10px]">{user?.role}</Badge>
</div>
</div>
@ -300,7 +457,7 @@ function NavItem({
isOpen,
onClick,
}: {
item: { type: PageType; label: string; icon: ElementType; id: string };
item: MenuItem;
isActive: boolean;
isOpen: boolean;
onClick: () => void;
@ -312,13 +469,13 @@ function NavItem({
variant="ghost"
onClick={onClick}
className={cn(
'w-full justify-start gap-3 py-2 px-3 h-auto rounded text-xs font-semibold tracking-tight',
'w-full justify-start gap-3 py-2 px-3 h-auto rounded-md text-xs tracking-tight',
isActive
? 'bg-secondary text-foreground border-l-2 border-primary pl-2 hover:bg-secondary hover:text-foreground'
: 'text-muted-foreground hover:bg-muted/80'
? 'bg-sidebar-accent font-bold text-foreground hover:bg-sidebar-accent hover:text-foreground dark:hover:bg-sidebar-accent'
: 'font-semibold text-sidebar-foreground hover:bg-sidebar-accent/60 hover:text-sidebar-accent-foreground dark:hover:bg-sidebar-accent/60'
)}
>
<Icon size={16} className={cn('size-4', isActive ? 'text-foreground' : 'text-muted-foreground/85')} />
<Icon size={16} className={cn('size-4', isActive ? 'text-primary' : 'text-sidebar-foreground/85')} />
{isOpen && <span className="truncate">{item.label}</span>}
</Button>
);
@ -340,7 +497,7 @@ function HeaderMeta({ user, today }: { user: SidebarUser; today: string }) {
as="span"
variant="caption"
id="header-company-name"
className="font-bold text-foreground underline decoration-1 truncate max-w-[140px] md:max-w-none"
className="font-bold text-foreground truncate max-w-[140px] md:max-w-none"
>
{user?.company}
</Typography>

View File

@ -2,8 +2,8 @@ import type { ComponentProps, ReactNode } from 'react';
import { Search, X } from 'lucide-react';
import { cn } from '@/lib/utils';
// 페이지 상단의 "검색/액션 바". card 외피 + 좌측(검색·필터) / 우측(액션 버튼) 레이아웃.
// products/partners/quotation에서 동일하게 반복되던 구조를 단일 출처로 통일.
// 페이지 상단의 "검색/액션 바" — 슬림 카드형(어드민 표준 문법). 좌측(검색·필터) / 우측(액션 버튼들).
// 카드 외피는 p-3 + 32px 컨트롤로 얇게 유지 — 페이지마다 높이가 달라지던 원인(두꺼운 패딩·줄바꿈)은 여기서 통제한다.
export function PageToolbar({
children,
actions,
@ -16,27 +16,28 @@ export function PageToolbar({
return (
<div
className={cn(
'flex flex-col md:flex-row md:items-center justify-between gap-4 p-4 rounded-lg border border-border bg-card',
'flex flex-col gap-2 rounded-lg border border-border bg-card p-3 md:flex-row md:items-center md:justify-between',
className
)}
>
<div className="flex flex-1 flex-col sm:flex-row gap-3">{children}</div>
{actions && <div className="flex flex-wrap items-center gap-2">{actions}</div>}
<div className="flex flex-1 flex-col gap-2 sm:flex-row sm:items-center">{children}</div>
{actions && <div className="flex items-center gap-2">{actions}</div>}
</div>
);
}
// 돋보기 아이콘 + 검색 input. 매 페이지 복붙하던 동일 마크업을 컴포넌트화.
// 폭은 sm 이상에서 고정(w-72) — 툴바가 플랫이라 검색이 화면 전체로 늘어나면 허전해진다.
// onClear 가 주어지고 입력값이 있으면 우측에 X 버튼 노출 → 클릭 시 비우고 재검색.
export function SearchInput({ className, onClear, ...props }: ComponentProps<'input'> & { onClear?: () => void }) {
const hasValue = props.value != null && String(props.value).length > 0;
return (
<div className="relative flex-1">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
<div className="relative w-full shrink-0 sm:w-72">
<Search className="absolute left-2.5 top-2 h-4 w-4 text-muted-foreground" />
<input
type="text"
className={cn(
'w-full pl-9 pr-9 py-2 text-xs bg-background border border-border rounded focus:outline-none focus:border-foreground/40 text-foreground transition-colors font-mono',
'h-8 w-full rounded-lg border border-border bg-background pl-8 pr-8 text-xs text-foreground transition-colors focus:outline-none focus:border-ring/60',
className
)}
{...props}
@ -46,7 +47,7 @@ export function SearchInput({ className, onClear, ...props }: ComponentProps<'in
type="button"
onClick={onClear}
aria-label="검색어 지우기"
className="absolute right-2.5 top-2.5 text-muted-foreground hover:text-foreground cursor-pointer transition-colors"
className="absolute right-2.5 top-2 text-muted-foreground hover:text-foreground cursor-pointer transition-colors"
>
<X className="h-4 w-4" />
</button>

View File

@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
<div
data-slot="card"
className={cn(
"flex flex-col gap-6 rounded-lg border border-border bg-card py-6 text-card-foreground shadow-sm",
"flex flex-col gap-6 rounded-lg border border-border bg-card py-6 text-card-foreground",
className
)}
{...props}

View File

@ -102,8 +102,9 @@ export function DataTable<T>({
<Table className="w-full text-xs">
<TableHeader>
<TableRow className="bg-muted/50 border-b border-border hover:bg-transparent">
{/* 체크박스 셀도 일반 헤더 셀과 같은 세로 패딩(py-2.5) — p-4 로 두면 체크박스 있는 표만 헤더가 높아진다 */}
{selection && (
<TableHead className="p-4 w-12 text-center">
<TableHead className="px-4 py-2.5 w-12 text-center">
<input
type="checkbox"
checked={isAllSelected}
@ -144,7 +145,7 @@ export function DataTable<T>({
>
{selection && (
<TableCell
className="p-4 text-center w-12"
className="px-4 py-2 text-center w-12"
onClick={(e) => e.stopPropagation()}
>
<input

View File

@ -1,4 +1,5 @@
import type { ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { X } from 'lucide-react';
import { Typography } from '@/components/ui/typography';
@ -12,16 +13,19 @@ type SheetProps = {
// 우측 슬라이드 드로어 껍데기. 백드롭 + 패널 + 헤더(액센트바·타이틀·닫기)만 담당하고
// 폼/내용은 children으로 받는다. ProductFormSheet·PartnerFormSheet 공통 셸.
// 백드롭 클릭 시 닫힘(기존 FormSheet 동작 유지).
// body 로 포털 — 페이지 스크롤 컨테이너(main) 안에 렌더되면 시트 스크롤이 끝에서
// 부모(main)로 체이닝돼 모바일에서 뒤 화면이 스크롤되므로, DOM 계보 자체를 분리한다.
export function Sheet({ open, title, onClose, children }: SheetProps) {
if (!open) return null;
return (
return createPortal(
<div className="fixed inset-0 z-50 flex justify-end bg-black/40 backdrop-blur-xs animate-fade-in">
{/* 백드롭 */}
<div className="flex-1 cursor-pointer" onClick={onClose} />
{/* 우측 드로어 패널 (패널 자체가 세로 스크롤 — justify-* 는 스크롤 시작점을 가려 안 씀) */}
<div className="w-full max-w-lg bg-card border-l border-border h-full shadow-2xl p-6 overflow-y-auto animate-slide-left">
{/* ( justify-* )
overscroll-contain: 스크롤 / pb safe-area */}
<div className="w-full max-w-lg bg-card border-l border-border h-full shadow-2xl p-6 pb-[calc(1.5rem+env(safe-area-inset-bottom))] overflow-y-auto overscroll-contain animate-slide-left">
<div>
{/* 헤더 */}
<div className="flex items-center justify-between pb-4 border-b border-border">
@ -40,6 +44,7 @@ export function Sheet({ open, title, onClose, children }: SheetProps) {
{children}
</div>
</div>
</div>
</div>,
document.body,
);
}

View File

@ -1,72 +0,0 @@
import type { ElementType, ReactNode } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Typography } from '@/components/ui/typography';
import { cn } from '@/lib/utils';
import { TONE_CHIP, type Tone } from '../tones';
// 대시보드 액션 위젯 공용 셸: 컬러 아이콘칩·제목·total 배지·빈상태. 본문(행 목록)은 children 으로 받는다.
export function ActionWidget({
title,
icon: Icon,
tone,
total,
empty,
emptyText = '처리할 항목 없음',
children,
}: {
title: string;
icon: ElementType;
tone: Tone;
total: number;
empty: boolean;
emptyText?: string;
children: ReactNode;
}) {
return (
<Card className="gap-3 py-4">
<CardContent className="space-y-2.5 px-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className={cn('flex size-7 items-center justify-center rounded-lg', TONE_CHIP[tone])}>
<Icon size={15} />
</span>
<Typography variant="h4" className="text-base">
{title}
</Typography>
</div>
<Badge variant="secondary">{total}</Badge>
</div>
{empty ? (
<Typography variant="muted">{emptyText}</Typography>
) : (
<div className="-mx-1.5 space-y-0.5">{children}</div>
)}
</CardContent>
</Card>
);
}
// 위젯 안의 클릭 가능한 행(견적 1건 → 상세 딥링크). 우측 슬롯에 D-n·미발송 배지 등을 건다.
export function ActionRow({
name,
right,
onClick,
}: {
name?: string;
right?: ReactNode;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className="flex w-full cursor-pointer items-center justify-between gap-2 rounded-md px-1.5 py-1.5 text-left transition-colors hover:bg-muted/60"
>
<Typography variant="small" className="truncate">
{name || '(이름 없음)'}
</Typography>
{right}
</button>
);
}

View File

@ -1,26 +1,23 @@
import { HelpCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Typography } from '@/components/ui/typography';
import { useAuth } from '@/features/auth/useAuth';
// 대시보드 상단 환영 배너. 제품 소개는 온보딩(이용안내 모달)이 맡고, 여기선 누구의/어느 회사 대시보드인지만 보여준다.
// 배지=현재 로그인 회사, 제목=사용자 이름 인사. 목업의 그라데이션 대신 primary 틴트 패널로 절제.
// 대시보드 상단 인사 행. 제품 소개는 온보딩(이용안내 모달)이 맡고, 여기선 인사+안내 진입점만 —
// 배너/그라데이션 없이 텍스트가 위계를 만든다(시안 1 "인디고 콘솔" 문법). 회사명은 헤더가 이미 보여줘 생략.
export function DashboardHero({ onOpenGuide }: { onOpenGuide: () => void }) {
const { user } = useAuth();
const name = user?.name?.trim();
const company = user?.company?.trim();
return (
<div className="flex flex-col gap-4 rounded-xl border border-primary/20 bg-primary/5 p-6 md:flex-row md:items-center md:justify-between">
<div className="space-y-2">
{company && <Badge>{company}</Badge>}
<Typography variant="h2">{name ? `${name}님, 환영합니다` : '환영합니다'}</Typography>
<Typography variant="muted" className="max-w-2xl">
···, .
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<Typography variant="h3" as="h1">{name ? `${name}님, 환영합니다` : '환영합니다'}</Typography>
<Typography variant="muted" className="mt-0.5 text-[13px]">
···, .
</Typography>
</div>
<Button variant="outline" onClick={onOpenGuide} className="shrink-0">
<Button variant="outline" size="sm" onClick={onOpenGuide} className="shrink-0">
<HelpCircle />
</Button>

View File

@ -1,28 +0,0 @@
import { Clock } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import type { DashboardActionList } from '@/api/generated/model/dashboardActionList';
import { ActionWidget, ActionRow } from './ActionWidget';
import { fmtDeadline } from '../fmt';
// 마감 임박: 견적명 + 우측에 마감 D-n.
export function DeadlineWidget({
data,
onOpen,
}: {
data?: DashboardActionList;
onOpen: (qtId: string) => void;
}) {
const items = data?.items ?? [];
return (
<ActionWidget title="마감 임박" icon={Clock} tone="amber" total={data?.total ?? 0} empty={items.length === 0}>
{items.map((it) => (
<ActionRow
key={it.qt_id}
name={it.name}
onClick={() => onOpen(it.qt_id)}
right={<Badge variant="outline">{fmtDeadline(it.end_time)}</Badge>}
/>
))}
</ActionWidget>
);
}

View File

@ -1,27 +0,0 @@
import { Mail } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import type { DashboardEmailUnsent } from '@/api/generated/model/dashboardEmailUnsent';
import { ActionWidget, ActionRow } from './ActionWidget';
// 메일 미발송: 견적 단위로 묶고 우측에 미발송 협력사 수. 발송 전엔 협상이 시작 안 되므로 미발송 수는 destructive 배지.
export function EmailUnsentWidget({
data,
onOpen,
}: {
data?: DashboardEmailUnsent;
onOpen: (qtId: string) => void;
}) {
const items = data?.quotations ?? [];
return (
<ActionWidget title="메일 미발송" icon={Mail} tone="rose" total={data?.total ?? 0} empty={items.length === 0}>
{items.map((it) => (
<ActionRow
key={it.qt_id}
name={it.name}
onClick={() => onOpen(it.qt_id)}
right={<Badge variant="destructive"> {it.unsent_count ?? 0}</Badge>}
/>
))}
</ActionWidget>
);
}

View File

@ -0,0 +1,25 @@
import { Card } from '@/components/ui/card';
import { Typography } from '@/components/ui/typography';
import { cn } from '@/lib/utils';
// KPI 숫자 스트립(시안 1 "인디고 콘솔" 문법) — 카드 하나에 칸을 보더로 나눠 숫자만 크게.
// alert 지표는 값이 0보다 클 때만 destructive 로 강조한다.
export function KpiStrip({ items }: { items: { label: string; value: number; alert?: boolean }[] }) {
return (
<Card className="grid grid-cols-2 gap-0 overflow-hidden py-0 sm:grid-cols-4 sm:divide-x sm:divide-border/70">
{items.map((it) => (
<div key={it.label} className="px-4 py-3">
<Typography variant="caption" className="block">
{it.label}
</Typography>
<Typography
variant="h3"
className={cn('mt-0.5 leading-none tabular-nums', it.alert && it.value > 0 && 'text-destructive')}
>
{it.value}
</Typography>
</div>
))}
</Card>
);
}

View File

@ -1,39 +0,0 @@
import type { ElementType } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { Typography } from '@/components/ui/typography';
import { cn } from '@/lib/utils';
import { TONE_CHIP, type Tone } from '../tones';
// 대시보드 KPI 숫자 타일. 컬러 아이콘칩 + 값 + 라벨. warn 지표는 값이 0보다 클 때만 숫자를 destructive 로 강조한다.
export function KpiTile({
label,
value,
icon: Icon,
tone,
alertWhenPositive,
}: {
label: string;
value: number;
icon: ElementType;
tone: Tone;
alertWhenPositive?: boolean;
}) {
const alert = !!alertWhenPositive && value > 0;
return (
<Card className="gap-0 py-0">
<CardContent className="flex items-center gap-3 px-4 py-3.5">
<div className={cn('flex size-9 shrink-0 items-center justify-center rounded-lg', TONE_CHIP[tone])}>
<Icon size={18} />
</div>
<div className="min-w-0 space-y-0.5">
<Typography variant="h3" className={cn('leading-none', alert && 'text-destructive')}>
{value}
</Typography>
<Typography variant="caption" className="block truncate">
{label}
</Typography>
</div>
</CardContent>
</Card>
);
}

View File

@ -1,37 +0,0 @@
import type { ElementType } from 'react';
import type { DashboardActionList } from '@/api/generated/model/dashboardActionList';
import { ActionWidget, ActionRow } from './ActionWidget';
import type { Tone } from '../tones';
// 동가 / 결렬: 견적명만 나열(수동 확인 대상). 제목·아이콘·톤은 호출부에서 지정.
export function RefWidget({
title,
icon,
tone,
data,
onOpen,
emptyText,
}: {
title: string;
icon: ElementType;
tone: Tone;
data?: DashboardActionList;
onOpen: (qtId: string) => void;
emptyText?: string;
}) {
const items = data?.items ?? [];
return (
<ActionWidget
title={title}
icon={icon}
tone={tone}
total={data?.total ?? 0}
empty={items.length === 0}
emptyText={emptyText}
>
{items.map((it) => (
<ActionRow key={it.qt_id} name={it.name} onClick={() => onOpen(it.qt_id)} />
))}
</ActionWidget>
);
}

View File

@ -1,14 +1,9 @@
import { Activity, CalendarPlus, Award, Ban } from 'lucide-react';
import { Typography } from '@/components/ui/typography';
import type { DashboardScope } from '@/api/generated/model/dashboardScope';
import { KpiTile } from './KpiTile';
import { DeadlineWidget } from './DeadlineWidget';
import { EmailUnsentWidget } from './EmailUnsentWidget';
import { RefWidget } from './RefWidget';
import { KpiStrip } from './KpiStrip';
import { WorkTable } from './WorkTable';
// 스코프 1개(회사 전체 / 내 견적) 블록: 라벨 + 요약 KPI + 액션 위젯.
// KPI = 리스트 없는 순수 지표만.
// 위젯 = 관리자가 직접 처리해야 하는 것만.
// 스코프 1개(회사 전체 / 내 견적) 블록: 섹션 라벨 + KPI 스트립 + 처리 필요 테이블(시안 1 문법).
export function ScopeSection({
label,
scope,
@ -19,24 +14,22 @@ export function ScopeSection({
onOpen: (qtId: string) => void;
}) {
const s = scope ?? {};
const pending =
(s.deadline_soon?.total ?? 0) + (s.email_unsent?.total ?? 0) + (s.ruptured?.total ?? 0);
return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<span className="h-4 w-1 rounded bg-primary" />
<Typography variant="h3">{label}</Typography>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<KpiTile label="진행중" value={s.in_progress ?? 0} icon={Activity} tone="blue" />
<KpiTile label="이번 달 생성" value={s.this_month ?? 0} icon={CalendarPlus} tone="purple" />
<KpiTile label="누적 낙찰" value={s.awarded?.total ?? 0} icon={Award} tone="emerald" />
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<DeadlineWidget data={s.deadline_soon} onOpen={onOpen} />
<EmailUnsentWidget data={s.email_unsent} onOpen={onOpen} />
<RefWidget title="개찰 (낙찰자 미정 마감)" icon={Ban} tone="amber" data={s.ruptured} onOpen={onOpen} />
</div>
</div>
<section className="space-y-3">
<Typography variant="label" className="block text-muted-foreground">
{label}
</Typography>
<KpiStrip
items={[
{ label: '진행중', value: s.in_progress ?? 0 },
{ label: '이번 달 생성', value: s.this_month ?? 0 },
{ label: '누적 낙찰', value: s.awarded?.total ?? 0 },
{ label: '처리 필요', value: pending, alert: true },
]}
/>
<WorkTable scope={s} onOpen={onOpen} />
</section>
);
}

View File

@ -0,0 +1,82 @@
import { CheckCircle2 } from 'lucide-react';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Typography } from '@/components/ui/typography';
import { cn } from '@/lib/utils';
import type { DashboardScope } from '@/api/generated/model/dashboardScope';
import { fmtDeadline } from '../fmt';
type WorkRow = { qtId: string; name?: string; dot: string; kind: string; meta: string };
// "처리 필요 견적" 단일 테이블(시안 1 문법) — 마감 임박·메일 미발송·개찰을 한 목록으로 합치고
// 유형은 컬러 도트+라벨로 구분한다. 행 클릭 = 견적 상세 딥링크.
export function WorkTable({ scope, onOpen }: { scope: DashboardScope; onOpen: (qtId: string) => void }) {
const rows: WorkRow[] = [
...(scope.deadline_soon?.items ?? []).map((it) => ({
qtId: it.qt_id,
name: it.name,
dot: 'bg-warning',
kind: '마감 임박',
meta: fmtDeadline(it.end_time),
})),
...(scope.email_unsent?.quotations ?? []).map((it) => ({
qtId: it.qt_id,
name: it.name,
dot: 'bg-destructive',
kind: '메일 발송 필요',
meta: `미발송 ${it.unsent_count ?? 0}`,
})),
...(scope.ruptured?.items ?? []).map((it) => ({
qtId: it.qt_id,
name: it.name,
dot: 'bg-muted-foreground/50',
kind: '개찰',
meta: '낙찰자 미정',
})),
];
const total =
(scope.deadline_soon?.total ?? 0) + (scope.email_unsent?.total ?? 0) + (scope.ruptured?.total ?? 0);
return (
<Card className="gap-0 overflow-hidden py-0">
<div className="flex items-center justify-between gap-2 border-b border-border/70 px-4 py-3">
<Typography variant="h4" className="text-sm">
</Typography>
<Badge variant="secondary" className="tabular-nums">
{total}
</Badge>
</div>
{rows.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-1.5 px-4 py-8">
<CheckCircle2 size={18} className="text-muted-foreground/50" />
<Typography variant="muted" className="text-xs">
</Typography>
</div>
) : (
<div className="divide-y divide-border/60">
{rows.map((row) => (
<button
key={`${row.kind}-${row.qtId}`}
type="button"
onClick={() => onOpen(row.qtId)}
className="grid w-full cursor-pointer grid-cols-[8px_1fr_auto] items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-muted/50 sm:grid-cols-[8px_1fr_9rem_auto]"
>
<span aria-hidden className={cn('size-2 rounded-full', row.dot)} />
<Typography variant="small" className="truncate">
{row.name || '(이름 없음)'}
</Typography>
<Typography variant="caption" className="hidden truncate sm:block">
{row.kind}
</Typography>
<Typography variant="caption" className="font-semibold tabular-nums text-foreground/80">
{row.meta}
</Typography>
</button>
))}
</div>
)}
</Card>
);
}

View File

@ -1,4 +1,5 @@
import { useState } from 'react';
import { createPortal } from 'react-dom';
import { CheckCircle2, X, UserCheck, MessageSquare, Layers, RefreshCw } from 'lucide-react';
import { Typography } from '@/components/ui/typography';
import {
@ -141,7 +142,9 @@ export function QuotationDetailSheet({
{ id: 'cards', label: `협상 카드 (${serverCards.length})`, icon: Layers },
];
return (
// body 로 포털 — main(페이지 스크롤 컨테이너) 안에 렌더되면 시트 내부 스크롤이 끝에서
// 부모로 체이닝돼 모바일에서 뒤 화면이 스크롤된다.
return createPortal(
<div className="fixed inset-0 z-40 bg-black/40 backdrop-blur-xs flex justify-end animate-fade-in">
<div className="flex-1 cursor-pointer" onClick={onClose} />
@ -213,7 +216,7 @@ export function QuotationDetailSheet({
{showHeaderCards ? (
<div
style={{ flex: '2 1 0%' }}
className="min-h-0 overflow-y-auto px-6 pb-6 bg-muted/30 border-b border-border"
className="min-h-0 overflow-y-auto overscroll-contain px-6 pb-6 bg-muted/30 border-b border-border"
>
<DrawerHeaderCards
quotation={quotation}
@ -270,7 +273,7 @@ export function QuotationDetailSheet({
</div>
{/* Tab content */}
<div style={{ flex: '1 1 0%' }} className="min-h-0 p-6 overflow-y-auto bg-background/50">
<div style={{ flex: '1 1 0%' }} className="min-h-0 p-6 pb-[calc(1.5rem+env(safe-area-inset-bottom))] overflow-y-auto overscroll-contain bg-background/50">
{activeTab === 'status' && (
<SessionsStatusTab
sessionViews={sessionViews}
@ -334,6 +337,7 @@ export function QuotationDetailSheet({
onClose={() => setRegenOpen(false)}
/>
)}
</div>
</div>,
document.body,
);
}

View File

@ -1,9 +1,9 @@
import { Plus, BookOpen, Upload, Download, FileSpreadsheet, ChevronDown } from 'lucide-react';
import { Plus, Upload, Download, FileSpreadsheet, ChevronDown } from 'lucide-react';
import { useOverlayRouter } from '@/lib/useOverlayRouter';
import { showToast } from '@/lib/notify';
import { confirm } from '@/lib/confirm';
import { PageContainer } from '@/components/layout/PageContainer';
import { SearchInput } from '@/components/layout/PageToolbar';
import { PageToolbar, SearchInput } from '@/components/layout/PageToolbar';
import { Button } from '@/components/ui/button';
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu';
import { TablePagination } from '@/components/ui/table-pagination';
@ -62,47 +62,44 @@ export default function CardsPage() {
return (
<PageContainer>
{/* Upper Information Banner */}
<div className="p-4 rounded-lg border border-border bg-card flex flex-col sm:flex-row items-center justify-between gap-4 font-mono text-xs">
<div className="flex items-center gap-3">
<BookOpen className="text-muted-foreground" size={20} />
<div>
<Typography variant="h3" className="text-xs"> </Typography>
<Typography variant="muted" className="text-[10px] mt-0.5">
() . · .
</Typography>
</div>
</div>
{/* 플랫 툴바 — 다른 목록 페이지와 동일 문법(검색 좌측, 주버튼+⋯ 우측) */}
<PageToolbar
actions={
<>
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="outline" />}>
<FileSpreadsheet />
<ChevronDown />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => overlay.open('modal', 'excel')}>
<Upload />
</DropdownMenuItem>
<DropdownMenuItem onClick={downloadCardTemplate}>
<Download />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="outline" />}>
<FileSpreadsheet />
<ChevronDown />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => overlay.open('modal', 'excel')}>
<Upload />
</DropdownMenuItem>
<DropdownMenuItem onClick={downloadCardTemplate}>
<Download />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<button
id="card-register-btn"
onClick={openCreate}
className="flex items-center gap-2 px-4 py-2.5 bg-primary text-primary-foreground text-xs font-bold rounded hover:opacity-95 cursor-pointer transition-colors whitespace-nowrap"
>
<Plus size={15} />
<span> </span>
</button>
</div>
</div>
<Button id="card-register-btn" onClick={openCreate}>
<Plus />
</Button>
</>
}
>
<SearchInput
id="card-search"
value={list.search}
onChange={(e) => list.setSearch(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && list.submitSearch()}
onClear={list.clearSearch}
placeholder="전체 카드이름, 카드번호, 코드 검색..."
/>
</PageToolbar>
{/* Primary Navigation Tab */}
<div className="flex border-b border-border">
@ -122,15 +119,10 @@ export default function CardsPage() {
))}
</div>
{/* Filtering Search Bar */}
<SearchInput
id="card-search"
value={list.search}
onChange={(e) => list.setSearch(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && list.submitSearch()}
onClear={list.clearSearch}
placeholder="전체 카드이름, 카드번호, 코드 검색..."
/>
{/* 배너에 있던 공용 카드 안내는 캡션 한 줄로 유지 */}
<Typography variant="caption" className="-mt-3 block">
() . · .
</Typography>
<CardTable
data={cards}

View File

@ -3,7 +3,6 @@ import { useNavigate, useSearchParams } from 'react-router';
import { User, Lock } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card } from '@/components/ui/card';
import { Typography } from '@/components/ui/typography';
import { useAuth } from '../features/auth/useAuth';
import { showToast } from '@/lib/notify';
@ -47,75 +46,104 @@ export default function LoginPage() {
};
return (
<div className="min-h-screen flex items-center justify-center bg-background px-4">
<Card className="w-full max-w-sm gap-0 p-8 transition-all hover:border-foreground/10">
<div className="flex flex-col items-center mb-6">
<Typography variant="h2" as="h1">NegoData</Typography>
<Typography variant="mono" className="mt-1">
Negosium Admin Console
<div className="min-h-screen grid bg-background lg:grid-cols-[1.1fr_1fr]">
{/* 브랜드 패널 — 데스크톱 전용. 첫인상 화면이라 제품 메시지를 여기서 전달한다 */}
<div className="hidden lg:flex flex-col justify-between bg-sidebar border-r border-sidebar-border p-10">
<BrandMark />
<div className="max-w-md space-y-3">
<Typography variant="h2" className="text-balance">
AI가 ,
</Typography>
<Typography variant="muted">
··· .
</Typography>
</div>
<Typography variant="mono">Negosium Admin Console</Typography>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<Typography as="label" variant="label" htmlFor="login-id">
</Typography>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
<Input
id="login-id"
type="text"
autoComplete="username"
value={loginId}
onChange={(e) => setLoginId(e.target.value)}
className="pl-9"
placeholder="아이디 입력"
/>
</div>
{/* 로그인 폼 */}
<div className="flex items-center justify-center px-4 py-12">
<div className="w-full max-w-sm">
<div className="mb-8 lg:hidden">
<BrandMark />
</div>
<div className="space-y-1.5">
<Typography as="label" variant="label" htmlFor="login-password">
</Typography>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
<Input
id="login-password"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="pl-9"
placeholder="비밀번호 입력"
/>
</div>
</div>
<Typography variant="h3" as="h1">
</Typography>
<Typography variant="muted" className="mt-1 mb-6">
NegoData .
</Typography>
{/* Error messages */}
{error && (
<div className="p-3 rounded bg-destructive/10 border border-destructive/20 text-xs text-destructive">
{error}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<Typography as="label" variant="label" htmlFor="login-id">
</Typography>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
<Input
id="login-id"
type="text"
autoComplete="username"
value={loginId}
onChange={(e) => setLoginId(e.target.value)}
className="pl-9"
placeholder="아이디 입력"
/>
</div>
</div>
)}
<Button
type="submit"
disabled={loading}
size="lg"
className="w-full"
>
{loading ? (
<span className="inline-block animate-spin h-4 w-4 border-2 border-primary-foreground border-t-transparent rounded-full" />
) : (
'로그인'
<div className="space-y-1.5">
<Typography as="label" variant="label" htmlFor="login-password">
</Typography>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
<Input
id="login-password"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="pl-9"
placeholder="비밀번호 입력"
/>
</div>
</div>
{/* Error messages */}
{error && (
<div className="p-3 rounded bg-destructive/10 border border-destructive/20 text-xs text-destructive">
{error}
</div>
)}
</Button>
</form>
</Card>
<Button
type="submit"
disabled={loading}
size="lg"
className="w-full"
>
{loading ? (
<span className="inline-block animate-spin h-4 w-4 border-2 border-primary-foreground border-t-transparent rounded-full" />
) : (
'로그인'
)}
</Button>
</form>
</div>
</div>
</div>
);
}
/** 인디고 사각 글리프 + 워드마크 (사이드바 로고와 같은 문법) */
function BrandMark() {
return (
<Typography as="div" variant="body" className="flex items-center gap-2 font-extrabold tracking-tight text-foreground">
<span aria-hidden className="size-4 rounded-[5px] bg-primary" />
NegoData
</Typography>
);
}

View File

@ -1,8 +1,11 @@
import { useEffect, useRef, type ReactNode } from 'react';
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { useNavigate } from 'react-router';
import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
import { Trophy, RefreshCw, XCircle, Bell, CheckCheck, FilePlus2 } from 'lucide-react';
import { PageContainer } from '@/components/layout/PageContainer';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Typography } from '@/components/ui/typography';
import { cn } from '@/lib/utils';
import {
@ -15,13 +18,16 @@ import type { NotificationData } from '@/api/generated/model/notificationData';
const PAGE_SIZE = 20;
type Tab = 'all' | 'unread';
export default function NotificationsPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [tab, setTab] = useState<Tab>('all');
// offset 페이징(page/size)을 그대로 쓰는 무한 스크롤: page 를 1→2→3 누적.
// 다음 페이지 여부는 응답의 total/page/size 로 판정(page*size < total).
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteQuery({
queryKey: ['/v1/notification/list', 'infinite'],
queryFn: ({ pageParam }) => listNotifications({ page: pageParam, size: PAGE_SIZE }),
initialPageParam: 1,
@ -37,6 +43,9 @@ export default function NotificationsPage() {
const items = data?.pages.flatMap((p) => p.notifications ?? []) ?? [];
const unread = data?.pages[0]?.unread ?? 0;
// 안읽음 탭은 API 에 필터 파라미터가 없어 로드된 페이지에서 클라이언트 필터.
// 필터 결과가 짧아도 하단 sentinel 이 계속 다음 페이지를 당겨온다.
const visible = tab === 'unread' ? items.filter((n) => !n.read_at) : items;
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['/v1/notification/list'] });
@ -61,71 +70,62 @@ export default function NotificationsPage() {
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
return (
<PageContainer>
<div className="flex items-center justify-between p-4 rounded-lg border border-border bg-card">
<div className="flex items-center gap-2">
<Bell size={18} className="text-foreground" />
<Typography as="span" variant="small" className="font-bold"></Typography>
{unread > 0 && (
<Typography as="span" variant="small" className="text-xs font-bold text-destructive">{unread} </Typography>
)}
<PageContainer className="mx-auto w-full max-w-3xl space-y-3">
{/* 필터 탭 + 모두 읽음 */}
<div className="flex items-center justify-between gap-3">
<div className="flex items-center rounded-lg bg-muted p-0.5">
{(
[
{ key: 'all', label: '전체' },
{ key: 'unread', label: `안읽음 ${unread}` },
] as { key: Tab; label: string }[]
).map((t) => (
<button
key={t.key}
type="button"
onClick={() => setTab(t.key)}
className={cn(
'rounded-md px-3 py-1 text-xs font-semibold transition-colors cursor-pointer tabular-nums',
tab === t.key
? 'bg-card text-foreground border border-border/60'
: 'text-muted-foreground hover:text-foreground',
)}
>
{t.label}
</button>
))}
</div>
<button
type="button"
<Button
variant="outline"
size="sm"
disabled={unread === 0 || readAll.isPending}
onClick={() => readAll.mutate(undefined, { onSuccess: invalidate })}
disabled={unread === 0}
className="flex items-center gap-1.5 px-3 py-2 bg-muted text-foreground border border-border text-xs font-semibold rounded hover:bg-muted-foreground/10 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer transition-colors"
>
<CheckCheck size={14} />
<Typography as="span" variant="small" className="text-xs text-inherit"> </Typography>
</button>
<CheckCheck />
</Button>
</div>
<div className="rounded-lg border border-border bg-card divide-y divide-border overflow-hidden">
{items.length === 0 ? (
<div className="p-10 text-center">
<Typography as="p" variant="small" className="text-muted-foreground"> .</Typography>
{/* 알림 리스트 — 날짜 그룹(오늘/어제/이전) + 컴팩트 행 */}
<Card className="gap-0 overflow-hidden py-0">
{isLoading ? (
<SkeletonRows />
) : visible.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-1.5 px-4 py-12">
<Bell size={18} className="text-muted-foreground/50" />
<Typography variant="muted" className="text-xs">
{tab === 'unread' ? '안읽은 알림이 없습니다.' : '알림이 없습니다.'}
</Typography>
</div>
) : (
items.map((n) => {
const r = render(n);
const isUnread = !n.read_at;
return (
<button
key={n.notification_id}
type="button"
onClick={() => openOne(n)}
className={cn(
'w-full flex items-start gap-3 px-4 py-3 text-left hover:bg-muted/60 transition-colors cursor-pointer',
isUnread && 'bg-primary/5'
)}
>
<span className={cn('shrink-0 mt-0.5', r.tone)}>{r.icon}</span>
<div className="flex-1 min-w-0">
{/* 1줄: 이벤트(낙찰/재생성/결렬/생성) — 제목 */}
<Typography as="div" variant="small" className={cn('text-xs font-bold', r.tone)}>
{r.event}
</Typography>
{/* 2줄: 무슨 견적인지(건명) + 결과 */}
<Typography as="div" variant="small" className={cn('text-sm truncate', isUnread ? 'font-bold text-foreground' : 'text-foreground/80')}>
{r.line}
</Typography>
{/* 3줄: 견적번호 · 날짜 — quotation 상세 헤더와 같은 mono 보조 표기 */}
<Typography as="div" variant="small" className="text-[11px] font-mono text-muted-foreground mt-0.5">
{r.number ? `${r.number} · ` : ''}{fmtKst(n.created_at)}
</Typography>
</div>
{isUnread && <span className="shrink-0 mt-1.5 h-2 w-2 rounded-full bg-destructive" />}
</button>
);
})
<NotificationList items={visible} onOpen={openOne} />
)}
</div>
</Card>
{hasNextPage && (
<div ref={sentinelRef} className="py-3 text-center">
<div ref={sentinelRef} className="py-2 text-center">
{isFetchingNextPage && (
<Typography as="span" variant="small" className="text-muted-foreground"> </Typography>
<Typography as="span" variant="caption"> </Typography>
)}
</div>
)}
@ -133,6 +133,123 @@ export default function NotificationsPage() {
);
}
/** 날짜 그룹 캡션을 끼워 넣은 알림 행 목록 */
function NotificationList({
items,
onOpen,
}: {
items: NotificationData[];
onOpen: (n: NotificationData) => void;
}) {
const nodes: ReactNode[] = [];
let prevGroup: string | null = null;
for (const n of items) {
const group = groupOf(n.created_at);
if (group !== prevGroup) {
prevGroup = group;
nodes.push(
<div key={`g-${group}`} className="border-b border-border/60 bg-muted/40 px-4 py-1">
<Typography variant="caption" className="text-[10px] font-bold uppercase tracking-wider">
{group}
</Typography>
</div>,
);
}
nodes.push(<NotificationRow key={n.notification_id} n={n} onOpen={onOpen} />);
}
return <div className="divide-y divide-border/50">{nodes}</div>;
}
/** 알림 1행 — [안읽음 도트][아이콘 칩][유형 pill+건명 / mono 서브줄][시간] */
function NotificationRow({ n, onOpen }: { n: NotificationData; onOpen: (n: NotificationData) => void }) {
const r = render(n);
const isUnread = !n.read_at;
const full = fmtKst(n.created_at);
const group = groupOf(n.created_at);
// 오늘/어제는 시각만, 이전은 날짜만 — 상세 시각은 title 툴팁으로
const short = full === '-' ? '-' : group === '이전' ? full.slice(0, 10) : full.slice(-5);
const sub = [r.detail, r.number].filter(Boolean).join(' · ');
// 밀도·타이포는 다른 리스트(UI/data-table 행 px-4·대시보드 WorkTable 행 py-2.5·text-sm 제목·caption 메타)와 통일
return (
<button
type="button"
onClick={() => onOpen(n)}
className="grid w-full cursor-pointer grid-cols-[auto_1fr_auto] items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
>
{/* 안읽음 도트는 전용 컬럼 대신 아이콘 칩 모서리에 — 읽은 행에 빈 공간이 안 생긴다 */}
<span className="relative flex size-7 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
{r.icon}
{isUnread && (
<span aria-hidden className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-primary ring-2 ring-card" />
)}
</span>
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<Badge className={cn('shrink-0', r.pillCls)}>{r.pill}</Badge>
<Typography
as="span"
variant="small"
className={cn('truncate', isUnread ? 'font-semibold text-foreground' : 'text-foreground/75')}
>
{r.name}
</Typography>
</div>
{sub && (
<Typography as="div" variant="caption" className="mt-0.5 truncate font-mono">
{sub}
</Typography>
)}
</div>
<Typography as="span" variant="caption" title={full} className="shrink-0 self-start pt-0.5 tabular-nums">
{short}
</Typography>
</button>
);
}
/** 초기 로딩 스켈레톤 — 행 3개 */
function SkeletonRows() {
return (
<div className="divide-y divide-border/50">
{[0, 1, 2].map((i) => (
<div key={i} className="grid animate-pulse grid-cols-[auto_1fr_auto] items-center gap-3 px-4 py-2.5">
<span className="size-7 rounded-md bg-muted" />
<div className="space-y-1.5">
<div className="h-3.5 w-2/5 rounded bg-muted" />
<div className="h-3 w-1/4 rounded bg-muted/70" />
</div>
<span className="h-3 w-9 rounded bg-muted/70" />
</div>
))}
</div>
);
}
// ── 표기 헬퍼 ─────────────────────────────────────────────
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
// UTC 절대시각(ms) → KST 기준 '그 날짜의 자정' UTC ms 앵커 (features/dashboard/fmt.ts 와 같은 방식)
function kstDayStart(ms: number): number {
const shifted = new Date(ms + KST_OFFSET_MS);
return Date.UTC(shifted.getUTCFullYear(), shifted.getUTCMonth(), shifted.getUTCDate());
}
// 알림 생성 시각 → 날짜 그룹 라벨
function groupOf(s?: string | null): '오늘' | '어제' | '이전' {
if (!s) return '이전';
const iso = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(s) ? s : s + 'Z';
const t = new Date(iso).getTime();
if (Number.isNaN(t)) return '이전';
const days = Math.round((kstDayStart(Date.now()) - kstDayStart(t)) / 86_400_000);
if (days <= 0) return '오늘';
if (days === 1) return '어제';
return '이전';
}
// 서버 시각(타임존 표식 없는 UTC) → 한국시간 'YYYY-MM-DD HH:mm'. 실패 시 '-'.
function fmtKst(s?: string | null): string {
if (!s) return '-';
@ -152,43 +269,63 @@ function qtName(d: Record<string, unknown>): string {
return name || num || '견적';
}
// 알림 1건 → 카드 3단 표기값.
// event : 이벤트 제목(낙찰/재생성/결렬/생성) — 한 줄에 쭉 늘어놓지 않고 분리
// line : 무슨 견적인지(건명) + 결과
// number: 견적번호(메타줄 보조 표기). icon/tone 은 유형별 색.
function render(n: NotificationData): { icon: ReactNode; tone: string; event: string; line: string; number: string } {
// 유형 pill 색 — 상태색 매핑(생성=브랜드 인디고 · 낙찰=녹 · 개찰/재생성=주황)과 동일 체계
const PILL_TONE = {
indigo: 'bg-primary/10 text-primary',
emerald: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-300',
amber: 'bg-amber-100 text-amber-700 dark:bg-amber-950/30 dark:text-amber-300',
muted: 'bg-muted text-muted-foreground',
} as const;
// 알림 1건 → 행 표기값. 색은 유형 pill 하나만 유채색(아이콘 칩은 중립).
function render(n: NotificationData): {
icon: ReactNode;
pill: string;
pillCls: string;
name: string;
detail: string;
number: string;
} {
const d = (n.data ?? {}) as Record<string, unknown>;
const name = qtName(d);
const number = (d.qt_number as string) || '';
switch (n.type) {
case NotificationType.CREATED:
return { icon: <FilePlus2 size={18} />, tone: 'text-sky-600', event: '견적 생성', line: name, number };
return { icon: <FilePlus2 size={15} />, pill: '생성', pillCls: PILL_TONE.indigo, name, detail: '', number };
case NotificationType.SUCCESS:
// 자동 낙찰과 담당자 직접 낙찰(data.manual)은 같은 SUCCESS — 문구로만 '직접'을 구분한다.
return {
icon: <Trophy size={18} />, tone: 'text-emerald-600', event: d.manual ? '견적 낙찰 · 직접' : '견적 낙찰',
line: `${name}${d.winner_name ?? '-'} ${Number(d.winner_price ?? 0).toLocaleString()}`,
icon: <Trophy size={15} />,
pill: d.manual ? '낙찰 · 직접' : '낙찰',
pillCls: PILL_TONE.emerald,
name,
detail: `${d.winner_name ?? '-'} · ${Number(d.winner_price ?? 0).toLocaleString()}`,
number,
};
case NotificationType.REGENERATED:
return {
icon: <RefreshCw size={18} />, tone: 'text-amber-600',
event: `견적 재생성 · ${d.reason === 'equal' ? '동가' : '전원 미참여'}`,
line: `${name}${d.next_round ?? ''}차로 다시 생성`,
icon: <RefreshCw size={15} />,
pill: '재생성',
pillCls: PILL_TONE.amber,
name,
detail: `${d.reason === 'equal' ? '동가' : '전원 미참여'} · ${d.next_round ?? ''}차로 다시 생성`,
number,
};
case NotificationType.FAILURE:
// 결렬 폐지 → '개찰'(낙찰자 미정으로 마감). reason 으로 사유만 부기.
return {
icon: <XCircle size={18} />, tone: 'text-amber-600', event: '견적 개찰',
line: `${name} — 낙찰자 미정 (${
icon: <XCircle size={15} />,
pill: '개찰',
pillCls: PILL_TONE.amber,
name,
detail: `낙찰자 미정 · ${
({ price: '목표가 초과', equal: '동가', rejected: '협상거부', no_show: '전원 미응찰' } as Record<string, string>)[
String(d.reason)
] ?? '마감'
})`,
}`,
number,
};
default:
return { icon: <Bell size={18} />, tone: 'text-muted-foreground', event: '견적 알림', line: name, number };
return { icon: <Bell size={15} />, pill: '알림', pillCls: PILL_TONE.muted, name, detail: '', number };
}
}

View File

@ -1,4 +1,6 @@
import { Settings, Plus } from 'lucide-react';
import { Settings, Plus, MoreHorizontal } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu';
import { keepPreviousData } from '@tanstack/react-query';
import { useOverlayRouter } from '@/lib/useOverlayRouter';
import { PageContainer } from '@/components/layout/PageContainer';
@ -72,23 +74,22 @@ export default function QuotationPage() {
<PageToolbar
actions={
<>
<button
id="quotation-settings-btn"
onClick={() => overlay.open('settings')}
className="flex items-center gap-2 px-3 py-2.5 bg-muted text-foreground border border-border text-xs font-semibold rounded hover:bg-muted-foreground/10 cursor-pointer transition-colors"
>
<Settings size={14} />
<span> </span>
</button>
<Button id="quotation-create-btn" onClick={() => overlay.open('create')}>
<Plus />
</Button>
<button
id="quotation-create-btn"
onClick={() => overlay.open('create')}
className="flex items-center gap-2 px-4 py-2.5 bg-primary text-primary-foreground text-xs font-bold rounded hover:opacity-95 cursor-pointer transition-colors"
>
<Plus size={15} />
<span> </span>
</button>
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="outline" size="icon" aria-label="추가 작업" />}>
<MoreHorizontal />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem id="quotation-settings-btn" onClick={() => overlay.open('settings')}>
<Settings />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
}
>

View File

@ -1,6 +1,7 @@
/*
* Design Tokens (Tailwind v4)
* negodata 디자인 시스템의 단일 소스. 색상 · 폰트 · radius.
* 디자인 방향: "인디고 콘솔"(Linear 문법) 조용한 무채 크롬 + 인디고(#5E6AD2) 액센트 .
*
* 다른 Tailwind v4 프로젝트에서 재사용하려면:
* 1) 파일을 복사하고
@ -39,82 +40,86 @@
}
:root {
--background: #ffffff;
--background-rgb: 250, 250, 250;
--foreground: #0a0a0a;
/* 뉴트럴은 인디고로 아주 살짝 기운 회색 계열. 캔버스는 거의 백색, 층은 헤어라인 보더로 만든다 */
--background: #fcfcfd;
--background-rgb: 252, 252, 253;
--foreground: #202433;
--card: #ffffff;
--card-foreground: #0a0a0a;
--border: #e5e5e5;
--muted: #f5f5f5;
--muted-foreground: #737373;
--primary: #171717;
--primary-foreground: #fafafa;
--secondary: #f5f5f5;
--secondary-foreground: #171717;
--accent: #f5f5f5;
--destructive: #e7000b;
--card-foreground: #202433;
--border: #e9e9ef;
--muted: #f1f1f5;
--muted-foreground: #6c7086;
--primary: #5e6ad2; /* 브랜드 인디고(Linear 계열). 흰 글자 대비 4.7:1 (AA) */
--primary-foreground: #ffffff;
--secondary: #f1f1f5;
--secondary-foreground: #33384d;
--accent: #ececf4; /* 메뉴 hover·선택 표면 */
--destructive: #d92d20;
--destructive-foreground: #fafafa;
--success: #10b981;
--warning: #f59e0b;
--info: #4880ef;
--ring: #a1a1a1;
--success: #17b26a;
--warning: #f79009;
--info: #6172f3;
--ring: #5e6ad2;
--popover: #ffffff;
--popover-foreground: #0a0a0a;
--accent-foreground: #171717;
--input: #e5e5e5;
--chart-1: #d4d4d4;
--chart-2: #737373;
--chart-3: #525252;
--chart-4: #404040;
--chart-5: #262626;
--radius: 0.625rem;
--sidebar: #fafafa;
--sidebar-foreground: #0a0a0a;
--sidebar-primary: #171717;
--sidebar-primary-foreground: #fafafa;
--sidebar-accent: #f5f5f5;
--sidebar-accent-foreground: #171717;
--sidebar-border: #e5e5e5;
--sidebar-ring: #a1a1a1;
--popover-foreground: #202433;
--accent-foreground: #33384d;
--input: #e4e4ec;
/* 차트 카테고리 팔레트 — 고정 순서(식별용), CVD·대비 검증 통과. features/statistics/palette.ts 와 같은 계열 */
--chart-1: #3b82f6;
--chart-2: #10b981;
--chart-3: #f59e0b;
--chart-4: #a855f7;
--chart-5: #f43f5e;
--radius: 0.5rem;
/* 사이드바 = 캔버스보다 반 단계 어두운 조용한 회색 면. 액센트는 활성 아이콘에만 */
--sidebar: #f7f7f9;
--sidebar-foreground: #5f6377;
--sidebar-primary: #5e6ad2;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #ececf4;
--sidebar-accent-foreground: #33384d;
--sidebar-border: #e9e9ef;
--sidebar-ring: #5e6ad2;
}
.dark {
--background: #0a0a0a;
--background-rgb: 9, 9, 11;
--foreground: #fafafa;
--card: #171717;
--card-foreground: #fafafa;
--border: #ffffff1a;
--muted: #262626;
--muted-foreground: #a1a1a1;
--primary: #e5e5e5;
--primary-foreground: #171717;
--secondary: #262626;
--secondary-foreground: #fafafa;
--accent: #262626;
--destructive: #ff6467;
--background: #141419;
--background-rgb: 20, 20, 25;
--foreground: #e6e6ee;
--card: #1b1b22;
--card-foreground: #e6e6ee;
--border: #ffffff14;
--muted: #24242d;
--muted-foreground: #9a9dae;
--primary: #8b93e8; /* 다크는 두 단계 밝은 인디고. 진한 텍스트 대비 AA 이상 */
--primary-foreground: #16161d;
--secondary: #24242d;
--secondary-foreground: #e6e6ee;
--accent: #26262f;
--destructive: #f97066;
--destructive-foreground: #fafafa;
--success: #10b981;
--warning: #f59e0b;
--info: #6ea8ff;
--ring: #737373;
--popover: #171717;
--popover-foreground: #fafafa;
--accent-foreground: #fafafa;
--success: #17b26a;
--warning: #f79009;
--info: #8b93e8;
--ring: #8b93e8;
--popover: #1b1b22;
--popover-foreground: #e6e6ee;
--accent-foreground: #ffffff;
--input: #ffffff26;
--chart-1: #d4d4d4;
--chart-2: #737373;
--chart-3: #525252;
--chart-4: #404040;
--chart-5: #262626;
--sidebar: #171717;
--sidebar-foreground: #fafafa;
--sidebar-primary: #1447e6;
--sidebar-primary-foreground: #fafafa;
--sidebar-accent: #262626;
--sidebar-accent-foreground: #fafafa;
--sidebar-border: #ffffff1a;
--sidebar-ring: #737373;
/* 라이트와 같은 순서, 다크 표면 밝기 밴드에 맞춰 emerald·amber 만 한 단계 진하게 */
--chart-1: #3b82f6;
--chart-2: #059669;
--chart-3: #d97706;
--chart-4: #a855f7;
--chart-5: #f43f5e;
--sidebar: #18181f;
--sidebar-foreground: #9a9dae;
--sidebar-primary: #8b93e8;
--sidebar-primary-foreground: #16161d;
--sidebar-accent: #26262f;
--sidebar-accent-foreground: #ffffff;
--sidebar-border: #ffffff12;
--sidebar-ring: #8b93e8;
}
@theme inline {