[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:
parent
9de140abd6
commit
6da8976a20
10
front/package-lock.json
generated
10
front/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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'
|
||||
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -1 +1 @@
|
||||
export { LoginForm } from './components/LoginForm'
|
||||
export { LoginForm } from '@/features/auth/components/LoginForm'
|
||||
|
||||
69
front/src/features/list/components/ListFilter.tsx
Normal file
69
front/src/features/list/components/ListFilter.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
21
front/src/features/list/components/SidebarFooter.tsx
Normal file
21
front/src/features/list/components/SidebarFooter.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
35
front/src/features/list/containers/FilterSection.tsx
Normal file
35
front/src/features/list/containers/FilterSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
4
front/src/features/list/index.ts
Normal file
4
front/src/features/list/index.ts
Normal 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'
|
||||
17
front/src/features/list/model/filterOptions.ts
Normal file
17
front/src/features/list/model/filterOptions.ts
Normal 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: ['남은 시간 적은 순', '남은 시간 긴 순'] },
|
||||
]
|
||||
34
front/src/features/list/model/useListFilterStore.ts
Normal file
34
front/src/features/list/model/useListFilterStore.ts
Normal 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 }),
|
||||
}))
|
||||
23
front/src/layouts/MainHeaderBar.tsx
Normal file
23
front/src/layouts/MainHeaderBar.tsx
Normal 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
|
||||
74
front/src/layouts/MainLayout.tsx
Normal file
74
front/src/layouts/MainLayout.tsx
Normal 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
|
||||
4
front/src/layouts/index.ts
Normal file
4
front/src/layouts/index.ts
Normal 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'
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user