[feat] negodata: 견적 삭제(개발자 전용)·연결 데이터 캐스케이드 soft-delete
- 견적관리 체크박스 선택 + 툴바 '선택 삭제'(상품·협력사 패턴), 상세 시트 단건 '삭제' - 백엔드 soft_delete 를 연결 협상 세션·대화까지 한 트랜잭션 캐스케이드 - 체크박스·선택삭제·상세삭제 모두 개발자(role 3)에게만 노출
This commit is contained in:
parent
2a004734d8
commit
5520e3c2b6
@ -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
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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={[
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user