[feat] front: List/Chat 레이아웃 + 목록 필터 섹션 구현

- layouts: 공통 2분할 레이아웃 MainLayout(좌 사이드바/우 메인, 로고 헤더,
  헤더 아래 콘텐츠 면) + 다크 헤더 바 MainHeaderBar 분리
- pages: ListPage/ChatPage 를 MainLayout 기반으로 구성(견적 선택/협상 잔여 시간 헤더)
- features/list: 필터 섹션 추가 — ListFilter(요소)/FilterSection(컨테이너)/
  filterOptions(데이터)/useListFilterStore(상태), SidebarFooter 분리
- deps: lucide-react 추가(필터 화살표·목록 이동 아이콘)
- refactor: 모든 import 를 @ alias 로 통일
- fix: Button secondary variant hover(=accent 동일색) 및 필터 화살표 hover 색상 보정

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
민헌 2026-06-16 16:19:57 +09:00
parent 9de140abd6
commit 6da8976a20
19 changed files with 374 additions and 15 deletions

View File

@ -10,6 +10,7 @@
"dependencies": {
"@tanstack/react-query": "^5.101.0",
"axios": "^1.18.0",
"lucide-react": "^1.18.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-router": "^7.17.0",
@ -2816,6 +2817,15 @@
"yallist": "^3.0.2"
}
},
"node_modules/lucide-react": {
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.18.0.tgz",
"integrity": "sha512-LZDb7H/0YfM+RJncD0hDQRCAu+vSGODqpe35TuVI8EuXaRjkczbsx7p8dY4J87F/MUSj6bpYqeI8nw8qXaAdmA==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",

View File

@ -15,6 +15,7 @@
"dependencies": {
"@tanstack/react-query": "^5.101.0",
"axios": "^1.18.0",
"lucide-react": "^1.18.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-router": "^7.17.0",

View File

@ -1,5 +1,5 @@
import { type ComponentProps } from 'react'
import { cn } from './cn'
import { cn } from '@/components/cn'
export type ButtonVariant =
| 'primary'
@ -19,7 +19,7 @@ const base =
// hover 는 한 단계 톤다운(brand-700) — 디자인 선호
const variantClass: Record<ButtonVariant, string> = {
primary: 'bg-primary text-primary-foreground hover:bg-brand-700',
secondary: 'bg-secondary text-secondary-foreground hover:bg-accent',
secondary: 'bg-secondary text-secondary-foreground hover:bg-border',
outline:
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
ghost: 'hover:bg-accent hover:text-accent-foreground',

View File

@ -1,5 +1,5 @@
import { type ComponentProps } from 'react'
import { cn } from './cn'
import { cn } from '@/components/cn'
export function Input({ type = 'text', className, ...props }: ComponentProps<'input'>) {
return (

View File

@ -1,5 +1,5 @@
import { type ComponentProps } from 'react'
import { cn } from './cn'
import { cn } from '@/components/cn'
import logoColor from '@/assets/imarketkorea-logo.png'
import logoWhite from '@/assets/imarketkorea-logo-white.png'

View File

@ -1,6 +1,6 @@
export { cn } from './cn'
export { Button } from './Button'
export type { ButtonProps, ButtonVariant, ButtonSize } from './Button'
export { Input } from './Input'
export { Logo } from './Logo'
export type { LogoProps, LogoVariant } from './Logo'
export { cn } from '@/components/cn'
export { Button } from '@/components/Button'
export type { ButtonProps, ButtonVariant, ButtonSize } from '@/components/Button'
export { Input } from '@/components/Input'
export { Logo } from '@/components/Logo'
export type { LogoProps, LogoVariant } from '@/components/Logo'

View File

@ -1 +1 @@
export { LoginForm } from './components/LoginForm'
export { LoginForm } from '@/features/auth/components/LoginForm'

View File

@ -0,0 +1,69 @@
import { useState } from 'react'
import { ChevronDown } from 'lucide-react'
import { cn } from '@/components'
export interface ListFilterProps {
title: string
items: readonly string[]
selectedItem: string | null
onItemClick: (item: string) => void
defaultOpen?: boolean
}
export function ListFilter({
title,
items,
selectedItem,
onItemClick,
defaultOpen = true,
}: ListFilterProps) {
const [open, setOpen] = useState(defaultOpen)
return (
<div className="flex flex-col w-full">
<div className="flex justify-between items-center h-12 pl-3 pr-1">
<h3 className="text-2xl font-bold leading-[30px] text-foreground whitespace-nowrap">
{title}
</h3>
<button
type="button"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
className="w-10 h-10 flex items-center justify-center cursor-pointer text-muted-foreground hover:text-foreground transition-colors"
>
<ChevronDown
size={24}
className={cn('transition-transform duration-300', !open && 'rotate-180')}
/>
</button>
</div>
{/* grid-rows 0fr→1fr 로 높이를 부드럽게 펼침 */}
<div
className={cn(
'grid transition-[grid-template-rows,opacity] duration-300 ease-out',
open ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0',
)}
>
<div className="overflow-hidden">
<div className="flex flex-col">
{items.map((item) => (
<button
key={item}
type="button"
onClick={() => onItemClick(item)}
className={cn(
'h-11 px-3 flex items-center rounded-lg text-left text-base text-foreground',
'whitespace-nowrap cursor-pointer transition-colors duration-200',
selectedItem === item ? 'bg-secondary' : 'hover:bg-secondary/60',
)}
>
{item}
</button>
))}
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,21 @@
import { Button } from '@/components'
/** 좌측 사이드바 하단: 공급사명 + 로그아웃 */
export function SidebarFooter() {
return (
<div className="flex w-full h-[60px] py-3 px-8 gap-3 items-center">
<div className="flex-1 flex items-center min-w-0 text-sm text-foreground">
{/* TODO: 공급사명 (auth store 연동) */}
<span className="truncate">-</span>
</div>
<Button
variant="secondary"
size="sm"
className="w-[61px] px-2 rounded-[4px] text-xs flex-shrink-0"
// TODO: 로그아웃 mutation 연동
>
</Button>
</div>
)
}

View File

@ -0,0 +1,35 @@
import { ListFilter } from '@/features/list/components/ListFilter'
import { FILTER_GROUPS, type FilterKey } from '@/features/list/model/filterOptions'
import { useListFilterStore } from '@/features/list/model/useListFilterStore'
export function FilterSection() {
const store = useListFilterStore()
// 필터 키 → store 상태/핸들러 매핑
const selected: Record<FilterKey, string | null> = {
type: store.selectedType,
status: store.selectedStatus,
deadline: store.selectedDeadline,
}
const toggle: Record<FilterKey, (item: string) => void> = {
type: store.toggleType,
status: store.toggleStatus,
deadline: store.toggleDeadline,
}
return (
<div className="flex flex-1 pt-8 pr-2 pl-5 overflow-hidden bg-background">
<div className="flex flex-col gap-4 pr-2 w-full overflow-y-auto">
{FILTER_GROUPS.map((group) => (
<ListFilter
key={group.key}
title={group.title}
items={group.items}
selectedItem={selected[group.key]}
onItemClick={toggle[group.key]}
/>
))}
</div>
</div>
)
}

View File

@ -0,0 +1,4 @@
// 공개 API: 페이지에서 쓰는 것만 노출 (ListFilter/FILTER_GROUPS 등은 feature 내부 구현)
export { FilterSection } from '@/features/list/containers/FilterSection'
export { SidebarFooter } from '@/features/list/components/SidebarFooter'
export { useListFilterStore } from '@/features/list/model/useListFilterStore'

View File

@ -0,0 +1,17 @@
export type FilterKey = 'type' | 'status' | 'deadline'
export interface FilterGroup {
key: FilterKey
title: string
items: readonly string[]
}
export const FILTER_GROUPS: readonly FilterGroup[] = [
{ key: 'type', title: '구분', items: ['재견적', '재협상'] },
{
key: 'status',
title: '상태',
items: ['협상생성', '협상중', '협상완료', '미참여', '협상거부'],
},
{ key: 'deadline', title: '마감일', items: ['남은 시간 적은 순', '남은 시간 긴 순'] },
]

View File

@ -0,0 +1,34 @@
import { create } from 'zustand'
interface ListFilterState {
selectedType: string | null
selectedStatus: string | null
selectedDeadline: string | null
currentPage: number
toggleType: (item: string) => void
toggleStatus: (item: string) => void
toggleDeadline: (item: string) => void
setCurrentPage: (page: number) => void
resetFilters: () => void
}
// 같은 항목을 다시 누르면 해제
const toggle = (current: string | null, item: string) => (current === item ? null : item)
export const useListFilterStore = create<ListFilterState>((set) => ({
selectedType: null,
selectedStatus: null,
selectedDeadline: null,
currentPage: 1,
toggleType: (item) =>
set((s) => ({ selectedType: toggle(s.selectedType, item), currentPage: 1 })),
toggleStatus: (item) =>
set((s) => ({ selectedStatus: toggle(s.selectedStatus, item), currentPage: 1 })),
toggleDeadline: (item) =>
set((s) => ({ selectedDeadline: toggle(s.selectedDeadline, item), currentPage: 1 })),
setCurrentPage: (page) => set({ currentPage: page }),
resetFilters: () =>
set({ selectedType: null, selectedStatus: null, selectedDeadline: null, currentPage: 1 }),
}))

View File

@ -0,0 +1,23 @@
import { type ReactNode } from 'react'
import { cn } from '@/components'
// 우측 상단 다크 헤더 바
const HEADER_BASE =
'flex w-full h-[56px] py-[18px] items-center rounded-t-[8px] bg-foreground text-background'
const HEADER_ALIGN = {
left: 'justify-start',
center: 'justify-center',
} as const
export interface MainHeaderBarProps {
align?: keyof typeof HEADER_ALIGN
className?: string
children: ReactNode
}
export function MainHeaderBar({ align = 'left', className, children }: MainHeaderBarProps) {
return <div className={cn(HEADER_BASE, HEADER_ALIGN[align], className)}>{children}</div>
}
export default MainHeaderBar

View File

@ -0,0 +1,74 @@
import { type ReactNode } from 'react'
import { Logo, cn } from '@/components'
// 좌측 폭: list=반응형 비율 / chat=고정 350px
const SIDEBAR_WIDTH = {
list:
'w-[20%] max-w-[320px] ' +
'max-[1520px]:w-[23%] max-[1410px]:w-[25%] ' +
'max-[1180px]:w-[27%] max-[1024px]:w-[29%] max-[960px]:w-[33%]',
chat: 'w-[350px]',
} as const
const styles = {
root: 'flex w-full min-h-screen max-h-screen bg-background',
scrollX: 'flex w-full min-h-screen max-h-screen overflow-x-auto overflow-y-hidden',
scrollXInner: 'flex w-full min-h-screen max-h-screen min-w-[1350px] bg-background',
sidebar: 'flex flex-col h-screen pt-2 bg-background',
logoHeader:
'flex w-full h-[56px] pl-[32px] pr-[24px] py-[8px] items-center justify-between shrink-0',
main: 'flex flex-1 flex-col h-screen overflow-hidden pt-2 pr-2 pb-2 pl-0',
// 헤더 아래 콘텐츠 면 (다크 헤더 rounded-t 와 합쳐져 카드 형태)
content: 'flex flex-1 flex-col min-h-0 w-full overflow-hidden rounded-b-[8px] bg-muted',
} as const
export type SidebarWidth = keyof typeof SIDEBAR_WIDTH
export interface MainLayoutProps {
sidebarWidth?: SidebarWidth
/** 루트 가로 스크롤 + 최소폭 (chat 전용) */
scrollX?: boolean
/** 로고 우측 컨트롤 */
logoAction?: ReactNode
sidebar: ReactNode
header: ReactNode
children?: ReactNode
}
export function MainLayout({
sidebarWidth = 'list',
scrollX = false,
logoAction,
sidebar,
header,
children,
}: MainLayoutProps) {
const panes = (
<>
<aside className={cn(styles.sidebar, SIDEBAR_WIDTH[sidebarWidth])}>
<div className={styles.logoHeader}>
<Logo size="sm" />
{logoAction}
</div>
{sidebar}
</aside>
<main className={styles.main}>
{header}
<div className={styles.content}>{children}</div>
</main>
</>
)
if (!scrollX) {
return <div className={styles.root}>{panes}</div>
}
return (
<div className={styles.scrollX}>
<div className={styles.scrollXInner}>{panes}</div>
</div>
)
}
export default MainLayout

View File

@ -0,0 +1,4 @@
export { MainLayout } from '@/layouts/MainLayout'
export type { MainLayoutProps, SidebarWidth } from '@/layouts/MainLayout'
export { MainHeaderBar } from '@/layouts/MainHeaderBar'
export type { MainHeaderBarProps } from '@/layouts/MainHeaderBar'

View File

@ -1,8 +1,8 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { Provider } from '@/core/provider'
import './index.css'
import App from './App.tsx'
import '@/index.css'
import App from '@/App'
createRoot(document.getElementById('root')!).render(
<StrictMode>

View File

@ -1,5 +1,47 @@
import { useNavigate } from 'react-router'
import { List } from 'lucide-react'
import { MainLayout, MainHeaderBar } from '@/layouts'
export function ChatPage() {
return <main className="min-h-svh" />
return (
<MainLayout
sidebarWidth="chat"
scrollX
logoAction={<ListNavButton />}
sidebar={<Sidebar />}
header={
<MainHeaderBar className="px-[140px]">
<span className="text-xl font-semibold"> : -</span>
</MainHeaderBar>
}
>
{/* TODO: MainContent (ChatSection / MenuSection) */}
</MainLayout>
)
}
function ListNavButton() {
const navigate = useNavigate()
return (
<button
type="button"
aria-label="목록으로 이동"
onClick={() => navigate('/list')}
className="cursor-pointer text-foreground"
>
<List size={24} />
</button>
)
}
function Sidebar() {
return (
<>
{/* TODO: ItemSection */}
<div className="flex-1 overflow-y-auto" />
{/* TODO: FooterSection */}
</>
)
}
export default ChatPage

View File

@ -1,5 +1,30 @@
import { MainLayout, MainHeaderBar } from '@/layouts'
import { FilterSection, SidebarFooter } from '@/features/list'
export function ListPage() {
return <main className="min-h-svh" />
return (
<MainLayout
sidebarWidth="list"
sidebar={<Sidebar />}
header={
<MainHeaderBar align="center" className="px-[81px]">
<span className="text-xl font-bold whitespace-nowrap"> </span>
</MainHeaderBar>
}
>
{/* TODO: Content (ButtonSection / TableSection / PageSection) */}
</MainLayout>
)
}
function Sidebar() {
return (
<>
<FilterSection />
{/* TODO: ArchiveSection */}
<SidebarFooter />
</>
)
}
export default ListPage