Merge remote-tracking branch 'origin/master'

This commit is contained in:
2026-02-09 21:32:50 +09:00
22 changed files with 588 additions and 619 deletions

View File

@@ -21,6 +21,7 @@ import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import { useDeleteDialog } from '@/hooks/useDeleteDialog';
import { TableRow, TableCell } from '@/components/ui/table';
import {
Select,
@@ -89,8 +90,21 @@ export function BillManagementClient({
const itemsPerPage = initialPagination.perPage;
// 삭제 다이얼로그
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
const deleteDialog = useDeleteDialog({
onDelete: async (id) => {
const result = await deleteBill(id);
if (result.success) {
setData(prev => prev.filter(item => item.id !== id));
setSelectedItems(prev => {
const newSet = new Set(prev);
newSet.delete(id);
return newSet;
});
}
return result;
},
entityName: '어음',
});
// 날짜 범위 상태
const [startDate, setStartDate] = useState('2025-09-01');
@@ -151,32 +165,6 @@ export function BillManagementClient({
router.push(`/ko/accounting/bills/${item.id}?mode=view`);
}, [router]);
const handleDeleteClick = useCallback((id: string) => {
setDeleteTargetId(id);
setShowDeleteDialog(true);
}, []);
const handleConfirmDelete = useCallback(async () => {
if (deleteTargetId) {
setIsLoading(true);
const result = await deleteBill(deleteTargetId);
if (result.success) {
setData(prev => prev.filter(item => item.id !== deleteTargetId));
setSelectedItems(prev => {
const newSet = new Set(prev);
newSet.delete(deleteTargetId);
return newSet;
});
toast.success('삭제되었습니다.');
} else {
toast.error(result.error || '삭제에 실패했습니다.');
}
setIsLoading(false);
}
setShowDeleteDialog(false);
setDeleteTargetId(null);
}, [deleteTargetId]);
// ===== 페이지 변경 =====
const handlePageChange = useCallback((page: number) => {
@@ -521,12 +509,12 @@ export function BillManagementClient({
/>
<DeleteConfirmDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
onConfirm={handleConfirmDelete}
open={deleteDialog.single.isOpen}
onOpenChange={deleteDialog.single.onOpenChange}
onConfirm={deleteDialog.single.confirm}
title="어음 삭제"
description="이 어음을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다."
loading={isLoading}
loading={deleteDialog.isPending}
/>
</>
);

View File

@@ -21,6 +21,7 @@ import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import { useDeleteDialog } from '@/hooks/useDeleteDialog';
import { TableRow, TableCell } from '@/components/ui/table';
import {
Select,
@@ -83,9 +84,21 @@ export function VendorManagementClient({ initialData, initialTotal }: VendorMana
const itemsPerPage = 20;
// 삭제 다이얼로그
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const deleteDialog = useDeleteDialog({
onDelete: async (id) => {
const result = await deleteClient(id);
if (result.success) {
setData(prev => prev.filter(item => item.id !== id));
setSelectedItems(prev => {
const newSet = new Set(prev);
newSet.delete(id);
return newSet;
});
}
return result;
},
entityName: '거래처',
});
// API 데이터 상태
const [data, setData] = useState<Vendor[]>(initialData);
@@ -178,33 +191,6 @@ export function VendorManagementClient({ initialData, initialTotal }: VendorMana
router.push(`/ko/accounting/vendors/${item.id}?mode=edit`);
}, [router]);
const handleDeleteClick = useCallback((id: string) => {
setDeleteTargetId(id);
setShowDeleteDialog(true);
}, []);
const handleConfirmDelete = useCallback(async () => {
if (!deleteTargetId) return;
setIsLoading(true);
const result = await deleteClient(deleteTargetId);
if (result.success) {
setData(prev => prev.filter(item => item.id !== deleteTargetId));
setSelectedItems(prev => {
const newSet = new Set(prev);
newSet.delete(deleteTargetId);
return newSet;
});
toast.success('거래처가 삭제되었습니다.');
} else {
toast.error(result.error || '거래처 삭제에 실패했습니다.');
}
setIsLoading(false);
setShowDeleteDialog(false);
setDeleteTargetId(null);
}, [deleteTargetId]);
// ===== 통계 카드 =====
const statCards: StatCard[] = useMemo(() => {
@@ -309,7 +295,7 @@ export function VendorManagementClient({ initialData, initialTotal }: VendorMana
variant="ghost"
size="icon"
className="h-8 w-8 text-red-500 hover:text-red-600 hover:bg-red-50"
onClick={() => handleDeleteClick(item.id)}
onClick={() => deleteDialog.single.open(item.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
@@ -318,7 +304,7 @@ export function VendorManagementClient({ initialData, initialTotal }: VendorMana
</TableCell>
</TableRow>
);
}, [handleRowClick, handleEdit, handleDeleteClick]);
}, [handleRowClick, handleEdit, deleteDialog.single.open]);
// ===== 모바일 카드 렌더링 =====
const renderMobileCard = useCallback((
@@ -367,7 +353,7 @@ export function VendorManagementClient({ initialData, initialTotal }: VendorMana
<Button
variant="outline"
className="flex-1 text-red-500 border-red-200 hover:bg-red-50 hover:text-red-600"
onClick={() => handleDeleteClick(item.id)}
onClick={() => deleteDialog.single.open(item.id)}
>
<Trash2 className="w-4 h-4 mr-2" />
</Button>
@@ -377,7 +363,7 @@ export function VendorManagementClient({ initialData, initialTotal }: VendorMana
onClick={() => handleRowClick(item)}
/>
);
}, [handleRowClick, handleEdit, handleDeleteClick]);
}, [handleRowClick, handleEdit, deleteDialog.single.open]);
// ===== UniversalListPage Config =====
const config: UniversalListConfig<Vendor> = useMemo(
@@ -575,12 +561,12 @@ export function VendorManagementClient({ initialData, initialTotal }: VendorMana
{/* 삭제 확인 다이얼로그 */}
<DeleteConfirmDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
onConfirm={handleConfirmDelete}
open={deleteDialog.single.isOpen}
onOpenChange={deleteDialog.single.onOpenChange}
onConfirm={deleteDialog.single.confirm}
title="거래처 삭제"
description="이 거래처를 삭제하시겠습니까? 이 작업은 취소할 수 없습니다."
loading={isLoading}
loading={deleteDialog.isPending}
/>
</>
);

View File

@@ -10,7 +10,7 @@
* - 삭제 클릭 시 "정말 삭제하시겠습니까?" 확인 Alert
*/
import { useState, useCallback } from 'react';
import { useState, useCallback, memo } from 'react';
import { format } from 'date-fns';
import { User, Pencil, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -25,7 +25,7 @@ interface CommentItemProps {
onDelete: (id: string) => void;
}
export function CommentItem({
export const CommentItem = memo(function CommentItem({
comment,
currentUserId,
onUpdate,
@@ -156,6 +156,6 @@ export function CommentItem({
/>
</div>
);
}
});
export default CommentItem;

View File

@@ -11,7 +11,8 @@
* - 등록 버튼 없음 (견적완료 시 자동 등록)
*/
import { useState, useMemo, useEffect } from 'react';
import { useState, useMemo } from 'react';
import { useStatsLoader } from '@/hooks/useStatsLoader';
import { FileText, Clock, Trophy, Pencil } from 'lucide-react';
import { useListHandlers } from '@/hooks/useListHandlers';
import { Button } from '@/components/ui/button';
@@ -84,18 +85,7 @@ export default function BiddingListClient({ initialData = [], initialStats }: Bi
// 검색어
const [searchQuery, setSearchQuery] = useState('');
// Stats 데이터
const [stats, setStats] = useState<BiddingStats | null>(initialStats || null);
// Stats 로드
useEffect(() => {
if (!initialStats) {
getBiddingStats().then((result) => {
if (result.success && result.data) {
setStats(result.data);
}
});
}
}, [initialStats]);
const { data: stats } = useStatsLoader(getBiddingStats, initialStats);
// ===== UniversalListPage Config =====
const config: UniversalListConfig<Bidding> = useMemo(

View File

@@ -10,7 +10,8 @@
* - filterConfig (multi: 거래처, 계약담당자, 공사PM / single: 상태, 정렬)
*/
import { useState, useMemo, useEffect } from 'react';
import { useState, useMemo } from 'react';
import { useStatsLoader } from '@/hooks/useStatsLoader';
import { FileText, Clock, CheckCircle, Pencil, Trash2 } from 'lucide-react';
import { useListHandlers } from '@/hooks/useListHandlers';
import { Button } from '@/components/ui/button';
@@ -83,18 +84,7 @@ export default function ContractListClient({ initialData = [], initialStats }: C
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [searchQuery, setSearchQuery] = useState('');
const [stats, setStats] = useState<ContractStats | null>(initialStats || null);
// Stats 로드
useEffect(() => {
if (!initialStats) {
getContractStats().then((result) => {
if (result.success && result.data) {
setStats(result.data);
}
});
}
}, [initialStats]);
const { data: stats } = useStatsLoader(getContractStats, initialStats);
// ===== UniversalListPage Config =====
const config: UniversalListConfig<Contract> = useMemo(

View File

@@ -13,6 +13,7 @@
import { useState, useMemo, useEffect } from 'react';
import { FileText, FileTextIcon, Clock, FileCheck, Pencil } from 'lucide-react';
import { useListHandlers } from '@/hooks/useListHandlers';
import { useStatsLoader } from '@/hooks/useStatsLoader';
import { Button } from '@/components/ui/button';
import { TableCell, TableRow } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
@@ -68,22 +69,11 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
// 검색어
const [searchQuery, setSearchQuery] = useState('');
// Stats 데이터
const [stats, setStats] = useState<EstimateStats | null>(initialStats || null);
const { data: stats } = useStatsLoader(getEstimateStats, initialStats);
// 필터 옵션 데이터
const [partnerOptions, setPartnerOptions] = useState<ClientOption[]>([]);
const [estimatorOptions, setEstimatorOptions] = useState<UserOption[]>([]);
// Stats 로드
useEffect(() => {
if (!initialStats) {
getEstimateStats().then((result) => {
if (result.success && result.data) {
setStats(result.data);
}
});
}
}, [initialStats]);
// 거래처/견적자 옵션 로드
useEffect(() => {
// 거래처 옵션 로드

View File

@@ -14,7 +14,8 @@
* - 삭제 기능 없음 (수정만 가능)
*/
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useState, useMemo, useCallback } from 'react';
import { useStatsLoader } from '@/hooks/useStatsLoader';
import { HardHat, Pencil, Clock, CheckCircle } from 'lucide-react';
import { useListHandlers } from '@/hooks/useListHandlers';
import { Button } from '@/components/ui/button';
@@ -84,7 +85,7 @@ export default function ConstructionManagementListClient({
const [activeStatTab, setActiveStatTab] = useState<'all' | 'in_progress' | 'completed'>('all');
const [startDate, setStartDate] = useState<string>('');
const [endDate, setEndDate] = useState<string>('');
const [stats, setStats] = useState<ConstructionManagementStats | null>(initialStats || null);
const { data: stats } = useStatsLoader(getConstructionManagementStats, initialStats);
const [searchQuery, setSearchQuery] = useState('');
// 달력 관련 상태
@@ -97,17 +98,6 @@ export default function ConstructionManagementListClient({
// 전체 데이터 (달력 이벤트용)
const [allConstructions, setAllConstructions] = useState<ConstructionManagement[]>(initialData);
// Stats 로드
useEffect(() => {
if (!initialStats) {
getConstructionManagementStats().then((result) => {
if (result.success && result.data) {
setStats(result.data);
}
});
}
}, [initialStats]);
// 필터 옵션 (memo)
const siteOptions: MultiSelectOption[] = useMemo(() =>
MOCK_CM_SITES.map(s => ({ value: s.value, label: s.label })),

View File

@@ -11,7 +11,8 @@
* - 삭제 기능 없음 (조회/수정 전용)
*/
import { useState, useMemo, useEffect } from 'react';
import { useState, useMemo } from 'react';
import { useStatsLoader } from '@/hooks/useStatsLoader';
import { FileText, Pencil } from 'lucide-react';
import { useListHandlers } from '@/hooks/useListHandlers';
import { Button } from '@/components/ui/button';
@@ -77,20 +78,9 @@ export default function ProgressBillingManagementListClient({
const [activeStatTab, setActiveStatTab] = useState<'all' | 'contractWaiting' | 'contractComplete'>('all');
const [startDate, setStartDate] = useState<string>('');
const [endDate, setEndDate] = useState<string>('');
const [stats, setStats] = useState<ProgressBillingStats | null>(initialStats || null);
const { data: stats } = useStatsLoader(getProgressBillingStats, initialStats);
const [searchQuery, setSearchQuery] = useState('');
// Stats 로드
useEffect(() => {
if (!initialStats) {
getProgressBillingStats().then((result) => {
if (result.success && result.data) {
setStats(result.data);
}
});
}
}, [initialStats]);
// ===== UniversalListPage Config =====
const config: UniversalListConfig<ProgressBilling> = useMemo(
() => ({

View File

@@ -1,6 +1,6 @@
'use client';
import { ReactNode, ComponentType } from 'react';
import { ReactNode, ComponentType, memo } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@@ -22,7 +22,7 @@ export interface InfoFieldProps {
className?: string;
}
export function InfoField({
export const InfoField = memo(function InfoField({
label,
value,
valueClassName = '',
@@ -34,7 +34,7 @@ export function InfoField({
<div className={cn('text-sm font-medium', valueClassName)}>{value}</div>
</div>
);
}
});
/**
* 통합 MobileCard Props

View File

@@ -12,8 +12,9 @@
* - 하단 출고 스케줄 캘린더 (시간축 주간 뷰)
*/
import { useState, useEffect, useMemo, useCallback } from 'react';
import { useState, useMemo, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { useStatsLoader } from '@/hooks/useStatsLoader';
import {
Truck,
Package,
@@ -53,7 +54,7 @@ export function ShipmentList() {
const router = useRouter();
// ===== 통계 (외부 관리) =====
const [shipmentStats, setShipmentStats] = useState<ShipmentStats | null>(null);
const { data: shipmentStats, reload: reloadStats } = useStatsLoader(getShipmentStats);
// ===== 날짜 범위 =====
const today = new Date();
@@ -71,22 +72,6 @@ export function ShipmentList() {
const [scheduleView, setScheduleView] = useState<CalendarView>('day-time');
const [shipmentData, setShipmentData] = useState<ShipmentItem[]>([]);
// 초기 통계 로드
useEffect(() => {
const loadStats = async () => {
try {
const statsResult = await getShipmentStats();
if (statsResult.success && statsResult.data) {
setShipmentStats(statsResult.data);
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[ShipmentList] loadStats error:', error);
}
};
loadStats();
}, []);
// ===== 행 클릭 핸들러 =====
const handleRowClick = useCallback(
(item: ShipmentItem) => {
@@ -191,10 +176,7 @@ export function ShipmentList() {
if (result.success) {
// 통계 다시 로드
const statsResult = await getShipmentStats();
if (statsResult.success && statsResult.data) {
setShipmentStats(statsResult.data);
}
await reloadStats();
// 캘린더용 데이터 저장
setShipmentData(result.data);

View File

@@ -13,7 +13,7 @@
* - 자재 투입 목록: 토글 (쉐브론 아이콘 + 텍스트)
*/
import { useState, useCallback } from 'react';
import { useState, useCallback, memo } from 'react';
import { ChevronDown, ChevronUp, Pencil, Trash2, ImageIcon } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
@@ -43,7 +43,7 @@ interface WorkItemCardProps {
onInspectionToggle?: (itemId: string, checked: boolean) => void;
}
export function WorkItemCard({
export const WorkItemCard = memo(function WorkItemCard({
item,
onStepClick,
onEditMaterial,
@@ -230,7 +230,7 @@ export function WorkItemCard({
</CardContent>
</Card>
);
}
});
// ===== 스크린 전용: 절단정보 =====
function ScreenCuttingInfo({ width, sheets }: { width: number; sheets: number }) {

View File

@@ -12,6 +12,7 @@
import { useState, useEffect, useMemo, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { useStatsLoader } from '@/hooks/useStatsLoader';
import {
ClipboardCheck,
Plus,
@@ -53,11 +54,7 @@ export function InspectionList() {
const router = useRouter();
// ===== 통계 =====
const [statsData, setStatsData] = useState<InspectionStats>({
receptionCount: 0,
inProgressCount: 0,
completedCount: 0,
});
const { data: statsData, reload: reloadStats } = useStatsLoader(getInspectionStats);
// ===== 날짜 범위 =====
const today = new Date();
@@ -77,22 +74,6 @@ export function InspectionList() {
const [calendarStatusFilter, setCalendarStatusFilter] = useState<string>('전체');
const [calendarInspectorFilter, setCalendarInspectorFilter] = useState<string>('전체');
// 초기 통계 로드
useEffect(() => {
const loadStats = async () => {
try {
const result = await getInspectionStats();
if (result.success && result.data) {
setStatsData(result.data);
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[InspectionList] loadStats error:', error);
}
};
loadStats();
}, []);
// 캘린더 데이터 로드
const loadCalendarData = useCallback(async () => {
try {
@@ -164,19 +145,19 @@ export function InspectionList() {
() => [
{
label: '접수',
value: statsData.receptionCount,
value: statsData?.receptionCount ?? 0,
icon: FileInput,
iconColor: 'text-gray-600',
},
{
label: '진행중',
value: statsData.inProgressCount,
value: statsData?.inProgressCount ?? 0,
icon: Loader2,
iconColor: 'text-blue-600',
},
{
label: '완료',
value: statsData.completedCount,
value: statsData?.completedCount ?? 0,
icon: CheckCircle2,
iconColor: 'text-green-600',
},
@@ -236,10 +217,7 @@ export function InspectionList() {
if (result.success) {
// 통계 재로드
const statsResult = await getInspectionStats();
if (statsResult.success && statsResult.data) {
setStatsData(statsResult.data);
}
await reloadStats();
return {
success: true,

View File

@@ -10,7 +10,7 @@
* - 삭제/일괄삭제 다이얼로그
*/
import { useState, useMemo, useCallback, useTransition } from 'react';
import { useState, useMemo, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { format, startOfMonth, endOfMonth } from 'date-fns';
import {
@@ -51,6 +51,7 @@ import {
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
import { StandardDialog } from '@/components/molecules/StandardDialog';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import { useDeleteDialog } from '@/hooks/useDeleteDialog';
import { toast } from 'sonner';
import { formatAmount, formatAmountManwon } from '@/utils/formatAmount';
import type { Quote, QuoteFilterType } from './types';
@@ -69,7 +70,12 @@ export function QuoteManagementClient({
initialPagination,
}: QuoteManagementClientProps) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const deleteDialog = useDeleteDialog({
onDelete: deleteQuote,
onBulkDelete: bulkDeleteQuotes,
onSuccess: () => window.location.reload(),
entityName: '견적',
});
// ===== 날짜 필터 상태 =====
const today = new Date();
@@ -84,12 +90,6 @@ export function QuoteManagementClient({
const [isCalculationDialogOpen, setIsCalculationDialogOpen] = useState(false);
const [calculationQuote, setCalculationQuote] = useState<Quote | null>(null);
// ===== 삭제 다이얼로그 상태 =====
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
const [bulkDeleteIds, setBulkDeleteIds] = useState<string[]>([]);
// ===== 전체 데이터 상태 (통계 계산용) =====
const [allQuotes, setAllQuotes] = useState<Quote[]>(initialData);
@@ -102,57 +102,6 @@ export function QuoteManagementClient({
router.push(`/sales/quote-management/${quote.id}?mode=edit`);
}, [router]);
const handleDeleteClick = useCallback((id: string) => {
setDeleteTargetId(id);
setIsDeleteDialogOpen(true);
}, []);
const handleConfirmDelete = useCallback(async () => {
if (!deleteTargetId) return;
startTransition(async () => {
const result = await deleteQuote(deleteTargetId);
if (result.success) {
const quote = allQuotes.find((q) => q.id === deleteTargetId);
setAllQuotes(allQuotes.filter((q) => q.id !== deleteTargetId));
toast.success(`견적이 삭제되었습니다${quote ? `: ${quote.quoteNumber}` : ''}`);
window.location.reload();
} else {
toast.error(result.error || '삭제에 실패했습니다.');
}
setIsDeleteDialogOpen(false);
setDeleteTargetId(null);
});
}, [deleteTargetId, allQuotes]);
const handleBulkDelete = useCallback((selectedIds: string[]) => {
if (selectedIds.length === 0) {
toast.error('삭제할 항목을 선택해주세요');
return;
}
setBulkDeleteIds(selectedIds);
setIsBulkDeleteDialogOpen(true);
}, []);
const handleConfirmBulkDelete = useCallback(async () => {
startTransition(async () => {
const result = await bulkDeleteQuotes(bulkDeleteIds);
if (result.success) {
setAllQuotes(allQuotes.filter((q) => !bulkDeleteIds.includes(q.id)));
toast.success(`${bulkDeleteIds.length}개의 견적이 삭제되었습니다`);
window.location.reload();
} else {
toast.error(result.error || '일괄 삭제에 실패했습니다.');
}
setIsBulkDeleteDialogOpen(false);
setBulkDeleteIds([]);
});
}, [bulkDeleteIds, allQuotes]);
const handleViewHistory = useCallback((quote: Quote) => {
toast.info(`수정 이력: ${quote.quoteNumber} (${quote.currentRevision}차 수정)`);
}, []);
@@ -413,7 +362,7 @@ export function QuoteManagementClient({
),
// 일괄 삭제 핸들러
onBulkDelete: handleBulkDelete,
onBulkDelete: deleteDialog.bulk.open,
// 테이블 행 렌더링
renderTableRow: (
@@ -489,8 +438,8 @@ export function QuoteManagementClient({
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteClick(quote.id)}
disabled={isPending}
onClick={() => deleteDialog.single.open(quote.id)}
disabled={deleteDialog.isPending}
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
@@ -585,9 +534,9 @@ export function QuoteManagementClient({
className="flex-1 min-w-[100px] h-11 border-red-200 text-red-600 hover:border-red-300 bg-transparent"
onClick={(e) => {
e.stopPropagation();
handleDeleteClick(quote.id);
deleteDialog.single.open(quote.id);
}}
disabled={isPending}
disabled={deleteDialog.isPending}
>
<Trash2 className="h-4 w-4 mr-2" />
@@ -600,7 +549,7 @@ export function QuoteManagementClient({
);
},
}),
[computeStats, router, handleView, handleEdit, handleDeleteClick, handleViewHistory, handleBulkDelete, getRevisionBadge, isPending, startDate, endDate, productCategoryFilter, statusFilter]
[computeStats, router, handleView, handleEdit, handleViewHistory, getRevisionBadge, deleteDialog, startDate, endDate, productCategoryFilter, statusFilter]
);
return (
@@ -735,34 +684,28 @@ export function QuoteManagementClient({
{/* 삭제 확인 다이얼로그 */}
<DeleteConfirmDialog
open={isDeleteDialogOpen}
onOpenChange={setIsDeleteDialogOpen}
open={deleteDialog.single.isOpen}
onOpenChange={deleteDialog.single.onOpenChange}
description={
<>
{deleteTargetId
? `견적번호: ${allQuotes.find((q) => q.id === deleteTargetId)?.quoteNumber || deleteTargetId}`
{deleteDialog.single.targetId
? `견적번호: ${allQuotes.find((q) => q.id === deleteDialog.single.targetId)?.quoteNumber || deleteDialog.single.targetId}`
: ''}
<br />
? .
</>
}
loading={isPending}
onConfirm={handleConfirmDelete}
loading={deleteDialog.isPending}
onConfirm={deleteDialog.single.confirm}
/>
{/* 일괄 삭제 확인 다이얼로그 */}
<DeleteConfirmDialog
open={isBulkDeleteDialogOpen}
onOpenChange={setIsBulkDeleteDialogOpen}
description={
<>
{bulkDeleteIds.length} ?
<br />
.
</>
}
loading={isPending}
onConfirm={handleConfirmBulkDelete}
open={deleteDialog.bulk.isOpen}
onOpenChange={deleteDialog.bulk.onOpenChange}
description={`선택한 ${deleteDialog.bulk.ids.length}개의 견적을 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다.`}
loading={deleteDialog.isPending}
onConfirm={deleteDialog.bulk.confirm}
/>
</>
);

View File

@@ -1,11 +1,8 @@
'use server';
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
import { createCrudService, type ActionResult } from '@/lib/api/create-crud-service';
import type { Rank } from './types';
const API_URL = process.env.NEXT_PUBLIC_API_URL;
// ===== API 응답 타입 =====
interface PositionApiData {
id: number;
@@ -18,60 +15,45 @@ interface PositionApiData {
updated_at?: string;
}
// ===== 데이터 변환: API → Frontend =====
function transformApiToFrontend(apiData: PositionApiData): Rank {
return {
id: apiData.id,
name: apiData.name,
order: apiData.sort_order,
isActive: apiData.is_active,
createdAt: apiData.created_at,
updatedAt: apiData.updated_at,
};
}
// ===== CRUD 서비스 생성 =====
const rankService = createCrudService<PositionApiData, Rank>({
basePath: '/api/v1/positions',
transform: (api) => ({
id: api.id,
name: api.name,
order: api.sort_order,
isActive: api.is_active,
createdAt: api.created_at,
updatedAt: api.updated_at,
}),
entityName: '직급',
defaultQueryParams: { type: 'rank' },
defaultCreateBody: { type: 'rank' },
});
// ===== Server Action 래퍼 =====
// Next.js Server Action은 'use server' 파일에서 직접 선언된 async function만 인식
// 팩토리 반환 함수를 직접 export하면 Server Action으로 인식 안 될 수 있음
// ===== 직급 목록 조회 =====
export async function getRanks(params?: {
is_active?: boolean;
q?: string;
}): Promise<ActionResult<Rank[]>> {
const searchParams = new URLSearchParams();
searchParams.set('type', 'rank');
if (params?.is_active !== undefined) {
searchParams.set('is_active', params.is_active.toString());
}
if (params?.q) {
searchParams.set('q', params.q);
}
return executeServerAction({
url: `${API_URL}/api/v1/positions?${searchParams.toString()}`,
transform: (data: PositionApiData[]) => data.map(transformApiToFrontend),
errorMessage: '직급 목록 조회에 실패했습니다.',
});
return rankService.getList(params);
}
// ===== 직급 생성 =====
export async function createRank(data: {
name: string;
sort_order?: number;
is_active?: boolean;
}): Promise<ActionResult<Rank>> {
return executeServerAction({
url: `${API_URL}/api/v1/positions`,
method: 'POST',
body: {
type: 'rank',
name: data.name,
sort_order: data.sort_order,
is_active: data.is_active ?? true,
},
transform: transformApiToFrontend,
errorMessage: '직급 생성에 실패했습니다.',
return rankService.create({
name: data.name,
sort_order: data.sort_order,
is_active: data.is_active ?? true,
});
}
// ===== 직급 수정 =====
export async function updateRank(
id: number,
data: {
@@ -80,32 +62,15 @@ export async function updateRank(
is_active?: boolean;
}
): Promise<ActionResult<Rank>> {
return executeServerAction({
url: `${API_URL}/api/v1/positions/${id}`,
method: 'PUT',
body: data,
transform: transformApiToFrontend,
errorMessage: '직급 수정에 실패했습니다.',
});
return rankService.update(id, data);
}
// ===== 직급 삭제 =====
export async function deleteRank(id: number): Promise<ActionResult> {
return executeServerAction({
url: `${API_URL}/api/v1/positions/${id}`,
method: 'DELETE',
errorMessage: '직급 삭제에 실패했습니다.',
});
return rankService.remove(id);
}
// ===== 직급 순서 변경 =====
export async function reorderRanks(
items: { id: number; sort_order: number }[]
): Promise<ActionResult> {
return executeServerAction({
url: `${API_URL}/api/v1/positions/reorder`,
method: 'PUT',
body: { items },
errorMessage: '순서 변경에 실패했습니다.',
});
}
return rankService.reorder(items);
}

View File

@@ -5,7 +5,7 @@
* 레거시 5130 사이트 컬럼 구조 기반
*/
import { useState, useMemo, useCallback, useTransition } from 'react';
import { useState, useMemo, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { Truck, Edit, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -20,7 +20,7 @@ import {
} from '@/components/templates/UniversalListPage';
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import { toast } from 'sonner';
import { useDeleteDialog } from '@/hooks/useDeleteDialog';
import type { Forklift } from '../types';
import { getForklifts, deleteForklift, bulkDeleteForklifts } from './actions';
@@ -30,12 +30,13 @@ interface ForkliftListProps {
export function ForkliftList({ initialData }: ForkliftListProps) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const deleteDialog = useDeleteDialog({
onDelete: deleteForklift,
onBulkDelete: bulkDeleteForklifts,
onSuccess: () => window.location.reload(),
entityName: '지게차',
});
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
const [bulkDeleteIds, setBulkDeleteIds] = useState<string[]>([]);
const [allData, setAllData] = useState<Forklift[]>(initialData);
const handleView = useCallback(
@@ -52,57 +53,6 @@ export function ForkliftList({ initialData }: ForkliftListProps) {
[router]
);
const handleDeleteClick = useCallback((id: string) => {
setDeleteTargetId(id);
setIsDeleteDialogOpen(true);
}, []);
const handleConfirmDelete = useCallback(async () => {
if (!deleteTargetId) return;
startTransition(async () => {
const result = await deleteForklift(deleteTargetId);
if (result.success) {
const forklift = allData.find((f) => f.id === deleteTargetId);
setAllData(allData.filter((f) => f.id !== deleteTargetId));
toast.success(`지게차가 삭제되었습니다${forklift ? `: ${forklift.vehicleNumber}` : ''}`);
window.location.reload();
} else {
toast.error(result.error || '삭제에 실패했습니다.');
}
setIsDeleteDialogOpen(false);
setDeleteTargetId(null);
});
}, [deleteTargetId, allData]);
const handleBulkDelete = useCallback((selectedIds: string[]) => {
if (selectedIds.length === 0) {
toast.error('삭제할 항목을 선택해주세요');
return;
}
setBulkDeleteIds(selectedIds);
setIsBulkDeleteDialogOpen(true);
}, []);
const handleConfirmBulkDelete = useCallback(async () => {
startTransition(async () => {
const result = await bulkDeleteForklifts(bulkDeleteIds);
if (result.success) {
setAllData(allData.filter((f) => !bulkDeleteIds.includes(f.id)));
toast.success(`${bulkDeleteIds.length}개의 지게차가 삭제되었습니다`);
window.location.reload();
} else {
toast.error(result.error || '일괄 삭제에 실패했습니다.');
}
setIsBulkDeleteDialogOpen(false);
setBulkDeleteIds([]);
});
}, [bulkDeleteIds, allData]);
const config: UniversalListConfig<Forklift> = useMemo(
() => ({
title: '지게차 관리',
@@ -190,7 +140,7 @@ export function ForkliftList({ initialData }: ForkliftListProps) {
</Button>
),
onBulkDelete: handleBulkDelete,
onBulkDelete: deleteDialog.bulk.open,
renderTableRow: (
forklift: Forklift,
@@ -314,9 +264,9 @@ export function ForkliftList({ initialData }: ForkliftListProps) {
className="flex-1 min-w-[100px] h-11 border-red-200 text-red-600 hover:border-red-300 bg-transparent"
onClick={(e) => {
e.stopPropagation();
handleDeleteClick(forklift.id);
deleteDialog.single.open(forklift.id);
}}
disabled={isPending}
disabled={deleteDialog.isPending}
>
<Trash2 className="h-4 w-4 mr-2" />
@@ -328,7 +278,7 @@ export function ForkliftList({ initialData }: ForkliftListProps) {
);
},
}),
[router, handleView, handleEdit, handleDeleteClick, handleBulkDelete, isPending]
[router, handleView, handleEdit, deleteDialog]
);
return (
@@ -336,31 +286,27 @@ export function ForkliftList({ initialData }: ForkliftListProps) {
<UniversalListPage config={config} initialData={initialData} />
<DeleteConfirmDialog
open={isDeleteDialogOpen}
onOpenChange={setIsDeleteDialogOpen}
open={deleteDialog.single.isOpen}
onOpenChange={deleteDialog.single.onOpenChange}
description={
<>
{deleteTargetId
? `차량번호: ${allData.find((f) => f.id === deleteTargetId)?.vehicleNumber || deleteTargetId}`
{deleteDialog.single.targetId
? `차량번호: ${allData.find((f) => f.id === deleteDialog.single.targetId)?.vehicleNumber || deleteDialog.single.targetId}`
: ''}
<br />
?
</>
}
loading={isPending}
onConfirm={handleConfirmDelete}
loading={deleteDialog.isPending}
onConfirm={deleteDialog.single.confirm}
/>
<DeleteConfirmDialog
open={isBulkDeleteDialogOpen}
onOpenChange={setIsBulkDeleteDialogOpen}
description={
<>
{bulkDeleteIds.length} ?
</>
}
loading={isPending}
onConfirm={handleConfirmBulkDelete}
open={deleteDialog.bulk.isOpen}
onOpenChange={deleteDialog.bulk.onOpenChange}
description={`선택한 ${deleteDialog.bulk.ids.length}개의 지게차를 삭제하시겠습니까?`}
loading={deleteDialog.isPending}
onConfirm={deleteDialog.bulk.confirm}
/>
</>
);

View File

@@ -5,7 +5,7 @@
* 레거시 5130 사이트 컬럼 구조 기반 (2025-01-28 스크린샷 검증)
*/
import { useState, useMemo, useCallback, useTransition } from 'react';
import { useState, useMemo, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { Car, Edit, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -20,7 +20,7 @@ import {
} from '@/components/templates/UniversalListPage';
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import { toast } from 'sonner';
import { useDeleteDialog } from '@/hooks/useDeleteDialog';
import type { Vehicle } from '../types';
import { getVehicles, deleteVehicle, bulkDeleteVehicles } from './actions';
@@ -30,14 +30,15 @@ interface VehicleListProps {
export function VehicleList({ initialData }: VehicleListProps) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
const [bulkDeleteIds, setBulkDeleteIds] = useState<string[]>([]);
const [allData, setAllData] = useState<Vehicle[]>(initialData);
const deleteDialog = useDeleteDialog({
onDelete: deleteVehicle,
onBulkDelete: bulkDeleteVehicles,
onSuccess: () => window.location.reload(),
entityName: '차량',
});
const handleView = useCallback(
(vehicle: Vehicle) => {
router.push(`/vehicle-management/vehicle/${vehicle.id}`);
@@ -52,57 +53,6 @@ export function VehicleList({ initialData }: VehicleListProps) {
[router]
);
const handleDeleteClick = useCallback((id: string) => {
setDeleteTargetId(id);
setIsDeleteDialogOpen(true);
}, []);
const handleConfirmDelete = useCallback(async () => {
if (!deleteTargetId) return;
startTransition(async () => {
const result = await deleteVehicle(deleteTargetId);
if (result.success) {
const vehicle = allData.find((v) => v.id === deleteTargetId);
setAllData(allData.filter((v) => v.id !== deleteTargetId));
toast.success(`차량이 삭제되었습니다${vehicle ? `: ${vehicle.vehicleNumber}` : ''}`);
window.location.reload();
} else {
toast.error(result.error || '삭제에 실패했습니다.');
}
setIsDeleteDialogOpen(false);
setDeleteTargetId(null);
});
}, [deleteTargetId, allData]);
const handleBulkDelete = useCallback((selectedIds: string[]) => {
if (selectedIds.length === 0) {
toast.error('삭제할 항목을 선택해주세요');
return;
}
setBulkDeleteIds(selectedIds);
setIsBulkDeleteDialogOpen(true);
}, []);
const handleConfirmBulkDelete = useCallback(async () => {
startTransition(async () => {
const result = await bulkDeleteVehicles(bulkDeleteIds);
if (result.success) {
setAllData(allData.filter((v) => !bulkDeleteIds.includes(v.id)));
toast.success(`${bulkDeleteIds.length}개의 차량이 삭제되었습니다`);
window.location.reload();
} else {
toast.error(result.error || '일괄 삭제에 실패했습니다.');
}
setIsBulkDeleteDialogOpen(false);
setBulkDeleteIds([]);
});
}, [bulkDeleteIds, allData]);
const config: UniversalListConfig<Vehicle> = useMemo(
() => ({
title: '차량 관리',
@@ -188,7 +138,7 @@ export function VehicleList({ initialData }: VehicleListProps) {
</Button>
),
onBulkDelete: handleBulkDelete,
onBulkDelete: deleteDialog.bulk.open,
renderTableRow: (
vehicle: Vehicle,
@@ -284,9 +234,9 @@ export function VehicleList({ initialData }: VehicleListProps) {
className="flex-1 min-w-[100px] h-11 border-red-200 text-red-600 hover:border-red-300 bg-transparent"
onClick={(e) => {
e.stopPropagation();
handleDeleteClick(vehicle.id);
deleteDialog.single.open(vehicle.id);
}}
disabled={isPending}
disabled={deleteDialog.isPending}
>
<Trash2 className="h-4 w-4 mr-2" />
@@ -298,7 +248,7 @@ export function VehicleList({ initialData }: VehicleListProps) {
);
},
}),
[router, handleView, handleEdit, handleDeleteClick, handleBulkDelete, isPending]
[router, handleView, handleEdit, deleteDialog]
);
return (
@@ -306,33 +256,19 @@ export function VehicleList({ initialData }: VehicleListProps) {
<UniversalListPage config={config} initialData={initialData} />
<DeleteConfirmDialog
open={isDeleteDialogOpen}
onOpenChange={setIsDeleteDialogOpen}
description={
<>
{deleteTargetId
? `차량번호: ${allData.find((v) => v.id === deleteTargetId)?.vehicleNumber || deleteTargetId}`
: ''}
<br />
? .
</>
}
loading={isPending}
onConfirm={handleConfirmDelete}
open={deleteDialog.single.isOpen}
onOpenChange={deleteDialog.single.onOpenChange}
description="이 차량을 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다."
loading={deleteDialog.isPending}
onConfirm={deleteDialog.single.confirm}
/>
<DeleteConfirmDialog
open={isBulkDeleteDialogOpen}
onOpenChange={setIsBulkDeleteDialogOpen}
description={
<>
{bulkDeleteIds.length} ?
<br />
.
</>
}
loading={isPending}
onConfirm={handleConfirmBulkDelete}
open={deleteDialog.bulk.isOpen}
onOpenChange={deleteDialog.bulk.onOpenChange}
description={`선택한 ${deleteDialog.bulk.ids.length}개의 차량을 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다.`}
loading={deleteDialog.isPending}
onConfirm={deleteDialog.bulk.confirm}
/>
</>
);

View File

@@ -4,7 +4,7 @@
* 차량일지/월간사진기록 리스트 - UniversalListPage 기반
*/
import { useState, useMemo, useCallback, useTransition } from 'react';
import { useState, useMemo, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { FileText, Edit, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -19,7 +19,7 @@ import {
} from '@/components/templates/UniversalListPage';
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import { toast } from 'sonner';
import { useDeleteDialog } from '@/hooks/useDeleteDialog';
import type { VehicleLog } from '../types';
import { getVehicleLogs, deleteVehicleLog, bulkDeleteVehicleLogs } from './actions';
@@ -30,13 +30,12 @@ interface VehicleLogListProps {
export function VehicleLogList({ initialData }: VehicleLogListProps) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
// ===== 삭제 다이얼로그 상태 =====
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
const [bulkDeleteIds, setBulkDeleteIds] = useState<string[]>([]);
const deleteDialog = useDeleteDialog({
onDelete: deleteVehicleLog,
onBulkDelete: bulkDeleteVehicleLogs,
onSuccess: () => window.location.reload(),
entityName: '차량일지',
});
// ===== 전체 데이터 상태 =====
const [allData, setAllData] = useState<VehicleLog[]>(initialData);
@@ -56,57 +55,6 @@ export function VehicleLogList({ initialData }: VehicleLogListProps) {
[router]
);
const handleDeleteClick = useCallback((id: string) => {
setDeleteTargetId(id);
setIsDeleteDialogOpen(true);
}, []);
const handleConfirmDelete = useCallback(async () => {
if (!deleteTargetId) return;
startTransition(async () => {
const result = await deleteVehicleLog(deleteTargetId);
if (result.success) {
const log = allData.find((v) => v.id === deleteTargetId);
setAllData(allData.filter((v) => v.id !== deleteTargetId));
toast.success(`차량일지가 삭제되었습니다${log ? `: ${log.title}` : ''}`);
window.location.reload();
} else {
toast.error(result.error || '삭제에 실패했습니다.');
}
setIsDeleteDialogOpen(false);
setDeleteTargetId(null);
});
}, [deleteTargetId, allData]);
const handleBulkDelete = useCallback((selectedIds: string[]) => {
if (selectedIds.length === 0) {
toast.error('삭제할 항목을 선택해주세요');
return;
}
setBulkDeleteIds(selectedIds);
setIsBulkDeleteDialogOpen(true);
}, []);
const handleConfirmBulkDelete = useCallback(async () => {
startTransition(async () => {
const result = await bulkDeleteVehicleLogs(bulkDeleteIds);
if (result.success) {
setAllData(allData.filter((v) => !bulkDeleteIds.includes(v.id)));
toast.success(`${bulkDeleteIds.length}개의 차량일지가 삭제되었습니다`);
window.location.reload();
} else {
toast.error(result.error || '일괄 삭제에 실패했습니다.');
}
setIsBulkDeleteDialogOpen(false);
setBulkDeleteIds([]);
});
}, [bulkDeleteIds, allData]);
// ===== UniversalListPage Config =====
const config: UniversalListConfig<VehicleLog> = useMemo(
() => ({
@@ -186,7 +134,7 @@ export function VehicleLogList({ initialData }: VehicleLogListProps) {
</Button>
),
onBulkDelete: handleBulkDelete,
onBulkDelete: deleteDialog.bulk.open,
renderTableRow: (
log: VehicleLog,
@@ -257,9 +205,9 @@ export function VehicleLogList({ initialData }: VehicleLogListProps) {
className="flex-1 min-w-[100px] h-11 border-red-200 text-red-600 hover:border-red-300 bg-transparent"
onClick={(e) => {
e.stopPropagation();
handleDeleteClick(log.id);
deleteDialog.single.open(log.id);
}}
disabled={isPending}
disabled={deleteDialog.isPending}
>
<Trash2 className="h-4 w-4 mr-2" />
@@ -271,7 +219,7 @@ export function VehicleLogList({ initialData }: VehicleLogListProps) {
);
},
}),
[router, handleView, handleEdit, handleDeleteClick, handleBulkDelete, isPending]
[router, handleView, handleEdit, deleteDialog]
);
return (
@@ -279,31 +227,27 @@ export function VehicleLogList({ initialData }: VehicleLogListProps) {
<UniversalListPage config={config} initialData={initialData} />
<DeleteConfirmDialog
open={isDeleteDialogOpen}
onOpenChange={setIsDeleteDialogOpen}
open={deleteDialog.single.isOpen}
onOpenChange={deleteDialog.single.onOpenChange}
description={
<>
{deleteTargetId
? `제목: ${allData.find((v) => v.id === deleteTargetId)?.title || deleteTargetId}`
{deleteDialog.single.targetId
? `제목: ${allData.find((v) => v.id === deleteDialog.single.targetId)?.title || deleteDialog.single.targetId}`
: ''}
<br />
?
</>
}
loading={isPending}
onConfirm={handleConfirmDelete}
loading={deleteDialog.isPending}
onConfirm={deleteDialog.single.confirm}
/>
<DeleteConfirmDialog
open={isBulkDeleteDialogOpen}
onOpenChange={setIsBulkDeleteDialogOpen}
description={
<>
{bulkDeleteIds.length} ?
</>
}
loading={isPending}
onConfirm={handleConfirmBulkDelete}
open={deleteDialog.bulk.isOpen}
onOpenChange={deleteDialog.bulk.onOpenChange}
description={`선택한 ${deleteDialog.bulk.ids.length}개의 차량일지를 삭제하시겠습니까?`}
loading={deleteDialog.isPending}
onConfirm={deleteDialog.bulk.confirm}
/>
</>
);