feat(WEB): 자재/출고/생산/품질/단가 기능 대폭 개선 및 신규 페이지 추가
자재관리: - 입고관리 재고조정 다이얼로그 신규 추가, 상세/목록 기능 확장 - 재고현황 컴포넌트 리팩토링 출고관리: - 출하관리 생성/수정/목록/상세 개선 - 차량배차관리 상세/수정/목록 기능 보강 생산관리: - 작업지시서 WIP 생산 모달 신규 추가 - 벤딩WIP/슬랫조인트바 검사 콘텐츠 신규 추가 - 작업자화면 기능 대폭 확장 (카드/목록 개선) - 검사성적서 모달 개선 품질관리: - 실적보고서 관리 페이지 신규 추가 - 검사관리 문서/타입/목데이터 개선 단가관리: - 단가배포 페이지 및 컴포넌트 신규 추가 - 단가표 관리 페이지 및 컴포넌트 신규 추가 공통: - 권한 시스템 추가 개선 (PermissionContext, usePermission, PermissionGuard) - 메뉴 폴링 훅 개선, 레이아웃 수정 - 모바일 줌/패닝 CSS 수정 - locale 유틸 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
537
src/components/pricing-distribution/PriceDistributionDetail.tsx
Normal file
537
src/components/pricing-distribution/PriceDistributionDetail.tsx
Normal file
@@ -0,0 +1,537 @@
|
||||
/**
|
||||
* 단가배포 상세/수정 페이지
|
||||
*
|
||||
* 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 '@/store/menuStore';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
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 ? (
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.effectiveDate}
|
||||
onChange={(e) => handleChange('effectiveDate', e.target.value)}
|
||||
className="h-8 text-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="bg-green-50 text-green-700 border-green-200">
|
||||
{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;
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* 단가표 보기 모달 (문서 스타일)
|
||||
*
|
||||
* DocumentViewer 래퍼 사용 (인쇄/공유/닫기)
|
||||
* DocumentHeader + ApprovalLine 활용
|
||||
* 경동기업 자재단가 조정 문서
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { DocumentViewer } from '@/components/document-system/viewer/DocumentViewer';
|
||||
import { DocumentHeader } from '@/components/document-system/components/DocumentHeader';
|
||||
import { ConstructionApprovalTable } from '@/components/document-system/components/ConstructionApprovalTable';
|
||||
import type { PriceDistributionDetail } from './types';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
detail: PriceDistributionDetail;
|
||||
}
|
||||
|
||||
export function PriceDistributionDocumentModal({ open, onOpenChange, detail }: Props) {
|
||||
const effectiveDate = detail.effectiveDate
|
||||
? new Date(detail.effectiveDate)
|
||||
: new Date();
|
||||
const year = effectiveDate.getFullYear();
|
||||
const month = effectiveDate.getMonth() + 1;
|
||||
const day = effectiveDate.getDate();
|
||||
|
||||
return (
|
||||
<DocumentViewer
|
||||
title="문서 상세_단가표_팝업"
|
||||
subtitle="단가표 보기"
|
||||
preset="readonly"
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
<div className="bg-white mx-auto" style={{ width: '210mm', minHeight: '297mm', padding: '15mm 20mm' }}>
|
||||
{/* 문서 헤더 + 결재란 */}
|
||||
<DocumentHeader
|
||||
title="경동기업 자재단가 조정"
|
||||
documentCode={detail.distributionNo}
|
||||
subtitle={`적용기간: ${year}년 ${month}월 ${day}일 ~`}
|
||||
layout="construction"
|
||||
className="pb-4 border-b-2 border-black"
|
||||
approval={null}
|
||||
customApproval={
|
||||
<ConstructionApprovalTable
|
||||
approvers={{
|
||||
writer: { name: detail.author },
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 수신/발신 정보 */}
|
||||
<div className="border border-gray-300 mb-6 mt-6">
|
||||
<table className="w-full text-sm">
|
||||
<tbody>
|
||||
<tr className="border-b border-gray-300">
|
||||
<td className="bg-gray-100 border-r border-gray-300 px-2 py-1 w-28 font-medium">수신자</td>
|
||||
<td className="border-r border-gray-300 px-2 py-1">경동기업 고객사</td>
|
||||
<td className="bg-gray-100 border-r border-gray-300 px-2 py-1 w-28 font-medium">발신자</td>
|
||||
<td className="px-2 py-1">경동기업</td>
|
||||
</tr>
|
||||
<tr className="border-b border-gray-300">
|
||||
<td className="bg-gray-100 border-r border-gray-300 px-2 py-1 font-medium">발신일자</td>
|
||||
<td className="border-r border-gray-300 px-2 py-1">
|
||||
{year}-{String(month).padStart(2, '0')}-{String(day).padStart(2, '0')}
|
||||
</td>
|
||||
<td className="bg-gray-100 border-r border-gray-300 px-2 py-1 font-medium">사무실 연락처</td>
|
||||
<td className="px-2 py-1">{detail.officePhone || '-'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="bg-gray-100 border-r border-gray-300 px-2 py-1 font-medium"></td>
|
||||
<td className="border-r border-gray-300 px-2 py-1"></td>
|
||||
<td className="bg-gray-100 border-r border-gray-300 px-2 py-1 font-medium">발주전용 연락처</td>
|
||||
<td className="px-2 py-1">{detail.orderPhone || '-'}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 안내 문구 */}
|
||||
<div className="text-sm leading-relaxed space-y-4 mb-8">
|
||||
<p>
|
||||
1. 귀사의 무궁한 발전을 기원합니다.
|
||||
</p>
|
||||
<p>
|
||||
2. 자사에서 귀사에 가격인 변동으로 인해 단가(단가표)에 아래와 같이 조정하여 단가표를 보내드리오니,
|
||||
우순거래처 귀 물록표를 보내드리오며 이 단가는 거래(단가표)로 알고서시고 첫 기간 다른 거래처에 단가가
|
||||
표를 보내 데이터가 안되도록 합니다.
|
||||
</p>
|
||||
<p>
|
||||
3. 귀사에 공급하는 자재가 조정되었으니,
|
||||
<br />
|
||||
<span className="ml-4">
|
||||
가. {year}년 {month}월 {day}일 발주요분부터~
|
||||
</span>
|
||||
<br />
|
||||
<span className="ml-4">
|
||||
나. 단가표
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 단가 테이블 */}
|
||||
<div className="overflow-x-auto mb-8">
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-100">
|
||||
<th className="border border-gray-300 px-3 py-2 text-center font-semibold">구분</th>
|
||||
<th className="border border-gray-300 px-3 py-2 text-center font-semibold">물록</th>
|
||||
<th className="border border-gray-300 px-3 py-2 text-center font-semibold">규격</th>
|
||||
<th className="border border-gray-300 px-3 py-2 text-center font-semibold">두께/T</th>
|
||||
<th className="border border-gray-300 px-3 py-2 text-center font-semibold">입가/M</th>
|
||||
<th className="border border-gray-300 px-3 py-2 text-center font-semibold">단위</th>
|
||||
<th className="border border-gray-300 px-3 py-2 text-center font-semibold">
|
||||
조정금액
|
||||
<br />
|
||||
<span className="text-xs font-normal">(VAT별도)</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{detail.items.map((item) => (
|
||||
<tr key={item.id}>
|
||||
<td className="border border-gray-300 px-3 py-2 text-center">{item.itemType}</td>
|
||||
<td className="border border-gray-300 px-3 py-2">{item.itemName}</td>
|
||||
<td className="border border-gray-300 px-3 py-2 text-center">{item.specification}</td>
|
||||
<td className="border border-gray-300 px-3 py-2 text-center">-</td>
|
||||
<td className="border border-gray-300 px-3 py-2 text-center">-</td>
|
||||
<td className="border border-gray-300 px-3 py-2 text-center">{item.unit}</td>
|
||||
<td className="border border-gray-300 px-3 py-2 text-right font-mono">
|
||||
{item.salesPrice.toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 하단 날짜 및 회사 정보 */}
|
||||
<div className="text-center mt-12 space-y-2">
|
||||
<p className="text-sm">
|
||||
{year}년 {month}월 {day}일
|
||||
</p>
|
||||
<p className="text-lg font-bold">
|
||||
㈜ 경동기업
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</DocumentViewer>
|
||||
);
|
||||
}
|
||||
|
||||
export default PriceDistributionDocumentModal;
|
||||
327
src/components/pricing-distribution/PriceDistributionList.tsx
Normal file
327
src/components/pricing-distribution/PriceDistributionList.tsx
Normal file
@@ -0,0 +1,327 @@
|
||||
/**
|
||||
* 단가배포 목록 클라이언트 컴포넌트
|
||||
*
|
||||
* UniversalListPage 공통 템플릿 활용
|
||||
* - 탭 없음, 통계 카드 없음
|
||||
* - 상태 필터: filterConfig (SELECT 드롭다운)
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { FileText, FilePlus } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { TableRow, TableCell } from '@/components/ui/table';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {
|
||||
UniversalListPage,
|
||||
type UniversalListConfig,
|
||||
type TableColumn,
|
||||
type FilterFieldConfig,
|
||||
type SelectionHandlers,
|
||||
type RowClickHandlers,
|
||||
} from '@/components/templates/UniversalListPage';
|
||||
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
|
||||
import { toast } from 'sonner';
|
||||
import type { PriceDistributionListItem, DistributionStatus } from './types';
|
||||
import {
|
||||
DISTRIBUTION_STATUS_LABELS,
|
||||
DISTRIBUTION_STATUS_STYLES,
|
||||
} from './types';
|
||||
import {
|
||||
getPriceDistributionList,
|
||||
createPriceDistribution,
|
||||
deletePriceDistribution,
|
||||
} from './actions';
|
||||
|
||||
export function PriceDistributionList() {
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<PriceDistributionListItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [showRegisterDialog, setShowRegisterDialog] = useState(false);
|
||||
const [isRegistering, setIsRegistering] = useState(false);
|
||||
const pageSize = 20;
|
||||
|
||||
// 날짜 범위 상태 (최근 30일)
|
||||
const today = new Date();
|
||||
const thirtyDaysAgo = new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
const [startDate, setStartDate] = useState<string>(thirtyDaysAgo.toISOString().split('T')[0]);
|
||||
const [endDate, setEndDate] = useState<string>(today.toISOString().split('T')[0]);
|
||||
|
||||
// 데이터 로드
|
||||
const loadData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const listResult = await getPriceDistributionList();
|
||||
if (listResult.success && listResult.data) {
|
||||
setData(listResult.data.items);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
// 검색 필터
|
||||
const searchFilter = (item: PriceDistributionListItem, search: string) => {
|
||||
const s = search.toLowerCase();
|
||||
return (
|
||||
item.distributionNo.toLowerCase().includes(s) ||
|
||||
item.distributionName.toLowerCase().includes(s) ||
|
||||
item.author.toLowerCase().includes(s)
|
||||
);
|
||||
};
|
||||
|
||||
// 상태 Badge 렌더링
|
||||
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 handleRegister = async () => {
|
||||
setIsRegistering(true);
|
||||
try {
|
||||
const result = await createPriceDistribution();
|
||||
if (result.success && result.data) {
|
||||
toast.success('단가배포가 등록되었습니다.');
|
||||
setShowRegisterDialog(false);
|
||||
router.push(`/master-data/price-distribution/${result.data.id}`);
|
||||
} else {
|
||||
toast.error(result.error || '등록에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('등록 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsRegistering(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 행 클릭 → 상세
|
||||
const handleRowClick = (item: PriceDistributionListItem) => {
|
||||
router.push(`/master-data/price-distribution/${item.id}`);
|
||||
};
|
||||
|
||||
// 상태 필터 설정
|
||||
const filterConfig: FilterFieldConfig[] = [
|
||||
{
|
||||
key: 'status',
|
||||
label: '상태',
|
||||
type: 'single',
|
||||
options: [
|
||||
{ value: 'initial', label: '최초작성' },
|
||||
{ value: 'revision', label: '보이수정' },
|
||||
{ value: 'finalized', label: '최종확정' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// 커스텀 필터 함수
|
||||
const customFilterFn = (items: PriceDistributionListItem[], filterValues: Record<string, string | string[]>) => {
|
||||
const status = filterValues.status as string;
|
||||
if (!status || status === '') return items;
|
||||
return items.filter((item) => item.status === status);
|
||||
};
|
||||
|
||||
// 테이블 컬럼
|
||||
const tableColumns: TableColumn[] = [
|
||||
{ key: 'rowNumber', label: '번호', className: 'w-[60px] text-center' },
|
||||
{ key: 'distributionNo', label: '단가배포번호', className: 'min-w-[120px]' },
|
||||
{ key: 'distributionName', label: '단가배포명', className: 'min-w-[150px]' },
|
||||
{ key: 'status', label: '상태', className: 'min-w-[100px]' },
|
||||
{ key: 'author', label: '작성자', className: 'min-w-[100px]' },
|
||||
{ key: 'createdAt', label: '등록일', className: 'min-w-[120px]' },
|
||||
];
|
||||
|
||||
// 테이블 행 렌더링
|
||||
const renderTableRow = (
|
||||
item: PriceDistributionListItem,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<PriceDistributionListItem>
|
||||
) => {
|
||||
const { isSelected, onToggle } = handlers;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className="hover:bg-muted/50 cursor-pointer"
|
||||
onClick={() => handleRowClick(item)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={isSelected} onCheckedChange={onToggle} />
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-center">
|
||||
{globalIndex}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-xs bg-gray-100 px-2 py-1 rounded">
|
||||
{item.distributionNo}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="font-medium">{item.distributionName}</span>
|
||||
</TableCell>
|
||||
<TableCell>{renderStatusBadge(item.status)}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{item.author}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{new Date(item.createdAt).toLocaleDateString('ko-KR')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
|
||||
// 모바일 카드 렌더링
|
||||
const renderMobileCard = (
|
||||
item: PriceDistributionListItem,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<PriceDistributionListItem>
|
||||
) => {
|
||||
const { isSelected, onToggle } = handlers;
|
||||
return (
|
||||
<ListMobileCard
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
title={item.distributionName}
|
||||
headerBadges={
|
||||
<code className="text-xs bg-gray-100 px-2 py-1 rounded">
|
||||
{item.distributionNo}
|
||||
</code>
|
||||
}
|
||||
statusBadge={renderStatusBadge(item.status)}
|
||||
isSelected={isSelected}
|
||||
onToggleSelection={onToggle}
|
||||
onCardClick={() => handleRowClick(item)}
|
||||
infoGrid={
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<InfoField label="작성자" value={item.author} />
|
||||
<InfoField
|
||||
label="등록일"
|
||||
value={new Date(item.createdAt).toLocaleDateString('ko-KR')}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 헤더 액션
|
||||
const headerActions = () => (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => setShowRegisterDialog(true)}
|
||||
className="ml-auto gap-2 bg-gray-900 text-white hover:bg-gray-800"
|
||||
>
|
||||
<FilePlus className="h-4 w-4" />
|
||||
단가배포 등록
|
||||
</Button>
|
||||
);
|
||||
|
||||
// UniversalListPage 설정
|
||||
const listConfig: UniversalListConfig<PriceDistributionListItem> = {
|
||||
title: '단가배포 목록',
|
||||
description: '단가표 기준 거래처별 단가 배포를 관리합니다',
|
||||
icon: FileText,
|
||||
basePath: '/master-data/price-distribution',
|
||||
idField: 'id',
|
||||
|
||||
actions: {
|
||||
getList: async () => ({
|
||||
success: true,
|
||||
data: data,
|
||||
totalCount: data.length,
|
||||
}),
|
||||
deleteBulk: async (ids: string[]) => {
|
||||
const result = await deletePriceDistribution(ids);
|
||||
return { success: result.success, error: result.error };
|
||||
},
|
||||
},
|
||||
|
||||
columns: tableColumns,
|
||||
headerActions,
|
||||
filterConfig,
|
||||
|
||||
// 날짜 범위 필터 + 프리셋 버튼
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
showPresets: true,
|
||||
startDate,
|
||||
endDate,
|
||||
onStartDateChange: setStartDate,
|
||||
onEndDateChange: setEndDate,
|
||||
dateField: 'createdAt',
|
||||
},
|
||||
|
||||
searchPlaceholder: '단가배포번호, 단가배포명, 작성자 검색...',
|
||||
itemsPerPage: pageSize,
|
||||
clientSideFiltering: true,
|
||||
searchFilter,
|
||||
customFilterFn,
|
||||
|
||||
renderTableRow,
|
||||
renderMobileCard,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<UniversalListPage<PriceDistributionListItem>
|
||||
config={listConfig}
|
||||
initialData={data}
|
||||
initialTotalCount={data.length}
|
||||
/>
|
||||
|
||||
{/* 단가배포 등록 확인 다이얼로그 */}
|
||||
<AlertDialog open={showRegisterDialog} onOpenChange={setShowRegisterDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>알림</AlertDialogTitle>
|
||||
<AlertDialogDescription className="space-y-2">
|
||||
<span className="block font-semibold text-foreground">
|
||||
새로운 단가배포 버전을 등록하시겠습니까?
|
||||
</span>
|
||||
<span className="block text-muted-foreground">
|
||||
현재 단가표 기준으로 자동 생성됩니다.
|
||||
</span>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isRegistering}>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleRegister}
|
||||
disabled={isRegistering}
|
||||
>
|
||||
{isRegistering ? '등록 중...' : '등록'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default PriceDistributionList;
|
||||
444
src/components/pricing-distribution/actions.ts
Normal file
444
src/components/pricing-distribution/actions.ts
Normal file
@@ -0,0 +1,444 @@
|
||||
'use server';
|
||||
|
||||
import type {
|
||||
PriceDistributionListItem,
|
||||
PriceDistributionDetail,
|
||||
PriceDistributionFormData,
|
||||
PriceDistributionStats,
|
||||
DistributionStatus,
|
||||
PriceDistributionItem,
|
||||
} from './types';
|
||||
|
||||
// ============================================================================
|
||||
// 목데이터
|
||||
// ============================================================================
|
||||
|
||||
const MOCK_ITEMS: PriceDistributionItem[] = [
|
||||
{
|
||||
id: 'item-1',
|
||||
pricingCode: '121212',
|
||||
itemCode: '123123',
|
||||
itemType: '반제품',
|
||||
itemName: '품목명A',
|
||||
specification: 'ST',
|
||||
unit: 'EA',
|
||||
purchasePrice: 10000,
|
||||
processingCost: 5000,
|
||||
marginRate: 50.0,
|
||||
salesPrice: 20000,
|
||||
status: '사용',
|
||||
author: '홍길동',
|
||||
changedDate: '2026-01-15',
|
||||
},
|
||||
{
|
||||
id: 'item-2',
|
||||
pricingCode: '121213',
|
||||
itemCode: '123124',
|
||||
itemType: '완제품',
|
||||
itemName: '품목명B',
|
||||
specification: '규격B',
|
||||
unit: 'SET',
|
||||
purchasePrice: 8000,
|
||||
processingCost: 3000,
|
||||
marginRate: 40.0,
|
||||
salesPrice: 14000,
|
||||
status: '사용',
|
||||
author: '김철수',
|
||||
changedDate: '2026-01-16',
|
||||
},
|
||||
{
|
||||
id: 'item-3',
|
||||
pricingCode: '121214',
|
||||
itemCode: '123125',
|
||||
itemType: '반제품',
|
||||
itemName: '품목명C',
|
||||
specification: 'ST',
|
||||
unit: 'EA',
|
||||
purchasePrice: 15000,
|
||||
processingCost: 5000,
|
||||
marginRate: 50.0,
|
||||
salesPrice: 27000,
|
||||
status: '사용',
|
||||
author: '이영희',
|
||||
changedDate: '2026-01-17',
|
||||
},
|
||||
{
|
||||
id: 'item-4',
|
||||
pricingCode: '121215',
|
||||
itemCode: '123126',
|
||||
itemType: '원자재',
|
||||
itemName: '품목명D',
|
||||
specification: 'AL',
|
||||
unit: 'KG',
|
||||
purchasePrice: 5000,
|
||||
processingCost: 2000,
|
||||
marginRate: 60.0,
|
||||
salesPrice: 10000,
|
||||
status: '사용',
|
||||
author: '박민수',
|
||||
changedDate: '2026-01-18',
|
||||
},
|
||||
{
|
||||
id: 'item-5',
|
||||
pricingCode: '121216',
|
||||
itemCode: '123127',
|
||||
itemType: '완제품',
|
||||
itemName: '품목명E',
|
||||
specification: '규격E',
|
||||
unit: 'SET',
|
||||
purchasePrice: 20000,
|
||||
processingCost: 8000,
|
||||
marginRate: 50.0,
|
||||
salesPrice: 38000,
|
||||
status: '사용',
|
||||
author: '홍길동',
|
||||
changedDate: '2026-01-19',
|
||||
},
|
||||
{
|
||||
id: 'item-6',
|
||||
pricingCode: '121217',
|
||||
itemCode: '123128',
|
||||
itemType: '반제품',
|
||||
itemName: '품목명F',
|
||||
specification: 'ST',
|
||||
unit: 'EA',
|
||||
purchasePrice: 12000,
|
||||
processingCost: 4000,
|
||||
marginRate: 50.0,
|
||||
salesPrice: 22000,
|
||||
status: '사용',
|
||||
author: '김철수',
|
||||
changedDate: '2026-01-20',
|
||||
},
|
||||
{
|
||||
id: 'item-7',
|
||||
pricingCode: '121218',
|
||||
itemCode: '123129',
|
||||
itemType: '원자재',
|
||||
itemName: '품목명G',
|
||||
specification: 'SUS',
|
||||
unit: 'KG',
|
||||
purchasePrice: 7000,
|
||||
processingCost: 3000,
|
||||
marginRate: 45.0,
|
||||
salesPrice: 13000,
|
||||
status: '사용',
|
||||
author: '이영희',
|
||||
changedDate: '2026-01-21',
|
||||
},
|
||||
];
|
||||
|
||||
const MOCK_LIST: PriceDistributionListItem[] = [
|
||||
{
|
||||
id: '1',
|
||||
distributionNo: '121212',
|
||||
distributionName: '2025년 1월',
|
||||
status: 'finalized',
|
||||
author: '김대표',
|
||||
createdAt: '2026-01-15',
|
||||
revisionCount: 3,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
distributionNo: '121213',
|
||||
distributionName: '2025년 1월',
|
||||
status: 'revision',
|
||||
author: '김대표',
|
||||
createdAt: '2026-01-20',
|
||||
revisionCount: 1,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
distributionNo: '121214',
|
||||
distributionName: '2025년 1월',
|
||||
status: 'initial',
|
||||
author: '김대표',
|
||||
createdAt: '2026-01-25',
|
||||
revisionCount: 0,
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
distributionNo: '121215',
|
||||
distributionName: '2025년 1월',
|
||||
status: 'revision',
|
||||
author: '김대표',
|
||||
createdAt: '2026-01-28',
|
||||
revisionCount: 2,
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
distributionNo: '121216',
|
||||
distributionName: '2025년 1월',
|
||||
status: 'finalized',
|
||||
author: '김대표',
|
||||
createdAt: '2026-02-01',
|
||||
revisionCount: 4,
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
distributionNo: '121217',
|
||||
distributionName: '2025년 1월',
|
||||
status: 'initial',
|
||||
author: '김대표',
|
||||
createdAt: '2026-02-03',
|
||||
revisionCount: 0,
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
distributionNo: '121218',
|
||||
distributionName: '2025년 1월',
|
||||
status: 'revision',
|
||||
author: '김대표',
|
||||
createdAt: '2026-02-03',
|
||||
revisionCount: 1,
|
||||
},
|
||||
];
|
||||
|
||||
const MOCK_DETAILS: Record<string, PriceDistributionDetail> = {
|
||||
'1': {
|
||||
id: '1',
|
||||
distributionNo: '121212',
|
||||
distributionName: '2025년 1월',
|
||||
status: 'finalized',
|
||||
createdAt: '2026-01-15',
|
||||
documentNo: '',
|
||||
effectiveDate: '2026-01-01',
|
||||
officePhone: '02-1234-1234',
|
||||
orderPhone: '02-1234-1234',
|
||||
author: '김대표',
|
||||
items: MOCK_ITEMS,
|
||||
},
|
||||
'2': {
|
||||
id: '2',
|
||||
distributionNo: '121213',
|
||||
distributionName: '2025년 1월',
|
||||
status: 'revision',
|
||||
createdAt: '2026-01-20',
|
||||
documentNo: '',
|
||||
effectiveDate: '2026-01-01',
|
||||
officePhone: '02-1234-1234',
|
||||
orderPhone: '02-1234-1234',
|
||||
author: '김대표',
|
||||
items: MOCK_ITEMS.slice(0, 5),
|
||||
},
|
||||
'3': {
|
||||
id: '3',
|
||||
distributionNo: '121214',
|
||||
distributionName: '2025년 1월',
|
||||
status: 'initial',
|
||||
createdAt: '2026-01-25',
|
||||
documentNo: '',
|
||||
effectiveDate: '2026-02-01',
|
||||
officePhone: '02-1234-1234',
|
||||
orderPhone: '02-1234-1234',
|
||||
author: '김대표',
|
||||
items: MOCK_ITEMS.slice(0, 3),
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// API 함수 (목데이터 기반)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 단가배포 목록 조회
|
||||
*/
|
||||
export async function getPriceDistributionList(params?: {
|
||||
page?: number;
|
||||
size?: number;
|
||||
q?: string;
|
||||
status?: DistributionStatus;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
data?: { items: PriceDistributionListItem[]; total: number };
|
||||
error?: string;
|
||||
}> {
|
||||
let items = [...MOCK_LIST];
|
||||
|
||||
if (params?.status) {
|
||||
items = items.filter((item) => item.status === params.status);
|
||||
}
|
||||
|
||||
if (params?.q) {
|
||||
const q = params.q.toLowerCase();
|
||||
items = items.filter(
|
||||
(item) =>
|
||||
item.distributionNo.toLowerCase().includes(q) ||
|
||||
item.distributionName.toLowerCase().includes(q) ||
|
||||
item.author.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
|
||||
if (params?.dateFrom) {
|
||||
items = items.filter((item) => item.createdAt >= params.dateFrom!);
|
||||
}
|
||||
|
||||
if (params?.dateTo) {
|
||||
items = items.filter((item) => item.createdAt <= params.dateTo!);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { items, total: items.length },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 단가배포 통계
|
||||
*/
|
||||
export async function getPriceDistributionStats(): Promise<{
|
||||
success: boolean;
|
||||
data?: PriceDistributionStats;
|
||||
error?: string;
|
||||
}> {
|
||||
const total = MOCK_LIST.length;
|
||||
const initial = MOCK_LIST.filter((p) => p.status === 'initial').length;
|
||||
const revision = MOCK_LIST.filter((p) => p.status === 'revision').length;
|
||||
const finalized = MOCK_LIST.filter((p) => p.status === 'finalized').length;
|
||||
return { success: true, data: { total, initial, revision, finalized } };
|
||||
}
|
||||
|
||||
/**
|
||||
* 단가배포 상세 조회
|
||||
*/
|
||||
export async function getPriceDistributionById(id: string): Promise<{
|
||||
success: boolean;
|
||||
data?: PriceDistributionDetail;
|
||||
error?: string;
|
||||
}> {
|
||||
const detail = MOCK_DETAILS[id];
|
||||
if (!detail) {
|
||||
// 목록에 있는 항목은 기본 상세 데이터 생성
|
||||
const listItem = MOCK_LIST.find((item) => item.id === id);
|
||||
if (listItem) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
id: listItem.id,
|
||||
distributionNo: listItem.distributionNo,
|
||||
distributionName: listItem.distributionName,
|
||||
status: listItem.status,
|
||||
createdAt: listItem.createdAt,
|
||||
documentNo: '',
|
||||
effectiveDate: '2026-01-01',
|
||||
officePhone: '02-1234-1234',
|
||||
orderPhone: '02-1234-1234',
|
||||
author: listItem.author,
|
||||
items: MOCK_ITEMS,
|
||||
},
|
||||
};
|
||||
}
|
||||
return { success: false, error: '단가배포를 찾을 수 없습니다.' };
|
||||
}
|
||||
return { success: true, data: detail };
|
||||
}
|
||||
|
||||
/**
|
||||
* 단가배포 등록 (현재 단가표 기준 자동 생성)
|
||||
*/
|
||||
export async function createPriceDistribution(): Promise<{
|
||||
success: boolean;
|
||||
data?: PriceDistributionDetail;
|
||||
error?: string;
|
||||
}> {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = now.getMonth() + 1;
|
||||
let name = `${year}년 ${month}월`;
|
||||
|
||||
// 중복 체크 → (N) 추가
|
||||
const existing = MOCK_LIST.filter((item) =>
|
||||
item.distributionName.startsWith(name)
|
||||
);
|
||||
if (existing.length > 0) {
|
||||
name = `${name}(${existing.length})`;
|
||||
}
|
||||
|
||||
const newId = String(Date.now());
|
||||
const newItem: PriceDistributionDetail = {
|
||||
id: newId,
|
||||
distributionNo: String(Math.floor(100000 + Math.random() * 900000)),
|
||||
distributionName: name,
|
||||
status: 'initial',
|
||||
createdAt: now.toISOString().split('T')[0],
|
||||
documentNo: '',
|
||||
effectiveDate: now.toISOString().split('T')[0],
|
||||
officePhone: '',
|
||||
orderPhone: '',
|
||||
author: '현재사용자',
|
||||
items: MOCK_ITEMS,
|
||||
};
|
||||
|
||||
return { success: true, data: newItem };
|
||||
}
|
||||
|
||||
/**
|
||||
* 단가배포 수정
|
||||
*/
|
||||
export async function updatePriceDistribution(
|
||||
id: string,
|
||||
data: PriceDistributionFormData
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
data?: PriceDistributionDetail;
|
||||
error?: string;
|
||||
}> {
|
||||
const detailData = MOCK_DETAILS[id];
|
||||
const listItem = MOCK_LIST.find((item) => item.id === id);
|
||||
if (!detailData && !listItem) {
|
||||
return { success: false, error: '단가배포를 찾을 수 없습니다.' };
|
||||
}
|
||||
|
||||
const detail: PriceDistributionDetail = detailData || {
|
||||
id,
|
||||
distributionNo: listItem!.distributionNo,
|
||||
distributionName: data.distributionName,
|
||||
status: 'revision' as DistributionStatus,
|
||||
createdAt: listItem!.createdAt,
|
||||
documentNo: data.documentNo,
|
||||
effectiveDate: data.effectiveDate,
|
||||
officePhone: data.officePhone,
|
||||
orderPhone: data.orderPhone,
|
||||
author: listItem!.author,
|
||||
items: MOCK_ITEMS,
|
||||
};
|
||||
|
||||
const updated: PriceDistributionDetail = {
|
||||
...detail,
|
||||
distributionName: data.distributionName,
|
||||
documentNo: data.documentNo,
|
||||
effectiveDate: data.effectiveDate,
|
||||
officePhone: data.officePhone,
|
||||
orderPhone: data.orderPhone,
|
||||
status: detail.status === 'initial' ? 'revision' : detail.status,
|
||||
};
|
||||
|
||||
return { success: true, data: updated };
|
||||
}
|
||||
|
||||
/**
|
||||
* 단가배포 최종확정
|
||||
*/
|
||||
export async function finalizePriceDistribution(id: string): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
const exists = MOCK_LIST.find((item) => item.id === id) || MOCK_DETAILS[id];
|
||||
if (!exists) {
|
||||
return { success: false, error: '단가배포를 찾을 수 없습니다.' };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* 단가배포 삭제
|
||||
*/
|
||||
export async function deletePriceDistribution(ids: string[]): Promise<{
|
||||
success: boolean;
|
||||
deletedCount?: number;
|
||||
error?: string;
|
||||
}> {
|
||||
return { success: true, deletedCount: ids.length };
|
||||
}
|
||||
3
src/components/pricing-distribution/index.ts
Normal file
3
src/components/pricing-distribution/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { PriceDistributionList } from './PriceDistributionList';
|
||||
export { PriceDistributionDetail } from './PriceDistributionDetail';
|
||||
export { PriceDistributionDocumentModal } from './PriceDistributionDocumentModal';
|
||||
96
src/components/pricing-distribution/types.ts
Normal file
96
src/components/pricing-distribution/types.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* 단가배포관리 타입 정의
|
||||
*/
|
||||
|
||||
// ===== 단가배포 상태 =====
|
||||
|
||||
export type DistributionStatus = 'initial' | 'revision' | 'finalized';
|
||||
|
||||
export const DISTRIBUTION_STATUS_LABELS: Record<DistributionStatus, string> = {
|
||||
initial: '최초작성',
|
||||
revision: '보이수정',
|
||||
finalized: '최종확정',
|
||||
};
|
||||
|
||||
export const DISTRIBUTION_STATUS_STYLES: Record<DistributionStatus, { bg: string; text: string; border: string }> = {
|
||||
initial: { bg: 'bg-blue-50', text: 'text-blue-700', border: 'border-blue-200' },
|
||||
revision: { bg: 'bg-orange-50', text: 'text-orange-700', border: 'border-orange-200' },
|
||||
finalized: { bg: 'bg-green-50', text: 'text-green-700', border: 'border-green-200' },
|
||||
};
|
||||
|
||||
// ===== 단가배포 목록 아이템 =====
|
||||
|
||||
export interface PriceDistributionListItem {
|
||||
id: string;
|
||||
distributionNo: string; // 단가배포번호
|
||||
distributionName: string; // 단가배포명 (YYYY년 MM월)
|
||||
status: DistributionStatus; // 상태
|
||||
author: string; // 작성자
|
||||
createdAt: string; // 등록일
|
||||
revisionCount: number; // 보이수정 횟수
|
||||
}
|
||||
|
||||
// ===== 단가배포 상세 =====
|
||||
|
||||
export interface PriceDistributionDetail {
|
||||
id: string;
|
||||
distributionNo: string; // 단가배포번호
|
||||
distributionName: string; // 단가배포명
|
||||
status: DistributionStatus; // 상태
|
||||
createdAt: string; // 작성일
|
||||
documentNo: string; // 용지번 (발송번)
|
||||
effectiveDate: string; // 적용시점
|
||||
officePhone: string; // 사무실 연락처
|
||||
orderPhone: string; // 발주전용 연락처
|
||||
author: string; // 작성자
|
||||
items: PriceDistributionItem[]; // 단가 목록
|
||||
}
|
||||
|
||||
// ===== 단가배포 품목 항목 =====
|
||||
|
||||
export interface PriceDistributionItem {
|
||||
id: string;
|
||||
pricingCode: string; // 단가번호
|
||||
itemCode: string; // 품목코드
|
||||
itemType: string; // 품목유형
|
||||
itemName: string; // 품목명
|
||||
specification: string; // 규격
|
||||
unit: string; // 단위
|
||||
purchasePrice: number; // 매입단가
|
||||
processingCost: number; // 가공비
|
||||
marginRate: number; // 마진율
|
||||
salesPrice: number; // 판매단가
|
||||
status: string; // 상태
|
||||
author: string; // 작성자
|
||||
changedDate: string; // 변경일
|
||||
}
|
||||
|
||||
// ===== 등급 필터 =====
|
||||
|
||||
export type TradeGrade = 'A등급' | 'B등급' | 'C등급' | 'D등급';
|
||||
|
||||
export const TRADE_GRADE_OPTIONS: { value: TradeGrade; label: string }[] = [
|
||||
{ value: 'A등급', label: 'A등급' },
|
||||
{ value: 'B등급', label: 'B등급' },
|
||||
{ value: 'C등급', label: 'C등급' },
|
||||
{ value: 'D등급', label: 'D등급' },
|
||||
];
|
||||
|
||||
// ===== 단가배포 폼 데이터 (수정용) =====
|
||||
|
||||
export interface PriceDistributionFormData {
|
||||
distributionName: string;
|
||||
documentNo: string;
|
||||
effectiveDate: string;
|
||||
officePhone: string;
|
||||
orderPhone: string;
|
||||
}
|
||||
|
||||
// ===== 통계 =====
|
||||
|
||||
export interface PriceDistributionStats {
|
||||
total: number;
|
||||
initial: number;
|
||||
revision: number;
|
||||
finalized: number;
|
||||
}
|
||||
Reference in New Issue
Block a user