diff --git a/frontend/src/features/list/components/ActionSection.tsx b/frontend/src/features/list/components/ActionSection.tsx
index 36ce83c..5a52efc 100644
--- a/frontend/src/features/list/components/ActionSection.tsx
+++ b/frontend/src/features/list/components/ActionSection.tsx
@@ -5,17 +5,42 @@ const PILL =
'text-lg font-semibold whitespace-nowrap ' +
interactive
-export function ActionSection() {
+export interface ActionSectionProps {
+ isParticipating: boolean
+ isRejecting: boolean
+ onParticipate: () => void
+ onReject: () => void
+}
+
+export function ActionSection({
+ isParticipating,
+ isRejecting,
+ onParticipate,
+ onReject,
+}: ActionSectionProps) {
return (
- {/* TODO: 협상 참여 동작 연동 */}
-
- {/* TODO: 거부 동작 연동 */}
+
diff --git a/frontend/src/features/list/components/RejectPopup.tsx b/frontend/src/features/list/components/RejectPopup.tsx
new file mode 100644
index 0000000..d7f2d45
--- /dev/null
+++ b/frontend/src/features/list/components/RejectPopup.tsx
@@ -0,0 +1,124 @@
+import { useState } from 'react'
+import { Modal } from '@/components'
+import { cn, interactive } from '@/lib'
+
+const REASONS = ['단종', '품절', '기타'] as const
+
+export interface RejectPopupProps {
+ onClose: () => void
+ /** 최종 거부 사유 (프리셋 라벨 또는 기타 입력 텍스트) */
+ onSubmit: (reason: string) => void
+}
+
+// 거부 사유 입력 팝업 (단종/품절/기타).
+export function RejectPopup({ onClose, onSubmit }: RejectPopupProps) {
+ const [selectedReason, setSelectedReason] = useState
(null)
+ const [customReason, setCustomReason] = useState('')
+ const [showError, setShowError] = useState(false)
+
+ const isEtcOpen = selectedReason === '기타'
+ const isSubmitDisabled = !selectedReason || (isEtcOpen && !customReason.trim())
+
+ const handleReasonClick = (reason: string) => {
+ setShowError(false)
+ if (selectedReason === reason) {
+ setSelectedReason(null)
+ setCustomReason('')
+ } else {
+ setSelectedReason(reason)
+ if (reason !== '기타') setCustomReason('')
+ }
+ }
+
+ const handleSubmit = () => {
+ if (isEtcOpen && !customReason.trim()) {
+ setShowError(true)
+ return
+ }
+ if (isSubmitDisabled || !selectedReason) return
+
+ const reason = isEtcOpen ? customReason.trim() : selectedReason
+ onSubmit(reason)
+ onClose()
+ }
+
+ return (
+
+
+ {/* 헤더 */}
+
+ 거부 사유를 입력해주세요
+
+
+ {/* 사유 선택 */}
+
+ {REASONS.map((reason) => (
+
+ ))}
+
+
+ {/* 기타 입력 + 에러 */}
+
+
+
+
+ {/* 에러 메시지 (항상 공간 확보) */}
+
+
+
+ {/* 전송 */}
+
+
+
+ )
+}
diff --git a/frontend/src/features/list/containers/ContentContainer.tsx b/frontend/src/features/list/containers/ContentContainer.tsx
index eb89662..c62644d 100644
--- a/frontend/src/features/list/containers/ContentContainer.tsx
+++ b/frontend/src/features/list/containers/ContentContainer.tsx
@@ -1,16 +1,92 @@
+import { useState } from 'react'
+import { useNavigate } from 'react-router'
+import { getApiErrorMessage, useParticipateMutation, useRejectMutation } from '@/apis'
+import { toast } from '@/lib'
import { useList } from '@/features/list/hooks/useList'
import { ActionSection } from '@/features/list/components/ActionSection'
+import { RejectPopup } from '@/features/list/components/RejectPopup'
import { TableSection } from '@/features/list/components/TableSection'
import { Pagination } from '@/features/list/components/Pagination'
+// 상태별 거부 불가 안내
+const REJECT_BLOCKED: Record = {
+ 미참여: '미참여 상태인 협상은 거부할 수 없습니다.',
+ 협상거부: '협상거부 상태인 협상은 거부할 수 없습니다.',
+ 협상완료: '협상완료 상태인 협상은 거부할 수 없습니다.',
+}
+
export function ContentContainer() {
- const { items, isLoading, totalPages, currentPage, setCurrentPage, selectedId, handleItemClick } =
- useList()
+ const navigate = useNavigate()
+ const {
+ items,
+ isLoading,
+ totalPages,
+ currentPage,
+ setCurrentPage,
+ selectedId,
+ selectedItem,
+ handleItemClick,
+ } = useList()
+ const participate = useParticipateMutation()
+ const reject = useRejectMutation()
+ const [isRejectOpen, setIsRejectOpen] = useState(false)
+
+ // 선택 검증: 선택된 협상이 없으면 안내 후 false
+ const requireSelection = (): boolean => {
+ if (!selectedItem) {
+ toast.info('상품을 선택해주세요.')
+ return false
+ }
+ return true
+ }
+
+ const handleParticipate = () => {
+ if (!requireSelection()) return
+
+ if (['협상거부', '미참여'].includes(selectedItem!.session_status)) {
+ toast.error('협상거부 또는 미참여한 협상에는 참여할 수 없습니다.')
+ return
+ }
+
+ const sessionId = selectedItem!.session_id
+ participate.mutate(sessionId, {
+ onSuccess: () => navigate(`/chat?session_id=${sessionId}`),
+ onError: (error) => toast.error(getApiErrorMessage(error, '협상 참여에 실패했습니다.')),
+ })
+ }
+
+ const handleReject = () => {
+ if (!requireSelection()) return
+
+ const blocked = REJECT_BLOCKED[selectedItem!.session_status]
+ if (blocked) {
+ toast.error(blocked)
+ return
+ }
+ setIsRejectOpen(true)
+ }
+
+ const handleRejectSubmit = (reason: string) => {
+ if (!selectedItem) return
+
+ reject.mutate(
+ { sessionId: selectedItem.session_id, request: { reject_reason: reason } },
+ {
+ onSuccess: () => toast.warning('참여 거절이 완료되었습니다.'),
+ onError: (error) => toast.error(getApiErrorMessage(error, '거절 처리에 실패했습니다.')),
+ },
+ )
+ }
return (
// 좌우 거터(80px) 일괄 적용
-
+
+ {isRejectOpen && (
+
setIsRejectOpen(false)} onSubmit={handleRejectSubmit} />
+ )}
)
}
diff --git a/frontend/src/features/list/hooks/useList.ts b/frontend/src/features/list/hooks/useList.ts
index dc16832..b23bdad 100644
--- a/frontend/src/features/list/hooks/useList.ts
+++ b/frontend/src/features/list/hooks/useList.ts
@@ -1,40 +1,33 @@
import { useMemo, useState } from 'react'
+import { useSessionListQuery } from '@/apis'
import { useListStore } from '@/features/list/stores/useListStore'
-import { MOCK_ITEMS } from '@/features/list/mocks/mockItems'
+import {
+ deadlineToOrder,
+ statusLabelToCode,
+ toListItem,
+ typeLabelToCode,
+} from '@/features/list/lib/adapter'
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),
- )
+ const query = useSessionListQuery({
+ status: selectedStatus ? statusLabelToCode(selectedStatus) : undefined,
+ qt_type: selectedType ? typeLabelToCode(selectedType) : undefined,
+ order: selectedDeadline ? deadlineToOrder(selectedDeadline) : undefined,
+ page: currentPage,
+ page_size: PAGE_SIZE,
+ })
- 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 items = useMemo(() => (query.data?.items ?? []).map(toListItem), [query.data])
+ const total = query.data?.total ?? 0
+ const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
const selectedItem = useMemo(
() => items.find((item) => item.session_id === selectedId) ?? null,
@@ -46,7 +39,7 @@ export function useList() {
return {
items,
- isLoading: false,
+ isLoading: query.isLoading,
totalPages,
currentPage,
setCurrentPage,
diff --git a/frontend/src/features/list/lib/adapter.ts b/frontend/src/features/list/lib/adapter.ts
new file mode 100644
index 0000000..03d75ea
--- /dev/null
+++ b/frontend/src/features/list/lib/adapter.ts
@@ -0,0 +1,41 @@
+// 백엔드 정수 코드값 ↔ 리스트 UI 의 한국어 라벨 변환 어댑터.
+// 라벨 단일 출처는 apis 의 *_LABEL 맵이며, 필터 라벨(FILTER_GROUPS)도 이와 일치한다.
+import {
+ QT_TYPE_LABEL,
+ SESSION_STATUS_LABEL,
+ type QtType,
+ type SessionListItem,
+ type SessionStatus,
+} from '@/apis'
+import type { ListItem } from '@/features/list/types'
+
+const invert = (labelMap: Record): Record =>
+ Object.fromEntries(Object.entries(labelMap).map(([code, label]) => [label, Number(code)]))
+
+const STATUS_LABEL_TO_CODE = invert(SESSION_STATUS_LABEL)
+const TYPE_LABEL_TO_CODE = invert(QT_TYPE_LABEL)
+
+/** 필터 상태 라벨 → SessionStatus 코드 */
+export const statusLabelToCode = (label: string): number | undefined => STATUS_LABEL_TO_CODE[label]
+
+/** 필터 구분 라벨 → QtType 코드 */
+export const typeLabelToCode = (label: string): number | undefined => TYPE_LABEL_TO_CODE[label]
+
+/** 마감일 필터 라벨 → 정렬 방향 (임박순=asc) */
+export const deadlineToOrder = (label: string): 'asc' | 'desc' =>
+ label === '남은 시간 적은 순' ? 'asc' : 'desc'
+
+/** API 세션 항목(정수 코드) → 화면용 ListItem(한국어 라벨) */
+export function toListItem(api: SessionListItem): ListItem {
+ return {
+ session_id: api.session_id,
+ session_status: SESSION_STATUS_LABEL[api.session_status as SessionStatus] ?? '-',
+ qt_type: QT_TYPE_LABEL[api.qt_type as QtType] ?? '-',
+ qt_number: api.qt_number,
+ qt_end_time: api.qt_end_time,
+ item_code: api.item_code,
+ item_name: api.item_name,
+ model_name: api.model_name,
+ maker_name: api.maker_name,
+ }
+}
diff --git a/frontend/src/features/list/mocks/mockItems.ts b/frontend/src/features/list/mocks/mockItems.ts
deleted file mode 100644
index ece904b..0000000
--- a/frontend/src/features/list/mocks/mockItems.ts
+++ /dev/null
@@ -1,115 +0,0 @@
-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: '로지텍',
- },
-]