[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 { PageType } from '@/types';
import { useAuth } from '@/features/auth/useAuth'; import { useAuth } from '@/features/auth/useAuth';
import { ProfileSheet } from '@/features/auth/components/ProfileSheet'; import { ProfileSheet } from '@/features/auth/components/ProfileSheet';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Typography } from '@/components/ui/typography'; import { Typography } from '@/components/ui/typography';
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { NotificationBell } from './NotificationBell'; import { NotificationBell } from './NotificationBell';
import { import {
@ -17,6 +18,7 @@ import {
FileSpreadsheet, FileSpreadsheet,
Layers, Layers,
LogOut, LogOut,
Search,
Sun, Sun,
Moon, Moon,
Building, Building,
@ -34,17 +36,34 @@ interface LayoutProps {
type SidebarUser = ReturnType<typeof useAuth>['user']; type SidebarUser = ReturnType<typeof useAuth>['user'];
type MenuItem = { type: PageType; label: string; icon: ElementType; id: string; ownerOnly?: boolean };
// ownerOnly 항목은 최고관리자에게만 노출된다(렌더 시 user.role 로 필터). // ownerOnly 항목은 최고관리자에게만 노출된다(렌더 시 user.role 로 필터).
const menuItems: { type: PageType; label: string; icon: ElementType; id: string; ownerOnly?: boolean }[] = [ // 그룹 라벨은 사이드바 섹션 헤더로 노출(접힘 상태에선 숨김).
{ type: 'DASHBOARD', label: '대시보드', icon: LayoutDashboard, id: 'sidebar-dashboard' }, const menuGroups: { label?: string; items: MenuItem[] }[] = [
{ type: 'STATISTICS', label: '통계', icon: BarChart3, id: 'sidebar-statistics' }, {
{ type: 'PRODUCTS', label: '상품관리', icon: Briefcase, id: 'sidebar-products' }, items: [
{ type: 'PARTNERS', label: '협력사관리', icon: Users, id: 'sidebar-partners' }, { type: 'DASHBOARD', label: '대시보드', icon: LayoutDashboard, id: 'sidebar-dashboard' },
{ type: 'QUOTATION', label: '견적관리', icon: FileSpreadsheet, id: 'sidebar-quotation' }, { type: 'STATISTICS', label: '통계', icon: BarChart3, id: 'sidebar-statistics' },
{ type: 'CARDS', label: '협상카드관리', icon: Layers, id: 'sidebar-cards' }, ],
{ type: 'MEMBERS', label: '회원관리', icon: UserCog, id: 'sidebar-members', ownerOnly: true }, },
{
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> = { const pageLabelMap: Record<PageType, string> = {
DASHBOARD: '대시보드', DASHBOARD: '대시보드',
STATISTICS: '통계', STATISTICS: '통계',
@ -65,10 +84,25 @@ export default function Layout({ children, currentPage, setPage, onLogout }: Lay
const [isSidebarOpen, setIsSidebarOpen] = useState(true); // 데스크톱 접기 토글 const [isSidebarOpen, setIsSidebarOpen] = useState(true); // 데스크톱 접기 토글
const [isMobileOpen, setIsMobileOpen] = useState(false); // 모바일 드로어 열림 const [isMobileOpen, setIsMobileOpen] = useState(false); // 모바일 드로어 열림
const [isProfileOpen, setIsProfileOpen] = useState(false); // 내 정보 수정 시트 const [isProfileOpen, setIsProfileOpen] = useState(false); // 내 정보 수정 시트
const [isCmdOpen, setIsCmdOpen] = useState(false); // ⌘K 빠른 이동
// 사이드바 펼침 여부(라벨/프로필 노출 기준). 모바일 드로어는 항상 펼친 상태로 본다. // 사이드바 펼침 여부(라벨/프로필 노출 기준). 모바일 드로어는 항상 펼친 상태로 본다.
const expanded = isMobileOpen || isSidebarOpen; 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 = () => { const toggleDarkMode = () => {
setIsDark(!isDark); setIsDark(!isDark);
if (!isDark) { if (!isDark) {
@ -94,7 +128,7 @@ export default function Layout({ children, currentPage, setPage, onLogout }: Lay
{/* Main Sidebar */} {/* Main Sidebar */}
<aside <aside
className={cn( 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), 데스크톱만 접기 토글
'w-60', 'w-60',
isSidebarOpen ? 'md:w-60' : 'md:w-16', isSidebarOpen ? 'md:w-60' : 'md:w-16',
@ -103,11 +137,12 @@ export default function Layout({ children, currentPage, setPage, onLogout }: Lay
'md:translate-x-0' 'md:translate-x-0'
)} )}
> >
<div> <div className="min-h-0 flex flex-col">
{/* Sidebar Brand Header */} {/* 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 && ( {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 NegoData
</Typography> </Typography>
)} )}
@ -117,8 +152,8 @@ export default function Layout({ children, currentPage, setPage, onLogout }: Lay
variant="ghost" variant="ghost"
onClick={() => setIsSidebarOpen((v) => !v)} onClick={() => setIsSidebarOpen((v) => !v)}
className={cn( className={cn(
'hidden md:inline-flex p-1 h-auto rounded text-muted-foreground', '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-muted/65' !isSidebarOpen && 'mx-auto bg-sidebar-accent'
)} )}
title={isSidebarOpen ? '메뉴 접기' : '메뉴 열기'} title={isSidebarOpen ? '메뉴 접기' : '메뉴 열기'}
> >
@ -129,7 +164,7 @@ export default function Layout({ children, currentPage, setPage, onLogout }: Lay
<Button <Button
variant="ghost" variant="ghost"
onClick={() => setIsMobileOpen(false)} 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="메뉴 닫기" title="메뉴 닫기"
> >
<X size={16} className="size-4" /> <X size={16} className="size-4" />
@ -140,32 +175,48 @@ export default function Layout({ children, currentPage, setPage, onLogout }: Lay
{expanded && <SidebarProfileCard user={user} />} {expanded && <SidebarProfileCard user={user} />}
{/* Nav Items */} {/* Nav Items */}
<nav className="mt-2 px-3 space-y-1"> <nav className="mt-1 px-3 overflow-y-auto">
{menuItems {menuGroups.map((group, gi) => {
.filter((item) => !item.ownerOnly || user?.role === '최고관리자') const items = group.items.filter((item) => !item.ownerOnly || user?.role === '최고관리자');
.map((item) => ( if (items.length === 0) return null;
<NavItem return (
key={item.type} <div key={group.label ?? gi} className="space-y-0.5">
item={item} {group.label && expanded && (
isActive={currentPage === item.type} <Typography
isOpen={expanded} variant="caption"
onClick={() => { className="block px-3 pt-4 pb-1 text-[10px] font-bold uppercase tracking-wider text-sidebar-foreground/70"
setPage(item.type); >
setIsMobileOpen(false); {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> </nav>
</div> </div>
{/* Sidebar Footer Controls */} {/* 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 <Button
id="sidebar-profile-edit" id="sidebar-profile-edit"
variant="ghost" variant="ghost"
onClick={() => setIsProfileOpen(true)} 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" /> <UserPen size={14} className="size-3.5" />
{expanded && <span>내 정보 수정</span>} {expanded && <span>내 정보 수정</span>}
@ -175,7 +226,7 @@ export default function Layout({ children, currentPage, setPage, onLogout }: Lay
<Button <Button
variant="ghost" variant="ghost"
onClick={toggleDarkMode} 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" />} {isDark ? <Sun size={14} className="text-warning size-3.5" /> : <Moon size={14} className="size-3.5" />}
{expanded && <span>{isDark ? '라이트 모드' : '다크 모드'}</span>} {expanded && <span>{isDark ? '라이트 모드' : '다크 모드'}</span>}
@ -186,7 +237,7 @@ export default function Layout({ children, currentPage, setPage, onLogout }: Lay
id="header-logout-button" id="header-logout-button"
variant="ghost" variant="ghost"
onClick={onLogout} 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" /> <LogOut size={14} className="size-3.5" />
{expanded && <span>시스템 로그아웃</span>} {expanded && <span>시스템 로그아웃</span>}
@ -202,9 +253,9 @@ export default function Layout({ children, currentPage, setPage, onLogout }: Lay
isSidebarOpen ? 'md:pl-60' : 'md:pl-16' isSidebarOpen ? 'md:pl-60' : 'md:pl-16'
)} )}
> >
{/* Global Header */} {/* Global Header — 좌: 페이지명 / 중: 빠른 이동(⌘K) / 우: 알림·계정 메타 */}
<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"> <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-4 min-w-0"> <div className="flex items-center gap-2 md:gap-3 min-w-0">
{/* 모바일 햄버거 */} {/* 모바일 햄버거 */}
<Button <Button
variant="ghost" variant="ghost"
@ -220,7 +271,24 @@ export default function Layout({ children, currentPage, setPage, onLogout }: Lay
</Typography> </Typography>
</div> </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 /> <NotificationBell />
<HeaderMeta user={user} today={today} /> <HeaderMeta user={user} today={today} />
</div> </div>
@ -237,11 +305,100 @@ export default function Layout({ children, currentPage, setPage, onLogout }: Lay
</footer> </footer>
</div> </div>
<CommandMenu
open={isCmdOpen}
onOpenChange={setIsCmdOpen}
items={visibleItems}
currentPage={currentPage}
onSelect={(type) => {
setPage(type);
setIsCmdOpen(false);
}}
/>
{isProfileOpen && <ProfileSheet open onClose={() => setIsProfileOpen(false)} />} {isProfileOpen && <ProfileSheet open onClose={() => setIsProfileOpen(false)} />}
</div> </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({ function InfoRow({
label, label,
@ -256,8 +413,8 @@ function InfoRow({
}) { }) {
return ( return (
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<Typography variant="caption">{label}</Typography> <Typography variant="caption" className="text-sidebar-foreground">{label}</Typography>
<Typography variant="caption" title={title} className={cn('font-bold text-foreground', valueClassName)}> <Typography variant="caption" title={title} className={cn('font-bold text-foreground/90', valueClassName)}>
{value} {value}
</Typography> </Typography>
</div> </div>
@ -267,9 +424,9 @@ function InfoRow({
/** 사이드바 사용자 프로필 카드 */ /** 사이드바 사용자 프로필 카드 */
function SidebarProfileCard({ user }: { user: SidebarUser }) { function SidebarProfileCard({ user }: { user: SidebarUser }) {
return ( 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="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-border/50"> <div className="flex items-center gap-2 pb-1.5 border-b border-sidebar-border/60">
<Building size={13} className="text-primary" /> <Building size={13} className="text-sidebar-primary" />
<Typography variant="caption" className="font-bold text-foreground truncate max-w-[140px]" title="소속 고객사"> <Typography variant="caption" className="font-bold text-foreground truncate max-w-[140px]" title="소속 고객사">
{user?.company} {user?.company}
</Typography> </Typography>
@ -281,11 +438,11 @@ function SidebarProfileCard({ user }: { user: SidebarUser }) {
label="이메일:" label="이메일:"
value={user?.email} value={user?.email}
title={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" /> <InfoRow label="연락처:" value={user?.contact} valueClassName="font-mono" />
<div className="flex justify-between items-center pt-1 border-t border-border/20"> <div className="flex justify-between items-center pt-1 border-t border-sidebar-border/60">
<Typography variant="caption">권한등급:</Typography> <Typography variant="caption" className="text-sidebar-foreground">권한등급:</Typography>
<Badge variant="secondary" className="text-[10px]">{user?.role}</Badge> <Badge variant="secondary" className="text-[10px]">{user?.role}</Badge>
</div> </div>
</div> </div>
@ -300,7 +457,7 @@ function NavItem({
isOpen, isOpen,
onClick, onClick,
}: { }: {
item: { type: PageType; label: string; icon: ElementType; id: string }; item: MenuItem;
isActive: boolean; isActive: boolean;
isOpen: boolean; isOpen: boolean;
onClick: () => void; onClick: () => void;
@ -312,13 +469,13 @@ function NavItem({
variant="ghost" variant="ghost"
onClick={onClick} onClick={onClick}
className={cn( 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 isActive
? 'bg-secondary text-foreground border-l-2 border-primary pl-2 hover:bg-secondary hover:text-foreground' ? 'bg-sidebar-accent font-bold text-foreground hover:bg-sidebar-accent hover:text-foreground dark:hover:bg-sidebar-accent'
: 'text-muted-foreground hover:bg-muted/80' : '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>} {isOpen && <span className="truncate">{item.label}</span>}
</Button> </Button>
); );
@ -340,7 +497,7 @@ function HeaderMeta({ user, today }: { user: SidebarUser; today: string }) {
as="span" as="span"
variant="caption" variant="caption"
id="header-company-name" 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} {user?.company}
</Typography> </Typography>

View File

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

View File

@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
<div <div
data-slot="card" data-slot="card"
className={cn( 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 className
)} )}
{...props} {...props}

View File

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

View File

@ -1,4 +1,5 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { X } from 'lucide-react'; import { X } from 'lucide-react';
import { Typography } from '@/components/ui/typography'; import { Typography } from '@/components/ui/typography';
@ -12,16 +13,19 @@ type SheetProps = {
// 우측 슬라이드 드로어 껍데기. 백드롭 + 패널 + 헤더(액센트바·타이틀·닫기)만 담당하고 // 우측 슬라이드 드로어 껍데기. 백드롭 + 패널 + 헤더(액센트바·타이틀·닫기)만 담당하고
// 폼/내용은 children으로 받는다. ProductFormSheet·PartnerFormSheet 공통 셸. // 폼/내용은 children으로 받는다. ProductFormSheet·PartnerFormSheet 공통 셸.
// 백드롭 클릭 시 닫힘(기존 FormSheet 동작 유지). // 백드롭 클릭 시 닫힘(기존 FormSheet 동작 유지).
// body 로 포털 — 페이지 스크롤 컨테이너(main) 안에 렌더되면 시트 스크롤이 끝에서
// 부모(main)로 체이닝돼 모바일에서 뒤 화면이 스크롤되므로, DOM 계보 자체를 분리한다.
export function Sheet({ open, title, onClose, children }: SheetProps) { export function Sheet({ open, title, onClose, children }: SheetProps) {
if (!open) return null; 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="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} /> <div className="flex-1 cursor-pointer" onClick={onClose} />
{/* 우측 드로어 패널 (패널 자체가 세로 스크롤 — justify-* 는 스크롤 시작점을 가려 안 씀) */} {/* 우측 드로어 패널 (패널 자체가 세로 스크롤 — 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"> 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>
{/* 헤더 */} {/* 헤더 */}
<div className="flex items-center justify-between pb-4 border-b border-border"> <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} {children}
</div> </div>
</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 { HelpCircle } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Typography } from '@/components/ui/typography'; import { Typography } from '@/components/ui/typography';
import { useAuth } from '@/features/auth/useAuth'; import { useAuth } from '@/features/auth/useAuth';
// 대시보드 상단 환영 배너. 제품 소개는 온보딩(이용안내 모달)이 맡고, 여기선 누구의/어느 회사 대시보드인지만 보여준다. // 대시보드 상단 인사 행. 제품 소개는 온보딩(이용안내 모달)이 맡고, 여기선 인사+안내 진입점만 —
// 배지=현재 로그인 회사, 제목=사용자 이름 인사. 목업의 그라데이션 대신 primary 틴트 패널로 절제. // 배너/그라데이션 없이 텍스트가 위계를 만든다(시안 1 "인디고 콘솔" 문법). 회사명은 헤더가 이미 보여줘 생략.
export function DashboardHero({ onOpenGuide }: { onOpenGuide: () => void }) { export function DashboardHero({ onOpenGuide }: { onOpenGuide: () => void }) {
const { user } = useAuth(); const { user } = useAuth();
const name = user?.name?.trim(); const name = user?.name?.trim();
const company = user?.company?.trim();
return ( 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="flex items-center justify-between gap-4">
<div className="space-y-2"> <div className="min-w-0">
{company && <Badge>{company}</Badge>} <Typography variant="h3" as="h1">{name ? `${name}님, 환영합니다` : '환영합니다'}</Typography>
<Typography variant="h2">{name ? `${name}님, 환영합니다` : '환영합니다'}</Typography> <Typography variant="muted" className="mt-0.5 text-[13px]">
<Typography variant="muted" className="max-w-2xl"> 견적 생성부터 초청메일·협상·마감·낙찰까지, 지금 처리할 일을 한눈에 봅니다.
견적 생성부터 초청메일·협상·마감·낙찰까지, 지금 처리할 일을 아래에서 한눈에 봅니다.
</Typography> </Typography>
</div> </div>
<Button variant="outline" onClick={onOpenGuide} className="shrink-0"> <Button variant="outline" size="sm" onClick={onOpenGuide} className="shrink-0">
<HelpCircle /> <HelpCircle />
이용안내 이용안내
</Button> </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 { Typography } from '@/components/ui/typography';
import type { DashboardScope } from '@/api/generated/model/dashboardScope'; import type { DashboardScope } from '@/api/generated/model/dashboardScope';
import { KpiTile } from './KpiTile'; import { KpiStrip } from './KpiStrip';
import { DeadlineWidget } from './DeadlineWidget'; import { WorkTable } from './WorkTable';
import { EmailUnsentWidget } from './EmailUnsentWidget';
import { RefWidget } from './RefWidget';
// 스코프 1개(회사 전체 / 내 견적) 블록: 라벨 + 요약 KPI + 액션 위젯. // 스코프 1개(회사 전체 / 내 견적) 블록: 섹션 라벨 + KPI 스트립 + 처리 필요 테이블(시안 1 문법).
// KPI = 리스트 없는 순수 지표만.
// 위젯 = 관리자가 직접 처리해야 하는 것만.
export function ScopeSection({ export function ScopeSection({
label, label,
scope, scope,
@ -19,24 +14,22 @@ export function ScopeSection({
onOpen: (qtId: string) => void; onOpen: (qtId: string) => void;
}) { }) {
const s = scope ?? {}; const s = scope ?? {};
const pending =
(s.deadline_soon?.total ?? 0) + (s.email_unsent?.total ?? 0) + (s.ruptured?.total ?? 0);
return ( return (
<div className="space-y-3"> <section className="space-y-3">
<div className="flex items-center gap-2"> <Typography variant="label" className="block text-muted-foreground">
<span className="h-4 w-1 rounded bg-primary" /> {label}
<Typography variant="h3">{label}</Typography> </Typography>
</div> <KpiStrip
items={[
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3"> { label: '진행중', value: s.in_progress ?? 0 },
<KpiTile label="진행중" value={s.in_progress ?? 0} icon={Activity} tone="blue" /> { label: '이번 달 생성', value: s.this_month ?? 0 },
<KpiTile label="이번 달 생성" value={s.this_month ?? 0} icon={CalendarPlus} tone="purple" /> { label: '누적 낙찰', value: s.awarded?.total ?? 0 },
<KpiTile label="누적 낙찰" value={s.awarded?.total ?? 0} icon={Award} tone="emerald" /> { label: '처리 필요', value: pending, alert: true },
</div> ]}
/>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> <WorkTable scope={s} onOpen={onOpen} />
<DeadlineWidget data={s.deadline_soon} onOpen={onOpen} /> </section>
<EmailUnsentWidget data={s.email_unsent} onOpen={onOpen} />
<RefWidget title="개찰 (낙찰자 미정 마감)" icon={Ban} tone="amber" data={s.ruptured} onOpen={onOpen} />
</div>
</div>
); );
} }

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 { useState } from 'react';
import { createPortal } from 'react-dom';
import { CheckCircle2, X, UserCheck, MessageSquare, Layers, RefreshCw } from 'lucide-react'; import { CheckCircle2, X, UserCheck, MessageSquare, Layers, RefreshCw } from 'lucide-react';
import { Typography } from '@/components/ui/typography'; import { Typography } from '@/components/ui/typography';
import { import {
@ -141,7 +142,9 @@ export function QuotationDetailSheet({
{ id: 'cards', label: `협상 카드 (${serverCards.length})`, icon: Layers }, { 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="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} /> <div className="flex-1 cursor-pointer" onClick={onClose} />
@ -213,7 +216,7 @@ export function QuotationDetailSheet({
{showHeaderCards ? ( {showHeaderCards ? (
<div <div
style={{ flex: '2 1 0%' }} 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 <DrawerHeaderCards
quotation={quotation} quotation={quotation}
@ -270,7 +273,7 @@ export function QuotationDetailSheet({
</div> </div>
{/* Tab content */} {/* 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' && ( {activeTab === 'status' && (
<SessionsStatusTab <SessionsStatusTab
sessionViews={sessionViews} sessionViews={sessionViews}
@ -334,6 +337,7 @@ export function QuotationDetailSheet({
onClose={() => setRegenOpen(false)} 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 { useOverlayRouter } from '@/lib/useOverlayRouter';
import { showToast } from '@/lib/notify'; import { showToast } from '@/lib/notify';
import { confirm } from '@/lib/confirm'; import { confirm } from '@/lib/confirm';
import { PageContainer } from '@/components/layout/PageContainer'; 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 { Button } from '@/components/ui/button';
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu'; import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu';
import { TablePagination } from '@/components/ui/table-pagination'; import { TablePagination } from '@/components/ui/table-pagination';
@ -62,47 +62,44 @@ export default function CardsPage() {
return ( return (
<PageContainer> <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"> <PageToolbar
<div className="flex items-center gap-3"> actions={
<BookOpen className="text-muted-foreground" size={20} /> <>
<div> <DropdownMenu>
<Typography variant="h3" className="text-xs">협상카드 및 와일드카드 관리</Typography> <DropdownMenuTrigger render={<Button variant="outline" />}>
<Typography variant="muted" className="text-[10px] mt-0.5"> <FileSpreadsheet />
우리 회사 카드와 기본 제공(공용) 카드를 함께 조회합니다. 공용 카드는 수정·삭제할 수 없습니다. 엑셀업로드
</Typography> <ChevronDown />
</div> </DropdownMenuTrigger>
</div> <DropdownMenuContent>
<DropdownMenuItem onClick={() => overlay.open('modal', 'excel')}>
<Upload />
일괄 업로드
</DropdownMenuItem>
<DropdownMenuItem onClick={downloadCardTemplate}>
<Download />
양식 다운로드
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex items-center gap-2"> <Button id="card-register-btn" onClick={openCreate}>
<DropdownMenu> <Plus />
<DropdownMenuTrigger render={<Button variant="outline" />}> 신규 카드 등록
<FileSpreadsheet /> </Button>
엑셀업로드 </>
<ChevronDown /> }
</DropdownMenuTrigger> >
<DropdownMenuContent> <SearchInput
<DropdownMenuItem onClick={() => overlay.open('modal', 'excel')}> id="card-search"
<Upload /> value={list.search}
일괄 업로드 onChange={(e) => list.setSearch(e.target.value)}
</DropdownMenuItem> onKeyDown={(e) => e.key === 'Enter' && list.submitSearch()}
<DropdownMenuItem onClick={downloadCardTemplate}> onClear={list.clearSearch}
<Download /> placeholder="전체 카드이름, 카드번호, 코드 검색..."
양식 다운로드 />
</DropdownMenuItem> </PageToolbar>
</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>
{/* Primary Navigation Tab */} {/* Primary Navigation Tab */}
<div className="flex border-b border-border"> <div className="flex border-b border-border">
@ -122,15 +119,10 @@ export default function CardsPage() {
))} ))}
</div> </div>
{/* Filtering Search Bar */} {/* 배너에 있던 공용 카드 안내는 캡션 한 줄로 유지 */}
<SearchInput <Typography variant="caption" className="-mt-3 block">
id="card-search" 우리 회사 카드와 기본 제공(공용) 카드를 함께 조회합니다. 공용 카드는 수정·삭제할 수 없습니다.
value={list.search} </Typography>
onChange={(e) => list.setSearch(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && list.submitSearch()}
onClear={list.clearSearch}
placeholder="전체 카드이름, 카드번호, 코드 검색..."
/>
<CardTable <CardTable
data={cards} data={cards}

View File

@ -3,7 +3,6 @@ import { useNavigate, useSearchParams } from 'react-router';
import { User, Lock } from 'lucide-react'; import { User, Lock } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Card } from '@/components/ui/card';
import { Typography } from '@/components/ui/typography'; import { Typography } from '@/components/ui/typography';
import { useAuth } from '../features/auth/useAuth'; import { useAuth } from '../features/auth/useAuth';
import { showToast } from '@/lib/notify'; import { showToast } from '@/lib/notify';
@ -47,75 +46,104 @@ export default function LoginPage() {
}; };
return ( return (
<div className="min-h-screen flex items-center justify-center bg-background px-4"> <div className="min-h-screen grid bg-background lg:grid-cols-[1.1fr_1fr]">
<Card className="w-full max-w-sm gap-0 p-8 transition-all hover:border-foreground/10"> {/* 브랜드 패널 — 데스크톱 전용. 첫인상 화면이라 제품 메시지를 여기서 전달한다 */}
<div className="hidden lg:flex flex-col justify-between bg-sidebar border-r border-sidebar-border p-10">
<div className="flex flex-col items-center mb-6"> <BrandMark />
<Typography variant="h2" as="h1">NegoData</Typography> <div className="max-w-md space-y-3">
<Typography variant="mono" className="mt-1"> <Typography variant="h2" className="text-balance">
Negosium Admin Console AI가 협력사와 대신, 스마트하게 협상합니다
</Typography>
<Typography variant="muted">
견적 생성부터 초청 메일·협상·마감·낙찰까지 — 팀의 구매 협상을 한 곳에서 관리하세요.
</Typography> </Typography>
</div> </div>
<Typography variant="mono">Negosium Admin Console</Typography>
</div>
<form onSubmit={handleSubmit} className="space-y-4"> {/* 로그인 폼 */}
<div className="space-y-1.5"> <div className="flex items-center justify-center px-4 py-12">
<Typography as="label" variant="label" htmlFor="login-id"> <div className="w-full max-w-sm">
아이디 <div className="mb-8 lg:hidden">
</Typography> <BrandMark />
<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> </div>
<div className="space-y-1.5"> <Typography variant="h3" as="h1">
<Typography as="label" variant="label" htmlFor="login-password"> 로그인
비밀번호 </Typography>
</Typography> <Typography variant="muted" className="mt-1 mb-6">
<div className="relative"> 계정으로 NegoData 콘솔에 접속합니다.
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" /> </Typography>
<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 */} <form onSubmit={handleSubmit} className="space-y-4">
{error && ( <div className="space-y-1.5">
<div className="p-3 rounded bg-destructive/10 border border-destructive/20 text-xs text-destructive"> <Typography as="label" variant="label" htmlFor="login-id">
{error} 아이디
</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> </div>
)}
<Button <div className="space-y-1.5">
type="submit" <Typography as="label" variant="label" htmlFor="login-password">
disabled={loading} 비밀번호
size="lg" </Typography>
className="w-full" <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" />
{loading ? ( <Input
<span className="inline-block animate-spin h-4 w-4 border-2 border-primary-foreground border-t-transparent rounded-full" /> 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> </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 { useNavigate } from 'react-router';
import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query'; import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
import { Trophy, RefreshCw, XCircle, Bell, CheckCheck, FilePlus2 } from 'lucide-react'; import { Trophy, RefreshCw, XCircle, Bell, CheckCheck, FilePlus2 } from 'lucide-react';
import { PageContainer } from '@/components/layout/PageContainer'; 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 { Typography } from '@/components/ui/typography';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { import {
@ -15,13 +18,16 @@ import type { NotificationData } from '@/api/generated/model/notificationData';
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
type Tab = 'all' | 'unread';
export default function NotificationsPage() { export default function NotificationsPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [tab, setTab] = useState<Tab>('all');
// offset 페이징(page/size)을 그대로 쓰는 무한 스크롤: page 를 1→2→3 누적. // offset 페이징(page/size)을 그대로 쓰는 무한 스크롤: page 를 1→2→3 누적.
// 다음 페이지 여부는 응답의 total/page/size 로 판정(page*size < total). // 다음 페이지 여부는 응답의 total/page/size 로 판정(page*size < total).
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({ const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteQuery({
queryKey: ['/v1/notification/list', 'infinite'], queryKey: ['/v1/notification/list', 'infinite'],
queryFn: ({ pageParam }) => listNotifications({ page: pageParam, size: PAGE_SIZE }), queryFn: ({ pageParam }) => listNotifications({ page: pageParam, size: PAGE_SIZE }),
initialPageParam: 1, initialPageParam: 1,
@ -37,6 +43,9 @@ export default function NotificationsPage() {
const items = data?.pages.flatMap((p) => p.notifications ?? []) ?? []; const items = data?.pages.flatMap((p) => p.notifications ?? []) ?? [];
const unread = data?.pages[0]?.unread ?? 0; 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'] }); const invalidate = () => queryClient.invalidateQueries({ queryKey: ['/v1/notification/list'] });
@ -61,71 +70,62 @@ export default function NotificationsPage() {
}, [hasNextPage, isFetchingNextPage, fetchNextPage]); }, [hasNextPage, isFetchingNextPage, fetchNextPage]);
return ( return (
<PageContainer> <PageContainer className="mx-auto w-full max-w-3xl space-y-3">
<div className="flex items-center justify-between p-4 rounded-lg border border-border bg-card"> {/* 필터 탭 + 모두 읽음 */}
<div className="flex items-center gap-2"> <div className="flex items-center justify-between gap-3">
<Bell size={18} className="text-foreground" /> <div className="flex items-center rounded-lg bg-muted p-0.5">
<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> { 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> </div>
<button <Button
type="button" variant="outline"
size="sm"
disabled={unread === 0 || readAll.isPending}
onClick={() => readAll.mutate(undefined, { onSuccess: invalidate })} 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} /> <CheckCheck />
<Typography as="span" variant="small" className="text-xs text-inherit">모두 읽음</Typography> 모두 읽음
</button> </Button>
</div> </div>
<div className="rounded-lg border border-border bg-card divide-y divide-border overflow-hidden"> {/* 알림 리스트 — 날짜 그룹(오늘/어제/이전) + 컴팩트 행 */}
{items.length === 0 ? ( <Card className="gap-0 overflow-hidden py-0">
<div className="p-10 text-center"> {isLoading ? (
<Typography as="p" variant="small" className="text-muted-foreground">알림이 없습니다.</Typography> <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> </div>
) : ( ) : (
items.map((n) => { <NotificationList items={visible} onOpen={openOne} />
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>
);
})
)} )}
</div> </Card>
{hasNextPage && ( {hasNextPage && (
<div ref={sentinelRef} className="py-3 text-center"> <div ref={sentinelRef} className="py-2 text-center">
{isFetchingNextPage && ( {isFetchingNextPage && (
<Typography as="span" variant="small" className="text-muted-foreground">불러오는 중…</Typography> <Typography as="span" variant="caption">불러오는 중…</Typography>
)} )}
</div> </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'. 실패 시 '-'. // 서버 시각(타임존 표식 없는 UTC) → 한국시간 'YYYY-MM-DD HH:mm'. 실패 시 '-'.
function fmtKst(s?: string | null): string { function fmtKst(s?: string | null): string {
if (!s) return '-'; if (!s) return '-';
@ -152,43 +269,63 @@ function qtName(d: Record<string, unknown>): string {
return name || num || '견적'; return name || num || '견적';
} }
// 알림 1건 → 카드 3단 표기값. // 유형 pill 색 — 상태색 매핑(생성=브랜드 인디고 · 낙찰=녹 · 개찰/재생성=주황)과 동일 체계
// event : 이벤트 제목(낙찰/재생성/결렬/생성) — 한 줄에 쭉 늘어놓지 않고 분리 const PILL_TONE = {
// line : 무슨 견적인지(건명) + 결과 indigo: 'bg-primary/10 text-primary',
// number: 견적번호(메타줄 보조 표기). icon/tone 은 유형별 색. emerald: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-300',
function render(n: NotificationData): { icon: ReactNode; tone: string; event: string; line: string; number: string } { 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 d = (n.data ?? {}) as Record<string, unknown>;
const name = qtName(d); const name = qtName(d);
const number = (d.qt_number as string) || ''; const number = (d.qt_number as string) || '';
switch (n.type) { switch (n.type) {
case NotificationType.CREATED: 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: case NotificationType.SUCCESS:
// 자동 낙찰과 담당자 직접 낙찰(data.manual)은 같은 SUCCESS — 문구로만 '직접'을 구분한다. // 자동 낙찰과 담당자 직접 낙찰(data.manual)은 같은 SUCCESS — 문구로만 '직접'을 구분한다.
return { return {
icon: <Trophy size={18} />, tone: 'text-emerald-600', event: d.manual ? '견적 낙찰 · 직접' : '견적 낙찰', icon: <Trophy size={15} />,
line: `${name} — ${d.winner_name ?? '-'} ${Number(d.winner_price ?? 0).toLocaleString()}원`, pill: d.manual ? '낙찰 · 직접' : '낙찰',
pillCls: PILL_TONE.emerald,
name,
detail: `${d.winner_name ?? '-'} · ${Number(d.winner_price ?? 0).toLocaleString()}원`,
number, number,
}; };
case NotificationType.REGENERATED: case NotificationType.REGENERATED:
return { return {
icon: <RefreshCw size={18} />, tone: 'text-amber-600', icon: <RefreshCw size={15} />,
event: `견적 재생성 · ${d.reason === 'equal' ? '동가' : '전원 미참여'}`, pill: '재생성',
line: `${name} — ${d.next_round ?? ''}차로 다시 생성`, pillCls: PILL_TONE.amber,
name,
detail: `${d.reason === 'equal' ? '동가' : '전원 미참여'} · ${d.next_round ?? ''}차로 다시 생성`,
number, number,
}; };
case NotificationType.FAILURE: case NotificationType.FAILURE:
// 결렬 폐지 → '개찰'(낙찰자 미정으로 마감). reason 으로 사유만 부기. // 결렬 폐지 → '개찰'(낙찰자 미정으로 마감). reason 으로 사유만 부기.
return { return {
icon: <XCircle size={18} />, tone: 'text-amber-600', event: '견적 개찰', icon: <XCircle size={15} />,
line: `${name} — 낙찰자 미정 (${ pill: '개찰',
pillCls: PILL_TONE.amber,
name,
detail: `낙찰자 미정 · ${
({ price: '목표가 초과', equal: '동가', rejected: '협상거부', no_show: '전원 미응찰' } as Record<string, string>)[ ({ price: '목표가 초과', equal: '동가', rejected: '협상거부', no_show: '전원 미응찰' } as Record<string, string>)[
String(d.reason) String(d.reason)
] ?? '마감' ] ?? '마감'
})`, }`,
number, number,
}; };
default: 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 { keepPreviousData } from '@tanstack/react-query';
import { useOverlayRouter } from '@/lib/useOverlayRouter'; import { useOverlayRouter } from '@/lib/useOverlayRouter';
import { PageContainer } from '@/components/layout/PageContainer'; import { PageContainer } from '@/components/layout/PageContainer';
@ -72,23 +74,22 @@ export default function QuotationPage() {
<PageToolbar <PageToolbar
actions={ actions={
<> <>
<button <Button id="quotation-create-btn" onClick={() => overlay.open('create')}>
id="quotation-settings-btn" <Plus />
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" </Button>
>
<Settings size={14} />
<span>견적 세팅</span>
</button>
<button <DropdownMenu>
id="quotation-create-btn" <DropdownMenuTrigger render={<Button variant="outline" size="icon" aria-label="추가 작업" />}>
onClick={() => overlay.open('create')} <MoreHorizontal />
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" </DropdownMenuTrigger>
> <DropdownMenuContent>
<Plus size={15} /> <DropdownMenuItem id="quotation-settings-btn" onClick={() => overlay.open('settings')}>
<span>신규 견적 등록</span> <Settings />
</button> 견적 세팅
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</> </>
} }
> >

View File

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