Files
sam-react-prod/src/components/pricing-distribution/PriceDistributionDetail.tsx
유병철 a38996b751 refactor(WEB): V2 파일 통합, store 구조 정리 및 대시보드 개선
- V2 컴포넌트를 원본에 통합 후 V2 파일 삭제 (InspectionModal, BillDetail, ContractDocumentModal, LaborDetailClient, PricingDetailClient, QuoteRegistration)
- store → stores 디렉토리 이동 및 favoritesStore 추가
- dashboard_type3~5 추가 및 기존 대시보드 차트/훅 분리
- Sidebar 리팩토링 및 HeaderFavoritesBar 추가
- DashboardSwitcher 컴포넌트 추가
- 백업 파일(.v1-backup) 및 불필요 코드 정리
- InspectionPreviewModal 레이아웃 개선

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 15:09:51 +09:00

539 lines
20 KiB
TypeScript

/**
* 단가배포 상세/수정 페이지
*
* mode 패턴:
* - view: 상세 조회 (읽기 전용) → 하단: 단가표 보기, 최종확정, 수정
* - edit: 수정 모드 → 하단: 취소, 저장
*/
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { ArrowLeft, FileText, CheckCircle2, Edit3, Save, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useMenuStore } from '@/stores/menuStore';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { DatePicker } from '@/components/ui/date-picker';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { getPresetStyle } from '@/lib/utils/status-config';
import { Checkbox } from '@/components/ui/checkbox';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { toast } from 'sonner';
import type {
PriceDistributionDetail as DetailType,
PriceDistributionFormData,
DistributionStatus,
} from './types';
import {
DISTRIBUTION_STATUS_LABELS,
DISTRIBUTION_STATUS_STYLES,
TRADE_GRADE_OPTIONS,
} from './types';
import {
getPriceDistributionById,
updatePriceDistribution,
finalizePriceDistribution,
} from './actions';
import { PriceDistributionDocumentModal } from './PriceDistributionDocumentModal';
import { usePermission } from '@/hooks/usePermission';
interface Props {
id: string;
mode?: 'view' | 'edit';
}
export function PriceDistributionDetail({ id, mode: propMode }: Props) {
const router = useRouter();
const searchParams = useSearchParams();
const { canUpdate, canApprove } = usePermission();
const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed);
const mode = propMode || (searchParams.get('mode') as 'view' | 'edit') || 'view';
const isEditMode = mode === 'edit';
const isViewMode = mode === 'view';
const [detail, setDetail] = useState<DetailType | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [showFinalizeDialog, setShowFinalizeDialog] = useState(false);
const [showDocumentModal, setShowDocumentModal] = useState(false);
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
const [gradeFilter, setGradeFilter] = useState<string>('all');
// 수정 가능 폼 데이터
const [formData, setFormData] = useState<PriceDistributionFormData>({
distributionName: '',
documentNo: '',
effectiveDate: '',
officePhone: '',
orderPhone: '',
});
// 데이터 로드
const loadData = useCallback(async () => {
setIsLoading(true);
try {
const result = await getPriceDistributionById(id);
if (result.success && result.data) {
setDetail(result.data);
setFormData({
distributionName: result.data.distributionName,
documentNo: result.data.documentNo,
effectiveDate: result.data.effectiveDate,
officePhone: result.data.officePhone,
orderPhone: result.data.orderPhone,
});
} else {
toast.error(result.error || '데이터를 불러올 수 없습니다.');
}
} finally {
setIsLoading(false);
}
}, [id]);
useEffect(() => {
loadData();
}, [loadData]);
// 폼 값 변경
const handleChange = (field: keyof PriceDistributionFormData, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
// 저장
const handleSave = async () => {
setIsSaving(true);
try {
const result = await updatePriceDistribution(id, formData);
if (result.success) {
toast.success('저장되었습니다.');
router.push(`/master-data/price-distribution/${id}`);
} else {
toast.error(result.error || '저장에 실패했습니다.');
}
} catch {
toast.error('저장 중 오류가 발생했습니다.');
} finally {
setIsSaving(false);
}
};
// 최종확정
const handleFinalize = async () => {
try {
const result = await finalizePriceDistribution(id);
if (result.success) {
toast.success('최종확정 되었습니다.');
setShowFinalizeDialog(false);
loadData();
} else {
toast.error(result.error || '최종확정에 실패했습니다.');
}
} catch {
toast.error('최종확정 중 오류가 발생했습니다.');
}
};
// 수정 모드 전환
const handleEditMode = () => {
router.push(`/master-data/price-distribution/${id}/edit`);
};
// 취소
const handleCancel = () => {
router.push(`/master-data/price-distribution/${id}`);
};
// 목록으로
const handleBack = () => {
router.push('/master-data/price-distribution');
};
// 체크박스 전체 선택/해제
const handleSelectAll = (checked: boolean) => {
if (!detail) return;
if (checked) {
setSelectedItems(new Set(detail.items.map((item) => item.id)));
} else {
setSelectedItems(new Set());
}
};
// 체크박스 개별 선택
const handleSelectItem = (itemId: string, checked: boolean) => {
setSelectedItems((prev) => {
const next = new Set(prev);
if (checked) {
next.add(itemId);
} else {
next.delete(itemId);
}
return next;
});
};
// 상태 뱃지
const renderStatusBadge = (status: DistributionStatus) => {
const style = DISTRIBUTION_STATUS_STYLES[status];
const label = DISTRIBUTION_STATUS_LABELS[status];
return (
<Badge variant="outline" className={`${style.bg} ${style.text} ${style.border}`}>
{label}
</Badge>
);
};
// 금액 포맷
const formatPrice = (price?: number) => {
if (price === undefined || price === null) return '-';
return price.toLocaleString();
};
if (isLoading) {
return (
<PageLayout>
<div className="flex items-center justify-center h-64">
<p className="text-muted-foreground"> ...</p>
</div>
</PageLayout>
);
}
if (!detail) {
return (
<PageLayout>
<div className="flex flex-col items-center justify-center h-64 gap-4">
<p className="text-muted-foreground"> .</p>
<Button variant="outline" onClick={handleBack}></Button>
</div>
</PageLayout>
);
}
const isAllSelected = detail.items.length > 0 && selectedItems.size === detail.items.length;
return (
<PageLayout>
<PageHeader
title={`단가배포 ${isEditMode ? '수정' : '상세'}`}
description={`${detail.distributionName} (${detail.distributionNo})`}
icon={FileText}
/>
{/* 기본 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* 단가배포번호 */}
<div className="space-y-1.5">
<Label className="text-muted-foreground text-xs"></Label>
<p className="text-sm font-medium">{detail.distributionNo}</p>
</div>
{/* 단가배포명 */}
<div className="space-y-1.5">
<Label className="text-muted-foreground text-xs"></Label>
{isEditMode ? (
<Input
value={formData.distributionName}
onChange={(e) => handleChange('distributionName', e.target.value)}
className="h-8 text-sm"
/>
) : (
<p className="text-sm font-medium">{detail.distributionName}</p>
)}
</div>
{/* 상태 */}
<div className="space-y-1.5">
<Label className="text-muted-foreground text-xs"></Label>
<Select value={detail.status} disabled>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="initial">{DISTRIBUTION_STATUS_LABELS.initial}</SelectItem>
<SelectItem value="revision">{DISTRIBUTION_STATUS_LABELS.revision}</SelectItem>
<SelectItem value="finalized">{DISTRIBUTION_STATUS_LABELS.finalized}</SelectItem>
</SelectContent>
</Select>
</div>
{/* 작성자 */}
<div className="space-y-1.5">
<Label className="text-muted-foreground text-xs"></Label>
<p className="text-sm font-medium">{detail.author}</p>
</div>
{/* 등록일 */}
<div className="space-y-1.5">
<Label className="text-muted-foreground text-xs"></Label>
<p className="text-sm font-medium">{detail.createdAt}</p>
</div>
{/* 적용시점 */}
<div className="space-y-1.5">
<Label className="text-muted-foreground text-xs"></Label>
{isEditMode ? (
<DatePicker
value={formData.effectiveDate}
onChange={(date) => handleChange('effectiveDate', date)}
size="sm"
/>
) : (
<p className="text-sm font-medium">
{detail.effectiveDate ? new Date(detail.effectiveDate).toLocaleDateString('ko-KR') : '-'}
</p>
)}
</div>
{/* 사무실 연락처 */}
<div className="space-y-1.5">
<Label className="text-muted-foreground text-xs"> </Label>
{isEditMode ? (
<Input
value={formData.officePhone}
onChange={(e) => handleChange('officePhone', e.target.value)}
className="h-8 text-sm"
placeholder="02-0000-0000"
/>
) : (
<p className="text-sm font-medium">{detail.officePhone || '-'}</p>
)}
</div>
{/* 발주전용 연락처 */}
<div className="space-y-1.5">
<Label className="text-muted-foreground text-xs"> </Label>
{isEditMode ? (
<Input
value={formData.orderPhone}
onChange={(e) => handleChange('orderPhone', e.target.value)}
className="h-8 text-sm"
placeholder="02-0000-0000"
/>
) : (
<p className="text-sm font-medium">{detail.orderPhone || '-'}</p>
)}
</div>
</div>
</CardContent>
</Card>
{/* 단가 목록 테이블 */}
<Card className="mt-4">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base"> </CardTitle>
<div className="flex items-center gap-3">
<Select value={gradeFilter} onValueChange={setGradeFilter}>
<SelectTrigger className="h-8 w-[120px] text-sm">
<SelectValue placeholder="등급" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{TRADE_GRADE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-sm text-muted-foreground">
{detail.items.length}
</span>
</div>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[40px] text-center">
<Checkbox
checked={isAllSelected}
onCheckedChange={(checked) => handleSelectAll(!!checked)}
/>
</TableHead>
<TableHead className="w-[50px] text-center"></TableHead>
<TableHead className="min-w-[100px]"></TableHead>
<TableHead className="min-w-[100px]"></TableHead>
<TableHead className="min-w-[80px]"></TableHead>
<TableHead className="min-w-[120px]"></TableHead>
<TableHead className="min-w-[80px]"></TableHead>
<TableHead className="min-w-[60px]"></TableHead>
<TableHead className="min-w-[100px] text-right"></TableHead>
<TableHead className="min-w-[80px] text-right"></TableHead>
<TableHead className="min-w-[70px] text-right"></TableHead>
<TableHead className="min-w-[100px] text-right"></TableHead>
<TableHead className="min-w-[80px]"></TableHead>
<TableHead className="min-w-[80px]"></TableHead>
<TableHead className="min-w-[100px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{detail.items.length === 0 ? (
<TableRow>
<TableCell colSpan={15} className="text-center text-muted-foreground py-8">
.
</TableCell>
</TableRow>
) : (
detail.items.map((item, index) => (
<TableRow key={item.id}>
<TableCell className="text-center">
<Checkbox
checked={selectedItems.has(item.id)}
onCheckedChange={(checked) => handleSelectItem(item.id, !!checked)}
/>
</TableCell>
<TableCell className="text-center text-muted-foreground">
{index + 1}
</TableCell>
<TableCell>{item.pricingCode}</TableCell>
<TableCell>{item.itemCode}</TableCell>
<TableCell className="text-muted-foreground">{item.itemType}</TableCell>
<TableCell className="font-medium">{item.itemName}</TableCell>
<TableCell className="text-muted-foreground">{item.specification}</TableCell>
<TableCell className="text-muted-foreground">{item.unit}</TableCell>
<TableCell className="text-right font-mono">{formatPrice(item.purchasePrice)}</TableCell>
<TableCell className="text-right font-mono">{formatPrice(item.processingCost)}</TableCell>
<TableCell className="text-right font-mono">{item.marginRate}%</TableCell>
<TableCell className="text-right font-mono font-semibold">{formatPrice(item.salesPrice)}</TableCell>
<TableCell>
<Badge variant="outline" className={getPresetStyle('success')}>
{item.status}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground">{item.author}</TableCell>
<TableCell className="text-muted-foreground">{item.changedDate}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
{/* 하단 버튼 (sticky 하단 바) */}
<div className={`fixed bottom-4 left-4 right-4 px-4 py-3 bg-background/95 backdrop-blur rounded-xl border shadow-lg z-50 transition-all duration-300 md:bottom-6 md:px-6 md:right-[48px] ${sidebarCollapsed ? 'md:left-[156px]' : 'md:left-[316px]'} flex items-center justify-between`}>
{/* 왼쪽: 목록으로 / 취소 */}
{isViewMode ? (
<Button variant="outline" onClick={handleBack} size="sm" className="md:size-default">
<ArrowLeft className="w-4 h-4 md:mr-2" />
<span className="hidden md:inline"></span>
</Button>
) : (
<Button variant="outline" onClick={handleCancel} disabled={isSaving} size="sm" className="md:size-default">
<X className="w-4 h-4 md:mr-2" />
<span className="hidden md:inline"></span>
</Button>
)}
{/* 오른쪽: 액션 버튼들 */}
<div className="flex items-center gap-1 md:gap-2">
{isViewMode && (
<>
<Button
variant="outline"
size="sm"
className="md:size-default"
onClick={() => setShowDocumentModal(true)}
>
<FileText className="w-4 h-4 md:mr-2" />
<span className="hidden md:inline"> </span>
</Button>
{canApprove && (
<Button
variant="outline"
size="sm"
className="md:size-default"
onClick={() => setShowFinalizeDialog(true)}
disabled={detail.status === 'finalized'}
>
<CheckCircle2 className="w-4 h-4 md:mr-2" />
<span className="hidden md:inline"></span>
</Button>
)}
{canUpdate && (
<Button
onClick={handleEditMode}
size="sm"
className="md:size-default"
disabled={detail.status === 'finalized'}
>
<Edit3 className="w-4 h-4 md:mr-2" />
<span className="hidden md:inline"></span>
</Button>
)}
</>
)}
{isEditMode && canUpdate && (
<Button onClick={handleSave} disabled={isSaving} size="sm" className="md:size-default">
<Save className="w-4 h-4 md:mr-2" />
<span className="hidden md:inline">{isSaving ? '저장 중...' : '저장'}</span>
</Button>
)}
</div>
</div>
{/* 최종확정 다이얼로그 */}
<AlertDialog open={showFinalizeDialog} onOpenChange={setShowFinalizeDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleFinalize}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 단가표 보기 모달 */}
<PriceDistributionDocumentModal
open={showDocumentModal}
onOpenChange={setShowDocumentModal}
detail={detail}
/>
</PageLayout>
);
}
export default PriceDistributionDetail;