[feat] negodata/front: 디자인 리뉴얼 1차 — 인디고 콘솔 토큰·⌘K 크롬·대시보드/로그인/알림함 개편, 툴바 통일, 시트 스크롤 수정
This commit is contained in:
parent
3d515055fb
commit
25e13d7b01
@ -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 }[] = [
|
||||
// 그룹 라벨은 사이드바 섹션 헤더로 노출(접힘 상태에선 숨김).
|
||||
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' },
|
||||
{ type: 'MEMBERS', label: '회원관리', icon: UserCog, id: 'sidebar-members', ownerOnly: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
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,10 +175,23 @@ 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) => (
|
||||
<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}
|
||||
@ -155,17 +203,20 @@ export default function Layout({ children, currentPage, setPage, onLogout }: Lay
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,19 +62,10 @@ 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>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 플랫 툴바 — 다른 목록 페이지와 동일 문법(검색 좌측, 주버튼+⋯ 우측) */}
|
||||
<PageToolbar
|
||||
actions={
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={<Button variant="outline" />}>
|
||||
<FileSpreadsheet />
|
||||
@ -93,16 +84,22 @@ export default function CardsPage() {
|
||||
</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"
|
||||
<Button id="card-register-btn" onClick={openCreate}>
|
||||
<Plus />
|
||||
신규 카드 등록
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Plus size={15} />
|
||||
<span>신규 카드 등록</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<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}
|
||||
|
||||
@ -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,15 +46,34 @@ 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>
|
||||
|
||||
{/* 로그인 폼 */}
|
||||
<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>
|
||||
|
||||
<Typography variant="h3" as="h1">
|
||||
로그인
|
||||
</Typography>
|
||||
<Typography variant="muted" className="mt-1 mb-6">
|
||||
계정으로 NegoData 콘솔에 접속합니다.
|
||||
</Typography>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
@ -114,8 +132,18 @@ export default function LoginPage() {
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
</Card>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
</div>
|
||||
<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={() => 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"
|
||||
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',
|
||||
)}
|
||||
>
|
||||
<CheckCheck size={14} />
|
||||
<Typography as="span" variant="small" className="text-xs text-inherit">모두 읽음</Typography>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={unread === 0 || readAll.isPending}
|
||||
onClick={() => readAll.mutate(undefined, { onSuccess: invalidate })}
|
||||
>
|
||||
<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'
|
||||
<NotificationList items={visible} onOpen={openOne} />
|
||||
)}
|
||||
>
|
||||
<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 && (
|
||||
<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 };
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
}
|
||||
>
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user