[feat] negodata: 견적 삭제(개발자 전용)·연결 데이터 캐스케이드 soft-delete

- 견적관리 체크박스 선택 + 툴바 '선택 삭제'(상품·협력사 패턴), 상세 시트 단건 '삭제'
- 백엔드 soft_delete 를 연결 협상 세션·대화까지 한 트랜잭션 캐스케이드
- 체크박스·선택삭제·상세삭제 모두 개발자(role 3)에게만 노출
This commit is contained in:
Mina Choi 2026-07-30 13:20:42 +09:00
parent 2a004734d8
commit 5520e3c2b6
5 changed files with 110 additions and 5 deletions

View File

@ -77,6 +77,7 @@ class IQuotationCRUD(ABC):
@abstractmethod @abstractmethod
async def soft_delete(self, cdb: AsyncSession, qt_id) -> ErrorType: async def soft_delete(self, cdb: AsyncSession, qt_id) -> ErrorType:
"""견적과 연결된 하위 데이터(세션·대화)를 함께 소프트 삭제."""
pass pass
@abstractmethod @abstractmethod
@ -547,9 +548,21 @@ class QuotationCRUD(IQuotationCRUD):
return ErrorType.DB_RUN_FAILED return ErrorType.DB_RUN_FAILED
async def soft_delete(self, cdb: AsyncSession, qt_id) -> ErrorType: async def soft_delete(self, cdb: AsyncSession, qt_id) -> ErrorType:
"""견적 + 연결된 하위 데이터(협상 세션·대화)를 한 트랜잭션으로 소프트 삭제한다.
대화(chats)세션(sessions)견적(quotations) 순서로 deleted=True. 스텝이라도 실패하면 execute_lambda_run 롤백한다."""
try: try:
query = update(quotations).where(quotations.qt_id == qt_id).values(deleted=True, updated_at=GTime.UTC()) now = GTime.UTC()
return await DB_SESSION_MNG.add(cdb, query) # 이 견적에 매달린 세션들 — chats 삭제 범위를 잡는 서브쿼리(quotation_id 기준, deleted 무관).
session_ids = select(sessions.session_id).where(sessions.quotation_id == qt_id)
for query in (
update(chats).where(chats.session_id.in_(session_ids)).values(deleted=True, updated_at=now),
update(sessions).where(sessions.quotation_id == qt_id).values(deleted=True, updated_at=now),
update(quotations).where(quotations.qt_id == qt_id).values(deleted=True, updated_at=now),
):
err_type = await DB_SESSION_MNG.add(cdb, query)
if err_type != ErrorType.SUCCESS:
return err_type
return ErrorType.SUCCESS
except Exception as ex: except Exception as ex:
LOG.e_no_callstack(ex) LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED return ErrorType.DB_RUN_FAILED

View File

@ -1,8 +1,9 @@
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { CheckCircle2, ChevronLeft, X, UserCheck, MessageSquare, Layers, RefreshCw } from 'lucide-react'; import { CheckCircle2, ChevronLeft, X, UserCheck, MessageSquare, Layers, RefreshCw, Trash2 } from 'lucide-react';
import { Typography } from '@/components/ui/typography'; import { Typography } from '@/components/ui/typography';
import { Button } from '@/components/ui/button';
import { import {
useGetQuotationSessions, useGetQuotationSessions,
useGetSessionChat, useGetSessionChat,
@ -47,6 +48,8 @@ type QuotationDetailSheetProps = {
onNotify: (qtId: string) => Promise<void>; onNotify: (qtId: string) => Promise<void>;
/** 협상 초청 메일 — 세션(공급사) 단위 재발송. */ /** 협상 초청 메일 — 세션(공급사) 단위 재발송. */
onNotifySession: (sessionId: string, qtId: string) => Promise<void>; onNotifySession: (sessionId: string, qtId: string) => Promise<void>;
/** 넘기면 헤더에 삭제 버튼 노출(개발자 전용 — 페이지에서 권한 판정 후 주입). 삭제 성공 시 시트를 닫는다. */
onDelete?: (id: string, name: string) => void;
onClose: () => void; onClose: () => void;
}; };
@ -58,6 +61,7 @@ export function QuotationDetailSheet({
onRegenerate, onRegenerate,
onNotify, onNotify,
onNotifySession, onNotifySession,
onDelete,
onClose, onClose,
}: QuotationDetailSheetProps) { }: QuotationDetailSheetProps) {
const [activeTab, setActiveTab] = useState<DrawerTab>('status'); const [activeTab, setActiveTab] = useState<DrawerTab>('status');
@ -215,6 +219,18 @@ export function QuotationDetailSheet({
</button> </button>
); );
})()} })()}
{/* 견적 삭제 — 개발자만(onDelete 주입 시). 연결된 협상 세션·대화까지 함께 소프트 삭제 후 시트 닫힘. */}
{onDelete && (
<Button
variant="destructive"
size="sm"
onClick={() => onDelete(quotation.qt_id ?? '', q_name)}
title="이 견적과 연결된 협상 세션·대화를 모두 삭제합니다(되돌릴 수 없음)."
>
<Trash2 />
</Button>
)}
<button <button
onClick={onClose} onClick={onClose}
className="p-1.5 rounded-full text-muted-foreground hover:bg-muted cursor-pointer" className="p-1.5 rounded-full text-muted-foreground hover:bg-muted cursor-pointer"

View File

@ -23,6 +23,9 @@ type QuotationTableProps = {
onOpenDetail: (id: string) => void; onOpenDetail: (id: string) => void;
/** 견적번호 클릭 → 그 번호로 목록 필터(같은 체인의 차수만 모아 보기). */ /** 견적번호 클릭 → 그 번호로 목록 필터(같은 체인의 차수만 모아 보기). */
onFilterChain?: (number: string) => void; onFilterChain?: (number: string) => void;
/** 미전달 시 선택(체크박스) 컬럼 자체를 숨긴다 — 일괄삭제 권한 없는 계정용(상품·협력사 패턴). */
selectedIds?: string[];
onSelectionChange?: (ids: string[]) => void;
footer?: ReactNode; footer?: ReactNode;
/** 툴바와 한 카드로 붙일 때 테이블 자체 테두리/라운드를 죽이는 용도 */ /** 툴바와 한 카드로 붙일 때 테이블 자체 테두리/라운드를 죽이는 용도 */
className?: string; className?: string;
@ -53,7 +56,7 @@ const outcomeBadgeClass = (state: ChainRoundState) => {
} }
}; };
export function QuotationTable({ data, products, onOpenDetail, onFilterChain, footer, className }: QuotationTableProps) { export function QuotationTable({ data, products, onOpenDetail, onFilterChain, selectedIds, onSelectionChange, footer, className }: QuotationTableProps) {
const label = useLabels(); // 회사 설정 용어 const label = useLabels(); // 회사 설정 용어
return ( return (
<DataTable <DataTable
@ -61,6 +64,7 @@ export function QuotationTable({ data, products, onOpenDetail, onFilterChain, fo
data={data} data={data}
rowKey={(est) => est.id ?? ''} rowKey={(est) => est.id ?? ''}
onRowClick={(est) => onOpenDetail(est.id ?? '')} onRowClick={(est) => onOpenDetail(est.id ?? '')}
selection={selectedIds && onSelectionChange ? { selectedKeys: selectedIds, onSelectionChange } : undefined}
empty="진행 중인 전자 견적 및 자동 협상 계약 내역이 존재하지 않습니다." empty="진행 중인 전자 견적 및 자동 협상 계약 내역이 존재하지 않습니다."
footer={footer} footer={footer}
columns={[ columns={[

View File

@ -16,6 +16,7 @@ import {
useStopQuotation, useStopQuotation,
useAwardQuotation, useAwardQuotation,
useRegenerateQuotation, useRegenerateQuotation,
useDeleteQuotation,
useNotifyQuotation, useNotifyQuotation,
useNotifySession, useNotifySession,
getGetQuotationQueryKey, getGetQuotationQueryKey,
@ -69,6 +70,7 @@ export function useQuotations(params: ListQuotationsParams) {
const stopQuotationMutation = useStopQuotation(); const stopQuotationMutation = useStopQuotation();
const awardQuotationMutation = useAwardQuotation(); const awardQuotationMutation = useAwardQuotation();
const regenerateQuotationMutation = useRegenerateQuotation(); const regenerateQuotationMutation = useRegenerateQuotation();
const deleteQuotationMutation = useDeleteQuotation();
const notifyQuotationMutation = useNotifyQuotation(); const notifyQuotationMutation = useNotifyQuotation();
const notifySessionMutation = useNotifySession(); const notifySessionMutation = useNotifySession();
@ -142,6 +144,17 @@ export function useQuotations(params: ListQuotationsParams) {
} }
}; };
// 견적 삭제(soft-delete) — 서버가 견적 + 연결된 협상 세션·대화를 함께 deleted 처리한다.
// 확인창·토스트는 호출부(페이지)에서 — 단건(상세 시트)·일괄(툴바 선택삭제)이 같은 raw 를 재사용한다(상품·협력사 패턴).
// 실패 시 throw(HTTP 200 이어도 success=false 면 실패). 성공 시 목록 무효화로 서버 정본 재동기화.
const deleteQuotation = async (id: string): Promise<void> => {
const res = await deleteQuotationMutation.mutateAsync({ qtId: id });
if (!res?.result?.success) {
throw new Error(res?.msg ?? res?.result?.desc ?? '견적 삭제 실패');
}
await invalidateQuotations();
};
const invalidateSettings = () => const invalidateSettings = () =>
queryClient.invalidateQueries({ queryKey: getListSettingsQueryKey() }); queryClient.invalidateQueries({ queryKey: getListSettingsQueryKey() });
@ -321,6 +334,7 @@ export function useQuotations(params: ListQuotationsParams) {
quotationSettings, quotationSettings,
closeQuotation, closeQuotation,
awardQuotation, awardQuotation,
deleteQuotation,
addSetting, addSetting,
deleteSetting, deleteSetting,
createQuotation, createQuotation,

View File

@ -1,8 +1,13 @@
import { Settings, Plus, MoreHorizontal } from 'lucide-react'; import { useState } from 'react';
import { Settings, Plus, MoreHorizontal, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu'; import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu';
import { keepPreviousData } from '@tanstack/react-query'; import { keepPreviousData } from '@tanstack/react-query';
import { useOverlayRouter } from '@/lib/useOverlayRouter'; import { useOverlayRouter } from '@/lib/useOverlayRouter';
import { useAuthStore } from '@/stores/auth';
import { confirm } from '@/lib/confirm';
import { showToast } from '@/lib/notify';
import { PageContainer } from '@/components/layout/PageContainer'; import { PageContainer } from '@/components/layout/PageContainer';
import { PageToolbar, SearchInput } from '@/components/layout/PageToolbar'; import { PageToolbar, SearchInput } from '@/components/layout/PageToolbar';
import { TablePagination } from '@/components/ui/table-pagination'; import { TablePagination } from '@/components/ui/table-pagination';
@ -43,6 +48,7 @@ export default function QuotationPage() {
quotationSettings, quotationSettings,
closeQuotation, closeQuotation,
awardQuotation, awardQuotation,
deleteQuotation,
addSetting, addSetting,
deleteSetting, deleteSetting,
createQuotation, createQuotation,
@ -52,6 +58,41 @@ export default function QuotationPage() {
} = useQuotations(params); } = useQuotations(params);
const totalPages = list.totalPages(total); const totalPages = list.totalPages(total);
// 견적 삭제(soft-delete)는 개발자(레벨3)에게만 노출한다 — 다른 변경 액션(마감·재생성)보다 파괴적이라 더 좁게 건다.
const isDeveloper = useAuthStore((s) => s.user?.role === '개발자');
const [selectedIds, setSelectedIds] = useState<string[]>([]);
// 선택 일괄삭제 — 단건 삭제 API 루프(상품·협력사 패턴). 삭제 시 연결된 협상 세션·대화까지 함께 soft-delete.
const handleBulkDelete = async () => {
if (selectedIds.length === 0) return;
if (!(await confirm({ title: '선택 견적 일괄 삭제', description: `선택한 ${selectedIds.length}개 견적을 삭제하시겠습니까? 연결된 협상 세션·대화 내역까지 함께 삭제되며 되돌릴 수 없습니다.`, confirmText: '삭제', destructive: true }))) return;
let ok = 0;
let fail = 0;
for (const id of selectedIds) {
try {
await deleteQuotation(id);
ok += 1;
} catch {
fail += 1;
}
}
setSelectedIds([]);
showToast(fail === 0 ? `${ok}개 견적이 삭제되었습니다.` : `${ok}개 삭제 · ${fail}개 실패`, fail === 0 ? 'info' : 'error');
};
// 단건 삭제(상세 시트) — 확인 후 삭제, 성공 시 true 로 시트를 닫게 한다.
const handleDeleteQuotation = async (id: string, name: string): Promise<boolean> => {
if (!(await confirm({ title: '견적 삭제', description: `[${name}] 견적을 삭제하시겠습니까? 연결된 협상 세션·대화 내역까지 함께 삭제되며 되돌릴 수 없습니다.`, confirmText: '삭제', destructive: true }))) return false;
try {
await deleteQuotation(id);
showToast(`[${name}] 견적이 삭제되었습니다.`, 'info');
return true;
} catch (err) {
showToast(err instanceof Error ? err.message : '견적 삭제에 실패했습니다.', 'error');
return false;
}
};
const overlay = useOverlayRouter(['detail', 'create', 'settings']); const overlay = useOverlayRouter(['detail', 'create', 'settings']);
const detailId = overlay.get('detail'); const detailId = overlay.get('detail');
const isCreateOpen = overlay.has('create'); const isCreateOpen = overlay.has('create');
@ -77,6 +118,19 @@ export default function QuotationPage() {
className="rounded-none border-0 border-b border-border" className="rounded-none border-0 border-b border-border"
actions={ actions={
<> <>
{isDeveloper && (
<Button
variant="outline"
disabled={selectedIds.length === 0}
onClick={handleBulkDelete}
className="text-rose-600 hover:text-rose-700"
>
<Trash2 />
{selectedIds.length > 0 && <Badge variant="destructive">{selectedIds.length}</Badge>}
</Button>
)}
<Button id="quotation-create-btn" onClick={() => overlay.open('create')}> <Button id="quotation-create-btn" onClick={() => overlay.open('create')}>
<Plus /> <Plus />
@ -160,6 +214,8 @@ export default function QuotationPage() {
className="rounded-none border-0" className="rounded-none border-0"
data={quotations} data={quotations}
products={products} products={products}
selectedIds={isDeveloper ? selectedIds : undefined}
onSelectionChange={isDeveloper ? setSelectedIds : undefined}
onOpenDetail={(id) => overlay.open('detail', id)} onOpenDetail={(id) => overlay.open('detail', id)}
onFilterChain={(number) => { onFilterChain={(number) => {
// 견적번호 클릭 → 검색어를 그 번호로 즉시 세팅(같은 체인의 차수만 모아 보기). // 견적번호 클릭 → 검색어를 그 번호로 즉시 세팅(같은 체인의 차수만 모아 보기).
@ -190,6 +246,8 @@ export default function QuotationPage() {
onRegenerate={regenerateQuotation} onRegenerate={regenerateQuotation}
onNotify={notifyQuotation} onNotify={notifyQuotation}
onNotifySession={notifySession} onNotifySession={notifySession}
// 개발자만 삭제 버튼 노출 — 실제 삭제(확인+성공) 시에만 시트를 닫는다.
onDelete={isDeveloper ? async (id, name) => { if (await handleDeleteQuotation(id, name)) overlay.close(); } : undefined}
onClose={overlay.close} onClose={overlay.close}
/> />
)} )}