diff --git a/front/package-lock.json b/front/package-lock.json index ff5dd4b..e1dc0fd 100644 --- a/front/package-lock.json +++ b/front/package-lock.json @@ -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", diff --git a/front/package.json b/front/package.json index fa76e6e..37a1496 100644 --- a/front/package.json +++ b/front/package.json @@ -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", diff --git a/front/src/components/Button.tsx b/front/src/components/Button.tsx index 6d5c995..949c786 100644 --- a/front/src/components/Button.tsx +++ b/front/src/components/Button.tsx @@ -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 = { 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', diff --git a/front/src/components/Input.tsx b/front/src/components/Input.tsx index d2f9703..6afa4c3 100644 --- a/front/src/components/Input.tsx +++ b/front/src/components/Input.tsx @@ -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 ( diff --git a/front/src/components/Logo.tsx b/front/src/components/Logo.tsx index 8278918..13bcf9d 100644 --- a/front/src/components/Logo.tsx +++ b/front/src/components/Logo.tsx @@ -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' diff --git a/front/src/components/index.ts b/front/src/components/index.ts index c47bd32..4a698d5 100644 --- a/front/src/components/index.ts +++ b/front/src/components/index.ts @@ -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' diff --git a/front/src/features/auth/index.ts b/front/src/features/auth/index.ts index ad04e7b..3510753 100644 --- a/front/src/features/auth/index.ts +++ b/front/src/features/auth/index.ts @@ -1 +1 @@ -export { LoginForm } from './components/LoginForm' +export { LoginForm } from '@/features/auth/components/LoginForm' diff --git a/front/src/features/list/components/ListFilter.tsx b/front/src/features/list/components/ListFilter.tsx new file mode 100644 index 0000000..9416a84 --- /dev/null +++ b/front/src/features/list/components/ListFilter.tsx @@ -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 ( +
+
+

+ {title} +

+ +
+ + {/* grid-rows 0fr→1fr 로 높이를 부드럽게 펼침 */} +
+
+
+ {items.map((item) => ( + + ))} +
+
+
+
+ ) +} diff --git a/front/src/features/list/components/SidebarFooter.tsx b/front/src/features/list/components/SidebarFooter.tsx new file mode 100644 index 0000000..454abc0 --- /dev/null +++ b/front/src/features/list/components/SidebarFooter.tsx @@ -0,0 +1,21 @@ +import { Button } from '@/components' + +/** 좌측 사이드바 하단: 공급사명 + 로그아웃 */ +export function SidebarFooter() { + return ( +
+
+ {/* TODO: 공급사명 (auth store 연동) */} + - +
+ +
+ ) +} diff --git a/front/src/features/list/containers/FilterSection.tsx b/front/src/features/list/containers/FilterSection.tsx new file mode 100644 index 0000000..13fd808 --- /dev/null +++ b/front/src/features/list/containers/FilterSection.tsx @@ -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 = { + type: store.selectedType, + status: store.selectedStatus, + deadline: store.selectedDeadline, + } + const toggle: Record void> = { + type: store.toggleType, + status: store.toggleStatus, + deadline: store.toggleDeadline, + } + + return ( +
+
+ {FILTER_GROUPS.map((group) => ( + + ))} +
+
+ ) +} diff --git a/front/src/features/list/index.ts b/front/src/features/list/index.ts new file mode 100644 index 0000000..43ad6cc --- /dev/null +++ b/front/src/features/list/index.ts @@ -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' diff --git a/front/src/features/list/model/filterOptions.ts b/front/src/features/list/model/filterOptions.ts new file mode 100644 index 0000000..7cdf965 --- /dev/null +++ b/front/src/features/list/model/filterOptions.ts @@ -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: ['남은 시간 적은 순', '남은 시간 긴 순'] }, +] diff --git a/front/src/features/list/model/useListFilterStore.ts b/front/src/features/list/model/useListFilterStore.ts new file mode 100644 index 0000000..27e892b --- /dev/null +++ b/front/src/features/list/model/useListFilterStore.ts @@ -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((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 }), +})) diff --git a/front/src/layouts/MainHeaderBar.tsx b/front/src/layouts/MainHeaderBar.tsx new file mode 100644 index 0000000..9e4652d --- /dev/null +++ b/front/src/layouts/MainHeaderBar.tsx @@ -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
{children}
+} + +export default MainHeaderBar diff --git a/front/src/layouts/MainLayout.tsx b/front/src/layouts/MainLayout.tsx new file mode 100644 index 0000000..1ae4894 --- /dev/null +++ b/front/src/layouts/MainLayout.tsx @@ -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 = ( + <> + + +
+ {header} +
{children}
+
+ + ) + + if (!scrollX) { + return
{panes}
+ } + + return ( +
+
{panes}
+
+ ) +} + +export default MainLayout diff --git a/front/src/layouts/index.ts b/front/src/layouts/index.ts new file mode 100644 index 0000000..412789b --- /dev/null +++ b/front/src/layouts/index.ts @@ -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' diff --git a/front/src/main.tsx b/front/src/main.tsx index 40181dd..b0f2ed4 100644 --- a/front/src/main.tsx +++ b/front/src/main.tsx @@ -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( diff --git a/front/src/pages/ChatPage.tsx b/front/src/pages/ChatPage.tsx index 1ab73e0..0d7cd19 100644 --- a/front/src/pages/ChatPage.tsx +++ b/front/src/pages/ChatPage.tsx @@ -1,5 +1,47 @@ +import { useNavigate } from 'react-router' +import { List } from 'lucide-react' +import { MainLayout, MainHeaderBar } from '@/layouts' + export function ChatPage() { - return
+ return ( + } + sidebar={} + header={ + + 협상 잔여 시간 : - + + } + > + {/* TODO: MainContent (ChatSection / MenuSection) */} + + ) +} + +function ListNavButton() { + const navigate = useNavigate() + return ( + + ) +} + +function Sidebar() { + return ( + <> + {/* TODO: ItemSection */} +
+ {/* TODO: FooterSection */} + + ) } export default ChatPage diff --git a/front/src/pages/ListPage.tsx b/front/src/pages/ListPage.tsx index c9ea48b..9d49500 100644 --- a/front/src/pages/ListPage.tsx +++ b/front/src/pages/ListPage.tsx @@ -1,5 +1,30 @@ +import { MainLayout, MainHeaderBar } from '@/layouts' +import { FilterSection, SidebarFooter } from '@/features/list' + export function ListPage() { - return
+ return ( + } + header={ + + 견적 선택 + + } + > + {/* TODO: Content (ButtonSection / TableSection / PageSection) */} + + ) +} + +function Sidebar() { + return ( + <> + + {/* TODO: ArchiveSection */} + + + ) } export default ListPage