Files
sam-react-prod/src/components/items/ItemDetailClient.tsx
유병철 afd7bda269 feat(WEB): 견적 시스템 개선, 엑셀 다운로드, PDF 생성 기능 추가
견적 시스템:
- QuoteRegistrationV2: 할인 모달, 거래명세서 모달, vatType 필드 추가
- DiscountModal: 할인율/할인금액 상호 계산 모달
- QuoteTransactionModal: 거래명세서 미리보기 모달
- LocationDetailPanel, LocationListPanel 개선

템플릿 기능:
- UniversalListPage: 엑셀 다운로드 기능 추가 (전체/선택 다운로드)
- DocumentViewer: PDF 생성 기능 개선

신규 API:
- /api/pdf/generate: Puppeteer 기반 PDF 생성 엔드포인트

UI 개선:
- 입력 컴포넌트 placeholder 스타일 개선 (opacity 50%)
- 각종 리스트 컴포넌트 정렬/필터링 개선

패키지 추가:
- html2canvas, jspdf, puppeteer, dom-to-image-more

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 19:49:03 +09:00

638 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 품목 상세 조회 Client Component
*
* 품목 정보를 읽기 전용으로 표시
*/
'use client';
import { useRouter } from 'next/navigation';
import type { ItemMaster } from '@/types/item';
import { ITEM_TYPE_LABELS, PART_TYPE_LABELS, PART_USAGE_LABELS, PRODUCT_CATEGORY_LABELS } from '@/types/item';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Label } from '@/components/ui/label';
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { ArrowLeft, Edit, Package, FileImage, Download, FileText, Check, Calendar } from 'lucide-react';
import { downloadFileById } from '@/lib/utils/fileDownload';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { useMenuStore } from '@/store/menuStore';
interface ItemDetailClientProps {
item: ItemMaster;
}
/**
* 품목 유형별 Badge 반환
*/
function getItemTypeBadge(itemType: string) {
const badges: Record<string, { className: string }> = {
FG: { className: 'bg-purple-50 text-purple-700 border-purple-200' },
PT: { className: 'bg-orange-50 text-orange-700 border-orange-200' },
SM: { className: 'bg-green-50 text-green-700 border-green-200' },
RM: { className: 'bg-blue-50 text-blue-700 border-blue-200' },
CS: { className: 'bg-gray-50 text-gray-700 border-gray-200' },
};
const config = badges[itemType] || { className: '' };
return (
<Badge variant="outline" className={config.className}>
{ITEM_TYPE_LABELS[itemType as keyof typeof ITEM_TYPE_LABELS]}
</Badge>
);
}
/**
* 조립품 품목코드 포맷팅 (하이픈 이후 제거)
*/
function formatItemCodeForAssembly(item: ItemMaster): string {
return item.itemCode;
}
/**
* 파일 다운로드 핸들러 (Blob 방식)
*/
async function handleFileDownload(fileId: number | undefined, fileName?: string): Promise<void> {
if (!fileId) return;
try {
await downloadFileById(fileId, fileName);
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[ItemDetailClient] 다운로드 실패:', error);
alert('파일 다운로드에 실패했습니다.');
}
}
/**
* Storage 경로를 전체 URL로 변환 (전개도 이미지용)
* - 이미 전체 URL인 경우 그대로 반환
* - 상대 경로인 경우 API URL + /storage/ 붙여서 반환
*/
function getStorageUrl(path: string | undefined): string | null {
if (!path) return null;
// 이미 전체 URL인 경우
if (path.startsWith('http://') || path.startsWith('https://')) {
return path;
}
// 상대 경로인 경우
const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';
return `${apiUrl}/storage/${path}`;
}
export default function ItemDetailClient({ item }: ItemDetailClientProps) {
const router = useRouter();
const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed);
return (
<div className="space-y-6 pb-24">
{/* 헤더 */}
<div className="flex items-start gap-3">
<div className="p-2 bg-primary/10 rounded-lg hidden md:block">
<Package className="w-6 h-6 text-primary" />
</div>
<div>
<h1 className="text-xl md:text-2xl"> </h1>
<p className="text-sm text-muted-foreground mt-1">
</p>
</div>
</div>
{/* 기본 정보 */}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div>
<Label className="text-muted-foreground"></Label>
<p className="mt-1">
<code className="text-sm bg-gray-100 px-2 py-1 rounded">
{formatItemCodeForAssembly(item)}
</code>
</p>
</div>
<div>
<Label className="text-muted-foreground"></Label>
<p className="mt-1 font-medium">{item.itemName}</p>
</div>
<div>
<Label className="text-muted-foreground"></Label>
<p className="mt-1">{getItemTypeBadge(item.itemType)}</p>
</div>
{item.itemType === "PT" && item.partType && (
<div>
<Label className="text-muted-foreground"> </Label>
<p className="mt-1">
<Badge variant="outline" className={
item.partType === 'ASSEMBLY' ? 'bg-blue-50 text-blue-700' :
item.partType === 'BENDING' ? 'bg-purple-50 text-purple-700' :
item.partType === 'PURCHASED' ? 'bg-green-50 text-green-700' :
'bg-gray-50 text-gray-700'
}>
{item.partType === 'ASSEMBLY' ? '조립 부품' :
item.partType === 'BENDING' ? '절곡 부품' :
item.partType === 'PURCHASED' ? '구매 부품' :
item.partType}
</Badge>
</p>
</div>
)}
{item.itemType === "PT" && item.partType === "BENDING" && item.partUsage && (
<div>
<Label className="text-muted-foreground"></Label>
<p className="mt-1">
<Badge variant="outline" className="bg-indigo-50 text-indigo-700">
{item.partUsage === "GUIDE_RAIL" ? "가이드레일용" :
item.partUsage === "BOTTOM_FINISH" ? "하단마감재용" :
item.partUsage === "CASE" ? "케이스용" :
item.partUsage === "DOOR" ? "도어용" :
item.partUsage === "BRACKET" ? "브라켓용" :
item.partUsage === "GENERAL" ? "범용 (공통 부품)" :
item.partUsage}
</Badge>
</p>
</div>
)}
{item.itemType !== "FG" && item.partType !== 'ASSEMBLY' && item.specification && (
<div>
<Label className="text-muted-foreground"></Label>
<p className="mt-1">{item.specification}</p>
</div>
)}
{item.itemType !== "FG" && (
<div>
<Label className="text-muted-foreground"></Label>
<p className="mt-1">
<Badge variant="secondary">{item.unit}</Badge>
</p>
</div>
)}
{/* 버전 정보 */}
<div className="md:col-span-2 lg:col-span-3 pt-4 border-t">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<Label className="text-muted-foreground"> </Label>
<div className="mt-1">
<Badge variant="secondary">V{item.currentRevision || 0}</Badge>
</div>
</div>
<div>
<Label className="text-muted-foreground"> </Label>
<p className="mt-1">{(item.revisions?.length || 0)}</p>
</div>
</div>
</div>
<div>
<Label className="text-muted-foreground"></Label>
<p className="mt-1 text-sm">{new Date(item.createdAt).toLocaleDateString('ko-KR')}</p>
</div>
</div>
</CardContent>
</Card>
{/* 제품(FG) 전용 정보 - 내용이 있을 때만 표시 */}
{item.itemType === 'FG' && (item.productCategory || item.lotAbbreviation || item.note) && (
<Card>
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{item.productCategory && (
<div>
<Label className="text-muted-foreground"> </Label>
<p className="mt-1 font-medium">{PRODUCT_CATEGORY_LABELS[item.productCategory]}</p>
</div>
)}
{item.lotAbbreviation && (
<div>
<Label className="text-muted-foreground"> </Label>
<p className="mt-1 font-medium">{item.lotAbbreviation}</p>
</div>
)}
</div>
{item.note && (
<div className="mt-4">
<Label className="text-muted-foreground"></Label>
<p className="mt-1 text-sm whitespace-pre-wrap">{item.note}</p>
</div>
)}
</CardContent>
</Card>
)}
{/* 조립 부품 세부 정보 */}
{item.itemType === 'PT' && item.partType === 'ASSEMBLY' && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Package className="h-5 w-5" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{/* 품목명 - itemName 표시 */}
<div>
<Label className="text-muted-foreground"></Label>
<p className="mt-1">
<Badge variant="outline" className="bg-indigo-50 text-indigo-700">
{item.itemName}
</Badge>
</p>
</div>
{/* 설치 유형 */}
<div>
<Label className="text-muted-foreground"> </Label>
<p className="mt-1">
{item.installationType ? (
<Badge variant="outline" className="bg-green-50 text-green-700">
{item.installationType === 'wall' ? '벽면형 (R)' :
item.installationType === 'side' ? '측면형 (S)' :
item.installationType === 'steel' ? '스틸 (B)' :
item.installationType === 'iron' ? '철재 (T)' :
item.installationType}
</Badge>
) : (
<span className="text-muted-foreground">-</span>
)}
</p>
</div>
{/* 마감 */}
<div>
<Label className="text-muted-foreground"></Label>
<p className="mt-1">
{item.assemblyType ? (
<code className="text-sm bg-gray-100 px-2 py-1 rounded">
{item.assemblyType}
</code>
) : (
<span className="text-muted-foreground">-</span>
)}
</p>
</div>
{/* 길이 */}
<div>
<Label className="text-muted-foreground"></Label>
<p className="mt-1 font-medium">
{item.assemblyLength || item.length ? `${item.assemblyLength || item.length}mm` : '-'}
</p>
</div>
{/* 측면 규격 */}
<div>
<Label className="text-muted-foreground"> </Label>
<p className="mt-1">
{item.sideSpecWidth && item.sideSpecHeight
? `${item.sideSpecWidth} × ${item.sideSpecHeight}mm`
: '-'}
</p>
</div>
{/* 재질 (있으면) */}
{item.material && (
<div>
<Label className="text-muted-foreground"></Label>
<p className="mt-1">
<Badge variant="outline" className="bg-indigo-50 text-indigo-700">
{item.material}
</Badge>
</p>
</div>
)}
</div>
</CardContent>
</Card>
)}
{/* 가이드레일 세부 정보 */}
{item.category3 === "가이드레일" && item.guideRailModelType && (
<Card>
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{item.guideRailModelType && (
<div>
<Label className="text-muted-foreground"></Label>
<p className="mt-1">
<Badge variant="outline" className="bg-orange-50 text-orange-700">
{item.guideRailModelType}
</Badge>
</p>
</div>
)}
{item.guideRailModel && (
<div>
<Label className="text-muted-foreground"></Label>
<p className="mt-1">
<Badge variant="outline" className="bg-orange-50 text-orange-700">
{item.guideRailModel}
</Badge>
</p>
</div>
)}
</div>
</CardContent>
</Card>
)}
{/* 절곡품/조립품 전개도 정보 - 조립부품은 항상 표시 */}
{item.itemType === 'PT' &&
(item.partType === 'BENDING' || item.partType === 'ASSEMBLY') && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base md:text-lg">
<FileImage className="h-4 w-4 md:h-5 md:w-5" />
{item.partType === 'ASSEMBLY' ? '조립품 전개도 (바라시)' : '절곡품 전개도 (바라시)'}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4 md:space-y-6 pt-0">
{/* 전개도 이미지 - 파일 ID가 있으면 프록시로 로드 */}
{(item.bendingDiagramFileId || item.bendingDiagram) ? (
<div>
<Label className="text-muted-foreground text-xs md:text-sm"> </Label>
<div className="mt-2 p-2 md:p-4 border rounded-lg bg-gray-50">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={item.bendingDiagramFileId
? `/api/proxy/files/${item.bendingDiagramFileId}/download`
: getStorageUrl(item.bendingDiagram) || ''
}
alt="전개도"
className="max-w-full h-auto max-h-64 md:max-h-96 mx-auto border rounded"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.style.display = 'none';
target.parentElement?.insertAdjacentHTML(
'beforeend',
'<p class="text-center text-gray-500 py-8">이미지를 불러올 수 없습니다</p>'
);
}}
/>
</div>
{item.bendingDiagramFileId && (
<div className="mt-2 flex justify-end">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => handleFileDownload(item.bendingDiagramFileId, '전개도_이미지')}
>
<Download className="h-4 w-4 mr-1" />
</Button>
</div>
)}
</div>
) : (
<div className="text-center py-6 md:py-8 text-xs md:text-sm text-muted-foreground border rounded-lg bg-gray-50">
.
</div>
)}
{/* 전개도 상세 데이터 */}
{item.bendingDetails && item.bendingDetails.length > 0 && (
<div>
<Label className="text-muted-foreground text-xs md:text-sm"> </Label>
<div className="mt-2 overflow-x-auto bg-white rounded border">
<Table>
<TableHeader>
<TableRow className="bg-gray-100">
<TableHead className="px-1 md:px-2 py-1 md:py-2 text-center"></TableHead>
<TableHead className="px-1 md:px-2 py-1 md:py-2 text-center"></TableHead>
<TableHead className="px-1 md:px-2 py-1 md:py-2 text-center"></TableHead>
<TableHead className="px-1 md:px-2 py-1 md:py-2 text-center"></TableHead>
<TableHead className="px-1 md:px-2 py-1 md:py-2 text-center"></TableHead>
<TableHead className="px-1 md:px-2 py-1 md:py-2 text-center"></TableHead>
<TableHead className="px-1 md:px-2 py-1 md:py-2 text-center">A각</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{item.bendingDetails.map((detail, detailIndex) => {
const calculated = detail.input + detail.elongation;
let sum = 0;
for (let i = 0; i <= detailIndex; i++) {
const d = item.bendingDetails![i];
sum += d.input + d.elongation;
}
return (
<TableRow key={detail.id} className={detail.shaded ? "bg-gray-200" : ""}>
<TableCell className="px-1 md:px-2 py-1 text-center">{detail.no}</TableCell>
<TableCell className="px-1 md:px-2 py-1 text-center">{detail.input}</TableCell>
<TableCell className="px-1 md:px-2 py-1 text-center">{detail.elongation}</TableCell>
<TableCell className="px-1 md:px-2 py-1 text-center bg-blue-50">{calculated}</TableCell>
<TableCell className="px-1 md:px-2 py-1 text-center bg-green-50 font-medium">{sum}</TableCell>
<TableCell className="px-1 md:px-2 py-1 text-center">
{detail.shaded ? <Check className="h-3 w-3 md:h-4 md:w-4 inline" /> : "-"}
</TableCell>
<TableCell className="px-1 md:px-2 py-1 text-center">{detail.aAngle || "-"}</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
<div className="mt-2 p-2 md:p-3 bg-blue-50 rounded-lg border border-blue-200">
<p className="text-xs md:text-sm">
<span className="font-medium"> :</span>{" "}
<span className="text-base md:text-lg font-bold text-blue-700">
{item.bendingDetails.reduce((sum, d) => sum + d.input + d.elongation, 0)} mm
</span>
</p>
</div>
</div>
)}
</CardContent>
</Card>
)}
{/* 제품(FG) 인정 정보 및 첨부 파일 */}
{item.itemType === 'FG' && (item.certificationNumber || item.specificationFile || item.certificationFile) && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base md:text-lg">
<FileText className="h-4 w-4 md:h-5 md:w-5" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4 md:space-y-6 pt-0">
{/* 인정 정보 */}
{item.certificationNumber && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 p-4 bg-gray-50 rounded-lg border">
<div>
<Label className="text-muted-foreground text-xs md:text-sm"></Label>
<p className="mt-1 font-medium">{item.certificationNumber}</p>
</div>
{item.certificationStartDate && (
<div>
<Label className="text-muted-foreground text-xs md:text-sm flex items-center gap-1">
<Calendar className="h-3 w-3" />
</Label>
<p className="mt-1">{new Date(item.certificationStartDate).toLocaleDateString('ko-KR')}</p>
</div>
)}
{item.certificationEndDate && (
<div>
<Label className="text-muted-foreground text-xs md:text-sm flex items-center gap-1">
<Calendar className="h-3 w-3" />
</Label>
<p className="mt-1">{new Date(item.certificationEndDate).toLocaleDateString('ko-KR')}</p>
</div>
)}
</div>
)}
{/* 첨부 파일 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* 시방서 */}
<div className="p-4 border rounded-lg">
<Label className="text-muted-foreground text-xs md:text-sm"></Label>
{item.specificationFile ? (
<div className="mt-2 flex items-center gap-2">
<FileText className="h-4 w-4 text-blue-600" />
<span className="text-sm truncate flex-1">
{item.specificationFileName || '시방서 파일'}
</span>
<button
type="button"
onClick={() => handleFileDownload(item.specificationFileId, item.specificationFileName)}
className="inline-flex items-center gap-1 text-sm text-blue-600 hover:text-blue-800"
>
<Download className="h-4 w-4" />
</button>
</div>
) : (
<p className="mt-2 text-sm text-muted-foreground"> .</p>
)}
</div>
{/* 인정서 */}
<div className="p-4 border rounded-lg">
<Label className="text-muted-foreground text-xs md:text-sm"></Label>
{item.certificationFile ? (
<div className="mt-2 flex items-center gap-2">
<FileText className="h-4 w-4 text-green-600" />
<span className="text-sm truncate flex-1">
{item.certificationFileName || '인정서 파일'}
</span>
<button
type="button"
onClick={() => handleFileDownload(item.certificationFileId, item.certificationFileName)}
className="inline-flex items-center gap-1 text-sm text-green-600 hover:text-green-800"
>
<Download className="h-4 w-4" />
</button>
</div>
) : (
<p className="mt-2 text-sm text-muted-foreground"> .</p>
)}
</div>
</div>
</CardContent>
</Card>
)}
{/* BOM 정보 - 절곡 부품은 제외 */}
{(item.itemType === 'FG' || (item.itemType === 'PT' && item.partType !== 'BENDING')) && item.bom && item.bom.length > 0 && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Package className="h-5 w-5" />
(BOM)
</CardTitle>
<Badge variant="outline" className="bg-blue-50 text-blue-700">
{item.bom.length}
</Badge>
</div>
</CardHeader>
<CardContent>
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{item.bom.map((line, index) => (
<TableRow key={line.id}>
<TableCell>{index + 1}</TableCell>
<TableCell>
<code className="text-xs bg-gray-100 px-2 py-1 rounded">
{line.childItemCode}
</code>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
{line.childItemName}
{line.isBending && (
<Badge variant="outline" className="text-xs bg-purple-50 text-purple-700">
</Badge>
)}
</div>
</TableCell>
<TableCell className="text-right">{line.quantity}</TableCell>
<TableCell>{line.unit}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
)}
{/* 하단 액션 버튼 (sticky) */}
<div className={`fixed bottom-6 ${sidebarCollapsed ? 'left-[156px]' : 'left-[316px]'} right-[48px] px-6 py-3 bg-background/95 backdrop-blur rounded-xl border shadow-lg z-50 transition-all duration-300 flex items-center justify-between`}>
<Button
type="button"
variant="outline"
onClick={() => router.back()}
>
<ArrowLeft className="w-4 h-4 mr-2" />
</Button>
<Button
type="button"
onClick={() => router.push(`/production/screen-production/${encodeURIComponent(item.itemCode)}?mode=edit&type=${item.itemType}&id=${item.id}`)}
>
<Edit className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
);
}