Files
sam-react-prod/src/components/items/ItemDetailClient.tsx

638 lines
27 KiB
TypeScript
Raw Normal View History

/**
* 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>
);
}