diff --git a/front/src/features/list/components/ActionSection.tsx b/front/src/features/list/components/ActionSection.tsx new file mode 100644 index 0000000..36ce83c --- /dev/null +++ b/front/src/features/list/components/ActionSection.tsx @@ -0,0 +1,24 @@ +import { cn, interactive } from '@/lib' + +const PILL = + 'flex items-center justify-center w-full max-w-[130px] h-[50px] rounded-full py-4 px-6 ' + + 'text-lg font-semibold whitespace-nowrap ' + + interactive + +export function ActionSection() { + return ( +
+ {/* TODO: 협상 참여 동작 연동 */} + + {/* TODO: 거부 동작 연동 */} + +
+ ) +} diff --git a/front/src/features/list/components/ListFilter.tsx b/front/src/features/list/components/FilterGroup.tsx similarity index 79% rename from front/src/features/list/components/ListFilter.tsx rename to front/src/features/list/components/FilterGroup.tsx index 9416a84..10fbf8b 100644 --- a/front/src/features/list/components/ListFilter.tsx +++ b/front/src/features/list/components/FilterGroup.tsx @@ -1,8 +1,8 @@ import { useState } from 'react' import { ChevronDown } from 'lucide-react' -import { cn } from '@/components' +import { cn, interactive } from '@/lib' -export interface ListFilterProps { +export interface FilterGroupProps { title: string items: readonly string[] selectedItem: string | null @@ -10,13 +10,13 @@ export interface ListFilterProps { defaultOpen?: boolean } -export function ListFilter({ +export function FilterGroup({ title, items, selectedItem, onItemClick, defaultOpen = true, -}: ListFilterProps) { +}: FilterGroupProps) { const [open, setOpen] = useState(defaultOpen) return ( @@ -29,7 +29,7 @@ export function ListFilter({ 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" + className={cn('w-10 h-10 flex items-center justify-center text-muted-foreground', interactive)} > - {/* grid-rows 0fr→1fr 로 높이를 부드럽게 펼침 */} + {/* grid-rows 0fr→1fr 펼침 */}
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', + 'whitespace-nowrap cursor-pointer transition active:scale-[0.98]', + selectedItem === item ? 'bg-secondary' : 'hover:bg-muted', )} > {item} diff --git a/front/src/features/list/components/Pagination.tsx b/front/src/features/list/components/Pagination.tsx new file mode 100644 index 0000000..44f3893 --- /dev/null +++ b/front/src/features/list/components/Pagination.tsx @@ -0,0 +1,102 @@ +import { cn, interactive } from '@/lib' +import { buildPageItems } from '@/features/list/lib/pagination' +import { ArrowIcon, DotsIcon } from '@/features/list/components/paginationIcons' + +interface PaginationProps { + totalPages: number + currentPage: number + onPageChange: (page: number) => void +} + +export function Pagination({ totalPages, currentPage, onPageChange }: PaginationProps) { + if (totalPages === 0) { + return
+ } + + const items = buildPageItems(currentPage, totalPages) + + return ( +
+
+ 1} + onClick={() => onPageChange(currentPage - 1)} + /> + +
+
+ {items.map((item, idx) => + item === 'dots' ? ( + + ) : ( + onPageChange(item)} + /> + ), + )} +
+
+ + onPageChange(currentPage + 1)} + /> +
+
+ ) +} + +function PageNumber({ + page, + active, + onClick, +}: { + page: number + active: boolean + onClick: () => void +}) { + return ( + + ) +} + +function NavButton({ + dir, + enabled, + onClick, +}: { + dir: 'prev' | 'next' + enabled: boolean + onClick: () => void +}) { + return ( + + ) +} diff --git a/front/src/features/list/components/SidebarFooter.tsx b/front/src/features/list/components/SidebarFooter.tsx deleted file mode 100644 index 454abc0..0000000 --- a/front/src/features/list/components/SidebarFooter.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { Button } from '@/components' - -/** 좌측 사이드바 하단: 공급사명 + 로그아웃 */ -export function SidebarFooter() { - return ( -
-
- {/* TODO: 공급사명 (auth store 연동) */} - - -
- -
- ) -} diff --git a/front/src/features/list/components/TableSection.tsx b/front/src/features/list/components/TableSection.tsx new file mode 100644 index 0000000..76d276b --- /dev/null +++ b/front/src/features/list/components/TableSection.tsx @@ -0,0 +1,79 @@ +import { type ReactNode } from 'react' +import { Loader2 } from 'lucide-react' +import { cn } from '@/lib' +import type { ListItem } from '@/features/list/types' +import { COLUMNS } from '@/features/list/components/tableColumns' + +interface TableSectionProps { + items: ListItem[] + selectedId?: string + isLoading?: boolean + onItemClick?: (item: ListItem) => void +} + +export function TableSection({ items, selectedId, isLoading, onItemClick }: TableSectionProps) { + return ( +
+
+ + + + {COLUMNS.map((col) => ( + + ))} + + + + + {isLoading ? ( + + + + ) : items.length === 0 ? ( + + 조회된 협상 내역이 없습니다. + + ) : ( + items.map((item) => ( + onItemClick?.(item)} + className={cn( + 'h-[53px] cursor-pointer text-lg text-foreground transition-colors', + item.session_id === selectedId ? 'bg-table-selected' : 'hover:bg-table-hover', + )} + > + {COLUMNS.map((col) => ( + + ))} + + )) + )} + +
+ {col.label} +
+ {col.render ? col.render(item) : item[col.key] || '-'} +
+
+
+ ) +} + +// 로딩/빈 상태 행 +function StateRow({ children }: { children: ReactNode }) { + return ( + + +
{children}
+ + + ) +} diff --git a/front/src/features/list/components/paginationIcons.tsx b/front/src/features/list/components/paginationIcons.tsx new file mode 100644 index 0000000..b7f8ba8 --- /dev/null +++ b/front/src/features/list/components/paginationIcons.tsx @@ -0,0 +1,32 @@ +// 페이지네이션 인라인 SVG (색 = currentColor) + +const ARROW_PATH = { + prev: 'M26.9995 30.6532L21.3457 24.9995L26.9995 19.3457L28.0532 20.3995L23.4532 24.9995L28.0532 29.5995L26.9995 30.6532Z', + next: 'M23.0005 30.6532L28.6543 24.9995L23.0005 19.3457L21.9468 20.3995L26.5468 24.9995L21.9468 29.5995L23.0005 30.6532Z', +} as const + +export function ArrowIcon({ dir }: { dir: 'prev' | 'next' }) { + return ( + + + + ) +} + +export function DotsIcon() { + return ( + + + + ) +} diff --git a/front/src/features/list/components/tableColumns.tsx b/front/src/features/list/components/tableColumns.tsx new file mode 100644 index 0000000..16f5a0f --- /dev/null +++ b/front/src/features/list/components/tableColumns.tsx @@ -0,0 +1,45 @@ +import { type ReactNode } from 'react' +import { formatDateTime } from '@/features/list/lib/datetime' +import type { ListItem } from '@/features/list/types' + +// 컬럼 설정 (렌더링은 TableSection) +export interface Column { + key: keyof ListItem + label: string + width: string + align: 'text-left' | 'text-center' + render?: (item: ListItem) => ReactNode +} + +const STATUS_COLOR: Record = { + 협상생성: 'text-info font-semibold', + 협상중: 'text-foreground font-semibold', + 협상완료: 'text-success font-semibold', + 미참여: 'text-muted-foreground font-semibold', + 협상거부: 'text-destructive font-semibold', +} + +export const COLUMNS: Column[] = [ + { key: 'item_code', label: '상품코드', width: 'w-[8.39%]', align: 'text-center' }, + { + key: 'session_status', + label: '상태', + width: 'w-[6.57%]', + align: 'text-center', + render: (item) => ( + {item.session_status || '-'} + ), + }, + { key: 'qt_number', label: '견적번호', width: 'w-[14.29%]', align: 'text-center' }, + { key: 'qt_type', label: '구분', width: 'w-[5.52%]', align: 'text-center' }, + { key: 'item_name', label: '상품명', width: 'w-[20.96%]', align: 'text-left' }, + { key: 'model_name', label: '모델명', width: 'w-[22.89%]', align: 'text-left' }, + { key: 'maker_name', label: '제조사', width: 'w-[8.67%]', align: 'text-center' }, + { + key: 'qt_end_time', + label: '마감일', + width: 'w-[13.72%]', + align: 'text-center', + render: (item) => formatDateTime(item.qt_end_time), + }, +] diff --git a/front/src/features/list/containers/ContentContainer.tsx b/front/src/features/list/containers/ContentContainer.tsx new file mode 100644 index 0000000..eb89662 --- /dev/null +++ b/front/src/features/list/containers/ContentContainer.tsx @@ -0,0 +1,27 @@ +import { useList } from '@/features/list/hooks/useList' +import { ActionSection } from '@/features/list/components/ActionSection' +import { TableSection } from '@/features/list/components/TableSection' +import { Pagination } from '@/features/list/components/Pagination' + +export function ContentContainer() { + const { items, isLoading, totalPages, currentPage, setCurrentPage, selectedId, handleItemClick } = + useList() + + return ( + // 좌우 거터(80px) 일괄 적용 +
+ + + +
+ ) +} diff --git a/front/src/features/list/containers/FilterContainer.tsx b/front/src/features/list/containers/FilterContainer.tsx new file mode 100644 index 0000000..b2bc295 --- /dev/null +++ b/front/src/features/list/containers/FilterContainer.tsx @@ -0,0 +1,52 @@ +import { useShallow } from 'zustand/react/shallow' +import { FilterGroup } from '@/features/list/components/FilterGroup' +import { FILTER_GROUPS, type FilterKey } from '@/features/list/filterOptions' +import { useListStore } from '@/features/list/stores/useListStore' + +export function FilterContainer() { + // 필터 필드만 구독 → 페이지 변경 시 리렌더 방지 + const { + selectedType, + selectedStatus, + selectedDeadline, + toggleType, + toggleStatus, + toggleDeadline, + } = useListStore( + useShallow((s) => ({ + selectedType: s.selectedType, + selectedStatus: s.selectedStatus, + selectedDeadline: s.selectedDeadline, + toggleType: s.toggleType, + toggleStatus: s.toggleStatus, + toggleDeadline: s.toggleDeadline, + })), + ) + + const selected: Record = { + type: selectedType, + status: selectedStatus, + deadline: selectedDeadline, + } + const toggle: Record void> = { + type: toggleType, + status: toggleStatus, + deadline: toggleDeadline, + } + + return ( +
+
+ {FILTER_GROUPS.map((group) => ( + + ))} +
+
+ ) +} diff --git a/front/src/features/list/containers/FilterSection.tsx b/front/src/features/list/containers/FilterSection.tsx deleted file mode 100644 index 13fd808..0000000 --- a/front/src/features/list/containers/FilterSection.tsx +++ /dev/null @@ -1,35 +0,0 @@ -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/model/filterOptions.ts b/front/src/features/list/filterOptions.ts similarity index 81% rename from front/src/features/list/model/filterOptions.ts rename to front/src/features/list/filterOptions.ts index 7cdf965..bf8f38c 100644 --- a/front/src/features/list/model/filterOptions.ts +++ b/front/src/features/list/filterOptions.ts @@ -1,12 +1,12 @@ export type FilterKey = 'type' | 'status' | 'deadline' -export interface FilterGroup { +export interface FilterGroupConfig { key: FilterKey title: string items: readonly string[] } -export const FILTER_GROUPS: readonly FilterGroup[] = [ +export const FILTER_GROUPS: readonly FilterGroupConfig[] = [ { key: 'type', title: '구분', items: ['재견적', '재협상'] }, { key: 'status', diff --git a/front/src/features/list/hooks/useList.ts b/front/src/features/list/hooks/useList.ts new file mode 100644 index 0000000..dc16832 --- /dev/null +++ b/front/src/features/list/hooks/useList.ts @@ -0,0 +1,57 @@ +import { useMemo, useState } from 'react' +import { useListStore } from '@/features/list/stores/useListStore' +import { MOCK_ITEMS } from '@/features/list/mocks/mockItems' +import type { ListItem } from '@/features/list/types' + +const PAGE_SIZE = 20 + +// 목데이터 클라이언트 필터 (추후 API 조회로 교체) +export function useList() { + const { selectedType, selectedStatus, selectedDeadline, currentPage, setCurrentPage } = + useListStore() + const [selectedId, setSelectedId] = useState(null) + + const filtered = useMemo(() => { + const result = MOCK_ITEMS.filter( + (item) => + (!selectedType || item.qt_type === selectedType) && + (!selectedStatus || item.session_status === selectedStatus), + ) + + if (selectedDeadline) { + const dir = selectedDeadline === '남은 시간 적은 순' ? 1 : -1 + result.sort( + (a, b) => + (new Date(a.qt_end_time).getTime() - new Date(b.qt_end_time).getTime()) * dir, + ) + } + + return result + }, [selectedType, selectedStatus, selectedDeadline]) + + const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)) + + const items = useMemo( + () => filtered.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE), + [filtered, currentPage], + ) + + const selectedItem = useMemo( + () => items.find((item) => item.session_id === selectedId) ?? null, + [items, selectedId], + ) + + const handleItemClick = (item: ListItem) => + setSelectedId((prev) => (prev === item.session_id ? null : item.session_id)) + + return { + items, + isLoading: false, + totalPages, + currentPage, + setCurrentPage, + selectedId, + selectedItem, + handleItemClick, + } +} diff --git a/front/src/features/list/index.ts b/front/src/features/list/index.ts index 43ad6cc..44120b7 100644 --- a/front/src/features/list/index.ts +++ b/front/src/features/list/index.ts @@ -1,4 +1,3 @@ -// 공개 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' +// 공개 API (페이지에서 쓰는 것만) +export { FilterContainer } from '@/features/list/containers/FilterContainer' +export { ContentContainer } from '@/features/list/containers/ContentContainer' diff --git a/front/src/features/list/lib/datetime.ts b/front/src/features/list/lib/datetime.ts new file mode 100644 index 0000000..80da812 --- /dev/null +++ b/front/src/features/list/lib/datetime.ts @@ -0,0 +1,8 @@ +// 'YYYY-MM-DD HH:mm'. 빈 값 '-', 파싱 실패 시 원본. +export function formatDateTime(value: string): string { + if (!value) return '-' + const d = new Date(value) + if (Number.isNaN(d.getTime())) return value + const pad = (n: number) => String(n).padStart(2, '0') + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}` +} diff --git a/front/src/features/list/lib/pagination.ts b/front/src/features/list/lib/pagination.ts new file mode 100644 index 0000000..e477056 --- /dev/null +++ b/front/src/features/list/lib/pagination.ts @@ -0,0 +1,18 @@ +export type PageItem = number | 'dots' + +// 9개 이하: 전체 / 초과: 앞4·뒤4 또는 1·중앙5·끝 + 생략(...) +export function buildPageItems(currentPage: number, totalPages: number): PageItem[] { + const pages = Array.from({ length: totalPages }, (_, i) => i + 1) + if (pages.length <= 9) return pages + + const prev = pages.slice(0, 4) + const middle = pages.slice(3, pages.length - 3) + const next = pages.slice(pages.length - 4) + + if (!middle.includes(currentPage)) { + return [...prev, 'dots', ...next] + } + + const center = [currentPage - 2, currentPage - 1, currentPage, currentPage + 1, currentPage + 2] + return [1, 'dots', ...center, 'dots', totalPages] +} diff --git a/front/src/features/list/mocks/mockItems.ts b/front/src/features/list/mocks/mockItems.ts new file mode 100644 index 0000000..ece904b --- /dev/null +++ b/front/src/features/list/mocks/mockItems.ts @@ -0,0 +1,115 @@ +import type { ListItem } from '@/features/list/types' + +// 임시 목데이터 (API 연동 전) +export const MOCK_ITEMS: ListItem[] = [ + { + session_id: 's-001', + session_status: '협상생성', + qt_type: '재견적', + qt_number: 'QT-2026-000101', + qt_end_time: '2026-06-18T18:00:00', + item_code: 'IMK-10231', + item_name: '사무용 노트북 14인치', + model_name: 'NB-1400-PRO', + maker_name: '삼성전자', + }, + { + session_id: 's-002', + session_status: '협상중', + qt_type: '재협상', + qt_number: 'QT-2026-000102', + qt_end_time: '2026-06-17T12:30:00', + item_code: 'IMK-10232', + item_name: '레이저 복합기', + model_name: 'MFC-7890DW', + maker_name: '브라더', + }, + { + session_id: 's-003', + session_status: '협상완료', + qt_type: '재견적', + qt_number: 'QT-2026-000103', + qt_end_time: '2026-06-20T09:00:00', + item_code: 'IMK-10233', + item_name: '27인치 4K 모니터', + model_name: 'U2723QE', + maker_name: '델', + }, + { + session_id: 's-004', + session_status: '협상거부', + qt_type: '재협상', + qt_number: 'QT-2026-000104', + qt_end_time: '2026-06-19T15:45:00', + item_code: 'IMK-10234', + item_name: '무선 기계식 키보드', + model_name: 'MX-KEYS-M', + maker_name: '로지텍', + }, + { + session_id: 's-005', + session_status: '미참여', + qt_type: '재견적', + qt_number: 'QT-2026-000105', + qt_end_time: '2026-06-22T11:00:00', + item_code: 'IMK-10235', + item_name: 'A4 무선 레이저프린터', + model_name: 'SL-M2030', + maker_name: 'HP', + }, + { + session_id: 's-006', + session_status: '협상중', + qt_type: '재견적', + qt_number: 'QT-2026-000106', + qt_end_time: '2026-06-16T20:00:00', + item_code: 'IMK-10236', + item_name: '회의실 대형 디스플레이 65인치', + model_name: 'QM65R', + maker_name: '삼성전자', + }, + { + session_id: 's-007', + session_status: '협상생성', + qt_type: '재협상', + qt_number: 'QT-2026-000107', + qt_end_time: '2026-06-25T17:00:00', + item_code: 'IMK-10237', + item_name: '인체공학 사무용 의자', + model_name: 'ERGO-700', + maker_name: '시디즈', + }, + { + session_id: 's-008', + session_status: '협상완료', + qt_type: '재견적', + qt_number: 'QT-2026-000108', + qt_end_time: '2026-06-21T10:30:00', + item_code: 'IMK-10238', + item_name: '네트워크 스위치 24포트', + model_name: 'SG350-28', + maker_name: '시스코', + }, + { + session_id: 's-009', + session_status: '미참여', + qt_type: '재협상', + qt_number: 'QT-2026-000109', + qt_end_time: '2026-06-23T14:00:00', + item_code: 'IMK-10239', + item_name: '외장 SSD 2TB', + model_name: 'T7-Shield-2T', + maker_name: '삼성전자', + }, + { + session_id: 's-010', + session_status: '협상중', + qt_type: '재견적', + qt_number: 'QT-2026-000110', + qt_end_time: '2026-06-24T16:20:00', + item_code: 'IMK-10240', + item_name: '화상회의용 웹캠', + model_name: 'BRIO-4K', + maker_name: '로지텍', + }, +] diff --git a/front/src/features/list/model/useListFilterStore.ts b/front/src/features/list/stores/useListStore.ts similarity index 87% rename from front/src/features/list/model/useListFilterStore.ts rename to front/src/features/list/stores/useListStore.ts index 27e892b..4be6085 100644 --- a/front/src/features/list/model/useListFilterStore.ts +++ b/front/src/features/list/stores/useListStore.ts @@ -1,6 +1,6 @@ import { create } from 'zustand' -interface ListFilterState { +interface ListState { selectedType: string | null selectedStatus: string | null selectedDeadline: string | null @@ -13,10 +13,9 @@ interface ListFilterState { resetFilters: () => void } -// 같은 항목을 다시 누르면 해제 const toggle = (current: string | null, item: string) => (current === item ? null : item) -export const useListFilterStore = create((set) => ({ +export const useListStore = create((set) => ({ selectedType: null, selectedStatus: null, selectedDeadline: null, diff --git a/front/src/features/list/types.ts b/front/src/features/list/types.ts new file mode 100644 index 0000000..0c97875 --- /dev/null +++ b/front/src/features/list/types.ts @@ -0,0 +1,11 @@ +export type ListItem = { + session_id: string + session_status: string + qt_type: string + qt_number: string + qt_end_time: string + item_code: string + item_name: string + model_name: string + maker_name: string +}