diff --git a/backend/crud/session_crud.py b/backend/crud/session_crud.py index 4db5ec9..687602f 100644 --- a/backend/crud/session_crud.py +++ b/backend/crud/session_crud.py @@ -1,18 +1,33 @@ from abc import ABC, abstractmethod from typing import Tuple -from sqlalchemy import case, cast, func, nulls_last, or_, select, text, update +from sqlalchemy import and_, case, cast, func, nulls_last, or_, select, text, update from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.ext.asyncio import AsyncSession from common.database.db_session_manager import DB_SESSION_MNG -from common.database.model.models import items, quotations, sessions +from common.database.model.models import chats, items, quotations, sessions from common.enums import CloseReason, ErrorType, QuotationStatus, RENEGOTIABLE_CLOSE_REASONS, SessionStatus from common.logger import LOG # 협상 세션 CRUD. 목록은 세션(negotiation) ⨝ 상품(partner) ⨝ 견적(quotation) 조인으로 만든다. # 마감일(qt_end_time)은 견적(quotation.end_time)이 진실값이다(session.end_time 은 협상 종료 시점 기록용). + + +def _effective_status(): + """표시용 세션 상태. 견적이 마감됐거나 마감시간이 지났으면 협상생성(1)은 더 참여할 수 없으므로 미참여(4)로 본다. + + 참여/채팅진입이 진입 시점에 하는 전이(negotiation_service._load_actionable_session, chat_service.init)와 같은 규칙을 + 목록에서는 쓰기 없이 파생으로만 맞춘다. 마감 일괄정리 이후에 만들어진 세션도 '협상 대기'로 남지 않는다. + """ + ended = or_(quotations.status == QuotationStatus.CLOSED.value, quotations.end_time < func.now()) + return case( + (and_(sessions.status == SessionStatus.CREATED.value, ended), SessionStatus.NOT_PARTICIPATED.value), + else_=sessions.status, + ) + + class ISessionCRUD(ABC): @abstractmethod async def list_by_supplier(self, cdb: AsyncSession, supplier_id, status, qt_type, order, offset, limit, keyword=None, result=None) -> Tuple[ErrorType, list]: @@ -47,19 +62,6 @@ class ISessionCRUD(ABC): pass @abstractmethod - async def chain_max_round(self, cdb: AsyncSession, number: str) -> Tuple[ErrorType, int]: - # 같은 견적번호(체인)의 최대 차수. 이미 다음 라운드가 있으면 재협상 요청은 의미가 없다. - try: - query = select(func.max(quotations.round)).where(quotations.number == number, quotations.deleted == False) # noqa: E712 - err_type, rows = await DB_SESSION_MNG.execute(cdb, query) - if err_type != ErrorType.SUCCESS: - return err_type, 0 - top = rows[0][0] if rows and rows[0] else None - return ErrorType.SUCCESS, int(top or 0) - except Exception as ex: - LOG.e_no_callstack(ex) - return ErrorType.DB_RUN_FAILED, 0 - async def merge_session_custom(self, cdb: AsyncSession, session_id, supplier_id, patch: dict) -> ErrorType: pass @@ -73,7 +75,8 @@ class SessionCRUD(ISessionCRUD): def __filters(supplier_id, status, qt_type, keyword=None, result=None): conds = [sessions.supplier_id == supplier_id, sessions.deleted == False] # noqa: E712 if status is not None: - conds.append(sessions.status == status) + # 표시 상태로 필터 — 탭/KPI 카운트가 목록 배지와 어긋나지 않게 파생값을 그대로 쓴다. + conds.append(_effective_status() == status) if qt_type is not None: conds.append(sessions.qt_type == qt_type) # 검색: 견적번호·상품명·상품코드 부분일치(대소문자 무시). items 는 목록/카운트 둘 다 조인돼 있다. @@ -113,7 +116,7 @@ class SessionCRUD(ISessionCRUD): else: # 그룹별로 정렬 방향이 달라, case 로 '자기 그룹 행만 end_time' 을 갖는 키를 만들고 # 반대 그룹은 NULL 로 눌러 간섭을 없앤다. status_rank 가 1차 키라 그룹 경계는 항상 유지. - actionable = sessions.status.in_((SessionStatus.CREATED.value, SessionStatus.IN_PROGRESS.value)) + actionable = _effective_status().in_((SessionStatus.CREATED.value, SessionStatus.IN_PROGRESS.value)) status_rank = case((actionable, 0), else_=1) action_order = case((actionable, quotations.end_time), else_=None).asc() done_order = case((~actionable, quotations.end_time), else_=None).desc() @@ -122,7 +125,7 @@ class SessionCRUD(ISessionCRUD): query = ( select( sessions.session_id, - sessions.status, + _effective_status(), # 마감 후 남은 협상생성은 미참여로 내린다 sessions.qt_type, sessions.qt_number, quotations.end_time, # qt_end_time = 견적 마감 시각 @@ -136,6 +139,8 @@ class SessionCRUD(ISessionCRUD): quotations.round, quotations.preferred_sp_id, # 낙찰자(공급사) — 나와 같으면 낙찰, 다르면 미낙찰 sessions.supplier_id, # 이 세션 소유 공급사(=조회자). 낙찰자와 대조 + # 대화 이력 유무 — 종료된 협상의 '결과 보기'(열람) 버튼을 띄울지 판단용. 열 게 없으면 프론트가 감춘다. + select(1).where(chats.session_id == sessions.session_id, chats.deleted == False).exists(), # noqa: E712 ) .join(items, items.item_id == sessions.item_id) .join(quotations, quotations.qt_id == sessions.quotation_id) @@ -231,7 +236,8 @@ class SessionCRUD(ISessionCRUD): err_type, rows = await DB_SESSION_MNG.execute(cdb, query) if err_type != ErrorType.SUCCESS: return err_type, 0 - top = rows[0][0] if rows and rows[0] else None + # 단일 컬럼 select 는 scalars() 로 내려와 rows 가 값 리스트다(행 튜플이 아님). + top = rows[0] if rows else None return ErrorType.SUCCESS, int(top or 0) except Exception as ex: LOG.e_no_callstack(ex) diff --git a/backend/router/v1/negotiation/protocol.py b/backend/router/v1/negotiation/protocol.py index df44f35..a986208 100644 --- a/backend/router/v1/negotiation/protocol.py +++ b/backend/router/v1/negotiation/protocol.py @@ -21,6 +21,7 @@ class ListItem(WebPacketProtocol): renegotiation_status: int = Field(0, description="현재 재협상 요청 상태(RenegotiationStatus). 요청 이력이 없으면 0") renegotiation_memo: str = Field("", description="담당자 심사 메모(반려 사유). 없으면 빈 문자열") result: int = Field(0, description="공급사 관점 협상 결과(SessionResult): 0=미정 1=낙찰 2=미낙찰 3=결렬(개찰, 재협상 대상)") + has_chat: bool = Field(False, description="대화 이력 존재 여부 — 종료된 협상(미참여·거부)의 '결과 보기' 노출 판단용") class Res_SessionList(Res_WebPacketProtocol): diff --git a/backend/services/chat_service.py b/backend/services/chat_service.py index 4e230a2..9c99d83 100644 --- a/backend/services/chat_service.py +++ b/backend/services/chat_service.py @@ -227,11 +227,8 @@ class ChatService: ) sess.status = SessionStatus.NOT_PARTICIPATED.value - # 미참여/협상거부 상태는 진입(열람) 불가 (participate/reject 와 동일 규칙). - # 위 마감 변환으로 미참여가 된 세션도 여기서 함께 막힌다. - if sess.status in (SessionStatus.NOT_PARTICIPATED.value, SessionStatus.REJECTED.value): - res.result.SetResult(ErrorType.NEGO_NOT_PARTICIPABLE) - return res + # 미참여/협상거부 세션도 '결과 보기'로 지난 대화를 열람할 수 있다(중간 이탈·거부로 끝난 건). + # 대화 재개는 send() 가 협상중(2)만 허용하므로 여기서 막지 않아도 읽기 전용이다. await self._ensure_in_progress(sess, quote) diff --git a/backend/services/negotiation_service.py b/backend/services/negotiation_service.py index 2ae6e74..f7b8a7b 100644 --- a/backend/services/negotiation_service.py +++ b/backend/services/negotiation_service.py @@ -182,6 +182,7 @@ class NegotiationService: renegotiation_status=status, renegotiation_memo=renego.get("memo") or "", result=NegotiationService._to_result(r[10], r[11], r[13], r[14]), + has_chat=bool(r[15]), ) @staticmethod diff --git a/backend/tests/test_chat.py b/backend/tests/test_chat.py index a786a7b..819bc20 100644 --- a/backend/tests/test_chat.py +++ b/backend/tests/test_chat.py @@ -380,23 +380,28 @@ async def test_send_blocked_when_prev_turn_pending(client, chat_seed, db_engine) async def test_init_marks_expired_created_as_not_participated(client, chat_seed, db_engine): + """검증: 마감시간이 지난 협상생성 세션으로 채팅 진입. + 기대결과: DB 상태가 미참여(4)로 정리되고, init 자체는 열람용으로 성공한다.""" token = await _login_token(client) sid, qid = chat_seed["sids"]["C"], chat_seed["qids"]["C"] # 협상생성(1) async with db_engine.begin() as conn: await conn.execute(text("UPDATE quotation.quotations SET end_time = now() - make_interval(hours => 1) WHERE qt_id = :qid"), {"qid": qid}) body = (await _init(client, token, sid)).json() - # 마감된 협상생성은 DB 상 미참여로 정리되고, 미참여는 진입 불가라 init 은 에러로 막는다. - assert body["result"]["code"] == 1301 # NEGO_NOT_PARTICIPABLE - assert await _session_status(db_engine, sid) == 4 # DB 는 미참여로 전이됨 + assert body["result"]["success"] is True + assert body["session_status"] == 4 + assert await _session_status(db_engine, sid) == 4 # DB 도 미참여로 전이 -async def test_init_blocks_rejected_session(client, chat_seed, db_engine): +async def test_init_allows_viewing_rejected_session(client, chat_seed, db_engine): + """검증: 협상거부(5)로 끝난 세션에 '결과 보기'로 재진입. + 기대결과: init 성공(열람 허용) — 대화 재개는 send 가 협상중만 허용해 막는다.""" token = await _login_token(client) sid = chat_seed["sids"]["P"] async with db_engine.begin() as conn: await conn.execute(text("UPDATE negotiation.sessions SET status = 5 WHERE session_id = :sid"), {"sid": sid}) # 협상거부 body = (await _init(client, token, sid)).json() - assert body["result"]["code"] == 1301 # NEGO_NOT_PARTICIPABLE — 거부 세션 진입 차단 + assert body["result"]["success"] is True and body["session_status"] == 5 + assert (await _send(client, token, sid, "네")).json()["result"]["code"] == 1400 # CHAT_NOT_IN_PROGRESS # ---- 순수 헬퍼 단위 테스트 (DB 불필요, ChatService @staticmethod) ---------- diff --git a/backend/tests/test_negotiation.py b/backend/tests/test_negotiation.py index 770f069..99e2963 100644 --- a/backend/tests/test_negotiation.py +++ b/backend/tests/test_negotiation.py @@ -16,6 +16,35 @@ TEST_SUPPLIER_NAME = "파이테스트협상공급사" MARK = "PYTESTNEGO-" # 시드 식별용 prefix (item code / qt number) +async def _seed_case(conn, code, sess_st, qt_type, hrs, quote_st, sup): + """상품·견적·세션 1세트 시드. 코드/견적번호에 MARK prefix 를 달아 cleanup 이 함께 지운다. + hrs 는 마감(quotation.end_time)까지의 시간 — 음수면 이미 마감시간이 지난 건. 반환: (session_id, qt_id).""" + item_id, qt_id, session_id = uuid.uuid4(), uuid.uuid4(), uuid.uuid4() + await conn.execute( + text( + "INSERT INTO partner.items (item_id, company_id, user_id, name, code, model_name, manufacturer) " + "VALUES (:iid, gen_random_uuid(), gen_random_uuid(), :name, :code, :model, '테스트제조사')" + ), + {"iid": item_id, "name": f"상품 {code}", "code": f"{MARK}{code}", "model": f"MODEL-{code}"}, + ) + await conn.execute( + text( + "INSERT INTO quotation.quotations (qt_id, user_id, qt_setting_id, version_id, name, number, type, status, start_time, end_time) " + "VALUES (:qid, gen_random_uuid(), gen_random_uuid(), gen_random_uuid(), :name, :num, :tp, :st, now(), now() + make_interval(hours => :hrs))" + ), + {"qid": qt_id, "name": f"견적 {code}", "num": f"{MARK}{code}", "tp": qt_type, "st": quote_st, "hrs": hrs}, + ) + await conn.execute( + text( + "INSERT INTO negotiation.sessions " + "(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, target_price, status, end_time) " + "VALUES (:sesid, :qid, :iid, :sup, :qtn, 1, :qtt, 100000, :st, now())" + ), + {"sesid": session_id, "qid": qt_id, "iid": item_id, "sup": sup, "qtn": f"{MARK}{code}", "qtt": qt_type, "st": sess_st}, + ) + return session_id, qt_id + + @pytest_asyncio.fixture async def nego_seed(db_engine): """공급사 + 유저 + 세션/견적 3건(본인) + 1건(타 공급사) 시드. 세션/견적 id 를 반환.""" @@ -33,6 +62,11 @@ async def nego_seed(db_engine): sids, qids = {}, {} async def _cleanup(conn): + # 대화는 세션보다 먼저 지운다(세션이 사라지면 대상을 못 고른다). + await conn.execute(text( + f"DELETE FROM negotiation.chats WHERE session_id IN " + f"(SELECT session_id FROM negotiation.sessions WHERE qt_number LIKE '{MARK}%')" + )) await conn.execute(text(f"DELETE FROM negotiation.sessions WHERE qt_number LIKE '{MARK}%'")) await conn.execute(text(f"DELETE FROM quotation.quotations WHERE number LIKE '{MARK}%'")) await conn.execute(text(f"DELETE FROM partner.items WHERE code LIKE '{MARK}%'")) @@ -52,31 +86,9 @@ async def nego_seed(db_engine): ), {"sid": supplier_id, "id": TEST_LOGIN_ID, "pw": pw_hash}, ) - for code, sess_st, qt_type, hrs, quote_st, sup in specs: - item_id, qt_id, session_id = uuid.uuid4(), uuid.uuid4(), uuid.uuid4() - sids[code], qids[code] = session_id, qt_id - await conn.execute( - text( - "INSERT INTO partner.items (item_id, company_id, user_id, name, code, model_name, manufacturer) " - "VALUES (:iid, gen_random_uuid(), gen_random_uuid(), :name, :code, :model, '테스트제조사')" - ), - {"iid": item_id, "name": f"상품 {code}", "code": f"{MARK}{code}", "model": f"MODEL-{code}"}, - ) - await conn.execute( - text( - "INSERT INTO quotation.quotations (qt_id, user_id, qt_setting_id, version_id, name, number, type, status, start_time, end_time) " - "VALUES (:qid, gen_random_uuid(), gen_random_uuid(), gen_random_uuid(), :name, :num, :tp, :st, now(), now() + make_interval(hours => :hrs))" - ), - {"qid": qt_id, "name": f"견적 {code}", "num": f"{MARK}{code}", "tp": qt_type, "st": quote_st, "hrs": hrs}, - ) - await conn.execute( - text( - "INSERT INTO negotiation.sessions " - "(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, target_price, status, end_time) " - "VALUES (:sesid, :qid, :iid, :sup, :qtn, 1, :qtt, 100000, :st, now())" - ), - {"sesid": session_id, "qid": qt_id, "iid": item_id, "sup": sup, "qtn": f"{MARK}{code}", "qtt": qt_type, "st": sess_st}, - ) + for spec in specs: + code = spec[0] + sids[code], qids[code] = await _seed_case(conn, *spec) yield {"supplier_id": supplier_id, "sids": sids, "qids": qids} @@ -141,6 +153,63 @@ async def test_list_filter_status(client, nego_seed): assert body["total"] == 1 and body["items"][0]["item_code"] == f"{MARK}B" +async def test_list_shows_closed_quotation_created_session_as_not_participated(client, db_engine, nego_seed): + """검증: 견적이 마감(3)된 뒤에도 세션이 협상생성(1)으로 남아 있는 건(마감 일괄정리 이후 생성 등). + 기대결과: 목록 상태는 미참여(4) — '협상 대기'로 새지 않고, status=1 필터에서도 빠지고 status=4 필터에 잡힌다.""" + async with db_engine.begin() as conn: + await _seed_case(conn, "CLOSED1", 1, 2, -1, 3, nego_seed["supplier_id"]) + token = await _login_token(client) + + listed = next(i for i in (await _list(client, token)).json()["items"] if i["item_code"] == f"{MARK}CLOSED1") + assert listed["session_status"] == 4 + + waiting = (await _list(client, token, status=1)).json() + assert waiting["total"] == 1 and {i["item_code"] for i in waiting["items"]} == {f"{MARK}A"} + assert f"{MARK}CLOSED1" in {i["item_code"] for i in (await _list(client, token, status=4)).json()["items"]} + + +async def test_list_shows_deadline_passed_created_session_as_not_participated(client, db_engine, nego_seed): + """검증: 견적은 아직 진행중(2)인데 마감시간(end_time)만 지난 협상생성 세션. + 기대결과: 미참여(4) — 참여/입장이 막히는 건이라 목록도 같은 상태로 보인다(DB 값은 그대로).""" + async with db_engine.begin() as conn: + session_id, _ = await _seed_case(conn, "OVERDUE", 1, 2, -3, 2, nego_seed["supplier_id"]) + token = await _login_token(client) + + listed = next(i for i in (await _list(client, token)).json()["items"] if i["item_code"] == f"{MARK}OVERDUE") + assert listed["session_status"] == 4 + assert await _session_status(db_engine, session_id) == 1 # 목록은 파생 표시만, 쓰기는 하지 않는다 + + +async def test_list_marks_stale_round_not_renegotiable(client, db_engine, nego_seed): + """검증: 개찰(결렬) 마감된 1차 견적에 2차가 이미 생성돼 있는 체인. + 기대결과: renegotiable False — 다음 라운드가 있으면 재협상 요청 대상이 아니다(체인 최대 차수 판정).""" + async with db_engine.begin() as conn: + await _seed_case(conn, "CHAIN", 3, 2, -2, 3, nego_seed["supplier_id"]) + await conn.execute(text( + f"UPDATE quotation.quotations SET close_reason = 5 WHERE number = '{MARK}CHAIN'")) + # 같은 견적번호의 2차 — 번호가 같아야 체인으로 묶인다. + await conn.execute(text( + "INSERT INTO quotation.quotations (qt_id, user_id, qt_setting_id, version_id, name, number, type, status, round, start_time, end_time) " + f"VALUES (gen_random_uuid(), gen_random_uuid(), gen_random_uuid(), gen_random_uuid(), '견적 CHAIN 2차', '{MARK}CHAIN', 2, 2, 2, now(), now() + make_interval(hours => 2))")) + token = await _login_token(client) + listed = next(i for i in (await _list(client, token)).json()["items"] if i["item_code"] == f"{MARK}CHAIN") + assert listed["result"] == 3 and listed["renegotiable"] is False + + +async def test_list_has_chat_flags_sessions_with_history(client, db_engine, nego_seed): + """검증: 대화 이력이 있는 세션과 없는 세션의 has_chat. + 기대결과: 이력 있는 건만 True — 종료 건의 '결과 보기' 노출이 이 값으로 갈린다.""" + async with db_engine.begin() as conn: + await conn.execute( + text("INSERT INTO negotiation.chats (session_id, seq, sender, target_price) VALUES (:sid, 1, 1, 0)"), + {"sid": nego_seed["sids"]["C"]}, + ) + token = await _login_token(client) + by_code = {i["item_code"]: i["has_chat"] for i in (await _list(client, token)).json()["items"]} + assert by_code[f"{MARK}C"] is True + assert by_code[f"{MARK}A"] is False + + async def test_list_filter_qt_type(client, nego_seed): token = await _login_token(client) body = (await _list(client, token, qt_type=2)).json() diff --git a/frontend/src/apis/negotiation/negotiation.type.ts b/frontend/src/apis/negotiation/negotiation.type.ts index dbbf984..bbb245d 100644 --- a/frontend/src/apis/negotiation/negotiation.type.ts +++ b/frontend/src/apis/negotiation/negotiation.type.ts @@ -62,6 +62,7 @@ export interface SessionListItem { renegotiation_status: number // 1=심사대기 2=승인 3=반려 4=철회, 이력 없으면 0 renegotiation_memo: string // 담당자 심사 메모(반려 사유) result: number // 협상 결과(SessionResult): 0=미정 1=낙찰 2=미낙찰 3=결렬(개찰) + has_chat: boolean // 대화 이력 존재 여부 — 종료된 협상의 '결과 보기' 노출 판단용 } /** 공급사 관점 협상 결과 (sessions 파생) */ diff --git a/frontend/src/features/chat/components/ChatSection.tsx b/frontend/src/features/chat/components/ChatSection.tsx index b566575..71a3bb6 100644 --- a/frontend/src/features/chat/components/ChatSection.tsx +++ b/frontend/src/features/chat/components/ChatSection.tsx @@ -1,10 +1,21 @@ +import { SessionStatus } from '@/apis' import { useChatStore } from '@/features/chat/stores/useChatStore' +import { useChatInitStore } from '@/features/chat/stores/useChatInitStore' import { ChatMessage } from '@/features/chat/components/ChatMessage' import { UserButton } from '@/features/chat/components/UserButton' +import { GO_TO_LIST_TEXT } from '@/features/chat/lib/userButtonConfig' import { MobileStepBar } from '@/features/chat/components/MobileStepBar' +import type { UserButtonConfig } from '@/features/chat/types' + +// 미참여·협상거부로 끝난 세션은 '결과 보기'(열람) 진입이라 입력을 잠근다. +// 마지막 봇 메시지가 입력을 요구하는 상태로 멈춰 있어도(중간 이탈) 서버가 전송을 막으므로 액션바는 복귀 버튼만 둔다. +const READ_ONLY_STATUSES: number[] = [SessionStatus.NOT_PARTICIPATED, SessionStatus.REJECTED] +const READ_ONLY_CONFIG: UserButtonConfig = { type: 'one-black', text: GO_TO_LIST_TEXT } export function ChatSection() { const { userButtonConfig } = useChatStore() + const sessionStatus = useChatInitStore((s) => s.session_status) + const config = READ_ONLY_STATUSES.includes(sessionStatus) ? READ_ONLY_CONFIG : userButtonConfig return (
@@ -12,7 +23,7 @@ export function ChatSection() { {/* 하단 액션 덱 */} {/* safe-b: 홈 인디케이터에 입력 버튼이 가리지 않도록 하단 안전영역 확보 */}
- +
) diff --git a/frontend/src/features/chat/hooks/useChatController.ts b/frontend/src/features/chat/hooks/useChatController.ts index ad0aaea..fd39747 100644 --- a/frontend/src/features/chat/hooks/useChatController.ts +++ b/frontend/src/features/chat/hooks/useChatController.ts @@ -24,8 +24,9 @@ const TERMINAL_CODES = new Set([ ErrorCode.NEGO_NOT_FOUND, ]) -// 진입 로드(init/messages) 시 '잘못된 접근'으로 볼 코드 → 권한 없음 / 없는(또는 남의) 세션 / -// 진입 불가 상태(미참여·거부). 토스트 안내 후 목록으로 복귀시킨다. (인증≠인가 — 로그아웃하지 않는다) +// 진입 로드(init/messages) 시 '잘못된 접근'으로 볼 코드 → 권한 없음 / 없는(또는 남의) 세션 / 진입 불가 상태. +// 토스트 안내 후 목록으로 복귀시킨다. (인증≠인가 — 로그아웃하지 않는다) +// 종료된 세션(완료·미참여·거부)은 열람 진입이 허용되므로 여기 걸리지 않는다 — 입력만 잠긴다(ChatSection). const INVALID_ACCESS_CODES = new Set([ ErrorCode.NEGO_FORBIDDEN, ErrorCode.NEGO_NOT_FOUND, @@ -115,8 +116,8 @@ export function useChatController(sessionId: string) { if (data.message) s.appendMessage(mapMessage(data.message)) s.setIsLoading(false) // 협상 종료 전이(완료/거부 등): 목록 캐시만 무효화해 /list 복귀 시 최신 상태를 보장한다. - // init 은 '진입 메타'라 화면에서 재조회하지 않는다 — 재조회하면 미참여/거부 진입 게이트(1301)에 - // 걸려 방금 정상 종료한 사용자가 튕겨난다. 재진입 시 최신 status 는 언마운트의 removeQueries 가 보장한다. + // init 은 '진입 메타'라 화면에서 재조회하지 않는다 — 방금 종료한 화면의 입력이 열람 모드로 + // 갈아치워지지 않게 한다. 재진입 시 최신 status 는 언마운트의 removeQueries 가 보장한다. if (data.session_status !== SessionStatus.IN_PROGRESS || data.message?.chat_end) { queryClient.invalidateQueries({ queryKey: negotiationKeys.sessions() }) } diff --git a/frontend/src/features/list/components/WorkspaceCards.tsx b/frontend/src/features/list/components/WorkspaceCards.tsx index fd923e2..0fb1009 100644 --- a/frontend/src/features/list/components/WorkspaceCards.tsx +++ b/frontend/src/features/list/components/WorkspaceCards.tsx @@ -2,7 +2,7 @@ import { Loader2, MessageSquareText } from 'lucide-react' import { cn, formatKstDateTime } from '@/lib' import { RENEGO_STATUS_LABEL } from '@/apis/negotiation/negotiation.type' import type { ListItem } from '@/features/list/types' -import { statusMeta, RESULT_META } from '@/features/list/lib/status' +import { statusMeta, canEnterChat, isEndedStatus, RESULT_META } from '@/features/list/lib/status' interface WorkspaceCardsProps { items: ListItem[] @@ -55,10 +55,11 @@ function Card({ onMemo: (item: ListItem) => void }) { const meta = statusMeta(item.session_status) - const canEnter = !['미참여', '협상거부'].includes(item.session_status) + const ended = isEndedStatus(item.session_status) + const canEnter = canEnterChat(item.session_status, item.hasChat) const canReject = ['협상생성', '협상중'].includes(item.session_status) const isDone = item.session_status === '협상완료' - const enterLabel = isDone ? '결과 보기' : '협상 입장' + const enterLabel = ended ? '결과 보기' : '협상 입장' // 재협상: 요청 가능하면 버튼, 이미 요청했으면 진행 상태를 보여준다. const renegoLabel = RENEGO_STATUS_LABEL[item.renegotiationStatus] ?? '' diff --git a/frontend/src/features/list/components/WorkspaceTable.tsx b/frontend/src/features/list/components/WorkspaceTable.tsx index 3bf5ead..3b8d408 100644 --- a/frontend/src/features/list/components/WorkspaceTable.tsx +++ b/frontend/src/features/list/components/WorkspaceTable.tsx @@ -3,7 +3,7 @@ import { Loader2, MessageSquareText } from 'lucide-react' import { cn, formatKstDateTime } from '@/lib' import { RENEGO_STATUS_LABEL } from '@/apis/negotiation/negotiation.type' import type { ListItem } from '@/features/list/types' -import { statusMeta, RESULT_META } from '@/features/list/lib/status' +import { statusMeta, canEnterChat, isEndedStatus, RESULT_META } from '@/features/list/lib/status' interface WorkspaceTableProps { items: ListItem[] @@ -73,10 +73,11 @@ function Row({ onMemo: (item: ListItem) => void }) { const meta = statusMeta(item.session_status) - const canEnter = !['미참여', '협상거부'].includes(item.session_status) + const ended = isEndedStatus(item.session_status) + const canEnter = canEnterChat(item.session_status, item.hasChat) const canReject = ['협상생성', '협상중'].includes(item.session_status) const isDone = item.session_status === '협상완료' - const enterLabel = isDone ? '결과 보기' : '협상 입장' + const enterLabel = ended ? '결과 보기' : '협상 입장' return ( diff --git a/frontend/src/features/list/containers/ListWorkspace.tsx b/frontend/src/features/list/containers/ListWorkspace.tsx index 9299eb9..9d1e736 100644 --- a/frontend/src/features/list/containers/ListWorkspace.tsx +++ b/frontend/src/features/list/containers/ListWorkspace.tsx @@ -24,6 +24,7 @@ import { ExtraInfoPopup } from '@/features/list/components/ExtraInfoPopup' import { RenegotiationPopup } from '@/features/list/components/RenegotiationPopup' import { RenegotiationMemoPopup } from '@/features/list/components/RenegotiationMemoPopup' import { GuidePopup } from '@/features/list/components/GuidePopup' +import { isEndedStatus } from '@/features/list/lib/status' import type { ListItem } from '@/features/list/types' // 상태별 거부 불가 안내 @@ -70,6 +71,11 @@ export function ListWorkspace() { const [guideOpen, setGuideOpen] = useState(false) const handleEnter = (item: ListItem) => { + // 종료 건(완료·미참여·거부)은 열람 전용 — 참여 API 는 미참여/거부를 막으므로 바로 채팅으로 보낸다. + if (isEndedStatus(item.session_status)) { + navigate(`/chat?session_id=${item.session_id}`) + return + } setEnteringId(item.session_id) participate.mutate(item.session_id, { onSuccess: () => navigate(`/chat?session_id=${item.session_id}`), diff --git a/frontend/src/features/list/lib/adapter.ts b/frontend/src/features/list/lib/adapter.ts index 17ca0dc..36c7118 100644 --- a/frontend/src/features/list/lib/adapter.ts +++ b/frontend/src/features/list/lib/adapter.ts @@ -42,5 +42,6 @@ export function toListItem(api: SessionListItem): ListItem { renegotiationStatus: api.renegotiation_status ?? 0, renegotiationMemo: api.renegotiation_memo ?? '', result: api.result ?? 0, + hasChat: api.has_chat ?? false, } } diff --git a/frontend/src/features/list/lib/status.ts b/frontend/src/features/list/lib/status.ts index 1b4fdf5..e6af319 100644 --- a/frontend/src/features/list/lib/status.ts +++ b/frontend/src/features/list/lib/status.ts @@ -18,6 +18,18 @@ export function statusMeta(label: string): StatusMeta { return STATUS_META[label] ?? { display: label || '-', badge: 'bg-neutral-20 text-neutral-60', dot: 'bg-neutral-60' } } +// 종료 상태 — 대화를 이어갈 수 없고 지난 이력만 '결과 보기'로 열람한다(백엔드 send 도 협상중만 허용). +const ENDED_STATUSES = ['협상완료', '미참여', '협상거부'] + +export function isEndedStatus(label: string): boolean { + return ENDED_STATUSES.includes(label) +} + +// 채팅 진입 가능 여부. 진행 건은 항상, 종료 건은 볼 대화가 있을 때만 — 초청만 받고 끝난 건은 열어도 빈 화면이다. +export function canEnterChat(label: string, hasChat: boolean): boolean { + return !isEndedStatus(label) || hasChat +} + // 협상 결과(SessionResult 코드) → 완료 건에 붙는 결과 배지. 0(미정)은 표시하지 않는다. // 결렬(3)은 재협상 요청 대상이라 눈에 띄게 노랑으로 둔다. export const RESULT_META: Record = { diff --git a/frontend/src/features/list/types.ts b/frontend/src/features/list/types.ts index dc0f2c1..d3e6bd5 100644 --- a/frontend/src/features/list/types.ts +++ b/frontend/src/features/list/types.ts @@ -13,4 +13,5 @@ export type ListItem = { renegotiationStatus: number // 0=없음 1=심사중 2=승인 3=반려 4=철회 renegotiationMemo: string // 담당자 심사 메모(반려 사유) result: number // 협상 결과: 0=미정 1=낙찰 2=미낙찰 3=결렬(개찰) + hasChat: boolean // 대화 이력 존재 — 종료된 협상의 '결과 보기' 노출 조건 }