feat(frontend): 협상 목록/참여/거부 연동
- 세션 목록을 useSessionListQuery 로 서버 조회(필터·정렬·페이지 위임), mock 제거. 정수 코드↔한국어 라벨 변환 어댑터(list/lib/adapter) - 협상 참여: useParticipateMutation, 성공 시 채팅 진입·실패 토스트 - 거부: 사유 입력 팝업(RejectPopup) + useRejectMutation, 상태별 차단 안내 - 선택/상태 검증을 토스트로 일원화(ActionSection 은 버튼만 담당) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c0d224ef21
commit
fab8e5aa53
@ -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 (
|
||||
<div className="flex w-full py-[35px] items-center justify-end gap-3">
|
||||
{/* TODO: 협상 참여 동작 연동 */}
|
||||
<button type="button" className={cn(PILL, 'bg-primary text-primary-foreground')}>
|
||||
협상 참여
|
||||
</button>
|
||||
{/* TODO: 거부 동작 연동 */}
|
||||
<button
|
||||
type="button"
|
||||
className={cn(PILL, 'bg-background text-primary border border-primary')}
|
||||
onClick={onParticipate}
|
||||
disabled={isParticipating}
|
||||
className={cn(
|
||||
PILL,
|
||||
'bg-primary text-primary-foreground',
|
||||
isParticipating && 'opacity-50 cursor-not-allowed',
|
||||
)}
|
||||
>
|
||||
협상 참여
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReject}
|
||||
disabled={isRejecting}
|
||||
className={cn(
|
||||
PILL,
|
||||
'bg-background text-primary border border-primary',
|
||||
isRejecting && 'opacity-50 cursor-not-allowed',
|
||||
)}
|
||||
>
|
||||
거부
|
||||
</button>
|
||||
|
||||
124
frontend/src/features/list/components/RejectPopup.tsx
Normal file
124
frontend/src/features/list/components/RejectPopup.tsx
Normal file
@ -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<string | null>(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 (
|
||||
<Modal onClose={onClose}>
|
||||
<div className="flex h-[500px] w-[700px] flex-col items-center justify-between rounded-[10px] bg-background py-20 shadow-lg transition-all duration-300 ease-out">
|
||||
{/* 헤더 */}
|
||||
<p className="text-center text-[26px] font-bold leading-9 tracking-[-0.52px] text-foreground">
|
||||
거부 사유를 입력해주세요
|
||||
</p>
|
||||
|
||||
{/* 사유 선택 */}
|
||||
<div className={cn('flex gap-5 transition-all duration-300 ease-out', isEtcOpen ? 'mt-0' : 'mt-12')}>
|
||||
{REASONS.map((reason) => (
|
||||
<button
|
||||
key={reason}
|
||||
type="button"
|
||||
onClick={() => handleReasonClick(reason)}
|
||||
className={cn(
|
||||
'flex h-[50px] w-[120px] items-center justify-center rounded-full px-8 title-2 text-foreground',
|
||||
'bg-neutral-20 border',
|
||||
interactive,
|
||||
selectedReason === reason ? 'border-foreground' : 'border-transparent',
|
||||
)}
|
||||
>
|
||||
{reason}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 기타 입력 + 에러 */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-[400px] overflow-hidden transition-all duration-300 ease-out',
|
||||
isEtcOpen ? 'visible h-[120px] opacity-100' : 'invisible h-0 opacity-0',
|
||||
)}
|
||||
>
|
||||
<textarea
|
||||
placeholder="사유를 입력하여 주십시오"
|
||||
value={customReason}
|
||||
onChange={(e) => {
|
||||
setCustomReason(e.target.value)
|
||||
setShowError(false)
|
||||
}}
|
||||
className={cn(
|
||||
'h-full w-full resize-none rounded-lg bg-background p-2.5 body-3 text-foreground',
|
||||
'placeholder:text-muted-foreground focus:outline-none',
|
||||
'transition-colors duration-200',
|
||||
showError ? 'border-2 border-destructive' : 'border border-foreground',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 에러 메시지 (항상 공간 확보) */}
|
||||
<div
|
||||
className={cn(
|
||||
'w-[400px] overflow-hidden transition-all duration-300 ease-out',
|
||||
showError && isEtcOpen ? 'mt-2 h-[24px] opacity-100' : 'h-0 opacity-0',
|
||||
)}
|
||||
>
|
||||
<p className="text-left body-5 text-destructive">기타 사유를 입력해주세요</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 전송 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitDisabled}
|
||||
className={cn(
|
||||
'flex h-[50px] w-full max-w-[130px] items-center justify-center rounded-full px-8 title-2',
|
||||
'bg-foreground text-background',
|
||||
interactive,
|
||||
isSubmitDisabled && 'cursor-not-allowed opacity-40',
|
||||
)}
|
||||
>
|
||||
전송
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@ -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<string, string> = {
|
||||
미참여: '미참여 상태인 협상은 거부할 수 없습니다.',
|
||||
협상거부: '협상거부 상태인 협상은 거부할 수 없습니다.',
|
||||
협상완료: '협상완료 상태인 협상은 거부할 수 없습니다.',
|
||||
}
|
||||
|
||||
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) 일괄 적용
|
||||
<div className="flex flex-1 flex-col min-h-0 px-[80px]">
|
||||
<ActionSection />
|
||||
<ActionSection
|
||||
isParticipating={participate.isPending}
|
||||
isRejecting={reject.isPending}
|
||||
onParticipate={handleParticipate}
|
||||
onReject={handleReject}
|
||||
/>
|
||||
<TableSection
|
||||
items={items}
|
||||
isLoading={isLoading}
|
||||
@ -22,6 +98,9 @@ export function ContentContainer() {
|
||||
currentPage={currentPage}
|
||||
onPageChange={setCurrentPage}
|
||||
/>
|
||||
{isRejectOpen && (
|
||||
<RejectPopup onClose={() => setIsRejectOpen(false)} onSubmit={handleRejectSubmit} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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<string | null>(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,
|
||||
|
||||
41
frontend/src/features/list/lib/adapter.ts
Normal file
41
frontend/src/features/list/lib/adapter.ts
Normal file
@ -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<number, string>): Record<string, number> =>
|
||||
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,
|
||||
}
|
||||
}
|
||||
@ -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: '로지텍',
|
||||
},
|
||||
]
|
||||
Loading…
Reference in New Issue
Block a user