807 lines
28 KiB
TypeScript
807 lines
28 KiB
TypeScript
|
|
'use client';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 견적관리 클라이언트 컴포넌트
|
||
|
|
*
|
||
|
|
* API 연동된 견적 관리 페이지
|
||
|
|
* - PageHeader, StatCards, SearchFilter, 체크박스
|
||
|
|
* - 데스크톱: TabsList + DataTable
|
||
|
|
* - 모바일: 커스텀 버튼 탭 + 카드 리스트
|
||
|
|
* - 완전한 반응형 지원
|
||
|
|
*/
|
||
|
|
|
||
|
|
import { useState, useRef, useEffect, useTransition, useCallback } from 'react';
|
||
|
|
import { useRouter } from 'next/navigation';
|
||
|
|
import {
|
||
|
|
FileText,
|
||
|
|
Edit,
|
||
|
|
Trash2,
|
||
|
|
CheckCircle,
|
||
|
|
History,
|
||
|
|
Calculator,
|
||
|
|
} from 'lucide-react';
|
||
|
|
import { Button } from '@/components/ui/button';
|
||
|
|
import { Badge } from '@/components/ui/badge';
|
||
|
|
import { getQuoteStatusBadge } from '@/components/atoms/BadgeSm';
|
||
|
|
import {
|
||
|
|
IntegratedListTemplateV2,
|
||
|
|
TabOption,
|
||
|
|
TableColumn,
|
||
|
|
} from '@/components/templates/IntegratedListTemplateV2';
|
||
|
|
import { toast } from 'sonner';
|
||
|
|
import { StandardDialog } from '@/components/molecules/StandardDialog';
|
||
|
|
import {
|
||
|
|
Table,
|
||
|
|
TableHeader,
|
||
|
|
TableRow,
|
||
|
|
TableHead,
|
||
|
|
TableBody,
|
||
|
|
TableCell,
|
||
|
|
} from '@/components/ui/table';
|
||
|
|
import { Separator } from '@/components/ui/separator';
|
||
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
||
|
|
import { formatAmount, formatAmountManwon } from '@/utils/formatAmount';
|
||
|
|
import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard';
|
||
|
|
import {
|
||
|
|
AlertDialog,
|
||
|
|
AlertDialogAction,
|
||
|
|
AlertDialogCancel,
|
||
|
|
AlertDialogContent,
|
||
|
|
AlertDialogDescription,
|
||
|
|
AlertDialogFooter,
|
||
|
|
AlertDialogHeader,
|
||
|
|
AlertDialogTitle,
|
||
|
|
} from '@/components/ui/alert-dialog';
|
||
|
|
import type { Quote, QuoteFilterType } from './types';
|
||
|
|
import { PRODUCT_CATEGORY_LABELS } from './types';
|
||
|
|
import { getQuotes, deleteQuote, bulkDeleteQuotes } from './actions';
|
||
|
|
import type { PaginationMeta } from './actions';
|
||
|
|
|
||
|
|
// ===== Props 타입 =====
|
||
|
|
interface QuoteManagementClientProps {
|
||
|
|
initialData: Quote[];
|
||
|
|
initialPagination: PaginationMeta;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function QuoteManagementClient({
|
||
|
|
initialData,
|
||
|
|
initialPagination,
|
||
|
|
}: QuoteManagementClientProps) {
|
||
|
|
const router = useRouter();
|
||
|
|
const [isPending, startTransition] = useTransition();
|
||
|
|
|
||
|
|
// 상태
|
||
|
|
const [quotes, setQuotes] = useState<Quote[]>(initialData);
|
||
|
|
const [pagination, setPagination] = useState(initialPagination);
|
||
|
|
const [searchTerm, setSearchTerm] = useState('');
|
||
|
|
const [filterType, setFilterType] = useState<QuoteFilterType>('all');
|
||
|
|
const [isCalculationDialogOpen, setIsCalculationDialogOpen] = useState(false);
|
||
|
|
const [calculationQuote, setCalculationQuote] = useState<Quote | null>(null);
|
||
|
|
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||
|
|
const [currentPage, setCurrentPage] = useState(1);
|
||
|
|
const itemsPerPage = 20;
|
||
|
|
|
||
|
|
// 삭제 확인 다이얼로그 state
|
||
|
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||
|
|
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||
|
|
|
||
|
|
// 일괄 삭제 확인 다이얼로그 state
|
||
|
|
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
|
||
|
|
|
||
|
|
// 모바일 인피니티 스크롤 state
|
||
|
|
const [mobileDisplayCount, setMobileDisplayCount] = useState(20);
|
||
|
|
const sentinelRef = useRef<HTMLDivElement>(null);
|
||
|
|
|
||
|
|
// API에서 데이터 다시 불러오기
|
||
|
|
const refreshData = useCallback(async () => {
|
||
|
|
startTransition(async () => {
|
||
|
|
const result = await getQuotes({
|
||
|
|
page: currentPage,
|
||
|
|
perPage: itemsPerPage,
|
||
|
|
search: searchTerm || undefined,
|
||
|
|
});
|
||
|
|
|
||
|
|
if (result.success) {
|
||
|
|
setQuotes(result.data);
|
||
|
|
setPagination(result.pagination);
|
||
|
|
} else {
|
||
|
|
toast.error(result.error || '데이터 조회에 실패했습니다.');
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}, [currentPage, searchTerm]);
|
||
|
|
|
||
|
|
// 검색 또는 페이지 변경 시 데이터 새로고침
|
||
|
|
useEffect(() => {
|
||
|
|
if (currentPage > 1 || searchTerm) {
|
||
|
|
refreshData();
|
||
|
|
}
|
||
|
|
}, [currentPage, refreshData]);
|
||
|
|
|
||
|
|
// 필터링된 데이터 (클라이언트 사이드 필터링)
|
||
|
|
const filteredQuotes = quotes.filter((quote) => {
|
||
|
|
// 탭 필터
|
||
|
|
if (filterType === 'initial') {
|
||
|
|
return quote.currentRevision === 0 && !quote.isFinal && quote.status !== 'converted';
|
||
|
|
} else if (filterType === 'revising') {
|
||
|
|
return quote.currentRevision > 0 && !quote.isFinal && quote.status !== 'converted';
|
||
|
|
} else if (filterType === 'final') {
|
||
|
|
return quote.isFinal && quote.status !== 'converted';
|
||
|
|
} else if (filterType === 'converted') {
|
||
|
|
return quote.status === 'converted';
|
||
|
|
}
|
||
|
|
return true;
|
||
|
|
}).sort((a, b) => {
|
||
|
|
return new Date(b.registrationDate).getTime() - new Date(a.registrationDate).getTime();
|
||
|
|
});
|
||
|
|
|
||
|
|
// 페이지네이션
|
||
|
|
const totalPages = Math.ceil(filteredQuotes.length / itemsPerPage);
|
||
|
|
const paginatedQuotes = filteredQuotes.slice(
|
||
|
|
(currentPage - 1) * itemsPerPage,
|
||
|
|
currentPage * itemsPerPage
|
||
|
|
);
|
||
|
|
|
||
|
|
// 모바일용 인피니티 스크롤 데이터
|
||
|
|
const mobileQuotes = filteredQuotes.slice(0, mobileDisplayCount);
|
||
|
|
|
||
|
|
// Intersection Observer를 이용한 인피니티 스크롤
|
||
|
|
useEffect(() => {
|
||
|
|
if (typeof window === 'undefined') return;
|
||
|
|
if (window.innerWidth >= 1280) return;
|
||
|
|
|
||
|
|
const observer = new IntersectionObserver(
|
||
|
|
(entries) => {
|
||
|
|
if (entries[0].isIntersecting && mobileDisplayCount < filteredQuotes.length) {
|
||
|
|
setMobileDisplayCount((prev) => Math.min(prev + 20, filteredQuotes.length));
|
||
|
|
}
|
||
|
|
},
|
||
|
|
{ threshold: 0.1, rootMargin: '100px' }
|
||
|
|
);
|
||
|
|
|
||
|
|
if (sentinelRef.current) {
|
||
|
|
observer.observe(sentinelRef.current);
|
||
|
|
}
|
||
|
|
|
||
|
|
return () => observer.disconnect();
|
||
|
|
}, [mobileDisplayCount, filteredQuotes.length]);
|
||
|
|
|
||
|
|
// 탭이나 검색어 변경 시 모바일 표시 개수 초기화
|
||
|
|
useEffect(() => {
|
||
|
|
setMobileDisplayCount(20);
|
||
|
|
}, [searchTerm, filterType]);
|
||
|
|
|
||
|
|
// 통계 계산
|
||
|
|
const now = new Date();
|
||
|
|
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||
|
|
const startOfWeek = new Date(now);
|
||
|
|
startOfWeek.setDate(now.getDate() - now.getDay());
|
||
|
|
|
||
|
|
const thisMonthQuotes = quotes.filter(
|
||
|
|
(q) => new Date(q.registrationDate) >= startOfMonth
|
||
|
|
);
|
||
|
|
const thisMonthAmount = thisMonthQuotes.reduce((sum, q) => sum + q.totalAmount, 0);
|
||
|
|
|
||
|
|
const ongoingQuotes = quotes.filter((q) => q.status === 'draft');
|
||
|
|
const ongoingAmount = ongoingQuotes.reduce((sum, q) => sum + q.totalAmount, 0);
|
||
|
|
|
||
|
|
const thisWeekQuotes = quotes.filter(
|
||
|
|
(q) => new Date(q.registrationDate) >= startOfWeek
|
||
|
|
);
|
||
|
|
|
||
|
|
const thisMonthConvertedCount = thisMonthQuotes.filter(
|
||
|
|
(q) => q.status === 'converted'
|
||
|
|
).length;
|
||
|
|
const thisMonthConversionRate =
|
||
|
|
thisMonthQuotes.length > 0
|
||
|
|
? ((thisMonthConvertedCount / thisMonthQuotes.length) * 100).toFixed(1)
|
||
|
|
: '0.0';
|
||
|
|
|
||
|
|
const stats = [
|
||
|
|
{
|
||
|
|
label: '이번 달 견적 금액',
|
||
|
|
value: formatAmountManwon(thisMonthAmount),
|
||
|
|
icon: Calculator,
|
||
|
|
iconColor: 'text-blue-600',
|
||
|
|
},
|
||
|
|
{
|
||
|
|
label: '진행중 견적 금액',
|
||
|
|
value: formatAmountManwon(ongoingAmount),
|
||
|
|
icon: FileText,
|
||
|
|
iconColor: 'text-orange-600',
|
||
|
|
},
|
||
|
|
{
|
||
|
|
label: '이번 주 신규 견적',
|
||
|
|
value: `${thisWeekQuotes.length}건`,
|
||
|
|
icon: Edit,
|
||
|
|
iconColor: 'text-green-600',
|
||
|
|
},
|
||
|
|
{
|
||
|
|
label: '이번 달 수주 전환율',
|
||
|
|
value: `${thisMonthConversionRate}%`,
|
||
|
|
icon: CheckCircle,
|
||
|
|
iconColor: 'text-purple-600',
|
||
|
|
},
|
||
|
|
];
|
||
|
|
|
||
|
|
// 핸들러
|
||
|
|
const handleView = (quote: Quote) => {
|
||
|
|
router.push(`/sales/quote-management/${quote.id}`);
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleEdit = (quote: Quote) => {
|
||
|
|
router.push(`/sales/quote-management/${quote.id}/edit`);
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleDelete = (quoteId: string) => {
|
||
|
|
setDeleteTargetId(quoteId);
|
||
|
|
setIsDeleteDialogOpen(true);
|
||
|
|
};
|
||
|
|
|
||
|
|
// 삭제 확인 후 실행
|
||
|
|
const handleConfirmDelete = async () => {
|
||
|
|
if (!deleteTargetId) return;
|
||
|
|
|
||
|
|
startTransition(async () => {
|
||
|
|
const result = await deleteQuote(deleteTargetId);
|
||
|
|
|
||
|
|
if (result.success) {
|
||
|
|
const quote = quotes.find((q) => q.id === deleteTargetId);
|
||
|
|
setQuotes(quotes.filter((q) => q.id !== deleteTargetId));
|
||
|
|
toast.success(`견적이 삭제되었습니다${quote ? `: ${quote.quoteNumber}` : ''}`);
|
||
|
|
} else {
|
||
|
|
toast.error(result.error || '삭제에 실패했습니다.');
|
||
|
|
}
|
||
|
|
|
||
|
|
setIsDeleteDialogOpen(false);
|
||
|
|
setDeleteTargetId(null);
|
||
|
|
});
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleViewHistory = (quote: Quote) => {
|
||
|
|
toast.info(`수정 이력: ${quote.quoteNumber} (${quote.currentRevision}차 수정)`);
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleViewCalculation = (quote: Quote) => {
|
||
|
|
setCalculationQuote(quote);
|
||
|
|
setIsCalculationDialogOpen(true);
|
||
|
|
};
|
||
|
|
|
||
|
|
// 체크박스 선택
|
||
|
|
const toggleSelection = (id: string) => {
|
||
|
|
const newSelection = new Set(selectedItems);
|
||
|
|
if (newSelection.has(id)) {
|
||
|
|
newSelection.delete(id);
|
||
|
|
} else {
|
||
|
|
newSelection.add(id);
|
||
|
|
}
|
||
|
|
setSelectedItems(newSelection);
|
||
|
|
};
|
||
|
|
|
||
|
|
const toggleSelectAll = () => {
|
||
|
|
if (selectedItems.size === paginatedQuotes.length && paginatedQuotes.length > 0) {
|
||
|
|
setSelectedItems(new Set());
|
||
|
|
} else {
|
||
|
|
setSelectedItems(new Set(paginatedQuotes.map((q) => q.id)));
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// 일괄 삭제
|
||
|
|
const handleBulkDelete = () => {
|
||
|
|
if (selectedItems.size === 0) {
|
||
|
|
toast.error('삭제할 항목을 선택해주세요');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
setIsBulkDeleteDialogOpen(true);
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleConfirmBulkDelete = async () => {
|
||
|
|
startTransition(async () => {
|
||
|
|
const result = await bulkDeleteQuotes(Array.from(selectedItems));
|
||
|
|
|
||
|
|
if (result.success) {
|
||
|
|
setQuotes(quotes.filter((q) => !selectedItems.has(q.id)));
|
||
|
|
toast.success(`${selectedItems.size}개의 견적이 삭제되었습니다`);
|
||
|
|
setSelectedItems(new Set());
|
||
|
|
} else {
|
||
|
|
toast.error(result.error || '일괄 삭제에 실패했습니다.');
|
||
|
|
}
|
||
|
|
|
||
|
|
setIsBulkDeleteDialogOpen(false);
|
||
|
|
});
|
||
|
|
};
|
||
|
|
|
||
|
|
// 상태 뱃지
|
||
|
|
const getRevisionBadge = (quote: Quote) => {
|
||
|
|
// getQuoteStatusBadge 함수에 맞게 변환
|
||
|
|
const legacyQuote = {
|
||
|
|
status: quote.status === 'converted' ? 'converted' : 'draft',
|
||
|
|
currentRevision: quote.currentRevision,
|
||
|
|
isFinal: quote.isFinal,
|
||
|
|
};
|
||
|
|
return getQuoteStatusBadge(legacyQuote as any);
|
||
|
|
};
|
||
|
|
|
||
|
|
// 탭 구성
|
||
|
|
const tabs: TabOption[] = [
|
||
|
|
{
|
||
|
|
value: 'all',
|
||
|
|
label: '전체',
|
||
|
|
count: quotes.length,
|
||
|
|
color: 'blue',
|
||
|
|
},
|
||
|
|
{
|
||
|
|
value: 'initial',
|
||
|
|
label: '최초작성',
|
||
|
|
count: quotes.filter(
|
||
|
|
(q) => q.currentRevision === 0 && !q.isFinal && q.status !== 'converted'
|
||
|
|
).length,
|
||
|
|
color: 'gray',
|
||
|
|
},
|
||
|
|
{
|
||
|
|
value: 'revising',
|
||
|
|
label: '수정중',
|
||
|
|
count: quotes.filter(
|
||
|
|
(q) => q.currentRevision > 0 && !q.isFinal && q.status !== 'converted'
|
||
|
|
).length,
|
||
|
|
color: 'orange',
|
||
|
|
},
|
||
|
|
{
|
||
|
|
value: 'final',
|
||
|
|
label: '최종확정',
|
||
|
|
count: quotes.filter((q) => q.isFinal && q.status !== 'converted').length,
|
||
|
|
color: 'green',
|
||
|
|
},
|
||
|
|
{
|
||
|
|
value: 'converted',
|
||
|
|
label: '수주전환',
|
||
|
|
count: quotes.filter((q) => q.status === 'converted').length,
|
||
|
|
color: 'purple',
|
||
|
|
},
|
||
|
|
];
|
||
|
|
|
||
|
|
// 테이블 컬럼 정의
|
||
|
|
const tableColumns: TableColumn[] = [
|
||
|
|
{ key: 'rowNumber', label: '번호', className: 'px-4' },
|
||
|
|
{ key: 'quoteNumber', label: '견적번호', className: 'px-4' },
|
||
|
|
{ key: 'registrationDate', label: '접수일', className: 'px-4' },
|
||
|
|
{ key: 'status', label: '상태', className: 'px-4' },
|
||
|
|
{ key: 'productCategory', label: '제품분류', className: 'px-4' },
|
||
|
|
{ key: 'quantity', label: '수량', className: 'px-4' },
|
||
|
|
{ key: 'amount', label: '금액', className: 'px-4' },
|
||
|
|
{ key: 'client', label: '발주처', className: 'px-4' },
|
||
|
|
{ key: 'site', label: '현장명', className: 'px-4' },
|
||
|
|
{ key: 'manager', label: '담당자', className: 'px-4' },
|
||
|
|
{ key: 'remarks', label: '비고', className: 'px-4' },
|
||
|
|
{ key: 'actions', label: '작업', className: 'px-4' },
|
||
|
|
];
|
||
|
|
|
||
|
|
// 테이블 행 렌더링
|
||
|
|
const renderTableRow = (quote: Quote, index: number, globalIndex: number) => {
|
||
|
|
const itemId = quote.id;
|
||
|
|
const isSelected = selectedItems.has(itemId);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<TableRow
|
||
|
|
key={quote.id}
|
||
|
|
className={`cursor-pointer hover:bg-muted/50 ${isSelected ? 'bg-blue-50' : ''}`}
|
||
|
|
onClick={() => handleView(quote)}
|
||
|
|
>
|
||
|
|
<TableCell onClick={(e) => e.stopPropagation()} className="text-center">
|
||
|
|
<Checkbox
|
||
|
|
checked={isSelected}
|
||
|
|
onCheckedChange={() => toggleSelection(itemId)}
|
||
|
|
/>
|
||
|
|
</TableCell>
|
||
|
|
<TableCell>{globalIndex}</TableCell>
|
||
|
|
<TableCell>
|
||
|
|
<code className="text-xs bg-gray-100 text-gray-700 px-2 py-1 rounded font-mono">
|
||
|
|
{quote.quoteNumber || '-'}
|
||
|
|
</code>
|
||
|
|
</TableCell>
|
||
|
|
<TableCell>{quote.registrationDate}</TableCell>
|
||
|
|
<TableCell>{getRevisionBadge(quote)}</TableCell>
|
||
|
|
<TableCell>
|
||
|
|
<Badge variant="outline">
|
||
|
|
{PRODUCT_CATEGORY_LABELS[quote.productCategory] || quote.productCategory}
|
||
|
|
</Badge>
|
||
|
|
</TableCell>
|
||
|
|
<TableCell className="text-center">{quote.quantity}</TableCell>
|
||
|
|
<TableCell className="text-right">{formatAmount(quote.totalAmount)}</TableCell>
|
||
|
|
<TableCell>{quote.clientName}</TableCell>
|
||
|
|
<TableCell>
|
||
|
|
<div className="flex flex-col gap-1">
|
||
|
|
<span>{quote.siteName || '-'}</span>
|
||
|
|
{quote.siteCode && (
|
||
|
|
<code className="inline-block w-fit text-xs bg-gray-100 text-gray-700 px-1.5 py-0.5 rounded font-mono whitespace-nowrap">
|
||
|
|
{quote.siteCode}
|
||
|
|
</code>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</TableCell>
|
||
|
|
<TableCell>{quote.managerName || '-'}</TableCell>
|
||
|
|
<TableCell>
|
||
|
|
<div className="max-w-[200px] line-clamp-2 text-sm">
|
||
|
|
{quote.description || '-'}
|
||
|
|
</div>
|
||
|
|
</TableCell>
|
||
|
|
<TableCell onClick={(e) => e.stopPropagation()}>
|
||
|
|
{isSelected && (
|
||
|
|
<div className="flex gap-1">
|
||
|
|
{quote.currentRevision > 0 && (
|
||
|
|
<Button
|
||
|
|
variant="ghost"
|
||
|
|
size="sm"
|
||
|
|
onClick={() => handleViewHistory(quote)}
|
||
|
|
>
|
||
|
|
<History className="h-4 w-4" />
|
||
|
|
</Button>
|
||
|
|
)}
|
||
|
|
<Button
|
||
|
|
variant="ghost"
|
||
|
|
size="sm"
|
||
|
|
onClick={() => handleEdit(quote)}
|
||
|
|
>
|
||
|
|
<Edit className="h-4 w-4" />
|
||
|
|
</Button>
|
||
|
|
{!quote.isFinal && (
|
||
|
|
<Button
|
||
|
|
variant="ghost"
|
||
|
|
size="sm"
|
||
|
|
onClick={() => handleDelete(quote.id)}
|
||
|
|
disabled={isPending}
|
||
|
|
>
|
||
|
|
<Trash2 className="h-4 w-4 text-red-500" />
|
||
|
|
</Button>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</TableCell>
|
||
|
|
</TableRow>
|
||
|
|
);
|
||
|
|
};
|
||
|
|
|
||
|
|
// 모바일 카드 렌더링
|
||
|
|
const renderMobileCard = (
|
||
|
|
quote: Quote,
|
||
|
|
index: number,
|
||
|
|
globalIndex: number,
|
||
|
|
isSelected: boolean,
|
||
|
|
onToggle: () => void
|
||
|
|
) => {
|
||
|
|
return (
|
||
|
|
<ListMobileCard
|
||
|
|
key={quote.id}
|
||
|
|
id={quote.id}
|
||
|
|
isSelected={isSelected}
|
||
|
|
onToggleSelection={onToggle}
|
||
|
|
onCardClick={() => handleView(quote)}
|
||
|
|
headerBadges={
|
||
|
|
<>
|
||
|
|
<Badge
|
||
|
|
variant="outline"
|
||
|
|
className="bg-gray-100 text-gray-700 font-mono text-xs"
|
||
|
|
>
|
||
|
|
#{globalIndex}
|
||
|
|
</Badge>
|
||
|
|
<code className="inline-block text-xs bg-gray-100 text-gray-700 px-2.5 py-0.5 rounded-md font-mono whitespace-nowrap">
|
||
|
|
{quote.quoteNumber}
|
||
|
|
</code>
|
||
|
|
</>
|
||
|
|
}
|
||
|
|
title={quote.clientName}
|
||
|
|
statusBadge={getRevisionBadge(quote)}
|
||
|
|
infoGrid={
|
||
|
|
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||
|
|
<InfoField label="현장명" value={quote.siteName || '-'} />
|
||
|
|
<InfoField label="현장코드" value={quote.siteCode || '-'} />
|
||
|
|
<InfoField label="접수일" value={quote.registrationDate} />
|
||
|
|
<InfoField label="담당자" value={quote.managerName || '-'} />
|
||
|
|
<InfoField
|
||
|
|
label="제품분류"
|
||
|
|
value={PRODUCT_CATEGORY_LABELS[quote.productCategory] || quote.productCategory}
|
||
|
|
/>
|
||
|
|
<InfoField label="수량" value={`${quote.quantity}개`} />
|
||
|
|
<InfoField
|
||
|
|
label="총 금액"
|
||
|
|
value={formatAmount(quote.totalAmount)}
|
||
|
|
valueClassName="text-green-600"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
}
|
||
|
|
actions={
|
||
|
|
isSelected ? (
|
||
|
|
<div className="flex gap-2 flex-wrap">
|
||
|
|
{quote.currentRevision > 0 && (
|
||
|
|
<Button
|
||
|
|
variant="outline"
|
||
|
|
size="default"
|
||
|
|
className="flex-1 min-w-[100px] h-11"
|
||
|
|
onClick={(e) => {
|
||
|
|
e.stopPropagation();
|
||
|
|
handleViewHistory(quote);
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<History className="h-4 w-4 mr-2" />
|
||
|
|
이력
|
||
|
|
</Button>
|
||
|
|
)}
|
||
|
|
<Button
|
||
|
|
variant="default"
|
||
|
|
size="default"
|
||
|
|
className="flex-1 min-w-[100px] h-11"
|
||
|
|
onClick={(e) => {
|
||
|
|
e.stopPropagation();
|
||
|
|
handleEdit(quote);
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<Edit className="h-4 w-4 mr-2" />
|
||
|
|
수정
|
||
|
|
</Button>
|
||
|
|
{!quote.isFinal && (
|
||
|
|
<Button
|
||
|
|
variant="outline"
|
||
|
|
size="default"
|
||
|
|
className="flex-1 min-w-[100px] h-11 border-red-200 text-red-600 hover:border-red-300 bg-[rgba(255,255,255,0)]"
|
||
|
|
onClick={(e) => {
|
||
|
|
e.stopPropagation();
|
||
|
|
handleDelete(quote.id);
|
||
|
|
}}
|
||
|
|
disabled={isPending}
|
||
|
|
>
|
||
|
|
<Trash2 className="h-4 w-4 mr-2" />
|
||
|
|
삭제
|
||
|
|
</Button>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
) : undefined
|
||
|
|
}
|
||
|
|
/>
|
||
|
|
);
|
||
|
|
};
|
||
|
|
|
||
|
|
// 검색 핸들러 (디바운스 처리)
|
||
|
|
const handleSearchChange = useCallback((value: string) => {
|
||
|
|
setSearchTerm(value);
|
||
|
|
setCurrentPage(1);
|
||
|
|
|
||
|
|
// 디바운스된 API 호출
|
||
|
|
const timeoutId = setTimeout(() => {
|
||
|
|
startTransition(async () => {
|
||
|
|
const result = await getQuotes({
|
||
|
|
page: 1,
|
||
|
|
perPage: itemsPerPage,
|
||
|
|
search: value || undefined,
|
||
|
|
});
|
||
|
|
|
||
|
|
if (result.success) {
|
||
|
|
setQuotes(result.data);
|
||
|
|
setPagination(result.pagination);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}, 300);
|
||
|
|
|
||
|
|
return () => clearTimeout(timeoutId);
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<>
|
||
|
|
<IntegratedListTemplateV2
|
||
|
|
title="견적 목록"
|
||
|
|
description="견적서 작성 및 관리"
|
||
|
|
icon={FileText}
|
||
|
|
headerActions={
|
||
|
|
<Button
|
||
|
|
className="ml-auto"
|
||
|
|
onClick={() => router.push('/sales/quote-management/new')}
|
||
|
|
>
|
||
|
|
<FileText className="w-4 h-4 mr-2" />
|
||
|
|
견적 등록
|
||
|
|
</Button>
|
||
|
|
}
|
||
|
|
stats={stats}
|
||
|
|
searchValue={searchTerm}
|
||
|
|
onSearchChange={handleSearchChange}
|
||
|
|
searchPlaceholder="견적번호, 발주처, 담당자, 현장코드, 현장명 검색..."
|
||
|
|
tabs={tabs}
|
||
|
|
activeTab={filterType}
|
||
|
|
onTabChange={(value) => {
|
||
|
|
setFilterType(value as QuoteFilterType);
|
||
|
|
setCurrentPage(1);
|
||
|
|
}}
|
||
|
|
tableColumns={tableColumns}
|
||
|
|
tableTitle={`${tabs.find((t) => t.value === filterType)?.label || '전체'} (${filteredQuotes.length}개)`}
|
||
|
|
data={paginatedQuotes}
|
||
|
|
totalCount={filteredQuotes.length}
|
||
|
|
allData={mobileQuotes}
|
||
|
|
mobileDisplayCount={mobileDisplayCount}
|
||
|
|
infinityScrollSentinelRef={sentinelRef}
|
||
|
|
selectedItems={selectedItems}
|
||
|
|
onToggleSelection={toggleSelection}
|
||
|
|
onToggleSelectAll={toggleSelectAll}
|
||
|
|
onBulkDelete={handleBulkDelete}
|
||
|
|
getItemId={(quote) => quote.id}
|
||
|
|
renderTableRow={renderTableRow}
|
||
|
|
renderMobileCard={renderMobileCard}
|
||
|
|
pagination={{
|
||
|
|
currentPage,
|
||
|
|
totalPages,
|
||
|
|
totalItems: filteredQuotes.length,
|
||
|
|
itemsPerPage,
|
||
|
|
onPageChange: setCurrentPage,
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
|
||
|
|
{/* 산출내역서 다이얼로그 */}
|
||
|
|
<StandardDialog
|
||
|
|
open={isCalculationDialogOpen}
|
||
|
|
onOpenChange={setIsCalculationDialogOpen}
|
||
|
|
title="산출내역서"
|
||
|
|
description={
|
||
|
|
calculationQuote
|
||
|
|
? `견적번호: ${calculationQuote.quoteNumber} | 발주처: ${calculationQuote.clientName}`
|
||
|
|
: ''
|
||
|
|
}
|
||
|
|
size="xl"
|
||
|
|
footer={
|
||
|
|
<Button onClick={() => setIsCalculationDialogOpen(false)}>닫기</Button>
|
||
|
|
}
|
||
|
|
>
|
||
|
|
{calculationQuote && (
|
||
|
|
<div className="space-y-6">
|
||
|
|
{/* 기본 정보 */}
|
||
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 p-4 bg-muted/50 rounded-lg">
|
||
|
|
<div>
|
||
|
|
<p className="text-sm text-muted-foreground">견적번호</p>
|
||
|
|
<p className="font-medium">{calculationQuote.quoteNumber}</p>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<p className="text-sm text-muted-foreground">발주처</p>
|
||
|
|
<p className="font-medium">{calculationQuote.clientName}</p>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<p className="text-sm text-muted-foreground">현장명</p>
|
||
|
|
<p className="font-medium">{calculationQuote.siteName || '-'}</p>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<p className="text-sm text-muted-foreground">접수일</p>
|
||
|
|
<p className="font-medium">{calculationQuote.registrationDate}</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<Separator />
|
||
|
|
|
||
|
|
{/* 산출 내역 테이블 */}
|
||
|
|
<div>
|
||
|
|
<h4 className="font-semibold mb-3 flex items-center gap-2">
|
||
|
|
<Calculator className="w-5 h-5" />
|
||
|
|
산출 내역
|
||
|
|
</h4>
|
||
|
|
<div className="border rounded-lg overflow-hidden">
|
||
|
|
<Table>
|
||
|
|
<TableHeader>
|
||
|
|
<TableRow>
|
||
|
|
<TableHead className="w-[60px]">번호</TableHead>
|
||
|
|
<TableHead>품목명</TableHead>
|
||
|
|
<TableHead>규격</TableHead>
|
||
|
|
<TableHead className="text-center">수량</TableHead>
|
||
|
|
<TableHead className="text-right">단가</TableHead>
|
||
|
|
<TableHead className="text-right">공급가</TableHead>
|
||
|
|
<TableHead className="text-right">부가세</TableHead>
|
||
|
|
<TableHead className="text-right">합계</TableHead>
|
||
|
|
</TableRow>
|
||
|
|
</TableHeader>
|
||
|
|
<TableBody>
|
||
|
|
{calculationQuote.items.length > 0 ? (
|
||
|
|
calculationQuote.items.map((item, idx) => (
|
||
|
|
<TableRow key={item.id}>
|
||
|
|
<TableCell>{idx + 1}</TableCell>
|
||
|
|
<TableCell>{item.productName}</TableCell>
|
||
|
|
<TableCell>{item.specification || '-'}</TableCell>
|
||
|
|
<TableCell className="text-center">{item.quantity}</TableCell>
|
||
|
|
<TableCell className="text-right">
|
||
|
|
{formatAmount(item.unitPrice)}원
|
||
|
|
</TableCell>
|
||
|
|
<TableCell className="text-right">
|
||
|
|
{formatAmount(item.supplyAmount)}원
|
||
|
|
</TableCell>
|
||
|
|
<TableCell className="text-right">
|
||
|
|
{formatAmount(item.taxAmount)}원
|
||
|
|
</TableCell>
|
||
|
|
<TableCell className="text-right font-medium">
|
||
|
|
{formatAmount(item.totalAmount)}원
|
||
|
|
</TableCell>
|
||
|
|
</TableRow>
|
||
|
|
))
|
||
|
|
) : (
|
||
|
|
<TableRow>
|
||
|
|
<TableCell>1</TableCell>
|
||
|
|
<TableCell>
|
||
|
|
{PRODUCT_CATEGORY_LABELS[calculationQuote.productCategory]}
|
||
|
|
</TableCell>
|
||
|
|
<TableCell>-</TableCell>
|
||
|
|
<TableCell className="text-center">
|
||
|
|
{calculationQuote.quantity}
|
||
|
|
</TableCell>
|
||
|
|
<TableCell className="text-right">
|
||
|
|
{formatAmount(
|
||
|
|
Math.floor(calculationQuote.totalAmount / calculationQuote.quantity)
|
||
|
|
)}
|
||
|
|
원
|
||
|
|
</TableCell>
|
||
|
|
<TableCell className="text-right">
|
||
|
|
{formatAmount(calculationQuote.supplyAmount)}원
|
||
|
|
</TableCell>
|
||
|
|
<TableCell className="text-right">
|
||
|
|
{formatAmount(calculationQuote.taxAmount)}원
|
||
|
|
</TableCell>
|
||
|
|
<TableCell className="text-right font-medium">
|
||
|
|
{formatAmount(calculationQuote.totalAmount)}원
|
||
|
|
</TableCell>
|
||
|
|
</TableRow>
|
||
|
|
)}
|
||
|
|
</TableBody>
|
||
|
|
</Table>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 합계 */}
|
||
|
|
<div className="bg-muted/50 p-4 rounded-lg">
|
||
|
|
<div className="flex justify-between items-center">
|
||
|
|
<span className="font-semibold">총 금액</span>
|
||
|
|
<span className="text-xl font-bold text-primary">
|
||
|
|
{formatAmount(calculationQuote.totalAmount)}원
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</StandardDialog>
|
||
|
|
|
||
|
|
{/* 삭제 확인 다이얼로그 */}
|
||
|
|
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||
|
|
<AlertDialogContent>
|
||
|
|
<AlertDialogHeader>
|
||
|
|
<AlertDialogTitle>견적 삭제 확인</AlertDialogTitle>
|
||
|
|
<AlertDialogDescription>
|
||
|
|
{deleteTargetId
|
||
|
|
? `견적번호: ${quotes.find((q) => q.id === deleteTargetId)?.quoteNumber || deleteTargetId}`
|
||
|
|
: ''}
|
||
|
|
<br />
|
||
|
|
이 견적을 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다.
|
||
|
|
</AlertDialogDescription>
|
||
|
|
</AlertDialogHeader>
|
||
|
|
<AlertDialogFooter>
|
||
|
|
<AlertDialogCancel disabled={isPending}>취소</AlertDialogCancel>
|
||
|
|
<AlertDialogAction onClick={handleConfirmDelete} disabled={isPending}>
|
||
|
|
{isPending ? '삭제 중...' : '삭제'}
|
||
|
|
</AlertDialogAction>
|
||
|
|
</AlertDialogFooter>
|
||
|
|
</AlertDialogContent>
|
||
|
|
</AlertDialog>
|
||
|
|
|
||
|
|
{/* 일괄 삭제 확인 다이얼로그 */}
|
||
|
|
<AlertDialog
|
||
|
|
open={isBulkDeleteDialogOpen}
|
||
|
|
onOpenChange={setIsBulkDeleteDialogOpen}
|
||
|
|
>
|
||
|
|
<AlertDialogContent>
|
||
|
|
<AlertDialogHeader>
|
||
|
|
<AlertDialogTitle>일괄 삭제 확인</AlertDialogTitle>
|
||
|
|
<AlertDialogDescription>
|
||
|
|
선택한 {selectedItems.size}개의 견적을 삭제하시겠습니까?
|
||
|
|
<br />
|
||
|
|
삭제된 데이터는 복구할 수 없습니다.
|
||
|
|
</AlertDialogDescription>
|
||
|
|
</AlertDialogHeader>
|
||
|
|
<AlertDialogFooter>
|
||
|
|
<AlertDialogCancel disabled={isPending}>취소</AlertDialogCancel>
|
||
|
|
<AlertDialogAction onClick={handleConfirmBulkDelete} disabled={isPending}>
|
||
|
|
{isPending ? '삭제 중...' : '삭제'}
|
||
|
|
</AlertDialogAction>
|
||
|
|
</AlertDialogFooter>
|
||
|
|
</AlertDialogContent>
|
||
|
|
</AlertDialog>
|
||
|
|
</>
|
||
|
|
);
|
||
|
|
}
|