fix: 품목기준관리 실시간 동기화 수정
- BOM 항목 추가/수정/삭제 시 섹션탭 즉시 반영 - 섹션 복제 시 UI 즉시 업데이트 (null vs undefined 이슈 해결) - 항목 수정 기능 추가 (useTemplateManagement) - 실시간 동기화 문서 추가 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -27,31 +27,45 @@ export function LoginPage() {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [rememberMe, setRememberMe] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [isChecking, setIsChecking] = useState(true);
|
||||
// 2025-11-27: isChecking 상태 제거 - 미들웨어에서 인증 체크하므로 불필요
|
||||
// const [isChecking, setIsChecking] = useState(true);
|
||||
const [isLoggingIn, setIsLoggingIn] = useState(false); // ✅ 로그인 진행 중 상태
|
||||
|
||||
// 이미 로그인된 상태인지 확인 (페이지 로드 시, 뒤로가기 시)
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
// 🔵 Next.js 내부 API - 쿠키에서 토큰 확인 (PHP 호출 X, 성능 최적화)
|
||||
const response = await fetch('/api/auth/check');
|
||||
|
||||
if (response.ok) {
|
||||
// 이미 로그인됨 → 대시보드로 리다이렉트 (replace로 히스토리에서 제거)
|
||||
router.replace('/dashboard');
|
||||
return;
|
||||
}
|
||||
// 인증 안됨 (401) → 현재 페이지 유지
|
||||
} catch {
|
||||
// API 호출 실패 → 현재 페이지 유지
|
||||
} finally {
|
||||
setIsChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
}, [router]);
|
||||
/**
|
||||
* 🚫 2025-11-27: auth/check API 호출 제거
|
||||
*
|
||||
* [이전 동작]
|
||||
* - 로그인 페이지 진입 시 /api/auth/check 호출
|
||||
* - 이미 로그인된 사용자를 대시보드로 리다이렉트
|
||||
*
|
||||
* [제거 이유]
|
||||
* 1. 미들웨어(middleware.ts)에서 이미 동일한 처리를 함
|
||||
* - guestOnlyRoutes(/login, /signup)에서 인증된 사용자 → /dashboard 리다이렉트
|
||||
* 2. 401 응답이 Network 탭에 에러로 표시되어 백엔드 개발자 혼란 유발
|
||||
* 3. 불필요한 API 호출로 인한 성능 저하
|
||||
*
|
||||
* [대체 방안]
|
||||
* - 미들웨어가 서버 사이드에서 쿠키 체크 후 리다이렉트 처리
|
||||
* - 클라이언트에서 추가 API 호출 불필요
|
||||
*
|
||||
* @see middleware.ts - isGuestOnlyRoute(), checkAuthentication()
|
||||
*/
|
||||
// useEffect(() => {
|
||||
// const checkAuth = async () => {
|
||||
// try {
|
||||
// const response = await fetch('/api/auth/check');
|
||||
// if (response.ok) {
|
||||
// router.replace('/dashboard');
|
||||
// return;
|
||||
// }
|
||||
// } catch {
|
||||
// // API 호출 실패 → 현재 페이지 유지
|
||||
// } finally {
|
||||
// setIsChecking(false);
|
||||
// }
|
||||
// };
|
||||
// checkAuth();
|
||||
// }, [router]);
|
||||
|
||||
const handleLogin = async () => {
|
||||
// ✅ 중복 요청 방지
|
||||
@@ -137,17 +151,17 @@ export function LoginPage() {
|
||||
};
|
||||
|
||||
|
||||
// 인증 체크 중일 때는 로딩 표시
|
||||
if (isChecking) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent mb-4"></div>
|
||||
<p className="text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// 2025-11-27: isChecking 로딩 UI 제거 - 미들웨어에서 처리하므로 불필요
|
||||
// if (isChecking) {
|
||||
// return (
|
||||
// <div className="min-h-screen flex items-center justify-center">
|
||||
// <div className="text-center">
|
||||
// <div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent mb-4"></div>
|
||||
// <p className="text-muted-foreground">Loading...</p>
|
||||
// </div>
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex flex-col">
|
||||
|
||||
140
src/components/common/ServerErrorPage.tsx
Normal file
140
src/components/common/ServerErrorPage.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
'use client';
|
||||
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
ServerCrash,
|
||||
RefreshCw,
|
||||
Home,
|
||||
ArrowLeft,
|
||||
MessageCircleQuestion,
|
||||
} from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
interface ServerErrorPageProps {
|
||||
title?: string;
|
||||
message?: string;
|
||||
errorCode?: string | number;
|
||||
onRetry?: () => void;
|
||||
showBackButton?: boolean;
|
||||
showHomeButton?: boolean;
|
||||
showContactInfo?: boolean;
|
||||
contactEmail?: string;
|
||||
}
|
||||
|
||||
export function ServerErrorPage({
|
||||
title = '서버 오류가 발생했습니다',
|
||||
message = '일시적인 문제가 발생했습니다. 잠시 후 다시 시도해 주세요.',
|
||||
errorCode,
|
||||
onRetry,
|
||||
showBackButton = true,
|
||||
showHomeButton = true,
|
||||
showContactInfo = true,
|
||||
contactEmail = 'admin@company.com',
|
||||
}: ServerErrorPageProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
const handleRetry = () => {
|
||||
if (onRetry) {
|
||||
onRetry();
|
||||
} else {
|
||||
window.location.reload();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-200px)] flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-2xl border border-border/20 bg-card/50 backdrop-blur">
|
||||
<CardHeader className="text-center pb-4">
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="relative">
|
||||
<div className="w-24 h-24 bg-gradient-to-br from-red-500/20 to-orange-500/10 rounded-2xl flex items-center justify-center">
|
||||
<ServerCrash className="w-12 h-12 text-red-500" />
|
||||
</div>
|
||||
<div className="absolute -top-1 -right-1 w-6 h-6 bg-red-500 rounded-full flex items-center justify-center">
|
||||
<span className="text-xs text-white font-bold">!</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CardTitle className="text-2xl md:text-3xl font-bold text-foreground mb-2">
|
||||
{title}
|
||||
</CardTitle>
|
||||
{errorCode && (
|
||||
<p className="text-muted-foreground text-sm md:text-base">
|
||||
오류 코드: <code className="bg-muted px-2 py-1 rounded text-xs">{errorCode}</code>
|
||||
</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="text-center space-y-6">
|
||||
<div className="bg-red-50 dark:bg-red-950/30 rounded-xl p-6 space-y-3 border border-red-200 dark:border-red-900/50">
|
||||
<p className="text-lg text-foreground font-medium">
|
||||
{message}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
문제가 지속되면 관리자에게 문의해 주세요.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-center pt-4">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={handleRetry}
|
||||
className="rounded-xl bg-red-500 hover:bg-red-600"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
다시 시도
|
||||
</Button>
|
||||
|
||||
{showBackButton && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.back()}
|
||||
className="rounded-xl"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
이전 페이지
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{showHomeButton && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push('/dashboard')}
|
||||
className="rounded-xl"
|
||||
>
|
||||
<Home className="w-4 h-4 mr-2" />
|
||||
대시보드로 이동
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showContactInfo && (
|
||||
<div className="pt-6 border-t border-border/20">
|
||||
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<MessageCircleQuestion className="w-4 h-4" />
|
||||
<span>
|
||||
문제가 계속되면{' '}
|
||||
<a
|
||||
href={`mailto:${contactEmail}`}
|
||||
className="text-primary hover:underline font-medium"
|
||||
>
|
||||
관리자에게 문의
|
||||
</a>
|
||||
해 주세요.
|
||||
</span>
|
||||
</div>
|
||||
{pathname && (
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
발생 위치: <code className="bg-muted px-1.5 py-0.5 rounded">{pathname}</code>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
365
src/components/items/ItemForm/BOMSection.tsx
Normal file
365
src/components/items/ItemForm/BOMSection.tsx
Normal file
@@ -0,0 +1,365 @@
|
||||
/**
|
||||
* BOMSection - 부품 구성 (BOM) 섹션
|
||||
*/
|
||||
|
||||
import { Fragment } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command';
|
||||
import { Check, Package, Plus, Search, Trash2 } from 'lucide-react';
|
||||
import type { BOMLine } from '@/types/item';
|
||||
import type { BOMSearchState } from './types';
|
||||
|
||||
export interface BOMSectionProps {
|
||||
bomLines: BOMLine[];
|
||||
setBomLines: (lines: BOMLine[]) => void;
|
||||
bomSearchStates: Record<string, BOMSearchState>;
|
||||
setBomSearchStates: (states: Record<string, BOMSearchState>) => void;
|
||||
isSubmitting: boolean;
|
||||
}
|
||||
|
||||
export default function BOMSection({
|
||||
bomLines,
|
||||
setBomLines,
|
||||
bomSearchStates,
|
||||
setBomSearchStates,
|
||||
isSubmitting,
|
||||
}: BOMSectionProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>부품 구성 (BOM)</CardTitle>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const newLine: BOMLine = {
|
||||
id: `bom-${Date.now()}`,
|
||||
childItemCode: '',
|
||||
childItemName: '',
|
||||
quantity: 1,
|
||||
unit: 'EA',
|
||||
};
|
||||
setBomLines([...bomLines, newLine]);
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
BOM 품목 추가
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{bomLines.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Package className="h-16 w-16 text-muted-foreground mb-4 opacity-20" />
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
아직 부품 구성이 추가되지 않았습니다
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
품목의 구성 부품, 원자재, 부자재를 추가할 수 있습니다
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[280px]">품목코드 / 품목명 입력</TableHead>
|
||||
<TableHead className="w-[180px]">품목명</TableHead>
|
||||
<TableHead className="w-[150px]">규격</TableHead>
|
||||
<TableHead className="w-[100px]">재질</TableHead>
|
||||
<TableHead className="w-20">수량</TableHead>
|
||||
<TableHead className="w-16">단위</TableHead>
|
||||
<TableHead className="w-24 text-right">단가</TableHead>
|
||||
<TableHead className="w-[180px]">비고</TableHead>
|
||||
<TableHead className="w-16">삭제</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{bomLines.map((line) => {
|
||||
// 각 라인별 검색 상태 가져오기
|
||||
const searchState = bomSearchStates[line.id] || { searchValue: '', isOpen: false };
|
||||
const searchValue = searchState.searchValue;
|
||||
const searchOpen = searchState.isOpen;
|
||||
|
||||
// TODO: 실제 itemMasters 데이터로 교체 필요
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const availableItems: any[] = [];
|
||||
|
||||
return (
|
||||
<Fragment key={line.id}>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
<Popover
|
||||
open={searchOpen}
|
||||
onOpenChange={(open) => {
|
||||
setBomSearchStates({
|
||||
...bomSearchStates,
|
||||
[line.id]: { ...searchState, isOpen: open },
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 relative">
|
||||
<PopoverAnchor asChild>
|
||||
<Input
|
||||
placeholder="품목코드 또는 품목명 입력..."
|
||||
value={line.childItemCode || searchValue}
|
||||
onChange={(e) => {
|
||||
// 단순 입력만 처리 (서버 자동완성 준비)
|
||||
setBomSearchStates({
|
||||
...bomSearchStates,
|
||||
[line.id]: { ...searchState, searchValue: e.target.value },
|
||||
});
|
||||
}}
|
||||
className="w-full"
|
||||
readOnly={!!line.childItemCode}
|
||||
/>
|
||||
</PopoverAnchor>
|
||||
{line.childItemCode && (
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none">
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setBomSearchStates({
|
||||
...bomSearchStates,
|
||||
[line.id]: { searchValue: '', isOpen: true },
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[400px] p-0 opacity-0 data-[state=open]:opacity-100 transition-opacity duration-150 delay-200"
|
||||
side="bottom"
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
disableSlideAnimation
|
||||
>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="품목코드 또는 품목명 검색..."
|
||||
value={searchValue}
|
||||
onValueChange={(value) => {
|
||||
setBomSearchStates({
|
||||
...bomSearchStates,
|
||||
[line.id]: { ...searchState, searchValue: value },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{availableItems.map((item) => (
|
||||
<CommandItem
|
||||
key={item.id}
|
||||
value={`${item.itemCode} ${item.itemName}`}
|
||||
onSelect={() => {
|
||||
// TODO: 품목 선택 시 데이터 채우기 로직
|
||||
const isBendingPart = item.partType === 'BENDING';
|
||||
|
||||
setBomLines(
|
||||
bomLines.map((l) =>
|
||||
l.id === line.id
|
||||
? {
|
||||
...l,
|
||||
childItemCode: item.itemCode || '',
|
||||
childItemName: item.itemName || '',
|
||||
specification: item.specification || '',
|
||||
material: item.material || '',
|
||||
unit: item.unit || 'EA',
|
||||
unitPrice: 0, // TODO: pricing에서 가져오기
|
||||
isBending: isBendingPart,
|
||||
bendingDiagram: isBendingPart
|
||||
? item.bendingDiagram
|
||||
: undefined,
|
||||
}
|
||||
: l
|
||||
)
|
||||
);
|
||||
setBomSearchStates({
|
||||
...bomSearchStates,
|
||||
[line.id]: { searchValue: '', isOpen: false },
|
||||
});
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-xs bg-gray-100 px-2 py-0.5 rounded">
|
||||
{item.itemCode}
|
||||
</code>
|
||||
<span className="text-sm">{item.itemName}</span>
|
||||
{item.specification && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({item.specification})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
{item.unit}
|
||||
</Badge>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{line.childItemName || '-'}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{line.specification || '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
<Input
|
||||
value={line.material || ''}
|
||||
onChange={(e) => {
|
||||
setBomLines(
|
||||
bomLines.map((l) =>
|
||||
l.id === line.id ? { ...l, material: e.target.value } : l
|
||||
)
|
||||
);
|
||||
}}
|
||||
placeholder="재질"
|
||||
className="w-full text-xs"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
value={line.quantity}
|
||||
onChange={(e) => {
|
||||
setBomLines(
|
||||
bomLines.map((l) =>
|
||||
l.id === line.id ? { ...l, quantity: Number(e.target.value) } : l
|
||||
)
|
||||
);
|
||||
}}
|
||||
min="0"
|
||||
step="0.01"
|
||||
className="w-full"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{line.unit}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
value={line.unitPrice || 0}
|
||||
onChange={(e) => {
|
||||
setBomLines(
|
||||
bomLines.map((l) =>
|
||||
l.id === line.id ? { ...l, unitPrice: Number(e.target.value) } : l
|
||||
)
|
||||
);
|
||||
}}
|
||||
min="0"
|
||||
className="w-full text-right"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
value={line.note || ''}
|
||||
onChange={(e) => {
|
||||
setBomLines(
|
||||
bomLines.map((l) =>
|
||||
l.id === line.id ? { ...l, note: e.target.value } : l
|
||||
)
|
||||
);
|
||||
}}
|
||||
placeholder="비고"
|
||||
className="w-full text-xs"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setBomLines(bomLines.filter((l) => l.id !== line.id));
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-600" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
{/* 절곡품인 경우 전개도 정보 표시 */}
|
||||
{line.isBending && line.bendingDiagram && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} className="bg-blue-50 p-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="bg-blue-100 text-blue-700">
|
||||
절곡품 전개도 정보
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* 전개도 이미지 */}
|
||||
<div>
|
||||
<Label className="text-xs mb-2 block">전개도 이미지</Label>
|
||||
<div className="border rounded-lg p-2 bg-white">
|
||||
<img
|
||||
src={line.bendingDiagram}
|
||||
alt="절곡 전개도"
|
||||
className="max-w-full h-auto max-h-[300px] object-contain mx-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
367
src/components/items/ItemForm/BendingDiagramSection.tsx
Normal file
367
src/components/items/ItemForm/BendingDiagramSection.tsx
Normal file
@@ -0,0 +1,367 @@
|
||||
/**
|
||||
* BendingDiagramSection - 절곡품/조립품 전개도 섹션
|
||||
*/
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { FileImage, Plus, Trash2, X } from 'lucide-react';
|
||||
import type { BendingDetail } from '@/types/item';
|
||||
import type { UseFormSetValue } from 'react-hook-form';
|
||||
import type { CreateItemFormData } from '@/lib/utils/validation';
|
||||
|
||||
export interface BendingDiagramSectionProps {
|
||||
selectedPartType: string;
|
||||
bendingDiagramInputMethod: 'file' | 'drawing';
|
||||
setBendingDiagramInputMethod: (method: 'file' | 'drawing') => void;
|
||||
bendingDiagram: string;
|
||||
setBendingDiagram: (diagram: string) => void;
|
||||
setBendingDiagramFile: (file: File | null) => void;
|
||||
setIsDrawingOpen: (open: boolean) => void;
|
||||
bendingDetails: BendingDetail[];
|
||||
setBendingDetails: (details: BendingDetail[]) => void;
|
||||
setWidthSum: (sum: string) => void;
|
||||
setValue: UseFormSetValue<CreateItemFormData>;
|
||||
isSubmitting: boolean;
|
||||
}
|
||||
|
||||
export default function BendingDiagramSection({
|
||||
selectedPartType,
|
||||
bendingDiagramInputMethod,
|
||||
setBendingDiagramInputMethod,
|
||||
bendingDiagram,
|
||||
setBendingDiagram,
|
||||
setBendingDiagramFile,
|
||||
setIsDrawingOpen,
|
||||
bendingDetails,
|
||||
setBendingDetails,
|
||||
setWidthSum,
|
||||
setValue,
|
||||
isSubmitting,
|
||||
}: BendingDiagramSectionProps) {
|
||||
// 폭 합계 업데이트 헬퍼
|
||||
const updateWidthSum = (details: BendingDetail[]) => {
|
||||
const totalSum = details.reduce((acc, d) => {
|
||||
const calc = d.input + d.elongation;
|
||||
return acc + calc;
|
||||
}, 0);
|
||||
setWidthSum(totalSum.toString());
|
||||
setValue('length', totalSum.toString());
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileImage className="h-5 w-5" />
|
||||
{selectedPartType === 'ASSEMBLY' ? '조립품 전개도 (바라시)' : '절곡품 전개도 (바라시)'}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* 입력방식 선택 */}
|
||||
<div>
|
||||
<Label>입력방식 선택</Label>
|
||||
<div className="mt-2 flex gap-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="radio"
|
||||
id="input-file"
|
||||
name="bendingInputMethod"
|
||||
value="file"
|
||||
checked={bendingDiagramInputMethod === 'file'}
|
||||
onChange={(e) => setBendingDiagramInputMethod(e.target.value as 'file')}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<Label htmlFor="input-file" className="cursor-pointer font-normal">
|
||||
파일 선택 (이미지 파일 업로드)
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="radio"
|
||||
id="input-drawing"
|
||||
name="bendingInputMethod"
|
||||
value="drawing"
|
||||
checked={bendingDiagramInputMethod === 'drawing'}
|
||||
onChange={(e) => setBendingDiagramInputMethod(e.target.value as 'drawing')}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<Label htmlFor="input-drawing" className="cursor-pointer font-normal">
|
||||
드로잉 (직접 그리기)
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
* 전개도 이미지를 파일로 업로드하거나 직접 그릴 수 있습니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 파일 선택 방식 */}
|
||||
{bendingDiagramInputMethod === 'file' && (
|
||||
<div>
|
||||
<Label>파일 선택</Label>
|
||||
<div className="mt-2 space-y-3">
|
||||
<Input
|
||||
type="file"
|
||||
accept="image/*,.pdf"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file && typeof window !== 'undefined') {
|
||||
setBendingDiagramFile(file);
|
||||
const reader = new window.FileReader();
|
||||
reader.onloadend = () => {
|
||||
setBendingDiagram(reader.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
* {selectedPartType === 'ASSEMBLY'
|
||||
? '조립품 전개도 이미지를 업로드하세요(JPG, PNG, PDF 등)'
|
||||
: '절곡품 전개도 이미지를 업로드하세요(JPG, PNG, PDF 등)'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 전개도 이미지 미리보기 */}
|
||||
{bendingDiagram && (
|
||||
<div className="mt-4 p-4 border rounded-lg bg-gray-50">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-sm font-medium">미리보기</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setBendingDiagram('');
|
||||
setBendingDiagramFile(null);
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
전개도 삭제
|
||||
</Button>
|
||||
</div>
|
||||
<img
|
||||
src={bendingDiagram}
|
||||
alt="전개도 미리보기"
|
||||
className="max-w-full h-auto max-h-96 mx-auto border rounded bg-white"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 드로잉 방식 */}
|
||||
{bendingDiagramInputMethod === 'drawing' && (
|
||||
<div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setIsDrawingOpen(true)}
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
>
|
||||
<FileImage className="h-4 w-4 mr-2" />
|
||||
{bendingDiagram ? '전개도 수정' : '전개도 그리기'}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
* 캔버스에서 직접 전개도를 그릴 수 있습니다
|
||||
</p>
|
||||
|
||||
{/* 전개도 미리보기 */}
|
||||
{bendingDiagram && (
|
||||
<div className="mt-4 p-4 border rounded-lg bg-gray-50">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-sm font-medium">미리보기</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setBendingDiagram('')}
|
||||
>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
전개도 삭제
|
||||
</Button>
|
||||
</div>
|
||||
<img
|
||||
src={bendingDiagram}
|
||||
alt="전개도 미리보기"
|
||||
className="max-w-full h-auto max-h-96 mx-auto border rounded bg-white"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 전개도 상세 입력 (치수 계산) - BENDING 전용 */}
|
||||
{selectedPartType === 'BENDING' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>전개도 상세 입력 (치수 계산)</Label>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const newId = `detail-${Date.now()}`;
|
||||
const newRow: BendingDetail = {
|
||||
id: newId,
|
||||
no: bendingDetails.length + 1,
|
||||
input: 0,
|
||||
elongation: -1,
|
||||
calculated: 0,
|
||||
sum: 0,
|
||||
shaded: false,
|
||||
aAngle: undefined,
|
||||
};
|
||||
setBendingDetails([...bendingDetails, newRow]);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
행 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{bendingDetails.length > 0 ? (
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 border-b">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-center w-16">번호</th>
|
||||
<th className="px-3 py-2 text-center">입력값</th>
|
||||
<th className="px-3 py-2 text-center">연신율</th>
|
||||
<th className="px-3 py-2 text-center">계산값</th>
|
||||
<th className="px-3 py-2 text-center w-20">음영</th>
|
||||
<th className="px-3 py-2 text-center">A각</th>
|
||||
<th className="px-3 py-2 text-center w-16">삭제</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{bendingDetails.map((detail, index) => {
|
||||
const calculated = detail.input + detail.elongation;
|
||||
|
||||
return (
|
||||
<tr key={detail.id} className={detail.shaded ? 'bg-gray-100' : ''}>
|
||||
<td className="px-3 py-2 text-center border-b">{detail.no}</td>
|
||||
<td className="px-3 py-2 border-b">
|
||||
<Input
|
||||
type="number"
|
||||
value={detail.input}
|
||||
onChange={(e) => {
|
||||
const newDetails = [...bendingDetails];
|
||||
const value = e.target.value === '' ? 0 : parseFloat(e.target.value);
|
||||
newDetails[index] = {
|
||||
...detail,
|
||||
input: isNaN(value) ? 0 : value,
|
||||
};
|
||||
setBendingDetails(newDetails);
|
||||
updateWidthSum(newDetails);
|
||||
}}
|
||||
className="h-8 text-center"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 border-b">
|
||||
<Input
|
||||
type="number"
|
||||
value={detail.elongation}
|
||||
onChange={(e) => {
|
||||
const newDetails = [...bendingDetails];
|
||||
const value = e.target.value === '' ? -1 : parseFloat(e.target.value);
|
||||
newDetails[index] = {
|
||||
...detail,
|
||||
elongation: isNaN(value) ? -1 : value,
|
||||
};
|
||||
setBendingDetails(newDetails);
|
||||
updateWidthSum(newDetails);
|
||||
}}
|
||||
className="h-8 text-center"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center border-b bg-gray-50">
|
||||
{calculated.toFixed(1)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center border-b">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={detail.shaded}
|
||||
onChange={(e) => {
|
||||
const newDetails = [...bendingDetails];
|
||||
newDetails[index] = {
|
||||
...detail,
|
||||
shaded: e.target.checked,
|
||||
};
|
||||
setBendingDetails(newDetails);
|
||||
}}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 border-b">
|
||||
<Input
|
||||
type="number"
|
||||
value={detail.aAngle || ''}
|
||||
onChange={(e) => {
|
||||
const newDetails = [...bendingDetails];
|
||||
newDetails[index] = {
|
||||
...detail,
|
||||
aAngle: parseFloat(e.target.value) || undefined,
|
||||
};
|
||||
setBendingDetails(newDetails);
|
||||
}}
|
||||
className="h-8 text-center"
|
||||
placeholder="각도"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center border-b">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
const newDetails = bendingDetails
|
||||
.filter((_, i) => i !== index)
|
||||
.map((d, i) => ({ ...d, no: i + 1 }));
|
||||
setBendingDetails(newDetails);
|
||||
updateWidthSum(newDetails);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
<tfoot className="bg-gray-50 border-t-2">
|
||||
<tr>
|
||||
<td colSpan={3} className="px-3 py-2 text-right font-semibold">
|
||||
폭 합계:
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center font-bold text-blue-600">
|
||||
{bendingDetails.length > 0
|
||||
? bendingDetails.reduce((acc, d) => {
|
||||
const calc = d.input + d.elongation;
|
||||
return acc + calc;
|
||||
}, 0).toFixed(1)
|
||||
: '0.0'} mm
|
||||
</td>
|
||||
<td colSpan={3}></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-sm text-muted-foreground border rounded-lg bg-gray-50">
|
||||
전개도 상세 데이터가 없습니다. "행 추가" 버튼을 클릭하여 추가하세요.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
* 전개도의 각 구간별 치수를 입력하여 정확한 전개 길이를 계산할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
62
src/components/items/ItemForm/FormHeader.tsx
Normal file
62
src/components/items/ItemForm/FormHeader.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* FormHeader - 품목 폼 헤더 컴포넌트
|
||||
*/
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Package, Save, X } from 'lucide-react';
|
||||
import type { ItemType } from '@/types/item';
|
||||
|
||||
interface FormHeaderProps {
|
||||
mode: 'create' | 'edit';
|
||||
selectedItemType: ItemType | '';
|
||||
isSubmitting: boolean;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export default function FormHeader({
|
||||
mode,
|
||||
selectedItemType,
|
||||
isSubmitting,
|
||||
onCancel,
|
||||
}: FormHeaderProps) {
|
||||
return (
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<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">
|
||||
{mode === 'create' ? '품목 등록' : '품목 수정'}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
품목 정보를 입력하세요
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 sm:gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onCancel}
|
||||
className="gap-1 sm:gap-2"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">취소</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={!selectedItemType || isSubmitting}
|
||||
className="gap-1 sm:gap-2"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{isSubmitting ? '저장 중...' : '저장'}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
src/components/items/ItemForm/ValidationAlert.tsx
Normal file
50
src/components/items/ItemForm/ValidationAlert.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* ValidationAlert - 폼 검증 에러 표시 컴포넌트
|
||||
*/
|
||||
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { FIELD_NAME_MAP } from './constants';
|
||||
import type { FieldErrors } from 'react-hook-form';
|
||||
import type { CreateItemFormData } from '@/lib/utils/validation';
|
||||
|
||||
interface ValidationAlertProps {
|
||||
errors: FieldErrors<CreateItemFormData>;
|
||||
}
|
||||
|
||||
export default function ValidationAlert({ errors }: ValidationAlertProps) {
|
||||
const errorCount = Object.keys(errors).length;
|
||||
|
||||
if (errorCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert className="bg-red-50 border-red-200">
|
||||
<AlertDescription className="text-red-900">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-lg">⚠️</span>
|
||||
<div className="flex-1">
|
||||
<strong className="block mb-2">
|
||||
입력 내용을 확인해주세요 ({errorCount}개 오류)
|
||||
</strong>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{Object.entries(errors).map(([field, error]) => {
|
||||
const fieldName = FIELD_NAME_MAP[field] || field;
|
||||
const errorMessage = error?.message || '입력 오류';
|
||||
|
||||
return (
|
||||
<li key={field} className="flex items-start gap-1">
|
||||
<span>•</span>
|
||||
<span>
|
||||
<strong>{fieldName}</strong>: {errorMessage}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
93
src/components/items/ItemForm/constants.ts
Normal file
93
src/components/items/ItemForm/constants.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* ItemForm 상수 정의
|
||||
*/
|
||||
|
||||
// 부품 유형별 분류 체계
|
||||
export const PART_TYPE_CATEGORIES = {
|
||||
ASSEMBLY: {
|
||||
label: "조립 부품 (Assembly Part)",
|
||||
categories: [
|
||||
{ value: "guide_rail", label: "가이드레일", code: "R" },
|
||||
{ value: "case", label: "케이스", code: "C" },
|
||||
{ value: "bottom_finish", label: "하단마감재", code: "B" },
|
||||
]
|
||||
},
|
||||
BENDING: {
|
||||
label: "절곡 부품 (Bending Part)",
|
||||
categories: [
|
||||
{ value: "guide_rail_wall", label: "가이드레일(벽면형)", code: "R" },
|
||||
{ value: "guide_rail_side", label: "가이드레일(측면형)", code: "S" },
|
||||
{ value: "case", label: "케이스", code: "C" },
|
||||
{ value: "bottom_finish_screen", label: "하단마감재(스크린)", code: "B" },
|
||||
{ value: "bottom_finish_steel", label: "하단마감재(철재)", code: "T" },
|
||||
{ value: "l_bar", label: "L-Bar", code: "L" },
|
||||
{ value: "smoke_barrier", label: "연기차단재", code: "G" },
|
||||
]
|
||||
},
|
||||
PURCHASED: {
|
||||
label: "구매 부품 (Purchased Part)",
|
||||
categories: [
|
||||
{ value: "electric_opener", label: "전동개폐기", code: "E" },
|
||||
{ value: "motor", label: "모터", code: "M" },
|
||||
{ value: "chain", label: "체인", code: "CH" },
|
||||
]
|
||||
}
|
||||
} as const;
|
||||
|
||||
// 부품 분류별 종류 옵션
|
||||
export const PART_ITEM_NAMES: Record<string, Array<{value: string, label: string, code: string}>> = {
|
||||
guide_rail_wall: [
|
||||
{ value: "RM", label: "분체", code: "M" },
|
||||
{ value: "RT", label: "분체(철재)", code: "T" },
|
||||
{ value: "RC", label: "C형", code: "C" },
|
||||
{ value: "RD", label: "D형", code: "D" },
|
||||
{ value: "RS", label: "SUS 마감재", code: "S" },
|
||||
{ value: "RM2", label: "분체티딩", code: "M" },
|
||||
],
|
||||
guide_rail_side: [
|
||||
{ value: "SC", label: "C형", code: "C" },
|
||||
{ value: "SD", label: "D형", code: "D" },
|
||||
{ value: "SS", label: "SUS 마감재①", code: "S" },
|
||||
{ value: "SU", label: "SUS 마감재②", code: "U" },
|
||||
{ value: "SF", label: "전면부", code: "F" },
|
||||
{ value: "SP", label: "점검구", code: "P" },
|
||||
],
|
||||
case: [
|
||||
{ value: "CF", label: "전면부", code: "F" },
|
||||
{ value: "CP", label: "점검구", code: "P" },
|
||||
{ value: "CL", label: "린텔부", code: "L" },
|
||||
{ value: "CB", label: "후면코너부", code: "B" },
|
||||
],
|
||||
bottom_finish_screen: [
|
||||
{ value: "BS", label: "SUS", code: "S" },
|
||||
{ value: "BE", label: "EGI", code: "E" },
|
||||
],
|
||||
bottom_finish_steel: [
|
||||
{ value: "TS", label: "SUS", code: "S" },
|
||||
{ value: "TE", label: "EGI", code: "E" },
|
||||
],
|
||||
l_bar: [
|
||||
{ value: "LA", label: "스크린용", code: "A" },
|
||||
],
|
||||
smoke_barrier: [
|
||||
{ value: "GI", label: "화이바원단(W50)", code: "I" },
|
||||
{ value: "GI2", label: "화이바원단(W80)", code: "I" },
|
||||
],
|
||||
};
|
||||
|
||||
// 필드명 한글 매핑 (에러 메시지용)
|
||||
export const FIELD_NAME_MAP: Record<string, string> = {
|
||||
'productName': '상품명',
|
||||
'itemName': '품목명',
|
||||
'itemType': '품목 유형',
|
||||
'partType': '부품 유형',
|
||||
'category1': '품목명',
|
||||
'material': '재질',
|
||||
'length': '폭 합계',
|
||||
'bendingLength': '모양&길이',
|
||||
'sideSpecWidth': '측면 규격 (가로)',
|
||||
'sideSpecHeight': '측면 규격 (세로)',
|
||||
'assemblyLength': '길이',
|
||||
'specification': '규격',
|
||||
'unit': '단위',
|
||||
};
|
||||
77
src/components/items/ItemForm/context/ItemFormContext.tsx
Normal file
77
src/components/items/ItemForm/context/ItemFormContext.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* ItemFormContext - 품목 폼 상태 컨텍스트
|
||||
*
|
||||
* 하위 컴포넌트에서 공유되는 폼 상태 관리
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, ReactNode } from 'react';
|
||||
import type { UseFormReturn } from 'react-hook-form';
|
||||
import type { CreateItemFormData } from '@/lib/utils/validation';
|
||||
import type { ItemType } from '@/types/item';
|
||||
import type { UseItemFormStateReturn } from '../hooks/useItemFormState';
|
||||
import type { UseBOMManagementReturn } from '../hooks/useBOMManagement';
|
||||
import type { UseBendingDetailsReturn } from '../hooks/useBendingDetails';
|
||||
|
||||
export interface ItemFormContextType {
|
||||
// React Hook Form
|
||||
form: UseFormReturn<CreateItemFormData>;
|
||||
|
||||
// 모드
|
||||
mode: 'create' | 'edit';
|
||||
|
||||
// 품목 유형
|
||||
selectedItemType: ItemType | '';
|
||||
setSelectedItemType: (type: ItemType | '') => void;
|
||||
|
||||
// 부품 유형
|
||||
selectedPartType: string;
|
||||
setSelectedPartType: (type: string) => void;
|
||||
|
||||
// 상태 훅
|
||||
formState: UseItemFormStateReturn;
|
||||
bomManagement: UseBOMManagementReturn;
|
||||
bendingDetails: UseBendingDetailsReturn;
|
||||
|
||||
// 품목코드 생성
|
||||
generateItemCode: () => string;
|
||||
|
||||
// 품목 유형 변경 핸들러
|
||||
handleItemTypeChange: (type: ItemType) => void;
|
||||
|
||||
// 제출 상태
|
||||
isSubmitting: boolean;
|
||||
}
|
||||
|
||||
const ItemFormContext = createContext<ItemFormContextType | null>(null);
|
||||
|
||||
export interface ItemFormProviderProps {
|
||||
children: ReactNode;
|
||||
value: ItemFormContextType;
|
||||
}
|
||||
|
||||
export function ItemFormProvider({ children, value }: ItemFormProviderProps) {
|
||||
return (
|
||||
<ItemFormContext.Provider value={value}>
|
||||
{children}
|
||||
</ItemFormContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useItemFormContext(): ItemFormContextType {
|
||||
const context = useContext(ItemFormContext);
|
||||
if (!context) {
|
||||
throw new Error('useItemFormContext must be used within an ItemFormProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택적으로 컨텍스트 사용 (컨텍스트가 없어도 에러 안 남)
|
||||
*/
|
||||
export function useOptionalItemFormContext(): ItemFormContextType | null {
|
||||
return useContext(ItemFormContext);
|
||||
}
|
||||
|
||||
export default ItemFormContext;
|
||||
12
src/components/items/ItemForm/context/index.ts
Normal file
12
src/components/items/ItemForm/context/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* 품목 폼 컨텍스트 export
|
||||
*/
|
||||
|
||||
export {
|
||||
ItemFormProvider,
|
||||
useItemFormContext,
|
||||
useOptionalItemFormContext,
|
||||
default as ItemFormContext,
|
||||
} from './ItemFormContext';
|
||||
|
||||
export type { ItemFormContextType, ItemFormProviderProps } from './ItemFormContext';
|
||||
354
src/components/items/ItemForm/forms/MaterialForm.tsx
Normal file
354
src/components/items/ItemForm/forms/MaterialForm.tsx
Normal file
@@ -0,0 +1,354 @@
|
||||
/**
|
||||
* 원자재/부자재/소모품 (RM/SM/CS) 폼 컴포넌트
|
||||
*/
|
||||
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { ItemType } from '@/types/item';
|
||||
import type { UseFormRegister, UseFormSetValue, UseFormGetValues, FieldErrors } from 'react-hook-form';
|
||||
import type { CreateItemFormData } from '@/lib/utils/validation';
|
||||
|
||||
interface MaterialFormProps {
|
||||
selectedItemType: ItemType;
|
||||
itemName: string;
|
||||
setItemName: (value: string) => void;
|
||||
selectedSpecification: string;
|
||||
setSelectedSpecification: (value: string) => void;
|
||||
materialStatus: string;
|
||||
setMaterialStatus: (value: string) => void;
|
||||
selectedUnit: string;
|
||||
setSelectedUnit: (value: string) => void;
|
||||
register: UseFormRegister<CreateItemFormData>;
|
||||
setValue: UseFormSetValue<CreateItemFormData>;
|
||||
getValues: UseFormGetValues<CreateItemFormData>;
|
||||
errors: FieldErrors<CreateItemFormData>;
|
||||
}
|
||||
|
||||
export default function MaterialForm({
|
||||
selectedItemType,
|
||||
itemName,
|
||||
setItemName,
|
||||
selectedSpecification,
|
||||
setSelectedSpecification,
|
||||
materialStatus,
|
||||
setMaterialStatus,
|
||||
selectedUnit,
|
||||
setSelectedUnit,
|
||||
register,
|
||||
setValue,
|
||||
getValues,
|
||||
errors,
|
||||
}: MaterialFormProps) {
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<Label htmlFor="itemName">
|
||||
품목명 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
{/* 원자재/부자재는 목록에서 선택, 소모품은 직접 입력 */}
|
||||
{selectedItemType === 'RM' ? (
|
||||
<>
|
||||
<Select
|
||||
value={itemName}
|
||||
onValueChange={(value) => {
|
||||
setItemName(value);
|
||||
setValue('itemName', value);
|
||||
// 품목명 변경 시 규격 초기화
|
||||
setSelectedSpecification('');
|
||||
setValue('specification', '');
|
||||
// 품목코드 자동생성
|
||||
const spec = getValues('specification') || '';
|
||||
setValue('itemCode', spec ? `${value}-${spec}` : value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className={errors.itemName ? 'border-red-500' : ''}>
|
||||
<SelectValue placeholder="품목명을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="철판">철판</SelectItem>
|
||||
<SelectItem value="알루미늄">알루미늄</SelectItem>
|
||||
<SelectItem value="스테인리스">스테인리스</SelectItem>
|
||||
<SelectItem value="아연도금강판">아연도금강판</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.itemName && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{errors.itemName.message}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : selectedItemType === 'SM' ? (
|
||||
<>
|
||||
<Select
|
||||
value={itemName}
|
||||
onValueChange={(value) => {
|
||||
setItemName(value);
|
||||
setValue('itemName', value);
|
||||
// 품목명 변경 시 규격 초기화
|
||||
setSelectedSpecification('');
|
||||
setValue('specification', '');
|
||||
// 품목코드 자동생성
|
||||
const spec = getValues('specification') || '';
|
||||
setValue('itemCode', spec ? `${value}-${spec}` : value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className={errors.itemName ? 'border-red-500' : ''}>
|
||||
<SelectValue placeholder="품목명을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="볼트">볼트</SelectItem>
|
||||
<SelectItem value="너트">너트</SelectItem>
|
||||
<SelectItem value="와셔">와셔</SelectItem>
|
||||
<SelectItem value="나사">나사</SelectItem>
|
||||
<SelectItem value="앵커">앵커</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.itemName && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{errors.itemName.message}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
id="itemName"
|
||||
placeholder="품목명을 입력하세요"
|
||||
value={itemName}
|
||||
onChange={(e) => {
|
||||
const newName = e.target.value;
|
||||
setItemName(newName);
|
||||
setValue('itemName', newName);
|
||||
// 품목코드 자동생성
|
||||
const spec = getValues('specification') || '';
|
||||
setValue('itemCode', spec ? `${newName}-${spec}` : newName);
|
||||
}}
|
||||
className={errors.itemName ? 'border-red-500' : ''}
|
||||
/>
|
||||
{errors.itemName && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{errors.itemName.message}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 규격(사양) */}
|
||||
{selectedItemType === 'CS' ? (
|
||||
<div>
|
||||
<Label htmlFor="specification">
|
||||
규격(사양) <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="specification"
|
||||
placeholder="예: 면-L, 고급형, A4"
|
||||
{...register('specification', {
|
||||
onChange: (e) => {
|
||||
// 품목코드 자동생성
|
||||
const spec = e.target.value;
|
||||
const name = itemName || '';
|
||||
setValue('itemCode', name && spec ? `${name}-${spec}` : name);
|
||||
}
|
||||
})}
|
||||
className={errors.specification ? 'border-red-500' : ''}
|
||||
/>
|
||||
{errors.specification && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{errors.specification.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="md:col-span-2">
|
||||
<Label htmlFor="specification">
|
||||
규격 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedSpecification}
|
||||
onValueChange={(value) => {
|
||||
setSelectedSpecification(value);
|
||||
setValue('specification', value);
|
||||
// 품목코드 자동생성
|
||||
const name = itemName || '';
|
||||
setValue('itemCode', name && value ? `${name}-${value}` : name);
|
||||
}}
|
||||
disabled={!itemName}
|
||||
>
|
||||
<SelectTrigger id="specification" className={errors.specification ? 'border-red-500' : ''}>
|
||||
<SelectValue placeholder={itemName ? "규격을 선택하세요" : "품목명을 먼저 선택하세요"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{selectedItemType === 'RM' && itemName === '철판' && (
|
||||
<>
|
||||
<SelectItem value="1.0T">1.0T</SelectItem>
|
||||
<SelectItem value="1.2T">1.2T</SelectItem>
|
||||
<SelectItem value="1.5T">1.5T</SelectItem>
|
||||
<SelectItem value="2.0T">2.0T</SelectItem>
|
||||
</>
|
||||
)}
|
||||
{selectedItemType === 'RM' && itemName === '알루미늄' && (
|
||||
<>
|
||||
<SelectItem value="0.8T">0.8T</SelectItem>
|
||||
<SelectItem value="1.0T">1.0T</SelectItem>
|
||||
<SelectItem value="1.5T">1.5T</SelectItem>
|
||||
</>
|
||||
)}
|
||||
{selectedItemType === 'RM' && itemName === '스테인리스' && (
|
||||
<>
|
||||
<SelectItem value="0.5T">0.5T</SelectItem>
|
||||
<SelectItem value="1.0T">1.0T</SelectItem>
|
||||
<SelectItem value="1.2T">1.2T</SelectItem>
|
||||
</>
|
||||
)}
|
||||
{selectedItemType === 'SM' && itemName === '볼트' && (
|
||||
<>
|
||||
<SelectItem value="M6x20">M6×20mm</SelectItem>
|
||||
<SelectItem value="M8x25">M8×25mm</SelectItem>
|
||||
<SelectItem value="M10x30">M10×30mm</SelectItem>
|
||||
<SelectItem value="M12x40">M12×40mm</SelectItem>
|
||||
<SelectItem value="M16x50">M16×50mm</SelectItem>
|
||||
</>
|
||||
)}
|
||||
{selectedItemType === 'SM' && itemName === '너트' && (
|
||||
<>
|
||||
<SelectItem value="M6">M6</SelectItem>
|
||||
<SelectItem value="M8">M8</SelectItem>
|
||||
<SelectItem value="M10">M10</SelectItem>
|
||||
<SelectItem value="M12">M12</SelectItem>
|
||||
<SelectItem value="M16">M16</SelectItem>
|
||||
</>
|
||||
)}
|
||||
{selectedItemType === 'SM' && itemName === '와셔' && (
|
||||
<>
|
||||
<SelectItem value="M6">M6</SelectItem>
|
||||
<SelectItem value="M8">M8</SelectItem>
|
||||
<SelectItem value="M10">M10</SelectItem>
|
||||
<SelectItem value="M12">M12</SelectItem>
|
||||
<SelectItem value="M16">M16</SelectItem>
|
||||
</>
|
||||
)}
|
||||
{selectedItemType === 'SM' && itemName === '나사' && (
|
||||
<>
|
||||
<SelectItem value="4x20">4×20mm</SelectItem>
|
||||
<SelectItem value="5x25">5×25mm</SelectItem>
|
||||
<SelectItem value="6x30">6×30mm</SelectItem>
|
||||
<SelectItem value="8x40">8×40mm</SelectItem>
|
||||
<SelectItem value="10x50">10×50mm</SelectItem>
|
||||
</>
|
||||
)}
|
||||
{selectedItemType === 'SM' && itemName === '앵커' && (
|
||||
<>
|
||||
<SelectItem value="6x30">6×30mm</SelectItem>
|
||||
<SelectItem value="8x40">8×40mm</SelectItem>
|
||||
<SelectItem value="10x50">10×50mm</SelectItem>
|
||||
<SelectItem value="12x60">12×60mm</SelectItem>
|
||||
<SelectItem value="16x80">16×80mm</SelectItem>
|
||||
</>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.specification && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{errors.specification.message}
|
||||
</p>
|
||||
)}
|
||||
{!errors.specification && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
* 규격은 품목명 선택 시 자동으로 필터링됩니다
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 품목코드 (자동생성) */}
|
||||
<div className="md:col-span-2">
|
||||
<Label htmlFor="itemCode-auto">품목코드 (자동생성)</Label>
|
||||
<Input
|
||||
id="itemCode-auto"
|
||||
placeholder="품목명과 규격이 입력되면 자동으로 생성됩니다"
|
||||
value={(() => {
|
||||
const name = itemName || '';
|
||||
const spec = getValues('specification') || '';
|
||||
return spec ? `${name}-${spec}` : name;
|
||||
})()}
|
||||
disabled
|
||||
className="bg-muted text-muted-foreground"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
* 품목코드는 '품목명-규격' 형식으로 자동 생성됩니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 품목 상태 (RM/SM만) */}
|
||||
{(selectedItemType === 'RM' || selectedItemType === 'SM') && (
|
||||
<div className="md:col-span-2">
|
||||
<Label htmlFor="isActive">품목 상태</Label>
|
||||
<Select
|
||||
value={materialStatus}
|
||||
onValueChange={(value) => {
|
||||
setMaterialStatus(value);
|
||||
setValue('isActive', value === 'true');
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="isActive">
|
||||
<SelectValue placeholder="품목 상태" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">활성</SelectItem>
|
||||
<SelectItem value="false">비활성</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
* 비활성 시 품목 사용이 제한됩니다
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 단위 (RM/SM/CS 공통) */}
|
||||
<div>
|
||||
<Label htmlFor="unit">
|
||||
단위 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedUnit}
|
||||
onValueChange={(value) => {
|
||||
setSelectedUnit(value);
|
||||
setValue('unit', value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="unit" className={errors.unit ? 'border-red-500' : ''}>
|
||||
<SelectValue placeholder="단위를 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="M">M (미터)</SelectItem>
|
||||
<SelectItem value="mm">mm (밀리미터)</SelectItem>
|
||||
<SelectItem value="EA">EA (개)</SelectItem>
|
||||
<SelectItem value="SET">SET (세트)</SelectItem>
|
||||
<SelectItem value="KG">KG (킬로그램)</SelectItem>
|
||||
<SelectItem value="T">T (톤)</SelectItem>
|
||||
<SelectItem value="BOX">BOX (박스)</SelectItem>
|
||||
<SelectItem value="L">L (리터)</SelectItem>
|
||||
<SelectItem value="M2">M² (제곱미터)</SelectItem>
|
||||
<SelectItem value="M3">M³ (세제곱미터)</SelectItem>
|
||||
<SelectItem value="ROLL">ROLL (롤)</SelectItem>
|
||||
<SelectItem value="SHEET">SHEET (장)</SelectItem>
|
||||
<SelectItem value="PACK">PACK (팩)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.unit && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{errors.unit.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
273
src/components/items/ItemForm/forms/PartForm.tsx
Normal file
273
src/components/items/ItemForm/forms/PartForm.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* 부품 (PT) 폼 컴포넌트
|
||||
* - ASSEMBLY (조립 부품)
|
||||
* - BENDING (절곡 부품)
|
||||
* - PURCHASED (구매 부품)
|
||||
*/
|
||||
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { UseFormRegister, UseFormSetValue, UseFormClearErrors, FieldErrors } from 'react-hook-form';
|
||||
import type { CreateItemFormData } from '@/lib/utils/validation';
|
||||
import { AssemblyPartForm, BendingPartForm, PurchasedPartForm } from './parts';
|
||||
|
||||
export interface PartFormProps {
|
||||
// Part Type
|
||||
selectedPartType: string;
|
||||
setSelectedPartType: (value: string) => void;
|
||||
// Category
|
||||
selectedCategory1: string;
|
||||
setSelectedCategory1: (value: string) => void;
|
||||
selectedInstallationType: string;
|
||||
setSelectedInstallationType: (value: string) => void;
|
||||
// ASSEMBLY
|
||||
sideSpecWidth: string;
|
||||
setSideSpecWidth: (value: string) => void;
|
||||
sideSpecHeight: string;
|
||||
setSideSpecHeight: (value: string) => void;
|
||||
assemblyLength: string;
|
||||
setAssemblyLength: (value: string) => void;
|
||||
assemblyUnit: string;
|
||||
setAssemblyUnit: (value: string) => void;
|
||||
// BENDING
|
||||
selectedBendingItemType: string;
|
||||
setSelectedBendingItemType: (value: string) => void;
|
||||
material: string;
|
||||
setMaterial: (value: string) => void;
|
||||
widthSum: string;
|
||||
setWidthSum: (value: string) => void;
|
||||
bendingLength: string;
|
||||
setBendingLength: (value: string) => void;
|
||||
partUnit: string;
|
||||
setPartUnit: (value: string) => void;
|
||||
bendingDetailsLength: number;
|
||||
// PURCHASED
|
||||
electricOpenerPower: string;
|
||||
setElectricOpenerPower: (value: string) => void;
|
||||
electricOpenerCapacity: string;
|
||||
setElectricOpenerCapacity: (value: string) => void;
|
||||
motorVoltage: string;
|
||||
setMotorVoltage: (value: string) => void;
|
||||
chainSpec: string;
|
||||
setChainSpec: (value: string) => void;
|
||||
// Common
|
||||
partStatus: string;
|
||||
setPartStatus: (value: string) => void;
|
||||
needsBOM: boolean;
|
||||
setNeedsBOM: (value: boolean) => void;
|
||||
// Item Code Generator
|
||||
generateItemCode: () => string;
|
||||
// Form
|
||||
register: UseFormRegister<CreateItemFormData>;
|
||||
setValue: UseFormSetValue<CreateItemFormData>;
|
||||
clearErrors: UseFormClearErrors<CreateItemFormData>;
|
||||
errors: FieldErrors<CreateItemFormData>;
|
||||
}
|
||||
|
||||
export default function PartForm({
|
||||
selectedPartType,
|
||||
setSelectedPartType,
|
||||
selectedCategory1,
|
||||
setSelectedCategory1,
|
||||
selectedInstallationType,
|
||||
setSelectedInstallationType,
|
||||
sideSpecWidth,
|
||||
setSideSpecWidth,
|
||||
sideSpecHeight,
|
||||
setSideSpecHeight,
|
||||
assemblyLength,
|
||||
setAssemblyLength,
|
||||
assemblyUnit,
|
||||
setAssemblyUnit,
|
||||
selectedBendingItemType,
|
||||
setSelectedBendingItemType,
|
||||
material,
|
||||
setMaterial,
|
||||
widthSum,
|
||||
setWidthSum,
|
||||
bendingLength,
|
||||
setBendingLength,
|
||||
partUnit,
|
||||
setPartUnit,
|
||||
bendingDetailsLength,
|
||||
electricOpenerPower,
|
||||
setElectricOpenerPower,
|
||||
electricOpenerCapacity,
|
||||
setElectricOpenerCapacity,
|
||||
motorVoltage,
|
||||
setMotorVoltage,
|
||||
chainSpec,
|
||||
setChainSpec,
|
||||
partStatus,
|
||||
setPartStatus,
|
||||
needsBOM,
|
||||
setNeedsBOM,
|
||||
generateItemCode,
|
||||
register,
|
||||
setValue,
|
||||
clearErrors,
|
||||
errors,
|
||||
}: PartFormProps) {
|
||||
// 부품 유형 변경 시 필드 초기화 핸들러
|
||||
const handlePartTypeChange = (value: string) => {
|
||||
setSelectedPartType(value);
|
||||
setValue('partType', value);
|
||||
clearErrors('partType');
|
||||
|
||||
// 공통 필드 초기화
|
||||
setSelectedCategory1('');
|
||||
setValue('category1', undefined);
|
||||
setPartUnit('EA');
|
||||
setValue('unit', 'EA');
|
||||
|
||||
// ASSEMBLY 부품 전용 필드 초기화
|
||||
setSelectedInstallationType('');
|
||||
setValue('installationType', undefined);
|
||||
setSideSpecWidth('');
|
||||
setValue('sideSpecWidth', '');
|
||||
setSideSpecHeight('');
|
||||
setValue('sideSpecHeight', '');
|
||||
setAssemblyLength('');
|
||||
setValue('assemblyLength', '');
|
||||
setAssemblyUnit('EA');
|
||||
|
||||
// BENDING 부품 전용 필드 초기화
|
||||
setSelectedBendingItemType('');
|
||||
setValue('category2', undefined);
|
||||
setMaterial('');
|
||||
setValue('material', '');
|
||||
setWidthSum('');
|
||||
setValue('length', '');
|
||||
setBendingLength('');
|
||||
setValue('bendingLength', '');
|
||||
|
||||
// PURCHASED 부품 전용 필드 초기화
|
||||
setElectricOpenerPower('');
|
||||
setValue('electricOpenerPower', '');
|
||||
setElectricOpenerCapacity('');
|
||||
setValue('electricOpenerCapacity', '');
|
||||
setMotorVoltage('');
|
||||
setValue('motorVoltage', '');
|
||||
setChainSpec('');
|
||||
setValue('chainSpec', '');
|
||||
|
||||
// BOM 설정 (절곡 부품은 BOM 없음, 조립 부품은 BOM 기본 true)
|
||||
setNeedsBOM(value === 'BENDING' ? false : value === 'ASSEMBLY' ? true : needsBOM);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 부품 유형 선택 - 항상 표시 */}
|
||||
<div>
|
||||
<Label>
|
||||
부품 유형 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedPartType}
|
||||
onValueChange={handlePartTypeChange}
|
||||
>
|
||||
<SelectTrigger className={errors.partType ? 'border-red-500' : ''}>
|
||||
<SelectValue placeholder="부품 유형을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ASSEMBLY">조립 부품 (Assembly Part)</SelectItem>
|
||||
<SelectItem value="BENDING">절곡 부품 (Bending Part) - 전개도만 사용</SelectItem>
|
||||
<SelectItem value="PURCHASED">구매 부품 (Purchased Part)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.partType && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{errors.partType.message}
|
||||
</p>
|
||||
)}
|
||||
{!errors.partType && selectedPartType === 'BENDING' && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
* 절곡 부품은 전개도(바라시)만 있으며, 부품 구성(BOM)은 사용하지 않습니다.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ASSEMBLY 부품인 경우 */}
|
||||
{selectedPartType === 'ASSEMBLY' && (
|
||||
<AssemblyPartForm
|
||||
selectedCategory1={selectedCategory1}
|
||||
setSelectedCategory1={setSelectedCategory1}
|
||||
selectedInstallationType={selectedInstallationType}
|
||||
setSelectedInstallationType={setSelectedInstallationType}
|
||||
sideSpecWidth={sideSpecWidth}
|
||||
setSideSpecWidth={setSideSpecWidth}
|
||||
sideSpecHeight={sideSpecHeight}
|
||||
setSideSpecHeight={setSideSpecHeight}
|
||||
assemblyLength={assemblyLength}
|
||||
setAssemblyLength={setAssemblyLength}
|
||||
assemblyUnit={assemblyUnit}
|
||||
setAssemblyUnit={setAssemblyUnit}
|
||||
partStatus={partStatus}
|
||||
setPartStatus={setPartStatus}
|
||||
needsBOM={needsBOM}
|
||||
setNeedsBOM={setNeedsBOM}
|
||||
register={register}
|
||||
setValue={setValue}
|
||||
errors={errors}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* BENDING 부품인 경우 */}
|
||||
{selectedPartType === 'BENDING' && (
|
||||
<BendingPartForm
|
||||
selectedCategory1={selectedCategory1}
|
||||
setSelectedCategory1={setSelectedCategory1}
|
||||
selectedBendingItemType={selectedBendingItemType}
|
||||
setSelectedBendingItemType={setSelectedBendingItemType}
|
||||
material={material}
|
||||
setMaterial={setMaterial}
|
||||
widthSum={widthSum}
|
||||
setWidthSum={setWidthSum}
|
||||
bendingLength={bendingLength}
|
||||
setBendingLength={setBendingLength}
|
||||
partUnit={partUnit}
|
||||
setPartUnit={setPartUnit}
|
||||
bendingDetailsLength={bendingDetailsLength}
|
||||
partStatus={partStatus}
|
||||
setPartStatus={setPartStatus}
|
||||
generateItemCode={generateItemCode}
|
||||
register={register}
|
||||
setValue={setValue}
|
||||
clearErrors={clearErrors}
|
||||
errors={errors}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* PURCHASED 부품인 경우 */}
|
||||
{selectedPartType === 'PURCHASED' && (
|
||||
<PurchasedPartForm
|
||||
selectedCategory1={selectedCategory1}
|
||||
setSelectedCategory1={setSelectedCategory1}
|
||||
electricOpenerPower={electricOpenerPower}
|
||||
setElectricOpenerPower={setElectricOpenerPower}
|
||||
electricOpenerCapacity={electricOpenerCapacity}
|
||||
setElectricOpenerCapacity={setElectricOpenerCapacity}
|
||||
motorVoltage={motorVoltage}
|
||||
setMotorVoltage={setMotorVoltage}
|
||||
chainSpec={chainSpec}
|
||||
setChainSpec={setChainSpec}
|
||||
partUnit={partUnit}
|
||||
setPartUnit={setPartUnit}
|
||||
partStatus={partStatus}
|
||||
setPartStatus={setPartStatus}
|
||||
needsBOM={needsBOM}
|
||||
setNeedsBOM={setNeedsBOM}
|
||||
register={register}
|
||||
setValue={setValue}
|
||||
errors={errors}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
337
src/components/items/ItemForm/forms/ProductForm.tsx
Normal file
337
src/components/items/ItemForm/forms/ProductForm.tsx
Normal file
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* 제품 (FG) 폼 컴포넌트
|
||||
*/
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { X } from 'lucide-react';
|
||||
import type { UseFormRegister, UseFormSetValue, UseFormGetValues, FieldErrors } from 'react-hook-form';
|
||||
import type { CreateItemFormData } from '@/lib/utils/validation';
|
||||
|
||||
interface ProductFormProps {
|
||||
productName: string;
|
||||
setProductName: (value: string) => void;
|
||||
productStatus: string;
|
||||
setProductStatus: (value: string) => void;
|
||||
remarks: string;
|
||||
setRemarks: (value: string) => void;
|
||||
needsBOM: boolean;
|
||||
setNeedsBOM: (value: boolean) => void;
|
||||
specificationFile: File | null;
|
||||
setSpecificationFile: (file: File | null) => void;
|
||||
certificationFile: File | null;
|
||||
setCertificationFile: (file: File | null) => void;
|
||||
isSubmitting: boolean;
|
||||
register: UseFormRegister<CreateItemFormData>;
|
||||
setValue: UseFormSetValue<CreateItemFormData>;
|
||||
getValues: UseFormGetValues<CreateItemFormData>;
|
||||
errors: FieldErrors<CreateItemFormData>;
|
||||
}
|
||||
|
||||
export default function ProductForm({
|
||||
productName,
|
||||
setProductName,
|
||||
productStatus,
|
||||
setProductStatus,
|
||||
remarks,
|
||||
setRemarks,
|
||||
needsBOM,
|
||||
setNeedsBOM,
|
||||
specificationFile,
|
||||
setSpecificationFile,
|
||||
certificationFile,
|
||||
setCertificationFile,
|
||||
isSubmitting,
|
||||
register,
|
||||
setValue,
|
||||
getValues,
|
||||
errors,
|
||||
}: ProductFormProps) {
|
||||
return (
|
||||
<>
|
||||
{/* 기본 정보 */}
|
||||
<div>
|
||||
<Label htmlFor="productName">
|
||||
상품명 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="productName"
|
||||
placeholder="상품명을 입력하세요 (예: 프리미엄 스크린)"
|
||||
value={productName}
|
||||
onChange={(e) => {
|
||||
const newName = e.target.value;
|
||||
setProductName(newName);
|
||||
setValue('productName', newName);
|
||||
}}
|
||||
className={errors.productName ? 'border-red-500' : ''}
|
||||
/>
|
||||
{errors.productName && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{errors.productName.message}
|
||||
</p>
|
||||
)}
|
||||
{!errors.productName && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
상품명을 입력해주세요
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="itemName">
|
||||
품목명 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="itemName"
|
||||
placeholder="품목명을 입력하세요"
|
||||
{...register('itemName')}
|
||||
className={errors.itemName ? 'border-red-500' : ''}
|
||||
/>
|
||||
{errors.itemName && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{errors.itemName.message}
|
||||
</p>
|
||||
)}
|
||||
{!errors.itemName && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
품목명을 입력해주세요
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<Label>품목코드 (자동생성)</Label>
|
||||
<Input
|
||||
value={(() => {
|
||||
const pName = productName || '';
|
||||
const iName = getValues('itemName') || '';
|
||||
return pName && iName ? `${pName}-${iName}` : '';
|
||||
})()}
|
||||
disabled
|
||||
className="bg-muted text-muted-foreground"
|
||||
placeholder="상품명과 품목명을 입력하면 자동으로 생성됩니다"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
* 품목코드는 '상품명-품목명' 형식으로 자동 생성됩니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>로트 약자</Label>
|
||||
<Input
|
||||
placeholder="로트 약자를 입력하세요"
|
||||
{...register('lotAbbreviation')}
|
||||
maxLength={10}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
* 로트 번호 생성 시 사용되는 약자 (선택사항)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>품목 상태</Label>
|
||||
<Select
|
||||
value={productStatus}
|
||||
onValueChange={(value) => {
|
||||
setProductStatus(value);
|
||||
setValue('isActive', value === 'true');
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="품목 상태" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">활성</SelectItem>
|
||||
<SelectItem value="false">비활성</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
* 비활성 시 품목 사용이 제한됩니다
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* FG 인정 정보 섹션 컴포넌트
|
||||
*/
|
||||
export function ProductCertificationSection({
|
||||
remarks,
|
||||
setRemarks,
|
||||
needsBOM,
|
||||
setNeedsBOM,
|
||||
specificationFile,
|
||||
setSpecificationFile,
|
||||
certificationFile,
|
||||
setCertificationFile,
|
||||
isSubmitting,
|
||||
register,
|
||||
}: Pick<ProductFormProps,
|
||||
| 'remarks'
|
||||
| 'setRemarks'
|
||||
| 'needsBOM'
|
||||
| 'setNeedsBOM'
|
||||
| 'specificationFile'
|
||||
| 'setSpecificationFile'
|
||||
| 'certificationFile'
|
||||
| 'setCertificationFile'
|
||||
| 'isSubmitting'
|
||||
| 'register'
|
||||
>) {
|
||||
return (
|
||||
<div className="pt-6 mt-6 border-t space-y-4">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold mb-4">인정 정보</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
제품 인정서 및 시방서를 관리합니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 인정번호, 유효기간, 파일 업로드, 비고 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="certificationNumber">인정번호</Label>
|
||||
<Input
|
||||
id="certificationNumber"
|
||||
placeholder="인정번호를 입력하세요"
|
||||
{...register('certificationNumber')}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="certificationStartDate">인정 유효기간 시작일</Label>
|
||||
<Input
|
||||
id="certificationStartDate"
|
||||
type="date"
|
||||
{...register('certificationStartDate')}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="certificationEndDate">인정 유효기간 종료일</Label>
|
||||
<Input
|
||||
id="certificationEndDate"
|
||||
type="date"
|
||||
{...register('certificationEndDate')}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 시방서 파일 */}
|
||||
<div className="space-y-2">
|
||||
<Label>시방서 (PDF, DOCX, HWP, JPG, PNG / 최대 20MB)</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="file"
|
||||
accept=".pdf,.docx,.hwp,.jpg,.jpeg,.png"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setSpecificationFile(file);
|
||||
}
|
||||
}}
|
||||
className="flex-1"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
{specificationFile && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSpecificationFile(null)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{specificationFile && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
첨부됨: {specificationFile.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 인정서 파일 */}
|
||||
<div className="space-y-2">
|
||||
<Label>인정서 (PDF, DOCX, HWP, JPG, PNG / 최대 20MB)</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="file"
|
||||
accept=".pdf,.docx,.hwp,.jpg,.jpeg,.png"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setCertificationFile(file);
|
||||
}
|
||||
}}
|
||||
className="flex-1"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
{certificationFile && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCertificationFile(null)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{certificationFile && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
첨부됨: {certificationFile.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 비고 */}
|
||||
<div className="md:col-span-2">
|
||||
<Label>비고</Label>
|
||||
<Textarea
|
||||
value={remarks}
|
||||
onChange={(e) => setRemarks(e.target.value)}
|
||||
placeholder="비고 사항을 입력하세요"
|
||||
rows={3}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
* 추가 설명이나 특이사항을 기록할 수 있습니다
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 부품구성 (BOM) 필요 여부 - FG 전용, 인정 정보 카드 내부 */}
|
||||
<div className="md:col-span-2 pt-6 mt-6 border-t">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="needsBOM-fg"
|
||||
checked={needsBOM}
|
||||
onCheckedChange={(checked) => setNeedsBOM(checked as boolean)}
|
||||
/>
|
||||
<Label htmlFor="needsBOM-fg" className="cursor-pointer">
|
||||
부품구성 (BOM) 필요
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1 ml-6">
|
||||
* 이 제품이 하위 구성품을 포함하는 경우 체크하세요
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
7
src/components/items/ItemForm/forms/index.ts
Normal file
7
src/components/items/ItemForm/forms/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* 품목 유형별 폼 컴포넌트 export
|
||||
*/
|
||||
|
||||
export { default as MaterialForm } from './MaterialForm';
|
||||
export { default as ProductForm, ProductCertificationSection } from './ProductForm';
|
||||
export { default as PartForm } from './PartForm';
|
||||
336
src/components/items/ItemForm/forms/parts/AssemblyPartForm.tsx
Normal file
336
src/components/items/ItemForm/forms/parts/AssemblyPartForm.tsx
Normal file
@@ -0,0 +1,336 @@
|
||||
/**
|
||||
* 조립 부품 (ASSEMBLY) 폼 컴포넌트
|
||||
* - 가이드레일, 케이스, 하단마감재
|
||||
*/
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { UseFormRegister, UseFormSetValue, FieldErrors } from 'react-hook-form';
|
||||
import type { CreateItemFormData } from '@/lib/utils/validation';
|
||||
|
||||
export interface AssemblyPartFormProps {
|
||||
selectedCategory1: string;
|
||||
setSelectedCategory1: (value: string) => void;
|
||||
selectedInstallationType: string;
|
||||
setSelectedInstallationType: (value: string) => void;
|
||||
sideSpecWidth: string;
|
||||
setSideSpecWidth: (value: string) => void;
|
||||
sideSpecHeight: string;
|
||||
setSideSpecHeight: (value: string) => void;
|
||||
assemblyLength: string;
|
||||
setAssemblyLength: (value: string) => void;
|
||||
assemblyUnit: string;
|
||||
setAssemblyUnit: (value: string) => void;
|
||||
partStatus: string;
|
||||
setPartStatus: (value: string) => void;
|
||||
needsBOM: boolean;
|
||||
setNeedsBOM: (value: boolean) => void;
|
||||
register: UseFormRegister<CreateItemFormData>;
|
||||
setValue: UseFormSetValue<CreateItemFormData>;
|
||||
errors: FieldErrors<CreateItemFormData>;
|
||||
}
|
||||
|
||||
export default function AssemblyPartForm({
|
||||
selectedCategory1,
|
||||
setSelectedCategory1,
|
||||
selectedInstallationType,
|
||||
setSelectedInstallationType,
|
||||
sideSpecWidth,
|
||||
setSideSpecWidth,
|
||||
sideSpecHeight,
|
||||
setSideSpecHeight,
|
||||
assemblyLength,
|
||||
setAssemblyLength,
|
||||
assemblyUnit,
|
||||
setAssemblyUnit,
|
||||
partStatus,
|
||||
setPartStatus,
|
||||
needsBOM,
|
||||
setNeedsBOM,
|
||||
register,
|
||||
setValue,
|
||||
errors,
|
||||
}: AssemblyPartFormProps) {
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<Label>
|
||||
품목명 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedCategory1}
|
||||
onValueChange={(value) => {
|
||||
setSelectedCategory1(value);
|
||||
setValue('category1', value);
|
||||
if (value === 'guide_rail') setValue('itemName', '가이드레일');
|
||||
else if (value === 'case') setValue('itemName', '케이스');
|
||||
else if (value === 'bottom_finish') setValue('itemName', '하단마감재');
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className={errors.category1 ? 'border-red-500' : ''}>
|
||||
<SelectValue placeholder="품목명을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="guide_rail">가이드레일</SelectItem>
|
||||
<SelectItem value="case">케이스</SelectItem>
|
||||
<SelectItem value="bottom_finish">하단마감재</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.category1 && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{errors.category1.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 가이드레일: 설치 유형 */}
|
||||
{selectedCategory1 === 'guide_rail' && (
|
||||
<div>
|
||||
<Label>
|
||||
설치 유형 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedInstallationType}
|
||||
onValueChange={(value) => {
|
||||
setSelectedInstallationType(value);
|
||||
setValue('installationType', value);
|
||||
setValue('category2', value === 'wall' ? 'R' : 'S');
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="설치 유형을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="wall">벽면형 (R)</SelectItem>
|
||||
<SelectItem value="side">측면형 (S)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 케이스: 설치 유형 */}
|
||||
{selectedCategory1 === 'case' && (
|
||||
<div>
|
||||
<Label>
|
||||
설치 유형 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedInstallationType}
|
||||
onValueChange={(value) => {
|
||||
setSelectedInstallationType(value);
|
||||
setValue('installationType', value);
|
||||
setValue('category2', 'C');
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="설치 유형을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="standard">표준형 (C)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 하단마감재: 설치 유형 */}
|
||||
{selectedCategory1 === 'bottom_finish' && (
|
||||
<div>
|
||||
<Label>
|
||||
설치 유형 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedInstallationType}
|
||||
onValueChange={(value) => {
|
||||
setSelectedInstallationType(value);
|
||||
setValue('installationType', value);
|
||||
setValue('category2', value === 'steel' ? 'B' : 'T');
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="설치 유형을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="steel">스크린 (B)</SelectItem>
|
||||
<SelectItem value="iron">철재 (T)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ASSEMBLY 공통: 단위, 비고, 측면규격 및 길이 */}
|
||||
<div>
|
||||
<Label>
|
||||
단위 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={assemblyUnit}
|
||||
onValueChange={(value) => {
|
||||
setAssemblyUnit(value);
|
||||
setValue('unit', value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="단위를 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="EA">EA (개)</SelectItem>
|
||||
<SelectItem value="M">M (미터)</SelectItem>
|
||||
<SelectItem value="mm">mm (밀리미터)</SelectItem>
|
||||
<SelectItem value="SET">SET (세트)</SelectItem>
|
||||
<SelectItem value="KG">KG (킬로그램)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<Label>비고</Label>
|
||||
<Input
|
||||
placeholder="비고 사항을 입력하세요"
|
||||
{...register('note')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 측면 규격 및 길이 */}
|
||||
<div className="col-span-2 border-t pt-4">
|
||||
<h4 className="text-sm font-semibold mb-3">측면 규격 및 길이</h4>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<Label>
|
||||
측면 규격 (가로) <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="예: 50"
|
||||
value={sideSpecWidth}
|
||||
onChange={(e) => {
|
||||
setSideSpecWidth(e.target.value);
|
||||
setValue('sideSpecWidth', e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>
|
||||
측면 규격 (세로) <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="예: 100"
|
||||
value={sideSpecHeight}
|
||||
onChange={(e) => {
|
||||
setSideSpecHeight(e.target.value);
|
||||
setValue('sideSpecHeight', e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>
|
||||
길이 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={assemblyLength}
|
||||
onValueChange={(value) => {
|
||||
setAssemblyLength(value);
|
||||
setValue('assemblyLength', value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="길이를 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1219">1219mm</SelectItem>
|
||||
<SelectItem value="2438">2438mm</SelectItem>
|
||||
<SelectItem value="3000">3000mm</SelectItem>
|
||||
<SelectItem value="3500">3500mm</SelectItem>
|
||||
<SelectItem value="4000">4000mm</SelectItem>
|
||||
<SelectItem value="4150">4150mm</SelectItem>
|
||||
<SelectItem value="4200">4200mm</SelectItem>
|
||||
<SelectItem value="4300">4300mm</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
* 품목코드: {(() => {
|
||||
const itemName = selectedCategory1 === 'guide_rail' ? '가이드레일' :
|
||||
selectedCategory1 === 'case' ? '케이스' :
|
||||
selectedCategory1 === 'bottom_finish' ? '하단마감재' : '';
|
||||
const installationTypeMap: Record<string, string> = {
|
||||
"standard": "표준형",
|
||||
"wall": "벽면형",
|
||||
"side": "측면형",
|
||||
"steel": "스크린",
|
||||
"iron": "철재"
|
||||
};
|
||||
const installTypeText = installationTypeMap[selectedInstallationType] || selectedInstallationType;
|
||||
const length = assemblyLength ? parseInt(assemblyLength) : 0;
|
||||
let lengthCode = "";
|
||||
if (length === 1219) lengthCode = "12";
|
||||
else if (length === 2438) lengthCode = "24";
|
||||
else if (length === 3000) lengthCode = "30";
|
||||
else if (length === 3500) lengthCode = "35";
|
||||
else if (length === 4000) lengthCode = "40";
|
||||
else if (length === 4150) lengthCode = "41";
|
||||
else if (length === 4200) lengthCode = "42";
|
||||
else if (length === 4300) lengthCode = "43";
|
||||
else lengthCode = Math.floor(length / 100).toString().padStart(2, '0');
|
||||
|
||||
if (itemName && installTypeText && sideSpecWidth && sideSpecHeight && assemblyLength) {
|
||||
return `${itemName} ${installTypeText}-${sideSpecWidth}*${sideSpecHeight}*${lengthCode}`;
|
||||
}
|
||||
return "품목명 설치유형-?*?*?";
|
||||
})()}
|
||||
</p>
|
||||
|
||||
{/* 품목 상태 */}
|
||||
<div className="mt-4">
|
||||
<Label>품목 상태</Label>
|
||||
<Select
|
||||
value={partStatus}
|
||||
onValueChange={(value) => {
|
||||
setPartStatus(value);
|
||||
setValue('isActive', value === 'true');
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="품목 상태" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">활성</SelectItem>
|
||||
<SelectItem value="false">비활성</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
* 비활성 시 품목 사용이 제한됩니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 부품구성 (BOM) 필요 여부 - ASSEMBLY 전용 */}
|
||||
{selectedCategory1 && (
|
||||
<div className="pt-6 mt-6 border-t">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="needsBOM-assembly"
|
||||
checked={needsBOM}
|
||||
onCheckedChange={(checked) => setNeedsBOM(checked as boolean)}
|
||||
/>
|
||||
<Label htmlFor="needsBOM-assembly" className="cursor-pointer">
|
||||
부품구성 (BOM) 필요
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1 ml-6">
|
||||
* 이 부품이 하위 구성품을 포함하는 경우 체크하세요
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
302
src/components/items/ItemForm/forms/parts/BendingPartForm.tsx
Normal file
302
src/components/items/ItemForm/forms/parts/BendingPartForm.tsx
Normal file
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* 절곡 부품 (BENDING) 폼 컴포넌트
|
||||
* - 가이드레일(벽면/측면), 케이스, 하단마감재 등
|
||||
*/
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { UseFormRegister, UseFormSetValue, UseFormClearErrors, FieldErrors } from 'react-hook-form';
|
||||
import type { CreateItemFormData } from '@/lib/utils/validation';
|
||||
import { PART_TYPE_CATEGORIES, PART_ITEM_NAMES } from '../../constants';
|
||||
|
||||
export interface BendingPartFormProps {
|
||||
selectedCategory1: string;
|
||||
setSelectedCategory1: (value: string) => void;
|
||||
selectedBendingItemType: string;
|
||||
setSelectedBendingItemType: (value: string) => void;
|
||||
material: string;
|
||||
setMaterial: (value: string) => void;
|
||||
widthSum: string;
|
||||
setWidthSum: (value: string) => void;
|
||||
bendingLength: string;
|
||||
setBendingLength: (value: string) => void;
|
||||
partUnit: string;
|
||||
setPartUnit: (value: string) => void;
|
||||
bendingDetailsLength: number;
|
||||
partStatus: string;
|
||||
setPartStatus: (value: string) => void;
|
||||
generateItemCode: () => string;
|
||||
register: UseFormRegister<CreateItemFormData>;
|
||||
setValue: UseFormSetValue<CreateItemFormData>;
|
||||
clearErrors: UseFormClearErrors<CreateItemFormData>;
|
||||
errors: FieldErrors<CreateItemFormData>;
|
||||
}
|
||||
|
||||
export default function BendingPartForm({
|
||||
selectedCategory1,
|
||||
setSelectedCategory1,
|
||||
selectedBendingItemType,
|
||||
setSelectedBendingItemType,
|
||||
material,
|
||||
setMaterial,
|
||||
widthSum,
|
||||
setWidthSum,
|
||||
bendingLength,
|
||||
setBendingLength,
|
||||
partUnit,
|
||||
setPartUnit,
|
||||
bendingDetailsLength,
|
||||
partStatus,
|
||||
setPartStatus,
|
||||
generateItemCode,
|
||||
register,
|
||||
setValue,
|
||||
clearErrors,
|
||||
errors,
|
||||
}: BendingPartFormProps) {
|
||||
return (
|
||||
<>
|
||||
{/* 품목명 선택 */}
|
||||
<div>
|
||||
<Label>
|
||||
품목명 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedCategory1}
|
||||
onValueChange={(val) => {
|
||||
setSelectedCategory1(val);
|
||||
setValue('category1', val);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="품목명을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PART_TYPE_CATEGORIES.BENDING?.categories.map((cat) => (
|
||||
<SelectItem key={cat.value} value={cat.value}>
|
||||
{cat.label} ({cat.code})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 종류 선택 */}
|
||||
{selectedCategory1 && PART_ITEM_NAMES[selectedCategory1] && (
|
||||
<div>
|
||||
<Label>
|
||||
종류 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedBendingItemType}
|
||||
onValueChange={(value) => {
|
||||
setSelectedBendingItemType(value);
|
||||
const selected = PART_ITEM_NAMES[selectedCategory1].find(item => item.label === value);
|
||||
if (selected) {
|
||||
setValue('itemName', selected.label);
|
||||
setValue('category2', selected.code);
|
||||
clearErrors('category2');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="종류를 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PART_ITEM_NAMES[selectedCategory1].map((item) => (
|
||||
<SelectItem key={item.value} value={item.label}>
|
||||
{item.label} ({item.code})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 재질, 폭 합계, 모양&길이 (Purple Section) */}
|
||||
{selectedBendingItemType && (
|
||||
<div className="md:col-span-2 grid grid-cols-1 md:grid-cols-3 gap-4 p-4 bg-purple-50 rounded-lg border border-purple-200">
|
||||
<div>
|
||||
<Label>
|
||||
재질 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={material}
|
||||
onValueChange={(value) => {
|
||||
setMaterial(value);
|
||||
setValue('material', value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className={errors.material ? 'border-red-500' : ''}>
|
||||
<SelectValue placeholder="재질을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="EGI 1.15T">EGI 1.15T</SelectItem>
|
||||
<SelectItem value="EGI 1.55T">EGI 1.55T</SelectItem>
|
||||
<SelectItem value="SUS 1.2T">SUS 1.2T</SelectItem>
|
||||
<SelectItem value="SUS 1.5T">SUS 1.5T</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.material && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{errors.material.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>
|
||||
폭 합계 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
value={widthSum}
|
||||
onChange={(e) => {
|
||||
setWidthSum(e.target.value);
|
||||
setValue('length', e.target.value);
|
||||
}}
|
||||
placeholder="전개도 상세를 입력해주세요"
|
||||
readOnly={bendingDetailsLength > 0}
|
||||
className={`${bendingDetailsLength > 0 ? "bg-blue-50 font-medium" : ""} ${errors.length ? 'border-red-500' : ''}`}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">mm</span>
|
||||
</div>
|
||||
{errors.length && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{errors.length.message}
|
||||
</p>
|
||||
)}
|
||||
{!errors.length && bendingDetailsLength > 0 && (
|
||||
<p className="text-xs text-blue-600 mt-1">
|
||||
* 전개도 상세 입력의 합계가 자동 반영됩니다
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>
|
||||
모양&길이 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={bendingLength}
|
||||
onValueChange={(value) => {
|
||||
setBendingLength(value);
|
||||
setValue('bendingLength', value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className={errors.bendingLength ? 'border-red-500' : ''}>
|
||||
<SelectValue placeholder="모양&길이를 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="W50x3000">W50×3000mm</SelectItem>
|
||||
<SelectItem value="W50x4000">W50×4000mm</SelectItem>
|
||||
<SelectItem value="W80x3000">W80×3000mm</SelectItem>
|
||||
<SelectItem value="W80x4000">W80×4000mm</SelectItem>
|
||||
<SelectItem value="1219">1219mm</SelectItem>
|
||||
<SelectItem value="2438">2438mm</SelectItem>
|
||||
<SelectItem value="3000">3000mm</SelectItem>
|
||||
<SelectItem value="3500">3500mm</SelectItem>
|
||||
<SelectItem value="4000">4000mm</SelectItem>
|
||||
<SelectItem value="4150">4150mm</SelectItem>
|
||||
<SelectItem value="4200">4200mm</SelectItem>
|
||||
<SelectItem value="4300">4300mm</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.bendingLength && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{errors.bendingLength.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 단위, 비고 (종류 선택 후 표시) */}
|
||||
{selectedBendingItemType && (
|
||||
<>
|
||||
<div>
|
||||
<Label>
|
||||
단위 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={partUnit}
|
||||
onValueChange={(value) => {
|
||||
setPartUnit(value);
|
||||
setValue('unit', value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="단위를 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="EA">EA (개)</SelectItem>
|
||||
<SelectItem value="M">M (미터)</SelectItem>
|
||||
<SelectItem value="mm">mm (밀리미터)</SelectItem>
|
||||
<SelectItem value="KG">KG (킬로그램)</SelectItem>
|
||||
<SelectItem value="L">L (리터)</SelectItem>
|
||||
<SelectItem value="SET">SET (세트)</SelectItem>
|
||||
<SelectItem value="BOX">BOX (박스)</SelectItem>
|
||||
<SelectItem value="ROLL">ROLL (롤)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>비고</Label>
|
||||
<Input
|
||||
placeholder="비고 사항을 입력하세요"
|
||||
{...register('note')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 품목코드 자동생성 */}
|
||||
<div className="md:col-span-2">
|
||||
<Label>품목코드 (자동생성)</Label>
|
||||
<Input
|
||||
value={generateItemCode()}
|
||||
disabled
|
||||
className="bg-muted text-muted-foreground"
|
||||
placeholder="품목명과 규격이 입력되면 자동으로 생성됩니다"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{(selectedCategory1 === "guide_rail_wall" || selectedCategory1 === "guide_rail_side")
|
||||
? "* 가이드레일 품목코드는 '제품구분(R/S)+종류(M/T/C/D/S/U)+모양&길이' 형식으로 자동 생성됩니다 (예: RD30, SM53)"
|
||||
: "* 절곡 부품 품목코드는 '품목명+종류+길이축약' 형식으로 자동 생성됩니다 (예: 케이스후면부30)"}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 품목 상태 */}
|
||||
<div className="md:col-span-2">
|
||||
<Label>품목 상태</Label>
|
||||
<Select
|
||||
value={partStatus}
|
||||
onValueChange={(value) => {
|
||||
setPartStatus(value);
|
||||
setValue('isActive', value === 'true');
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="품목 상태" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">활성</SelectItem>
|
||||
<SelectItem value="false">비활성</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
* 비활성 시 품목 사용이 제한됩니다
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
318
src/components/items/ItemForm/forms/parts/PurchasedPartForm.tsx
Normal file
318
src/components/items/ItemForm/forms/parts/PurchasedPartForm.tsx
Normal file
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* 구매 부품 (PURCHASED) 폼 컴포넌트
|
||||
* - 전동개폐기, 모터, 체인 등
|
||||
*/
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { UseFormRegister, UseFormSetValue, FieldErrors } from 'react-hook-form';
|
||||
import type { CreateItemFormData } from '@/lib/utils/validation';
|
||||
import { PART_TYPE_CATEGORIES } from '../../constants';
|
||||
|
||||
export interface PurchasedPartFormProps {
|
||||
selectedCategory1: string;
|
||||
setSelectedCategory1: (value: string) => void;
|
||||
electricOpenerPower: string;
|
||||
setElectricOpenerPower: (value: string) => void;
|
||||
electricOpenerCapacity: string;
|
||||
setElectricOpenerCapacity: (value: string) => void;
|
||||
motorVoltage: string;
|
||||
setMotorVoltage: (value: string) => void;
|
||||
chainSpec: string;
|
||||
setChainSpec: (value: string) => void;
|
||||
partUnit: string;
|
||||
setPartUnit: (value: string) => void;
|
||||
partStatus: string;
|
||||
setPartStatus: (value: string) => void;
|
||||
needsBOM: boolean;
|
||||
setNeedsBOM: (value: boolean) => void;
|
||||
register: UseFormRegister<CreateItemFormData>;
|
||||
setValue: UseFormSetValue<CreateItemFormData>;
|
||||
errors: FieldErrors<CreateItemFormData>;
|
||||
}
|
||||
|
||||
export default function PurchasedPartForm({
|
||||
selectedCategory1,
|
||||
setSelectedCategory1,
|
||||
electricOpenerPower,
|
||||
setElectricOpenerPower,
|
||||
electricOpenerCapacity,
|
||||
setElectricOpenerCapacity,
|
||||
motorVoltage,
|
||||
setMotorVoltage,
|
||||
chainSpec,
|
||||
setChainSpec,
|
||||
partUnit,
|
||||
setPartUnit,
|
||||
partStatus,
|
||||
setPartStatus,
|
||||
needsBOM,
|
||||
setNeedsBOM,
|
||||
register,
|
||||
setValue,
|
||||
errors,
|
||||
}: PurchasedPartFormProps) {
|
||||
return (
|
||||
<>
|
||||
{/* 품목명 선택 */}
|
||||
<div>
|
||||
<Label>
|
||||
품목명 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedCategory1}
|
||||
onValueChange={(val) => {
|
||||
setSelectedCategory1(val);
|
||||
setValue('category1', val);
|
||||
const cat = PART_TYPE_CATEGORIES.PURCHASED?.categories.find(c => c.value === val);
|
||||
if (cat) {
|
||||
setValue('category2', cat.code);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="품목명을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PART_TYPE_CATEGORIES.PURCHASED?.categories.map((cat) => (
|
||||
<SelectItem key={cat.value} value={cat.value}>
|
||||
{cat.label} ({cat.code})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 전동개폐기 전용 필드 */}
|
||||
{selectedCategory1 === 'electric_opener' && (
|
||||
<>
|
||||
<div>
|
||||
<Label>
|
||||
전원 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={electricOpenerPower}
|
||||
onValueChange={(value) => {
|
||||
setElectricOpenerPower(value);
|
||||
setValue('electricOpenerPower', value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className={errors.electricOpenerPower ? 'border-red-500' : ''}>
|
||||
<SelectValue placeholder="전원을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="220V">220V</SelectItem>
|
||||
<SelectItem value="380V">380V</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.electricOpenerPower && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{errors.electricOpenerPower.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label>
|
||||
용량 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={electricOpenerCapacity}
|
||||
onValueChange={(value) => {
|
||||
setElectricOpenerCapacity(value);
|
||||
setValue('electricOpenerCapacity', value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className={errors.electricOpenerCapacity ? 'border-red-500' : ''}>
|
||||
<SelectValue placeholder="용량을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="150">150 KG</SelectItem>
|
||||
<SelectItem value="300">300 KG</SelectItem>
|
||||
<SelectItem value="400">400 KG</SelectItem>
|
||||
<SelectItem value="500">500 KG</SelectItem>
|
||||
<SelectItem value="600">600 KG</SelectItem>
|
||||
<SelectItem value="800">800 KG</SelectItem>
|
||||
<SelectItem value="1000">1000 KG</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.electricOpenerCapacity && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{errors.electricOpenerCapacity.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 모터 전용 필드 */}
|
||||
{selectedCategory1 === 'motor' && (
|
||||
<div className="md:col-span-2 grid grid-cols-2 gap-4 p-4 bg-green-50 rounded-lg border border-green-200">
|
||||
<div>
|
||||
<Label>모터 용량 (kg) *</Label>
|
||||
<Input type="number" placeholder="예: 1.5" step="0.1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>전압 (V) *</Label>
|
||||
<Select
|
||||
value={motorVoltage}
|
||||
onValueChange={(value) => {
|
||||
setMotorVoltage(value);
|
||||
setValue('motorVoltage', value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className={errors.motorVoltage ? 'border-red-500' : ''}>
|
||||
<SelectValue placeholder="전압을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="220">220V</SelectItem>
|
||||
<SelectItem value="380">380V</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.motorVoltage && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{errors.motorVoltage.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 체인 전용 필드 */}
|
||||
{selectedCategory1 === 'chain' && (
|
||||
<div className="md:col-span-2 grid grid-cols-2 gap-4 p-4 bg-yellow-50 rounded-lg border border-yellow-200">
|
||||
<div>
|
||||
<Label>체인 규격 *</Label>
|
||||
<Select
|
||||
value={chainSpec}
|
||||
onValueChange={(value) => {
|
||||
setChainSpec(value);
|
||||
setValue('chainSpec', value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className={errors.chainSpec ? 'border-red-500' : ''}>
|
||||
<SelectValue placeholder="규격을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="40">40</SelectItem>
|
||||
<SelectItem value="50">50</SelectItem>
|
||||
<SelectItem value="60">60</SelectItem>
|
||||
<SelectItem value="80">80</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.chainSpec && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{errors.chainSpec.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label>길이 (링크 수) *</Label>
|
||||
<Input type="number" placeholder="예: 100" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 품목명 선택 후에만 단위, 비고 표시 */}
|
||||
{selectedCategory1 && (
|
||||
<>
|
||||
<div>
|
||||
<Label>
|
||||
단위 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={partUnit}
|
||||
onValueChange={(value) => {
|
||||
setPartUnit(value);
|
||||
setValue('unit', value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="단위를 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="EA">EA (개)</SelectItem>
|
||||
<SelectItem value="M">M (미터)</SelectItem>
|
||||
<SelectItem value="mm">mm (밀리미터)</SelectItem>
|
||||
<SelectItem value="KG">KG (킬로그램)</SelectItem>
|
||||
<SelectItem value="L">L (리터)</SelectItem>
|
||||
<SelectItem value="SET">SET (세트)</SelectItem>
|
||||
<SelectItem value="BOX">BOX (박스)</SelectItem>
|
||||
<SelectItem value="ROLL">ROLL (롤)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>비고</Label>
|
||||
<Input
|
||||
placeholder="비고 사항을 입력하세요"
|
||||
{...register('note')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 품목코드 자동생성 */}
|
||||
<div className="md:col-span-2">
|
||||
<Label>품목코드 (자동생성)</Label>
|
||||
<Input
|
||||
value=""
|
||||
disabled
|
||||
className="bg-muted text-muted-foreground"
|
||||
placeholder="품목명과 규격이 입력되면 자동으로 생성됩니다"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
* 품목코드는 '품목명-규격' 형식으로 자동 생성됩니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 품목 상태 */}
|
||||
<div className="md:col-span-2">
|
||||
<Label>품목 상태</Label>
|
||||
<Select
|
||||
value={partStatus}
|
||||
onValueChange={(value) => {
|
||||
setPartStatus(value);
|
||||
setValue('isActive', value === 'true');
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="품목 상태" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">활성</SelectItem>
|
||||
<SelectItem value="false">비활성</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
* 비활성 시 품목 사용이 제한됩니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 부품구성 (BOM) 필요 여부 */}
|
||||
<div className="md:col-span-2 pt-6 mt-6 border-t">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="needsBOM-purchased"
|
||||
checked={needsBOM}
|
||||
onCheckedChange={(checked) => setNeedsBOM(checked as boolean)}
|
||||
/>
|
||||
<Label htmlFor="needsBOM-purchased" className="cursor-pointer">
|
||||
부품구성 (BOM) 필요
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1 ml-6">
|
||||
* 이 부품이 하위 구성품을 포함하는 경우 체크하세요
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
12
src/components/items/ItemForm/forms/parts/index.ts
Normal file
12
src/components/items/ItemForm/forms/parts/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* 부품 유형별 폼 컴포넌트 export
|
||||
*/
|
||||
|
||||
export { default as AssemblyPartForm } from './AssemblyPartForm';
|
||||
export type { AssemblyPartFormProps } from './AssemblyPartForm';
|
||||
|
||||
export { default as BendingPartForm } from './BendingPartForm';
|
||||
export type { BendingPartFormProps } from './BendingPartForm';
|
||||
|
||||
export { default as PurchasedPartForm } from './PurchasedPartForm';
|
||||
export type { PurchasedPartFormProps } from './PurchasedPartForm';
|
||||
12
src/components/items/ItemForm/hooks/index.ts
Normal file
12
src/components/items/ItemForm/hooks/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* 품목 폼 커스텀 훅 export
|
||||
*/
|
||||
|
||||
export { useItemFormState } from './useItemFormState';
|
||||
export type { UseItemFormStateProps, ItemFormState, UseItemFormStateReturn } from './useItemFormState';
|
||||
|
||||
export { useBOMManagement } from './useBOMManagement';
|
||||
export type { BOMSearchState, UseBOMManagementProps, UseBOMManagementReturn } from './useBOMManagement';
|
||||
|
||||
export { useBendingDetails } from './useBendingDetails';
|
||||
export type { UseBendingDetailsProps, UseBendingDetailsReturn } from './useBendingDetails';
|
||||
221
src/components/items/ItemForm/hooks/useBOMManagement.ts
Normal file
221
src/components/items/ItemForm/hooks/useBOMManagement.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* BOM 관리 커스텀 훅
|
||||
*
|
||||
* BOM 라인 추가, 수정, 삭제 및 검색 상태 관리
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import type { BOMLine } from '@/types/item';
|
||||
|
||||
export interface BOMSearchState {
|
||||
searchValue: string;
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
export interface UseBOMManagementProps {
|
||||
initialBomLines?: BOMLine[];
|
||||
}
|
||||
|
||||
export function useBOMManagement({ initialBomLines = [] }: UseBOMManagementProps = {}) {
|
||||
const [bomLines, setBomLines] = useState<BOMLine[]>(initialBomLines);
|
||||
const [bomSearchStates, setBomSearchStates] = useState<Record<string, BOMSearchState>>({});
|
||||
|
||||
/**
|
||||
* 새 BOM 라인 추가
|
||||
*/
|
||||
const addBomLine = useCallback(() => {
|
||||
const newLine: BOMLine = {
|
||||
id: `bom-${Date.now()}`,
|
||||
childItemCode: '',
|
||||
childItemName: '',
|
||||
quantity: 1,
|
||||
unit: 'EA',
|
||||
};
|
||||
setBomLines((prev) => [...prev, newLine]);
|
||||
return newLine;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* BOM 라인 삭제
|
||||
*/
|
||||
const removeBomLine = useCallback((lineId: string) => {
|
||||
setBomLines((prev) => prev.filter((line) => line.id !== lineId));
|
||||
// 검색 상태도 제거
|
||||
setBomSearchStates((prev) => {
|
||||
const newStates = { ...prev };
|
||||
delete newStates[lineId];
|
||||
return newStates;
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* BOM 라인 업데이트
|
||||
*/
|
||||
const updateBomLine = useCallback((lineId: string, updates: Partial<BOMLine>) => {
|
||||
setBomLines((prev) =>
|
||||
prev.map((line) =>
|
||||
line.id === lineId ? { ...line, ...updates } : line
|
||||
)
|
||||
);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 품목 선택 시 BOM 라인 데이터 채우기
|
||||
*/
|
||||
const selectItemForBomLine = useCallback((
|
||||
lineId: string,
|
||||
item: {
|
||||
itemCode?: string;
|
||||
itemName?: string;
|
||||
specification?: string;
|
||||
material?: string;
|
||||
unit?: string;
|
||||
partType?: string;
|
||||
bendingDiagram?: string;
|
||||
}
|
||||
) => {
|
||||
const isBendingPart = item.partType === 'BENDING';
|
||||
|
||||
setBomLines((prev) =>
|
||||
prev.map((line) =>
|
||||
line.id === lineId
|
||||
? {
|
||||
...line,
|
||||
childItemCode: item.itemCode || '',
|
||||
childItemName: item.itemName || '',
|
||||
specification: item.specification || '',
|
||||
material: item.material || '',
|
||||
unit: item.unit || 'EA',
|
||||
unitPrice: 0, // TODO: pricing에서 가져오기
|
||||
isBending: isBendingPart,
|
||||
bendingDiagram: isBendingPart ? item.bendingDiagram : undefined,
|
||||
}
|
||||
: line
|
||||
)
|
||||
);
|
||||
|
||||
// 검색 팝오버 닫기
|
||||
setBomSearchStates((prev) => ({
|
||||
...prev,
|
||||
[lineId]: { searchValue: '', isOpen: false },
|
||||
}));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 검색 상태 업데이트
|
||||
*/
|
||||
const updateSearchState = useCallback((lineId: string, updates: Partial<BOMSearchState>) => {
|
||||
setBomSearchStates((prev) => ({
|
||||
...prev,
|
||||
[lineId]: { ...(prev[lineId] || { searchValue: '', isOpen: false }), ...updates },
|
||||
}));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 검색 팝오버 열기
|
||||
*/
|
||||
const openSearch = useCallback((lineId: string) => {
|
||||
setBomSearchStates((prev) => ({
|
||||
...prev,
|
||||
[lineId]: { searchValue: '', isOpen: true },
|
||||
}));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 검색 팝오버 닫기
|
||||
*/
|
||||
const closeSearch = useCallback((lineId: string) => {
|
||||
setBomSearchStates((prev) => ({
|
||||
...prev,
|
||||
[lineId]: { ...(prev[lineId] || { searchValue: '' }), isOpen: false },
|
||||
}));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 모든 BOM 라인 초기화
|
||||
*/
|
||||
const resetBomLines = useCallback(() => {
|
||||
setBomLines([]);
|
||||
setBomSearchStates({});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* BOM 라인 수량 변경
|
||||
*/
|
||||
const updateQuantity = useCallback((lineId: string, quantity: number) => {
|
||||
updateBomLine(lineId, { quantity });
|
||||
}, [updateBomLine]);
|
||||
|
||||
/**
|
||||
* BOM 라인 단가 변경
|
||||
*/
|
||||
const updateUnitPrice = useCallback((lineId: string, unitPrice: number) => {
|
||||
updateBomLine(lineId, { unitPrice });
|
||||
}, [updateBomLine]);
|
||||
|
||||
/**
|
||||
* BOM 라인 재질 변경
|
||||
*/
|
||||
const updateMaterial = useCallback((lineId: string, material: string) => {
|
||||
updateBomLine(lineId, { material });
|
||||
}, [updateBomLine]);
|
||||
|
||||
/**
|
||||
* BOM 라인 비고 변경
|
||||
*/
|
||||
const updateNote = useCallback((lineId: string, note: string) => {
|
||||
updateBomLine(lineId, { note });
|
||||
}, [updateBomLine]);
|
||||
|
||||
/**
|
||||
* 특정 라인의 검색 상태 가져오기
|
||||
*/
|
||||
const getSearchState = useCallback((lineId: string): BOMSearchState => {
|
||||
return bomSearchStates[lineId] || { searchValue: '', isOpen: false };
|
||||
}, [bomSearchStates]);
|
||||
|
||||
/**
|
||||
* BOM 합계 계산
|
||||
*/
|
||||
const calculateTotal = useCallback(() => {
|
||||
return bomLines.reduce((total, line) => {
|
||||
return total + (line.quantity * (line.unitPrice || 0));
|
||||
}, 0);
|
||||
}, [bomLines]);
|
||||
|
||||
return {
|
||||
// 상태
|
||||
bomLines,
|
||||
setBomLines,
|
||||
bomSearchStates,
|
||||
setBomSearchStates,
|
||||
|
||||
// BOM 라인 조작
|
||||
addBomLine,
|
||||
removeBomLine,
|
||||
updateBomLine,
|
||||
selectItemForBomLine,
|
||||
|
||||
// 필드 업데이트 헬퍼
|
||||
updateQuantity,
|
||||
updateUnitPrice,
|
||||
updateMaterial,
|
||||
updateNote,
|
||||
|
||||
// 검색 상태 관리
|
||||
updateSearchState,
|
||||
openSearch,
|
||||
closeSearch,
|
||||
getSearchState,
|
||||
|
||||
// 유틸리티
|
||||
resetBomLines,
|
||||
calculateTotal,
|
||||
|
||||
// 상태 체크
|
||||
hasBomLines: bomLines.length > 0,
|
||||
bomLinesCount: bomLines.length,
|
||||
};
|
||||
}
|
||||
|
||||
export type UseBOMManagementReturn = ReturnType<typeof useBOMManagement>;
|
||||
182
src/components/items/ItemForm/hooks/useBendingDetails.ts
Normal file
182
src/components/items/ItemForm/hooks/useBendingDetails.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* 전개도 상세 관리 커스텀 훅
|
||||
*
|
||||
* BENDING 부품의 전개도 치수 계산 및 관리
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import type { BendingDetail } from '@/types/item';
|
||||
import type { UseFormSetValue } from 'react-hook-form';
|
||||
import type { CreateItemFormData } from '@/lib/utils/validation';
|
||||
|
||||
export interface UseBendingDetailsProps {
|
||||
initialDetails?: BendingDetail[];
|
||||
setValue?: UseFormSetValue<CreateItemFormData>;
|
||||
}
|
||||
|
||||
export function useBendingDetails({ initialDetails = [], setValue }: UseBendingDetailsProps = {}) {
|
||||
const [bendingDetails, setBendingDetails] = useState<BendingDetail[]>(initialDetails);
|
||||
const [widthSum, setWidthSum] = useState<string>('');
|
||||
|
||||
/**
|
||||
* 폭 합계 계산
|
||||
*/
|
||||
const calculateWidthSum = useCallback((details: BendingDetail[]): number => {
|
||||
return details.reduce((acc, d) => {
|
||||
const calc = d.input + d.elongation;
|
||||
return acc + calc;
|
||||
}, 0);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 폭 합계 업데이트 (상태 + form setValue)
|
||||
*/
|
||||
const updateWidthSum = useCallback((details: BendingDetail[]) => {
|
||||
const totalSum = calculateWidthSum(details);
|
||||
const sumStr = totalSum.toFixed(1);
|
||||
setWidthSum(sumStr);
|
||||
setValue?.('length', sumStr);
|
||||
}, [calculateWidthSum, setValue]);
|
||||
|
||||
/**
|
||||
* bendingDetails 변경 시 자동 폭 합계 업데이트
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (bendingDetails.length > 0) {
|
||||
updateWidthSum(bendingDetails);
|
||||
}
|
||||
}, [bendingDetails, updateWidthSum]);
|
||||
|
||||
/**
|
||||
* 새 행 추가
|
||||
*/
|
||||
const addDetail = useCallback(() => {
|
||||
const newId = `detail-${Date.now()}`;
|
||||
const newRow: BendingDetail = {
|
||||
id: newId,
|
||||
no: bendingDetails.length + 1,
|
||||
input: 0,
|
||||
elongation: -1,
|
||||
calculated: 0,
|
||||
sum: 0,
|
||||
shaded: false,
|
||||
aAngle: undefined,
|
||||
};
|
||||
const newDetails = [...bendingDetails, newRow];
|
||||
setBendingDetails(newDetails);
|
||||
return newRow;
|
||||
}, [bendingDetails]);
|
||||
|
||||
/**
|
||||
* 행 삭제
|
||||
*/
|
||||
const removeDetail = useCallback((index: number) => {
|
||||
const newDetails = bendingDetails
|
||||
.filter((_, i) => i !== index)
|
||||
.map((d, i) => ({ ...d, no: i + 1 })); // 번호 재정렬
|
||||
setBendingDetails(newDetails);
|
||||
updateWidthSum(newDetails);
|
||||
}, [bendingDetails, updateWidthSum]);
|
||||
|
||||
/**
|
||||
* 행 업데이트
|
||||
*/
|
||||
const updateDetail = useCallback((index: number, updates: Partial<BendingDetail>) => {
|
||||
const newDetails = [...bendingDetails];
|
||||
newDetails[index] = { ...newDetails[index], ...updates };
|
||||
setBendingDetails(newDetails);
|
||||
updateWidthSum(newDetails);
|
||||
}, [bendingDetails, updateWidthSum]);
|
||||
|
||||
/**
|
||||
* 입력값 변경
|
||||
*/
|
||||
const updateInput = useCallback((index: number, value: number) => {
|
||||
updateDetail(index, { input: isNaN(value) ? 0 : value });
|
||||
}, [updateDetail]);
|
||||
|
||||
/**
|
||||
* 연신율 변경
|
||||
*/
|
||||
const updateElongation = useCallback((index: number, value: number) => {
|
||||
updateDetail(index, { elongation: isNaN(value) ? -1 : value });
|
||||
}, [updateDetail]);
|
||||
|
||||
/**
|
||||
* 음영 토글
|
||||
*/
|
||||
const toggleShaded = useCallback((index: number) => {
|
||||
const detail = bendingDetails[index];
|
||||
if (detail) {
|
||||
updateDetail(index, { shaded: !detail.shaded });
|
||||
}
|
||||
}, [bendingDetails, updateDetail]);
|
||||
|
||||
/**
|
||||
* A각 변경
|
||||
*/
|
||||
const updateAAngle = useCallback((index: number, value: number | undefined) => {
|
||||
updateDetail(index, { aAngle: value });
|
||||
}, [updateDetail]);
|
||||
|
||||
/**
|
||||
* 특정 행의 계산값 가져오기
|
||||
*/
|
||||
const getCalculatedValue = useCallback((index: number): number => {
|
||||
const detail = bendingDetails[index];
|
||||
if (!detail) return 0;
|
||||
return detail.input + detail.elongation;
|
||||
}, [bendingDetails]);
|
||||
|
||||
/**
|
||||
* 모든 상세 초기화
|
||||
*/
|
||||
const resetDetails = useCallback(() => {
|
||||
setBendingDetails([]);
|
||||
setWidthSum('');
|
||||
setValue?.('length', '');
|
||||
}, [setValue]);
|
||||
|
||||
/**
|
||||
* 상세 데이터 설정 (외부에서 초기화 시)
|
||||
*/
|
||||
const setDetails = useCallback((details: BendingDetail[]) => {
|
||||
setBendingDetails(details);
|
||||
if (details.length > 0) {
|
||||
updateWidthSum(details);
|
||||
}
|
||||
}, [updateWidthSum]);
|
||||
|
||||
return {
|
||||
// 상태
|
||||
bendingDetails,
|
||||
setBendingDetails: setDetails,
|
||||
widthSum,
|
||||
setWidthSum,
|
||||
|
||||
// 행 조작
|
||||
addDetail,
|
||||
removeDetail,
|
||||
updateDetail,
|
||||
|
||||
// 필드 업데이트
|
||||
updateInput,
|
||||
updateElongation,
|
||||
toggleShaded,
|
||||
updateAAngle,
|
||||
|
||||
// 계산
|
||||
calculateWidthSum,
|
||||
getCalculatedValue,
|
||||
updateWidthSum,
|
||||
|
||||
// 유틸리티
|
||||
resetDetails,
|
||||
|
||||
// 상태 체크
|
||||
hasDetails: bendingDetails.length > 0,
|
||||
detailsCount: bendingDetails.length,
|
||||
};
|
||||
}
|
||||
|
||||
export type UseBendingDetailsReturn = ReturnType<typeof useBendingDetails>;
|
||||
364
src/components/items/ItemForm/hooks/useItemFormState.ts
Normal file
364
src/components/items/ItemForm/hooks/useItemFormState.ts
Normal file
@@ -0,0 +1,364 @@
|
||||
/**
|
||||
* 품목 폼 상태 관리 커스텀 훅
|
||||
*
|
||||
* 25개 이상의 useState를 통합 관리
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import type { ItemMaster, ItemType, BendingDetail, BOMLine } from '@/types/item';
|
||||
import type { UseFormSetValue, UseFormClearErrors } from 'react-hook-form';
|
||||
import type { CreateItemFormData } from '@/lib/utils/validation';
|
||||
|
||||
export interface UseItemFormStateProps {
|
||||
mode: 'create' | 'edit';
|
||||
initialData?: Partial<ItemMaster>;
|
||||
}
|
||||
|
||||
export interface ItemFormState {
|
||||
// 기본 상태
|
||||
isSubmitting: boolean;
|
||||
selectedItemType: ItemType | '';
|
||||
|
||||
// 파일 상태
|
||||
specificationFile: File | null;
|
||||
certificationFile: File | null;
|
||||
bendingDiagramFile: File | null;
|
||||
bendingDiagram: string;
|
||||
bendingDiagramInputMethod: 'file' | 'drawing';
|
||||
isDrawingOpen: boolean;
|
||||
|
||||
// FG(제품) 상태
|
||||
productName: string;
|
||||
productStatus: string;
|
||||
|
||||
// PT(부품) 상태
|
||||
selectedPartType: string;
|
||||
partStatus: string;
|
||||
|
||||
// SM/RM/CS 상태
|
||||
itemName: string;
|
||||
selectedCategory1: string;
|
||||
selectedInstallationType: string;
|
||||
materialStatus: string;
|
||||
selectedSpecification: string;
|
||||
selectedUnit: string;
|
||||
|
||||
// ASSEMBLY 부품 상태
|
||||
sideSpecWidth: string;
|
||||
sideSpecHeight: string;
|
||||
assemblyLength: string;
|
||||
assemblyUnit: string;
|
||||
|
||||
// 전동개폐기 상태
|
||||
electricOpenerPower: string;
|
||||
electricOpenerCapacity: string;
|
||||
|
||||
// 모터/체인 상태
|
||||
motorVoltage: string;
|
||||
chainSpec: string;
|
||||
|
||||
// BENDING 부품 상태
|
||||
selectedBendingItemType: string;
|
||||
material: string;
|
||||
bendingLength: string;
|
||||
widthSum: string;
|
||||
partUnit: string;
|
||||
bendingDetails: BendingDetail[];
|
||||
|
||||
// BOM 상태
|
||||
bomLines: BOMLine[];
|
||||
bomSearchStates: Record<string, { searchValue: string; isOpen: boolean }>;
|
||||
needsBOM: boolean;
|
||||
|
||||
// 비고
|
||||
remarks: string;
|
||||
}
|
||||
|
||||
export function useItemFormState({ mode, initialData }: UseItemFormStateProps) {
|
||||
// 기본 상태
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [selectedItemType, setSelectedItemType] = useState<ItemType | ''>(
|
||||
mode === 'edit' ? (initialData?.itemType || 'FG') : ''
|
||||
);
|
||||
|
||||
// BOM 상태
|
||||
const [bomLines, setBomLines] = useState<BOMLine[]>(initialData?.bom || []);
|
||||
const [bomSearchStates, setBomSearchStates] = useState<Record<string, { searchValue: string; isOpen: boolean }>>({});
|
||||
|
||||
// 파일 상태
|
||||
const [specificationFile, setSpecificationFile] = useState<File | null>(null);
|
||||
const [certificationFile, setCertificationFile] = useState<File | null>(null);
|
||||
const [bendingDiagramFile, setBendingDiagramFile] = useState<File | null>(null);
|
||||
const [bendingDiagram, setBendingDiagram] = useState<string>(initialData?.bendingDiagram || '');
|
||||
const [bendingDiagramInputMethod, setBendingDiagramInputMethod] = useState<'file' | 'drawing'>('file');
|
||||
const [isDrawingOpen, setIsDrawingOpen] = useState(false);
|
||||
|
||||
// FG(제품) 상태
|
||||
const [productName, setProductName] = useState<string>(initialData?.itemName || '');
|
||||
const [productStatus, setProductStatus] = useState<string>(
|
||||
initialData?.isActive !== undefined ? String(initialData.isActive) : 'true'
|
||||
);
|
||||
|
||||
// PT(부품) 상태
|
||||
const [selectedPartType, setSelectedPartType] = useState<string>(initialData?.partType || '');
|
||||
const [partStatus, setPartStatus] = useState<string>(
|
||||
initialData?.isActive !== undefined ? String(initialData.isActive) : 'true'
|
||||
);
|
||||
|
||||
// SM/RM/CS 상태
|
||||
const [itemName, setItemName] = useState<string>(initialData?.itemName || '');
|
||||
const [selectedCategory1, setSelectedCategory1] = useState<string>(initialData?.category1 || '');
|
||||
const [selectedInstallationType, setSelectedInstallationType] = useState<string>(
|
||||
initialData?.installationType || ''
|
||||
);
|
||||
const [materialStatus, setMaterialStatus] = useState<string>(
|
||||
initialData?.isActive !== undefined ? String(initialData.isActive) : 'true'
|
||||
);
|
||||
const [selectedSpecification, setSelectedSpecification] = useState<string>(initialData?.specification || '');
|
||||
const [selectedUnit, setSelectedUnit] = useState<string>(initialData?.unit || '');
|
||||
|
||||
// ASSEMBLY 부품 상태
|
||||
const [sideSpecWidth, setSideSpecWidth] = useState<string>(initialData?.sideSpecWidth || '');
|
||||
const [sideSpecHeight, setSideSpecHeight] = useState<string>(initialData?.sideSpecHeight || '');
|
||||
const [assemblyLength, setAssemblyLength] = useState<string>(initialData?.assemblyLength || '');
|
||||
const [assemblyUnit, setAssemblyUnit] = useState<string>(initialData?.unit || 'EA');
|
||||
|
||||
// 전동개폐기 상태
|
||||
const [electricOpenerPower, setElectricOpenerPower] = useState<string>('');
|
||||
const [electricOpenerCapacity, setElectricOpenerCapacity] = useState<string>('');
|
||||
|
||||
// 모터/체인 상태
|
||||
const [motorVoltage, setMotorVoltage] = useState<string>('');
|
||||
const [chainSpec, setChainSpec] = useState<string>('');
|
||||
|
||||
// BENDING 부품 상태
|
||||
const [selectedBendingItemType, setSelectedBendingItemType] = useState<string>('');
|
||||
const [material, setMaterial] = useState<string>(initialData?.material || '');
|
||||
const [bendingLength, setBendingLength] = useState<string>(initialData?.bendingLength || '');
|
||||
const [widthSum, setWidthSum] = useState<string>(initialData?.length || '');
|
||||
const [partUnit, setPartUnit] = useState<string>(initialData?.unit || 'EA');
|
||||
const [bendingDetails, setBendingDetails] = useState<BendingDetail[]>(
|
||||
initialData?.bendingDetails || []
|
||||
);
|
||||
|
||||
// BOM 필요 여부
|
||||
const [needsBOM, setNeedsBOM] = useState<boolean>(false);
|
||||
|
||||
// 비고 (FG 전용)
|
||||
const [remarks, setRemarks] = useState<string>(initialData?.note || '');
|
||||
|
||||
// 품목 유형 변경 시 모든 상태 초기화
|
||||
const resetAllStates = useCallback((
|
||||
setValue: UseFormSetValue<CreateItemFormData>,
|
||||
clearErrors: UseFormClearErrors<CreateItemFormData>,
|
||||
type: ItemType
|
||||
) => {
|
||||
// FG(제품) 상태 초기화
|
||||
setProductName('');
|
||||
setProductStatus('true');
|
||||
|
||||
// PT(부품) 상태 초기화
|
||||
setSelectedPartType('');
|
||||
setPartStatus('true');
|
||||
|
||||
// SM/RM/CS 상태 초기화
|
||||
setItemName('');
|
||||
setSelectedCategory1('');
|
||||
setSelectedInstallationType('');
|
||||
setMaterialStatus('true');
|
||||
setSelectedSpecification('');
|
||||
setSelectedUnit('');
|
||||
|
||||
// ASSEMBLY 부품 상태 초기화
|
||||
setSideSpecWidth('');
|
||||
setSideSpecHeight('');
|
||||
setAssemblyLength('');
|
||||
setAssemblyUnit('EA');
|
||||
|
||||
// 전동개폐기 상태 초기화
|
||||
setElectricOpenerPower('');
|
||||
setElectricOpenerCapacity('');
|
||||
|
||||
// 모터/체인 상태 초기화
|
||||
setMotorVoltage('');
|
||||
setChainSpec('');
|
||||
|
||||
// BENDING 부품 상태 초기화
|
||||
setSelectedBendingItemType('');
|
||||
setMaterial('');
|
||||
setBendingLength('');
|
||||
setWidthSum('');
|
||||
setPartUnit('EA');
|
||||
setBendingDetails([]);
|
||||
|
||||
// BOM 및 파일 초기화
|
||||
setNeedsBOM(false);
|
||||
setBomLines([]);
|
||||
setSpecificationFile(null);
|
||||
setCertificationFile(null);
|
||||
setBendingDiagramFile(null);
|
||||
setBendingDiagram('');
|
||||
|
||||
// react-hook-form 필드 초기화
|
||||
setValue('itemCode', '');
|
||||
setValue('itemName', '');
|
||||
setValue('unit', (type === 'SM' || type === 'RM' || type === 'CS') ? '' : 'EA');
|
||||
setValue('specification', '');
|
||||
setValue('purchasePrice', 0);
|
||||
setValue('salesPrice', 0);
|
||||
setValue('processingCost', 0);
|
||||
setValue('laborCost', 0);
|
||||
setValue('installCost', 0);
|
||||
setValue('isActive', true);
|
||||
|
||||
// 검증 에러 초기화
|
||||
clearErrors();
|
||||
}, []);
|
||||
|
||||
// 부품 유형 변경 시 부품 관련 상태만 초기화
|
||||
const resetPartStates = useCallback((
|
||||
setValue: UseFormSetValue<CreateItemFormData>
|
||||
) => {
|
||||
// 공통 필드 초기화
|
||||
setSelectedCategory1('');
|
||||
setValue('category1', undefined);
|
||||
setPartUnit('EA');
|
||||
setValue('unit', 'EA');
|
||||
|
||||
// ASSEMBLY 부품 전용 필드 초기화
|
||||
setSelectedInstallationType('');
|
||||
setValue('installationType', undefined);
|
||||
setSideSpecWidth('');
|
||||
setValue('sideSpecWidth', '');
|
||||
setSideSpecHeight('');
|
||||
setValue('sideSpecHeight', '');
|
||||
setAssemblyLength('');
|
||||
setValue('assemblyLength', '');
|
||||
setAssemblyUnit('EA');
|
||||
|
||||
// BENDING 부품 전용 필드 초기화
|
||||
setSelectedBendingItemType('');
|
||||
setValue('category2', undefined);
|
||||
setMaterial('');
|
||||
setValue('material', '');
|
||||
setWidthSum('');
|
||||
setValue('length', '');
|
||||
setBendingLength('');
|
||||
setValue('bendingLength', '');
|
||||
setBendingDetails([]);
|
||||
|
||||
// PURCHASED 부품 전용 필드 초기화
|
||||
setElectricOpenerPower('');
|
||||
setValue('electricOpenerPower', '');
|
||||
setElectricOpenerCapacity('');
|
||||
setValue('electricOpenerCapacity', '');
|
||||
setMotorVoltage('');
|
||||
setValue('motorVoltage', '');
|
||||
setChainSpec('');
|
||||
setValue('chainSpec', '');
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// 기본 상태
|
||||
isSubmitting,
|
||||
setIsSubmitting,
|
||||
selectedItemType,
|
||||
setSelectedItemType,
|
||||
|
||||
// BOM 상태
|
||||
bomLines,
|
||||
setBomLines,
|
||||
bomSearchStates,
|
||||
setBomSearchStates,
|
||||
|
||||
// 파일 상태
|
||||
specificationFile,
|
||||
setSpecificationFile,
|
||||
certificationFile,
|
||||
setCertificationFile,
|
||||
bendingDiagramFile,
|
||||
setBendingDiagramFile,
|
||||
bendingDiagram,
|
||||
setBendingDiagram,
|
||||
bendingDiagramInputMethod,
|
||||
setBendingDiagramInputMethod,
|
||||
isDrawingOpen,
|
||||
setIsDrawingOpen,
|
||||
|
||||
// FG(제품) 상태
|
||||
productName,
|
||||
setProductName,
|
||||
productStatus,
|
||||
setProductStatus,
|
||||
|
||||
// PT(부품) 상태
|
||||
selectedPartType,
|
||||
setSelectedPartType,
|
||||
partStatus,
|
||||
setPartStatus,
|
||||
|
||||
// SM/RM/CS 상태
|
||||
itemName,
|
||||
setItemName,
|
||||
selectedCategory1,
|
||||
setSelectedCategory1,
|
||||
selectedInstallationType,
|
||||
setSelectedInstallationType,
|
||||
materialStatus,
|
||||
setMaterialStatus,
|
||||
selectedSpecification,
|
||||
setSelectedSpecification,
|
||||
selectedUnit,
|
||||
setSelectedUnit,
|
||||
|
||||
// ASSEMBLY 부품 상태
|
||||
sideSpecWidth,
|
||||
setSideSpecWidth,
|
||||
sideSpecHeight,
|
||||
setSideSpecHeight,
|
||||
assemblyLength,
|
||||
setAssemblyLength,
|
||||
assemblyUnit,
|
||||
setAssemblyUnit,
|
||||
|
||||
// 전동개폐기 상태
|
||||
electricOpenerPower,
|
||||
setElectricOpenerPower,
|
||||
electricOpenerCapacity,
|
||||
setElectricOpenerCapacity,
|
||||
|
||||
// 모터/체인 상태
|
||||
motorVoltage,
|
||||
setMotorVoltage,
|
||||
chainSpec,
|
||||
setChainSpec,
|
||||
|
||||
// BENDING 부품 상태
|
||||
selectedBendingItemType,
|
||||
setSelectedBendingItemType,
|
||||
material,
|
||||
setMaterial,
|
||||
bendingLength,
|
||||
setBendingLength,
|
||||
widthSum,
|
||||
setWidthSum,
|
||||
partUnit,
|
||||
setPartUnit,
|
||||
bendingDetails,
|
||||
setBendingDetails,
|
||||
|
||||
// BOM 필요 여부
|
||||
needsBOM,
|
||||
setNeedsBOM,
|
||||
|
||||
// 비고
|
||||
remarks,
|
||||
setRemarks,
|
||||
|
||||
// 헬퍼 함수
|
||||
resetAllStates,
|
||||
resetPartStates,
|
||||
};
|
||||
}
|
||||
|
||||
export type UseItemFormStateReturn = ReturnType<typeof useItemFormState>;
|
||||
File diff suppressed because it is too large
Load Diff
21
src/components/items/ItemForm/types.ts
Normal file
21
src/components/items/ItemForm/types.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* ItemForm 타입 정의
|
||||
*/
|
||||
|
||||
import type { ItemMaster } from '@/types/item';
|
||||
import type { CreateItemFormData } from '@/lib/utils/validation';
|
||||
|
||||
export interface ItemFormProps {
|
||||
mode: 'create' | 'edit';
|
||||
initialData?: ItemMaster;
|
||||
onSubmit: (data: CreateItemFormData) => Promise<void>;
|
||||
}
|
||||
|
||||
// BOM 검색 상태 타입
|
||||
export interface BOMSearchState {
|
||||
searchValue: string;
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
// 전개도 입력 방식
|
||||
export type BendingDiagramInputMethod = 'file' | 'drawing';
|
||||
@@ -1,10 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { useItemMaster } from '@/contexts/ItemMasterContext';
|
||||
import type { SectionTemplate, BOMItem } from '@/contexts/ItemMasterContext';
|
||||
import type { SectionTemplate, BOMItem, TemplateField } from '@/contexts/ItemMasterContext';
|
||||
import { MasterFieldTab, HierarchyTab, SectionsTab } from './ItemMasterDataManagement/tabs';
|
||||
import { FieldDialog } from './ItemMasterDataManagement/dialogs/FieldDialog';
|
||||
// ConditionalFieldConfig type removed - not currently used
|
||||
@@ -20,16 +20,21 @@ import { TemplateFieldDialog } from './ItemMasterDataManagement/dialogs/Template
|
||||
import { LoadTemplateDialog } from './ItemMasterDataManagement/dialogs/LoadTemplateDialog';
|
||||
import { ColumnDialog } from './ItemMasterDataManagement/dialogs/ColumnDialog';
|
||||
import { SectionTemplateDialog } from './ItemMasterDataManagement/dialogs/SectionTemplateDialog';
|
||||
import { ImportSectionDialog } from './ItemMasterDataManagement/dialogs/ImportSectionDialog';
|
||||
import { ImportFieldDialog } from './ItemMasterDataManagement/dialogs/ImportFieldDialog';
|
||||
import { itemMasterApi } from '@/lib/api/item-master';
|
||||
import { getErrorMessage, ApiError } from '@/lib/api/error-handler';
|
||||
import { LoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import { ErrorMessage } from '@/components/ui/error-message';
|
||||
import { ServerErrorPage } from '@/components/common/ServerErrorPage';
|
||||
import {
|
||||
transformPagesResponse,
|
||||
transformSectionsResponse,
|
||||
transformSectionTemplatesResponse,
|
||||
transformMasterFieldsResponse,
|
||||
transformFieldsResponse,
|
||||
transformCustomTabsResponse,
|
||||
transformUnitOptionsResponse,
|
||||
transformSectionTemplateFromSection,
|
||||
} from '@/lib/api/transformers';
|
||||
import {
|
||||
Database,
|
||||
@@ -85,7 +90,7 @@ export function ItemMasterDataManagement() {
|
||||
deleteSection,
|
||||
addFieldToSection: _addFieldToSection,
|
||||
updateField: _updateField,
|
||||
deleteField,
|
||||
deleteField: _deleteField,
|
||||
reorderFields,
|
||||
itemMasterFields,
|
||||
loadItemMasterFields,
|
||||
@@ -98,11 +103,47 @@ export function ItemMasterDataManagement() {
|
||||
updateSectionTemplate: _updateSectionTemplate,
|
||||
deleteSectionTemplate: _deleteSectionTemplate,
|
||||
resetAllData,
|
||||
tenantId: _tenantId
|
||||
tenantId: _tenantId,
|
||||
// 2025-11-26 추가: 독립 엔티티 관리
|
||||
independentSections,
|
||||
loadIndependentSections,
|
||||
independentFields: _independentFields,
|
||||
loadIndependentFields,
|
||||
refreshIndependentSections,
|
||||
refreshIndependentFields,
|
||||
linkSectionToPage,
|
||||
unlinkSectionFromPage: _unlinkSectionFromPage,
|
||||
linkFieldToSection,
|
||||
unlinkFieldFromSection,
|
||||
getSectionUsage,
|
||||
getFieldUsage,
|
||||
cloneSection,
|
||||
reorderSections,
|
||||
// 2025-11-27 추가: BOM 항목 API 함수
|
||||
addBOMItem,
|
||||
updateBOMItem,
|
||||
deleteBOMItem,
|
||||
} = useItemMaster();
|
||||
|
||||
console.log('ItemMasterDataManagement: Current sectionTemplates', sectionTemplates);
|
||||
|
||||
// 2025-11-27: itemPages 변화 추적 (디버깅용)
|
||||
useEffect(() => {
|
||||
console.log('[ItemMasterDataManagement] ⚡ itemPages changed:', {
|
||||
pageCount: itemPages.length,
|
||||
pages: itemPages.map(p => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
sectionsCount: p.sections.length,
|
||||
sections: p.sections.map(s => ({
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
fieldsCount: s.fields?.length || 0
|
||||
}))
|
||||
}))
|
||||
});
|
||||
}, [itemPages]);
|
||||
|
||||
// ===== 커스텀 훅 초기화 =====
|
||||
const pageManagement = usePageManagement();
|
||||
const sectionManagement = useSectionManagement();
|
||||
@@ -135,7 +176,7 @@ export function ItemMasterDataManagement() {
|
||||
expandedSections: _expandedSections, setExpandedSections: _setExpandedSections,
|
||||
handleAddSection, handleLinkTemplate,
|
||||
handleEditSectionTitle, handleSaveSectionTitle,
|
||||
handleDeleteSection: _handleDeleteSection, toggleSection: _toggleSection,
|
||||
handleUnlinkSection, handleDeleteSection: _handleDeleteSection, toggleSection: _toggleSection,
|
||||
} = sectionManagement;
|
||||
|
||||
const {
|
||||
@@ -273,6 +314,92 @@ export function ItemMasterDataManagement() {
|
||||
}))
|
||||
);
|
||||
|
||||
// 2025-11-26: itemPages의 모든 섹션 + 독립 섹션(independentSections)을 SectionTemplate 형식으로 변환
|
||||
// 이렇게 하면 계층구조 탭과 섹션 탭이 같은 데이터 소스를 사용하여 자동 동기화됨
|
||||
// 독립 섹션: 페이지에서 연결 해제된 섹션 (page_id = null)
|
||||
const sectionsAsTemplates: SectionTemplate[] = useMemo(() => {
|
||||
console.log('[sectionsAsTemplates] useMemo 재계산! itemPages:', itemPages.map(p => ({
|
||||
id: p.id,
|
||||
sections: p.sections.map(s => ({ id: s.id, fieldsCount: s.fields?.length || 0 }))
|
||||
})));
|
||||
|
||||
// 1. itemPages에 연결된 섹션들
|
||||
const linkedSections = itemPages.flatMap(page =>
|
||||
page.sections.map(section => ({
|
||||
id: section.id,
|
||||
tenant_id: section.tenant_id || 0,
|
||||
template_name: section.title,
|
||||
section_type: section.section_type,
|
||||
description: section.description || null,
|
||||
default_fields: null,
|
||||
// ItemField → TemplateField 변환
|
||||
fields: section.fields?.map(field => ({
|
||||
id: field.id.toString(),
|
||||
name: field.field_name,
|
||||
fieldKey: field.field_name.toLowerCase().replace(/\s+/g, '_'),
|
||||
property: {
|
||||
inputType: field.field_type,
|
||||
// 2025-11-27: is_required와 properties.required 둘 다 체크
|
||||
required: field.is_required || field.properties?.required,
|
||||
options: field.options?.map((opt: { label: string; value: string }) => opt.label || opt.value),
|
||||
},
|
||||
description: field.placeholder || undefined,
|
||||
} as TemplateField)),
|
||||
bomItems: section.bom_items,
|
||||
created_by: section.created_by || null,
|
||||
updated_by: section.updated_by || null,
|
||||
created_at: section.created_at,
|
||||
updated_at: section.updated_at,
|
||||
}))
|
||||
);
|
||||
|
||||
// 2. 독립 섹션들 (page_id = null, 연결 해제된 섹션)
|
||||
const unlinkedSections = independentSections.map(section => ({
|
||||
id: section.id,
|
||||
tenant_id: section.tenant_id || 0,
|
||||
template_name: section.title,
|
||||
section_type: section.section_type,
|
||||
description: section.description || null,
|
||||
default_fields: null,
|
||||
fields: section.fields?.map(field => ({
|
||||
id: field.id.toString(),
|
||||
name: field.field_name,
|
||||
fieldKey: field.field_name.toLowerCase().replace(/\s+/g, '_'),
|
||||
property: {
|
||||
inputType: field.field_type,
|
||||
// 2025-11-27: is_required와 properties.required 둘 다 체크
|
||||
required: field.is_required || field.properties?.required,
|
||||
options: field.options?.map((opt: { label: string; value: string }) => opt.label || opt.value),
|
||||
},
|
||||
description: field.placeholder || undefined,
|
||||
} as TemplateField)),
|
||||
bomItems: section.bom_items,
|
||||
created_by: section.created_by || null,
|
||||
updated_by: section.updated_by || null,
|
||||
created_at: section.created_at,
|
||||
updated_at: section.updated_at,
|
||||
}));
|
||||
|
||||
// 3. 중복 제거 (같은 섹션이 여러 페이지에 연결되었거나, 연결 섹션과 독립 섹션에 동시 존재하는 경우)
|
||||
const allSections = [...linkedSections, ...unlinkedSections];
|
||||
const uniqueSections = Array.from(
|
||||
new Map(allSections.map(s => [s.id, s])).values()
|
||||
);
|
||||
return uniqueSections;
|
||||
}, [itemPages, independentSections]);
|
||||
|
||||
// 2025-11-27: sectionsAsTemplates 변화 추적 (디버깅용)
|
||||
useEffect(() => {
|
||||
console.log('[ItemMasterDataManagement] 📋 sectionsAsTemplates changed:', {
|
||||
count: sectionsAsTemplates.length,
|
||||
sections: sectionsAsTemplates.map(s => ({
|
||||
id: s.id,
|
||||
name: s.template_name,
|
||||
fieldsCount: s.fields?.length || 0
|
||||
}))
|
||||
});
|
||||
}, [sectionsAsTemplates]);
|
||||
|
||||
// 마운트 상태 추적 (SSR 호환)
|
||||
const [_mounted, setMounted] = useState(false);
|
||||
|
||||
@@ -294,22 +421,67 @@ export function ItemMasterDataManagement() {
|
||||
|
||||
const data = await itemMasterApi.init();
|
||||
|
||||
// 페이지 데이터 로드 (이미 존재하는 데이터를 state에 로드 - API 호출 없음)
|
||||
// 2025-11-26: 백엔드가 entity_relationships 기반으로 변경됨
|
||||
// - pages[].sections: entity_relationships 기반으로 연결된 섹션 (이미 포함)
|
||||
// - sections: 모든 독립 섹션 (재사용 가능 목록)
|
||||
// - sectionTemplates: 삭제됨 → sections로 대체
|
||||
|
||||
// 1. 페이지 데이터 로드 (섹션이 이미 포함되어 있음)
|
||||
const transformedPages = transformPagesResponse(data.pages);
|
||||
loadItemPages(transformedPages);
|
||||
|
||||
// 섹션 템플릿 로드 (덮어쓰기 - API 호출 없음!)
|
||||
const transformedTemplates = transformSectionTemplatesResponse(data.sectionTemplates);
|
||||
loadSectionTemplates(transformedTemplates);
|
||||
// 2. 독립 섹션 로드 (모든 재사용 가능 섹션)
|
||||
// 백엔드가 sections 배열로 모든 독립 섹션을 반환
|
||||
if (data.sections && data.sections.length > 0) {
|
||||
const transformedSections = transformSectionsResponse(data.sections);
|
||||
loadIndependentSections(transformedSections);
|
||||
console.log('✅ 독립 섹션 로드:', transformedSections.length);
|
||||
}
|
||||
|
||||
// 마스터 필드 로드 (덮어쓰기 - API 호출 없음!)
|
||||
const transformedFields = transformMasterFieldsResponse(data.masterFields);
|
||||
loadItemMasterFields(transformedFields);
|
||||
// 3. 섹션 템플릿 로드 (sectionTemplates → sections로 통합됨)
|
||||
// 기존 sectionTemplates가 있으면 호환성 유지, 없으면 sections 사용
|
||||
if (data.sectionTemplates && data.sectionTemplates.length > 0) {
|
||||
const transformedTemplates = transformSectionTemplatesResponse(data.sectionTemplates);
|
||||
loadSectionTemplates(transformedTemplates);
|
||||
} else if (data.sections && data.sections.length > 0) {
|
||||
// sectionTemplates가 없으면 sections에서 is_template=true인 것만 사용
|
||||
const templates = data.sections
|
||||
.filter((s: { is_template?: boolean }) => s.is_template)
|
||||
.map(transformSectionTemplateFromSection);
|
||||
if (templates.length > 0) {
|
||||
loadSectionTemplates(templates);
|
||||
}
|
||||
}
|
||||
|
||||
// 커스텀 탭 로드 (local state)
|
||||
// 필드 로드 (2025-11-27: masterFields가 fields로 통합됨)
|
||||
// data.fields = 모든 필드 목록 (백엔드 init API에서 반환)
|
||||
if (data.fields && data.fields.length > 0) {
|
||||
const transformedFields = transformFieldsResponse(data.fields);
|
||||
|
||||
// 2025-11-27: section_id가 null인 필드만 필터링 (독립 필드)
|
||||
const independentOnlyFields = transformedFields.filter(
|
||||
f => f.section_id === null || f.section_id === undefined
|
||||
);
|
||||
|
||||
// 2025-11-27: 항목탭용 (itemMasterFields) - 모든 필드 로드
|
||||
// 계층구조에서 추가한 필드도 항목탭에 바로 표시되도록 함
|
||||
// addFieldToSection에서 setItemMasterFields를 호출하므로 일관성 유지
|
||||
loadItemMasterFields(transformedFields as any);
|
||||
|
||||
// 독립 필드용 (independentFields) - section_id=null인 필드만
|
||||
loadIndependentFields(independentOnlyFields);
|
||||
|
||||
console.log('✅ 필드 로드:', {
|
||||
total: transformedFields.length,
|
||||
independent: independentOnlyFields.length,
|
||||
allFieldsForItemsTab: transformedFields.length,
|
||||
});
|
||||
}
|
||||
|
||||
// 커스텀 탭 로드 (local state) - 교체 방식 (복제 방지)
|
||||
if (data.customTabs && data.customTabs.length > 0) {
|
||||
const transformedTabs = transformCustomTabsResponse(data.customTabs);
|
||||
setCustomTabs(prev => [...prev, ...transformedTabs]);
|
||||
setCustomTabs(transformedTabs);
|
||||
}
|
||||
|
||||
// 단위 옵션 로드 (local state)
|
||||
@@ -319,9 +491,9 @@ export function ItemMasterDataManagement() {
|
||||
}
|
||||
|
||||
console.log('✅ Initial data loaded:', {
|
||||
pages: data.pages.length,
|
||||
templates: data.sectionTemplates.length,
|
||||
masterFields: data.masterFields.length,
|
||||
pages: data.pages?.length || 0,
|
||||
sections: data.sections?.length || 0,
|
||||
fields: data.fields?.length || 0,
|
||||
customTabs: data.customTabs?.length || 0,
|
||||
unitOptions: data.unitOptions?.length || 0,
|
||||
});
|
||||
@@ -365,6 +537,61 @@ export function ItemMasterDataManagement() {
|
||||
// BOM 관리 상태 (훅에 없음)
|
||||
const [_bomItems, setBomItems] = useState<BOMItem[]>([]);
|
||||
|
||||
// 2025-11-26 추가: 섹션/필드 불러오기 다이얼로그 상태
|
||||
const [isImportSectionDialogOpen, setIsImportSectionDialogOpen] = useState(false);
|
||||
const [isImportFieldDialogOpen, setIsImportFieldDialogOpen] = useState(false);
|
||||
const [selectedImportSectionId, setSelectedImportSectionId] = useState<number | null>(null);
|
||||
const [selectedImportFieldId, setSelectedImportFieldId] = useState<number | null>(null);
|
||||
const [importFieldTargetSectionId, setImportFieldTargetSectionId] = useState<number | null>(null);
|
||||
|
||||
// 2025-11-26 추가: 섹션 불러오기 핸들러
|
||||
const handleImportSection = async () => {
|
||||
if (!selectedPageId || !selectedImportSectionId) return;
|
||||
|
||||
try {
|
||||
await linkSectionToPage(selectedPageId, selectedImportSectionId);
|
||||
toast.success('섹션을 불러왔습니다.');
|
||||
setSelectedImportSectionId(null);
|
||||
} catch (error) {
|
||||
console.error('섹션 불러오기 실패:', error);
|
||||
toast.error(getErrorMessage(error));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 필드 불러오기 핸들러
|
||||
*
|
||||
* @description 2025-11-27: API 변경으로 단순화
|
||||
* - 이전: source 파라미터로 'master' | 'independent' 구분
|
||||
* - 현재: 모든 필드가 item_fields로 통합 → linkFieldToSection만 사용
|
||||
* - section_id=NULL인 필드를 섹션에 연결하는 방식으로 통일
|
||||
*/
|
||||
const handleImportField = async () => {
|
||||
if (!importFieldTargetSectionId || !selectedImportFieldId) return;
|
||||
|
||||
try {
|
||||
// 2025-11-27: 통합된 필드 연결 방식
|
||||
await linkFieldToSection(importFieldTargetSectionId, selectedImportFieldId);
|
||||
toast.success('필드를 섹션에 연결했습니다.');
|
||||
|
||||
setSelectedImportFieldId(null);
|
||||
setImportFieldTargetSectionId(null);
|
||||
} catch (error) {
|
||||
console.error('필드 불러오기 실패:', error);
|
||||
toast.error(getErrorMessage(error));
|
||||
}
|
||||
};
|
||||
|
||||
// 2025-11-26 추가: 섹션 복제 핸들러
|
||||
const handleCloneSection = async (sectionId: number) => {
|
||||
try {
|
||||
await cloneSection(sectionId);
|
||||
toast.success('섹션이 복제되었습니다.');
|
||||
} catch (error) {
|
||||
console.error('섹션 복제 실패:', error);
|
||||
toast.error(getErrorMessage(error));
|
||||
}
|
||||
};
|
||||
|
||||
// ===== 이하 핸들러들은 훅으로 이동되어 제거됨 =====
|
||||
// handleAddOption, handleDeleteOption → useAttributeManagement
|
||||
@@ -385,7 +612,7 @@ export function ItemMasterDataManagement() {
|
||||
};
|
||||
|
||||
// 섹션 삭제 핸들러 (pendingChanges 제거 포함) - 훅에 없어 유지
|
||||
const handleDeleteSectionWithTracking = (pageId: number, sectionId: number) => {
|
||||
const _handleDeleteSectionWithTracking = (pageId: number, sectionId: number) => {
|
||||
const page = itemPages.find(p => p.id === pageId);
|
||||
const sectionToDelete = page?.sections.find(s => s.id === sectionId);
|
||||
const fieldIds = sectionToDelete?.fields?.map(f => f.id) || [];
|
||||
@@ -393,10 +620,16 @@ export function ItemMasterDataManagement() {
|
||||
console.log('섹션 삭제 완료:', { sectionId, removedFields: fieldIds.length });
|
||||
};
|
||||
|
||||
// 필드 삭제 핸들러 (pendingChanges 제거 포함) - 훅에 없어 유지
|
||||
const handleDeleteFieldWithTracking = (_pageId: string, _sectionId: string, fieldId: string) => {
|
||||
deleteField(Number(fieldId));
|
||||
console.log('필드 삭제 완료:', fieldId);
|
||||
// 필드 연결 해제 핸들러 (2025-11-27: 삭제 → unlink로 변경)
|
||||
// 섹션에서 필드 연결만 해제하고, 필드 자체는 독립 필드 목록에 유지됨
|
||||
const handleUnlinkFieldWithTracking = async (_pageId: string, sectionId: string, fieldId: string) => {
|
||||
try {
|
||||
await unlinkFieldFromSection(Number(sectionId), Number(fieldId));
|
||||
console.log('필드 연결 해제 완료:', fieldId);
|
||||
} catch (error) {
|
||||
console.error('필드 연결 해제 실패:', error);
|
||||
toast.error('필드 연결 해제에 실패했습니다');
|
||||
}
|
||||
};
|
||||
|
||||
// 절대경로 업데이트 - 로컬에서 처리
|
||||
@@ -424,7 +657,9 @@ export function ItemMasterDataManagement() {
|
||||
const handleLoadTemplateWrapper = () => handleLoadTemplate(selectedPage);
|
||||
|
||||
// setter 래퍼들 (Dispatch<SetStateAction> 타입 호환성)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const setNewSectionTypeWrapper: React.Dispatch<React.SetStateAction<'fields' | 'bom'>> = setNewSectionType as any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const setNewPageItemTypeWrapper: React.Dispatch<React.SetStateAction<'FG' | 'PT' | 'SM' | 'RM' | 'CS'>> = setNewPageItemType as any;
|
||||
|
||||
// ===== 유틸리티 함수들 =====
|
||||
@@ -436,23 +671,24 @@ export function ItemMasterDataManagement() {
|
||||
};
|
||||
|
||||
// 섹션 순서 변경 핸들러 (드래그앤드롭)
|
||||
const moveSection = (dragIndex: number, hoverIndex: number) => {
|
||||
const moveSection = async (dragIndex: number, hoverIndex: number) => {
|
||||
if (!selectedPage) return;
|
||||
|
||||
const sections = [...selectedPage.sections];
|
||||
const [draggedSection] = sections.splice(dragIndex, 1);
|
||||
sections.splice(hoverIndex, 0, draggedSection);
|
||||
|
||||
// order 값 재설정
|
||||
const updatedSections = sections.map((section, idx) => ({
|
||||
...section,
|
||||
order: idx + 1
|
||||
}));
|
||||
// 새로운 순서의 섹션 ID 배열 생성
|
||||
const sectionIds = sections.map(s => s.id);
|
||||
|
||||
// 페이지 업데이트
|
||||
updateItemPage(selectedPage.id, { sections: updatedSections });
|
||||
// hasUnsavedChanges는 computed value이므로 자동 계산됨
|
||||
toast.success('섹션 순서가 변경되었습니다 (저장 필요)');
|
||||
try {
|
||||
// API를 통해 섹션 순서 변경 (Context의 reorderSections 사용)
|
||||
await reorderSections(selectedPage.id, sectionIds);
|
||||
toast.success('섹션 순서가 변경되었습니다');
|
||||
} catch (error) {
|
||||
console.error('섹션 순서 변경 실패:', error);
|
||||
toast.error('섹션 순서 변경에 실패했습니다');
|
||||
}
|
||||
};
|
||||
|
||||
// 필드 순서 변경 핸들러
|
||||
@@ -520,12 +756,12 @@ export function ItemMasterDataManagement() {
|
||||
// 에러 발생 시 UI
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen p-4">
|
||||
<ErrorMessage
|
||||
message={error}
|
||||
onRetry={() => window.location.reload()}
|
||||
/>
|
||||
</div>
|
||||
<ServerErrorPage
|
||||
title="데이터를 불러올 수 없습니다"
|
||||
message={error}
|
||||
onRetry={() => window.location.reload()}
|
||||
showContactInfo={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -935,6 +1171,7 @@ export function ItemMasterDataManagement() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
{propertiesArray.map((property: any) => {
|
||||
const inputTypeLabel =
|
||||
property.type === 'textbox' ? '텍스트박스' :
|
||||
@@ -1147,7 +1384,7 @@ export function ItemMasterDataManagement() {
|
||||
{/* 섹션관리 탭 */}
|
||||
<TabsContent value="sections" className="space-y-4">
|
||||
<SectionsTab
|
||||
sectionTemplates={sectionTemplates}
|
||||
sectionTemplates={sectionsAsTemplates}
|
||||
setIsSectionTemplateDialogOpen={setIsSectionTemplateDialogOpen}
|
||||
setCurrentTemplateId={setCurrentTemplateId}
|
||||
setIsTemplateFieldDialogOpen={setIsTemplateFieldDialogOpen}
|
||||
@@ -1160,6 +1397,10 @@ export function ItemMasterDataManagement() {
|
||||
handleDeleteBOMItemFromTemplate={handleDeleteBOMItemFromTemplate}
|
||||
ITEM_TYPE_OPTIONS={ITEM_TYPE_OPTIONS}
|
||||
INPUT_TYPE_OPTIONS={INPUT_TYPE_OPTIONS}
|
||||
unitOptions={unitOptions.map(opt => ({ value: opt.value, label: opt.label }))}
|
||||
onCloneSection={handleCloneSection}
|
||||
setIsImportFieldDialogOpen={setIsImportFieldDialogOpen}
|
||||
setImportFieldTargetSectionId={setImportFieldTargetSectionId}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
@@ -1169,6 +1410,7 @@ export function ItemMasterDataManagement() {
|
||||
itemPages={itemPages}
|
||||
selectedPage={selectedPage}
|
||||
ITEM_TYPE_OPTIONS={ITEM_TYPE_OPTIONS}
|
||||
unitOptions={unitOptions.map(opt => ({ value: opt.value, label: opt.label }))}
|
||||
editingPageId={editingPageId}
|
||||
setEditingPageId={setEditingPageId}
|
||||
editingPageName={editingPageName}
|
||||
@@ -1199,11 +1441,18 @@ export function ItemMasterDataManagement() {
|
||||
handleEditSectionTitle={handleEditSectionTitle}
|
||||
handleSaveSectionTitle={handleSaveSectionTitleWrapper}
|
||||
moveSection={moveSection}
|
||||
deleteSection={handleDeleteSectionWithTracking}
|
||||
unlinkSection={handleUnlinkSection}
|
||||
updateSection={updateSection}
|
||||
deleteField={handleDeleteFieldWithTracking}
|
||||
deleteField={handleUnlinkFieldWithTracking}
|
||||
handleEditField={handleEditField}
|
||||
moveField={moveField}
|
||||
setIsImportSectionDialogOpen={setIsImportSectionDialogOpen}
|
||||
setIsImportFieldDialogOpen={setIsImportFieldDialogOpen}
|
||||
setImportFieldTargetSectionId={setImportFieldTargetSectionId}
|
||||
// 2025-11-27 추가: BOM 항목 API 함수
|
||||
addBOMItem={addBOMItem}
|
||||
updateBOMItem={updateBOMItem}
|
||||
deleteBOMItem={deleteBOMItem}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
@@ -1346,7 +1595,7 @@ export function ItemMasterDataManagement() {
|
||||
handleAddSection={handleAddSectionWrapper}
|
||||
sectionInputMode={sectionInputMode}
|
||||
setSectionInputMode={setSectionInputMode}
|
||||
sectionTemplates={sectionTemplates}
|
||||
sectionTemplates={sectionsAsTemplates}
|
||||
selectedTemplateId={selectedSectionTemplateId}
|
||||
setSelectedTemplateId={setSelectedSectionTemplateId}
|
||||
handleLinkTemplate={handleLinkTemplateWrapper}
|
||||
@@ -1555,6 +1804,35 @@ export function ItemMasterDataManagement() {
|
||||
setSelectedTemplateId={setSelectedTemplateId}
|
||||
handleLoadTemplate={handleLoadTemplateWrapper}
|
||||
/>
|
||||
|
||||
{/* 섹션 불러오기 다이얼로그 */}
|
||||
<ImportSectionDialog
|
||||
isOpen={isImportSectionDialogOpen}
|
||||
setIsOpen={setIsImportSectionDialogOpen}
|
||||
independentSections={independentSections}
|
||||
selectedSectionId={selectedImportSectionId}
|
||||
setSelectedSectionId={setSelectedImportSectionId}
|
||||
onImport={handleImportSection}
|
||||
onRefresh={refreshIndependentSections}
|
||||
onGetUsage={getSectionUsage}
|
||||
/>
|
||||
|
||||
{/* 필드 불러오기 다이얼로그 - 2025-11-27: 탭 통합 (항목+독립필드 → 필드) */}
|
||||
<ImportFieldDialog
|
||||
isOpen={isImportFieldDialogOpen}
|
||||
setIsOpen={setIsImportFieldDialogOpen}
|
||||
fields={itemMasterFields}
|
||||
selectedFieldId={selectedImportFieldId}
|
||||
setSelectedFieldId={setSelectedImportFieldId}
|
||||
onImport={handleImportField}
|
||||
onRefresh={refreshIndependentFields}
|
||||
onGetUsage={getFieldUsage}
|
||||
targetSectionTitle={
|
||||
importFieldTargetSectionId
|
||||
? selectedPage?.sections.find(s => s.id === importFieldTargetSectionId)?.title
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -305,7 +305,7 @@ export function ConditionalDisplayUI({
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
<span className="text-xs flex-1">{section.section_name}</span>
|
||||
<span className="text-xs flex-1">{section.title}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -75,7 +75,7 @@ export function DraggableField({ field, index, moveField, onDelete, onEdit }: Dr
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{INPUT_TYPE_OPTIONS.find(t => t.value === field.field_type)?.label || field.field_type}
|
||||
</Badge>
|
||||
{field.is_required && (
|
||||
{(field.is_required || field.properties?.required) && (
|
||||
<Badge variant="destructive" className="text-xs">필수</Badge>
|
||||
)}
|
||||
{field.display_condition && (
|
||||
|
||||
@@ -106,9 +106,9 @@ export function DraggableSection({
|
||||
) : (
|
||||
<div
|
||||
className="flex items-center gap-2 flex-1 cursor-pointer group min-w-0"
|
||||
onClick={() => onEditTitle(section.id, section.section_name)}
|
||||
onClick={() => onEditTitle(section.id, section.title)}
|
||||
>
|
||||
<span className="text-blue-900 truncate text-sm sm:text-base">{section.section_name}</span>
|
||||
<span className="text-blue-900 truncate text-sm sm:text-base">{section.title}</span>
|
||||
<Edit className="h-3 w-3 text-gray-400 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -157,7 +157,7 @@ export function FieldDialog({
|
||||
<DialogHeader className="sticky top-0 bg-white z-10 px-6 py-4 border-b">
|
||||
<DialogTitle>{editingFieldId ? '항목 수정' : '항목 추가'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
재사용 가능한 마스터 항목을 선택하거나 직접 입력하세요
|
||||
재사용 가능한 항목을 선택하거나 직접 입력하세요
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
|
||||
@@ -181,16 +181,16 @@ export function FieldDialog({
|
||||
}}
|
||||
className="flex-1"
|
||||
>
|
||||
마스터 항목 선택
|
||||
항목 선택
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 마스터 항목 목록 */}
|
||||
{/* 항목 목록 */}
|
||||
{fieldInputMode === 'master' && !editingFieldId && showMasterFieldList && (
|
||||
<div className="border rounded p-3 space-y-2 max-h-[400px] overflow-y-auto">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Label>마스터 항목 목록</Label>
|
||||
<Label>항목 목록</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -201,7 +201,7 @@ export function FieldDialog({
|
||||
</div>
|
||||
{itemMasterFields.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
등록된 마스터 항목이 없습니다
|
||||
등록된 항목이 없습니다
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
@@ -418,10 +418,10 @@ export function FieldDialog({
|
||||
</div>
|
||||
<DialogFooter className="shrink-0 bg-white z-10 px-6 py-4 border-t">
|
||||
<Button variant="outline" onClick={handleClose}>취소</Button>
|
||||
<Button onClick={() => {
|
||||
<Button onClick={async () => {
|
||||
setIsSubmitted(true);
|
||||
if ((fieldInputMode === 'custom' || editingFieldId) && (isNameEmpty || isKeyEmpty)) return;
|
||||
handleAddField();
|
||||
await handleAddField();
|
||||
setIsSubmitted(false);
|
||||
}}>저장</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -65,7 +65,7 @@ interface FieldDrawerProps {
|
||||
selectedSectionForField: ItemSection | null;
|
||||
selectedPage: ItemPage | null;
|
||||
itemMasterFields: ItemMasterField[];
|
||||
handleAddField: () => void;
|
||||
handleAddField: () => Promise<void>;
|
||||
setIsColumnDialogOpen: (open: boolean) => void;
|
||||
setEditingColumnId: (id: string | null) => void;
|
||||
setColumnName: (name: string) => void;
|
||||
@@ -136,7 +136,7 @@ export function FieldDrawer({
|
||||
<DrawerHeader className="px-4 py-3 border-b">
|
||||
<DrawerTitle>{editingFieldId ? '항목 수정' : '항목 추가'}</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
재사용 가능한 마스터 항목을 선택하거나 직접 입력하세요
|
||||
재사용 가능한 항목을 선택하거나 직접 입력하세요
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
|
||||
@@ -161,16 +161,16 @@ export function FieldDrawer({
|
||||
}}
|
||||
className="flex-1"
|
||||
>
|
||||
마스터 항목 선택
|
||||
항목 선택
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 마스터 항목 목록 */}
|
||||
{/* 항목 목록 */}
|
||||
{fieldInputMode === 'master' && !editingFieldId && showMasterFieldList && (
|
||||
<div className="border rounded p-3 space-y-2 max-h-[400px] overflow-y-auto">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Label>마스터 항목 목록</Label>
|
||||
<Label>항목 목록</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -181,7 +181,7 @@ export function FieldDrawer({
|
||||
</div>
|
||||
{itemMasterFields.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
등록된 마스터 항목이 없습니다
|
||||
등록된 항목이 없습니다
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
@@ -600,7 +600,7 @@ export function FieldDrawer({
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
<span className="flex-1 text-sm">{section.section_name}</span>
|
||||
<span className="flex-1 text-sm">{section.title}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
@@ -620,7 +620,7 @@ export function FieldDrawer({
|
||||
|
||||
<DrawerFooter className="px-4 py-3 border-t flex-row gap-2">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} className="flex-1">취소</Button>
|
||||
<Button onClick={handleAddField} className="flex-1">저장</Button>
|
||||
<Button onClick={async () => await handleAddField()} className="flex-1">저장</Button>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 필드 불러오기 다이얼로그
|
||||
*
|
||||
* @description 2025-11-27: API 변경으로 "항목/독립필드" 탭 통합
|
||||
* - item_master_fields 테이블 삭제됨
|
||||
* - 모든 필드가 item_fields로 통합 (section_id=NULL이 독립 필드)
|
||||
* - 이전: itemMasterFields + independentFields 분리
|
||||
* - 현재: fields 단일 목록으로 통합
|
||||
*/
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { FormInput, Search, Info, Loader2, Hash, Calendar, CheckSquare, ChevronDown, Type, AlignLeft, Database } from 'lucide-react';
|
||||
import type { ItemField, ItemMasterField } from '@/contexts/ItemMasterContext';
|
||||
import type { FieldUsageResponse } from '@/types/item-master-api';
|
||||
|
||||
interface ImportFieldDialogProps {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
/** 필드 목록 (item_fields WHERE section_id IS NULL) - ItemField 또는 ItemMasterField 모두 허용 */
|
||||
fields: (ItemField | ItemMasterField)[];
|
||||
/** @deprecated 2025-11-27: fields로 통합됨. 하위 호환성을 위해 유지 */
|
||||
independentFields?: ItemField[];
|
||||
/** @deprecated 2025-11-27: fields로 통합됨. 하위 호환성을 위해 유지 */
|
||||
itemMasterFields?: ItemMasterField[];
|
||||
selectedFieldId: number | null;
|
||||
setSelectedFieldId: (id: number | null) => void;
|
||||
onImport: () => void;
|
||||
onRefresh: () => Promise<void>;
|
||||
onGetUsage?: (fieldId: number) => Promise<FieldUsageResponse>;
|
||||
isLoading?: boolean;
|
||||
targetSectionTitle?: string;
|
||||
}
|
||||
|
||||
// Field type icon mapping
|
||||
const FIELD_TYPE_ICONS: Record<string, React.ReactNode> = {
|
||||
textbox: <Type className="w-4 h-4" />,
|
||||
number: <Hash className="w-4 h-4" />,
|
||||
dropdown: <ChevronDown className="w-4 h-4" />,
|
||||
checkbox: <CheckSquare className="w-4 h-4" />,
|
||||
date: <Calendar className="w-4 h-4" />,
|
||||
textarea: <AlignLeft className="w-4 h-4" />,
|
||||
};
|
||||
|
||||
const FIELD_TYPE_LABELS: Record<string, string> = {
|
||||
textbox: '텍스트박스',
|
||||
number: '숫자',
|
||||
dropdown: '드롭다운',
|
||||
checkbox: '체크박스',
|
||||
date: '날짜',
|
||||
textarea: '텍스트영역',
|
||||
};
|
||||
|
||||
export function ImportFieldDialog({
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
fields = [],
|
||||
independentFields,
|
||||
itemMasterFields,
|
||||
selectedFieldId,
|
||||
setSelectedFieldId,
|
||||
onImport,
|
||||
onRefresh,
|
||||
onGetUsage,
|
||||
isLoading = false,
|
||||
targetSectionTitle,
|
||||
}: ImportFieldDialogProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [usageInfo, setUsageInfo] = useState<FieldUsageResponse | null>(null);
|
||||
const [isLoadingUsage, setIsLoadingUsage] = useState(false);
|
||||
|
||||
// 2025-11-27: 하위 호환성 - fields가 없으면 independentFields나 itemMasterFields 사용
|
||||
const allFields = fields.length > 0
|
||||
? fields
|
||||
: (independentFields || itemMasterFields || []);
|
||||
|
||||
// Filter fields by search query
|
||||
// Note: ItemField와 ItemMasterField 타입의 속성 차이를 처리하기 위해 타입 단언 사용
|
||||
const filteredFields = allFields.filter(field => {
|
||||
const f = field as ItemField & ItemMasterField; // 두 타입의 모든 속성 접근 허용
|
||||
return (
|
||||
f.field_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
f.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
f.category?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
f.placeholder?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
});
|
||||
|
||||
// Load fields when dialog opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
onRefresh();
|
||||
setSearchQuery('');
|
||||
setUsageInfo(null);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Load usage info when field is selected
|
||||
useEffect(() => {
|
||||
const loadUsage = async () => {
|
||||
if (selectedFieldId && onGetUsage) {
|
||||
setIsLoadingUsage(true);
|
||||
try {
|
||||
const usage = await onGetUsage(selectedFieldId);
|
||||
setUsageInfo(usage);
|
||||
} catch (error) {
|
||||
console.error('Failed to load usage info:', error);
|
||||
setUsageInfo(null);
|
||||
} finally {
|
||||
setIsLoadingUsage(false);
|
||||
}
|
||||
} else {
|
||||
setUsageInfo(null);
|
||||
}
|
||||
};
|
||||
loadUsage();
|
||||
}, [selectedFieldId, onGetUsage]);
|
||||
|
||||
const handleClose = () => {
|
||||
setIsOpen(false);
|
||||
setSelectedFieldId(null);
|
||||
setSearchQuery('');
|
||||
setUsageInfo(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => {
|
||||
if (!open) handleClose();
|
||||
else setIsOpen(open);
|
||||
}}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>필드 불러오기</DialogTitle>
|
||||
<DialogDescription>
|
||||
필드를 선택하여 {targetSectionTitle ? `"${targetSectionTitle}" 섹션` : '현재 섹션'}에 연결합니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="필드 검색..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-muted-foreground">필드 목록을 불러오는 중...</span>
|
||||
</div>
|
||||
) : filteredFields.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<FormInput className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground">
|
||||
{searchQuery ? '검색 결과가 없습니다' : '등록된 필드가 없습니다'}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
항목 탭에서 새 필드를 생성해주세요
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-80 overflow-y-auto">
|
||||
{/* 통합 필드 목록 - ItemField와 ItemMasterField 타입 호환을 위해 intersection 타입 사용 */}
|
||||
{filteredFields.map((field) => {
|
||||
// 두 타입의 모든 속성에 안전하게 접근하기 위한 타입 단언
|
||||
const f = field as ItemField & ItemMasterField;
|
||||
return (
|
||||
<div
|
||||
key={f.id}
|
||||
onClick={() => setSelectedFieldId(f.id)}
|
||||
className={`p-4 border rounded-lg cursor-pointer transition-colors ${
|
||||
selectedFieldId === f.id
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-950'
|
||||
: 'hover:bg-gray-50 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 rounded-lg bg-blue-100 dark:bg-blue-900">
|
||||
{FIELD_TYPE_ICONS[f.field_type] || <Database className="w-5 h-5 text-blue-600 dark:text-blue-400" />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<h4 className="font-medium">{f.field_name}</h4>
|
||||
<Badge variant="secondary">
|
||||
{FIELD_TYPE_LABELS[f.field_type] || f.field_type}
|
||||
</Badge>
|
||||
{f.category && (
|
||||
<Badge variant="outline" className="text-xs">{f.category}</Badge>
|
||||
)}
|
||||
{(f.is_required || f.properties?.required) && (
|
||||
<Badge variant="destructive" className="text-xs">필수</Badge>
|
||||
)}
|
||||
</div>
|
||||
{(f.description || f.placeholder) && (
|
||||
<p className="text-sm text-muted-foreground mb-2 truncate">
|
||||
{f.description || f.placeholder}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
{f.default_value && (
|
||||
<span>기본값: {f.default_value}</span>
|
||||
)}
|
||||
{f.options && f.options.length > 0 && (
|
||||
<span>{f.options.length}개 옵션</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Usage Info Panel */}
|
||||
{selectedFieldId && (
|
||||
<div className="mt-4 p-4 bg-gray-50 dark:bg-gray-900 rounded-lg border">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Info className="h-4 w-4 text-blue-500" />
|
||||
<Label className="font-medium">사용처 정보</Label>
|
||||
</div>
|
||||
{isLoadingUsage ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span>사용처를 확인하는 중...</span>
|
||||
</div>
|
||||
) : usageInfo ? (
|
||||
<div className="text-sm space-y-1">
|
||||
<p>총 {usageInfo.total_usage_count}개 섹션에서 사용 중</p>
|
||||
{usageInfo.linked_sections.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{usageInfo.linked_sections.slice(0, 5).map(section => (
|
||||
<Badge key={section.id} variant="outline" className="text-xs">
|
||||
{section.title}
|
||||
</Badge>
|
||||
))}
|
||||
{usageInfo.linked_sections.length > 5 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
+{usageInfo.linked_sections.length - 5}개 더
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
이 필드를 선택하면 섹션에 연결됩니다.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleClose}>취소</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
onImport();
|
||||
handleClose();
|
||||
}}
|
||||
disabled={!selectedFieldId || isLoading}
|
||||
>
|
||||
불러오기
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Package, Folder, Search, Info, Loader2 } from 'lucide-react';
|
||||
import type { ItemSection } from '@/contexts/ItemMasterContext';
|
||||
import type { SectionUsageResponse } from '@/types/item-master-api';
|
||||
|
||||
interface ImportSectionDialogProps {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
independentSections: ItemSection[];
|
||||
selectedSectionId: number | null;
|
||||
setSelectedSectionId: (id: number | null) => void;
|
||||
onImport: () => void;
|
||||
onRefresh: () => Promise<void>;
|
||||
onGetUsage?: (sectionId: number) => Promise<SectionUsageResponse>;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function ImportSectionDialog({
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
independentSections,
|
||||
selectedSectionId,
|
||||
setSelectedSectionId,
|
||||
onImport,
|
||||
onRefresh,
|
||||
onGetUsage,
|
||||
isLoading = false,
|
||||
}: ImportSectionDialogProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [usageInfo, setUsageInfo] = useState<SectionUsageResponse | null>(null);
|
||||
const [isLoadingUsage, setIsLoadingUsage] = useState(false);
|
||||
|
||||
// Filter sections by search query
|
||||
const filteredSections = independentSections.filter(section =>
|
||||
section.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
section.description?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
// Load sections when dialog opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
onRefresh();
|
||||
setSearchQuery('');
|
||||
setUsageInfo(null);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Load usage info when section is selected
|
||||
useEffect(() => {
|
||||
const loadUsage = async () => {
|
||||
if (selectedSectionId && onGetUsage) {
|
||||
setIsLoadingUsage(true);
|
||||
try {
|
||||
const usage = await onGetUsage(selectedSectionId);
|
||||
setUsageInfo(usage);
|
||||
} catch (error) {
|
||||
console.error('Failed to load usage info:', error);
|
||||
setUsageInfo(null);
|
||||
} finally {
|
||||
setIsLoadingUsage(false);
|
||||
}
|
||||
} else {
|
||||
setUsageInfo(null);
|
||||
}
|
||||
};
|
||||
loadUsage();
|
||||
}, [selectedSectionId, onGetUsage]);
|
||||
|
||||
const handleClose = () => {
|
||||
setIsOpen(false);
|
||||
setSelectedSectionId(null);
|
||||
setSearchQuery('');
|
||||
setUsageInfo(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => {
|
||||
if (!open) handleClose();
|
||||
else setIsOpen(open);
|
||||
}}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>섹션 불러오기</DialogTitle>
|
||||
<DialogDescription>
|
||||
독립 섹션을 선택하여 현재 페이지에 연결합니다. 섹션 데이터는 공유됩니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="섹션 검색..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-muted-foreground">섹션 목록을 불러오는 중...</span>
|
||||
</div>
|
||||
) : filteredSections.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Folder className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground">
|
||||
{searchQuery ? '검색 결과가 없습니다' : '사용 가능한 독립 섹션이 없습니다'}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
섹션 탭에서 새 섹션을 생성해주세요
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{filteredSections.map((section) => (
|
||||
<div
|
||||
key={section.id}
|
||||
onClick={() => setSelectedSectionId(section.id)}
|
||||
className={`p-4 border rounded-lg cursor-pointer transition-colors ${
|
||||
selectedSectionId === section.id
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-950'
|
||||
: 'hover:bg-gray-50 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 rounded-lg bg-blue-100 dark:bg-blue-900">
|
||||
{section.section_type === 'BOM' ? (
|
||||
<Package className="w-5 h-5 text-blue-600 dark:text-blue-400" />
|
||||
) : (
|
||||
<Folder className="w-5 h-5 text-blue-600 dark:text-blue-400" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<h4 className="font-medium">{section.title}</h4>
|
||||
<Badge variant={section.section_type === 'BOM' ? 'default' : 'secondary'}>
|
||||
{section.section_type}
|
||||
</Badge>
|
||||
{section.is_template && (
|
||||
<Badge variant="outline" className="text-xs">템플릿</Badge>
|
||||
)}
|
||||
</div>
|
||||
{section.description && (
|
||||
<p className="text-sm text-muted-foreground mb-2 line-clamp-2">{section.description}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span>{section.fields?.length || 0}개 필드</span>
|
||||
{section.section_type === 'BOM' && (
|
||||
<span>{section.bom_items?.length || 0}개 BOM 항목</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Usage Info Panel */}
|
||||
{selectedSectionId && (
|
||||
<div className="mt-4 p-4 bg-gray-50 dark:bg-gray-900 rounded-lg border">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Info className="h-4 w-4 text-blue-500" />
|
||||
<Label className="font-medium">사용처 정보</Label>
|
||||
</div>
|
||||
{isLoadingUsage ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span>사용처를 확인하는 중...</span>
|
||||
</div>
|
||||
) : usageInfo ? (
|
||||
<div className="text-sm space-y-1">
|
||||
<p>총 {usageInfo.total_usage_count}개 페이지에서 사용 중</p>
|
||||
{usageInfo.linked_pages.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{usageInfo.linked_pages.slice(0, 5).map(page => (
|
||||
<Badge key={page.id} variant="outline" className="text-xs">
|
||||
{page.page_name}
|
||||
</Badge>
|
||||
))}
|
||||
{usageInfo.linked_pages.length > 5 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
+{usageInfo.linked_pages.length - 5}개 더
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">사용처 정보를 불러올 수 없습니다</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleClose}>취소</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
onImport();
|
||||
handleClose();
|
||||
}}
|
||||
disabled={!selectedSectionId || isLoading}
|
||||
>
|
||||
불러오기
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -122,9 +122,9 @@ export function MasterFieldDialog({
|
||||
}}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingMasterFieldId ? '마스터 항목 수정' : '마스터 항목 추가'}</DialogTitle>
|
||||
<DialogTitle>{editingMasterFieldId ? '항목 수정' : '항목 추가'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
여러 섹션에서 재사용할 수 있는 항목 템플릿을 생성합니다
|
||||
여러 섹션에서 재사용할 수 있는 항목을 생성합니다
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { FileText, Package, Check } from 'lucide-react';
|
||||
import type { SectionTemplate } from '@/contexts/ItemMasterContext';
|
||||
|
||||
@@ -91,7 +90,66 @@ export function SectionDialog({
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
|
||||
{/* 입력 모드 선택 */}
|
||||
{/* 1. 섹션 유형 선택 (항상 표시) */}
|
||||
<div>
|
||||
<Label className="mb-3 block">섹션 유형 *</Label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* 일반 섹션 */}
|
||||
<div
|
||||
onClick={() => {
|
||||
setNewSectionType('fields');
|
||||
// 타입 변경 시 선택된 템플릿 초기화
|
||||
setSelectedTemplateId(null);
|
||||
setNewSectionTitle('');
|
||||
setNewSectionDescription('');
|
||||
}}
|
||||
className={`flex items-center gap-3 p-4 border rounded-lg cursor-pointer transition-all ${
|
||||
newSectionType === 'fields'
|
||||
? 'border-blue-500 bg-blue-50 ring-2 ring-blue-500'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<FileText className={`h-5 w-5 ${newSectionType === 'fields' ? 'text-blue-600' : 'text-gray-400'}`} />
|
||||
<div className="flex-1">
|
||||
<div className={`font-medium ${newSectionType === 'fields' ? 'text-blue-900' : 'text-gray-900'}`}>
|
||||
일반 섹션
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">필드 항목 관리</div>
|
||||
</div>
|
||||
{newSectionType === 'fields' && (
|
||||
<Check className="h-5 w-5 text-blue-600" />
|
||||
)}
|
||||
</div>
|
||||
{/* BOM 섹션 */}
|
||||
<div
|
||||
onClick={() => {
|
||||
setNewSectionType('bom');
|
||||
// 타입 변경 시 선택된 템플릿 초기화
|
||||
setSelectedTemplateId(null);
|
||||
setNewSectionTitle('');
|
||||
setNewSectionDescription('');
|
||||
}}
|
||||
className={`flex items-center gap-3 p-4 border rounded-lg cursor-pointer transition-all ${
|
||||
newSectionType === 'bom'
|
||||
? 'border-blue-500 bg-blue-50 ring-2 ring-blue-500'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<Package className={`h-5 w-5 ${newSectionType === 'bom' ? 'text-blue-600' : 'text-gray-400'}`} />
|
||||
<div className="flex-1">
|
||||
<div className={`font-medium ${newSectionType === 'bom' ? 'text-blue-900' : 'text-gray-900'}`}>
|
||||
모듈 섹션 (BOM)
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">자재명세서 관리</div>
|
||||
</div>
|
||||
{newSectionType === 'bom' && (
|
||||
<Check className="h-5 w-5 text-blue-600" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. 입력 모드 선택 */}
|
||||
<div className="flex gap-2 p-1 bg-gray-100 rounded">
|
||||
<Button
|
||||
variant={sectionInputMode === 'custom' ? 'default' : 'ghost'}
|
||||
@@ -99,6 +157,8 @@ export function SectionDialog({
|
||||
onClick={() => {
|
||||
setSectionInputMode('custom');
|
||||
setSelectedTemplateId(null);
|
||||
setNewSectionTitle('');
|
||||
setNewSectionDescription('');
|
||||
}}
|
||||
className="flex-1"
|
||||
>
|
||||
@@ -107,137 +167,98 @@ export function SectionDialog({
|
||||
<Button
|
||||
variant={sectionInputMode === 'template' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setSectionInputMode('template')}
|
||||
onClick={() => {
|
||||
setSectionInputMode('template');
|
||||
setSelectedTemplateId(null);
|
||||
setNewSectionTitle('');
|
||||
setNewSectionDescription('');
|
||||
}}
|
||||
className="flex-1"
|
||||
>
|
||||
템플릿 선택
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 템플릿 목록 */}
|
||||
{/* 3. 템플릿 목록 - 선택된 섹션 타입에 따라 필터링 */}
|
||||
{sectionInputMode === 'template' && (
|
||||
<div className="border rounded p-3 space-y-2 max-h-[300px] overflow-y-auto">
|
||||
<div className="border rounded p-3 space-y-2 max-h-[250px] overflow-y-auto">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Label>섹션 템플릿 목록</Label>
|
||||
<Label className="text-sm font-medium">
|
||||
{newSectionType === 'bom' ? '모듈(BOM)' : '일반'} 섹션 템플릿 목록
|
||||
</Label>
|
||||
</div>
|
||||
{sectionTemplates.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
등록된 섹션 템플릿이 없습니다.<br/>
|
||||
섹션 탭에서 템플릿을 먼저 등록해주세요.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{sectionTemplates.map(template => (
|
||||
<div
|
||||
key={template.id}
|
||||
className={`p-3 border rounded cursor-pointer transition-colors ${
|
||||
selectedTemplateId === template.id
|
||||
? 'bg-blue-50 border-blue-300'
|
||||
: 'hover:bg-gray-50'
|
||||
}`}
|
||||
onClick={() => handleSelectTemplate(template)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{template.section_type === 'BOM' ? (
|
||||
<Package className="h-4 w-4 text-orange-500" />
|
||||
) : (
|
||||
<FileText className="h-4 w-4 text-blue-500" />
|
||||
{(() => {
|
||||
// 선택된 타입에 맞는 템플릿만 필터링
|
||||
const filteredTemplates = sectionTemplates.filter(template =>
|
||||
newSectionType === 'bom'
|
||||
? template.section_type === 'BOM'
|
||||
: template.section_type !== 'BOM'
|
||||
);
|
||||
|
||||
if (filteredTemplates.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
등록된 {newSectionType === 'bom' ? '모듈(BOM)' : '일반'} 섹션 템플릿이 없습니다.<br/>
|
||||
섹션 탭에서 템플릿을 먼저 등록해주세요.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{filteredTemplates.map(template => (
|
||||
<div
|
||||
key={template.id}
|
||||
className={`p-3 border rounded cursor-pointer transition-colors ${
|
||||
selectedTemplateId === template.id
|
||||
? 'bg-blue-50 border-blue-300'
|
||||
: 'hover:bg-gray-50'
|
||||
}`}
|
||||
onClick={() => handleSelectTemplate(template)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{template.section_type === 'BOM' ? (
|
||||
<Package className="h-4 w-4 text-orange-500" />
|
||||
) : (
|
||||
<FileText className="h-4 w-4 text-blue-500" />
|
||||
)}
|
||||
<span className="font-medium">{template.template_name}</span>
|
||||
</div>
|
||||
{template.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1">{template.description}</p>
|
||||
)}
|
||||
{template.fields && template.fields.length > 0 && (
|
||||
<p className="text-xs text-blue-600 mt-1">
|
||||
{template.fields.length}개 항목 포함
|
||||
</p>
|
||||
)}
|
||||
<span className="font-medium">{template.template_name}</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{template.section_type === 'BOM' ? '모듈(BOM)' : '일반'}
|
||||
</Badge>
|
||||
</div>
|
||||
{template.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1">{template.description}</p>
|
||||
)}
|
||||
{template.fields && template.fields.length > 0 && (
|
||||
<p className="text-xs text-blue-600 mt-1">
|
||||
{template.fields.length}개 항목 포함
|
||||
</p>
|
||||
{selectedTemplateId === template.id && (
|
||||
<Check className="h-5 w-5 text-blue-600" />
|
||||
)}
|
||||
</div>
|
||||
{selectedTemplateId === template.id && (
|
||||
<Check className="h-5 w-5 text-blue-600" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 직접 입력 폼 또는 선택된 템플릿 정보 표시 */}
|
||||
{(sectionInputMode === 'custom' || selectedTemplateId) && (
|
||||
{/* 4. 직접 입력 폼 */}
|
||||
{sectionInputMode === 'custom' && (
|
||||
<>
|
||||
{/* 섹션 유형 선택 - 템플릿 선택 시 비활성화 */}
|
||||
<div>
|
||||
<Label className="mb-3 block">섹션 유형 *</Label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* 일반 섹션 */}
|
||||
<div
|
||||
onClick={() => {
|
||||
if (sectionInputMode === 'custom') setNewSectionType('fields');
|
||||
}}
|
||||
className={`flex items-center gap-3 p-4 border rounded-lg transition-all ${
|
||||
sectionInputMode === 'template' ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'
|
||||
} ${
|
||||
newSectionType === 'fields'
|
||||
? 'border-blue-500 bg-blue-50 ring-2 ring-blue-500'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<FileText className={`h-5 w-5 ${newSectionType === 'fields' ? 'text-blue-600' : 'text-gray-400'}`} />
|
||||
<div className="flex-1">
|
||||
<div className={`font-medium ${newSectionType === 'fields' ? 'text-blue-900' : 'text-gray-900'}`}>
|
||||
일반 섹션
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">필드 항목 관리</div>
|
||||
</div>
|
||||
{newSectionType === 'fields' && (
|
||||
<Check className="h-5 w-5 text-blue-600" />
|
||||
)}
|
||||
</div>
|
||||
{/* BOM 섹션 */}
|
||||
<div
|
||||
onClick={() => {
|
||||
if (sectionInputMode === 'custom') setNewSectionType('bom');
|
||||
}}
|
||||
className={`flex items-center gap-3 p-4 border rounded-lg transition-all ${
|
||||
sectionInputMode === 'template' ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'
|
||||
} ${
|
||||
newSectionType === 'bom'
|
||||
? 'border-blue-500 bg-blue-50 ring-2 ring-blue-500'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<Package className={`h-5 w-5 ${newSectionType === 'bom' ? 'text-blue-600' : 'text-gray-400'}`} />
|
||||
<div className="flex-1">
|
||||
<div className={`font-medium ${newSectionType === 'bom' ? 'text-blue-900' : 'text-gray-900'}`}>
|
||||
모듈 섹션 (BOM)
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">자재명세서 관리</div>
|
||||
</div>
|
||||
{newSectionType === 'bom' && (
|
||||
<Check className="h-5 w-5 text-blue-600" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>섹션 제목 *</Label>
|
||||
<Input
|
||||
value={newSectionTitle}
|
||||
onChange={(e) => setNewSectionTitle(e.target.value)}
|
||||
placeholder={newSectionType === 'bom' ? '예: BOM 구성' : '예: 기본 정보'}
|
||||
disabled={sectionInputMode === 'template'}
|
||||
className={isSubmitted && isTitleEmpty && sectionInputMode === 'custom' ? 'border-red-500 focus-visible:ring-red-500' : ''}
|
||||
className={isSubmitted && isTitleEmpty ? 'border-red-500 focus-visible:ring-red-500' : ''}
|
||||
/>
|
||||
{isSubmitted && isTitleEmpty && sectionInputMode === 'custom' && (
|
||||
{isSubmitted && isTitleEmpty && (
|
||||
<p className="text-xs text-red-500 mt-1">섹션 제목을 입력해주세요</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -247,20 +268,10 @@ export function SectionDialog({
|
||||
value={newSectionDescription}
|
||||
onChange={(e) => setNewSectionDescription(e.target.value)}
|
||||
placeholder="섹션에 대한 설명"
|
||||
disabled={sectionInputMode === 'template'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{sectionInputMode === 'template' && selectedTemplateId && (
|
||||
<div className="bg-green-50 p-3 rounded-md border border-green-200">
|
||||
<p className="text-sm text-green-700">
|
||||
<strong>템플릿 연결:</strong> 선택한 템플릿을 페이지에 연결합니다.
|
||||
템플릿에 포함된 항목들도 함께 추가됩니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{newSectionType === 'bom' && sectionInputMode === 'custom' && (
|
||||
{newSectionType === 'bom' && (
|
||||
<div className="bg-blue-50 p-3 rounded-md">
|
||||
<p className="text-sm text-blue-700">
|
||||
<strong>BOM 섹션:</strong> 자재명세서(BOM) 관리를 위한 전용 섹션입니다.
|
||||
@@ -270,6 +281,16 @@ export function SectionDialog({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 5. 선택된 템플릿 정보 표시 */}
|
||||
{sectionInputMode === 'template' && selectedTemplateId && (
|
||||
<div className="bg-green-50 p-3 rounded-md border border-green-200">
|
||||
<p className="text-sm text-green-700">
|
||||
<strong>선택된 템플릿:</strong> "{newSectionTitle}"을(를) 페이지에 연결합니다.
|
||||
템플릿에 포함된 항목들도 함께 추가됩니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="shrink-0 bg-white z-10 px-6 py-4 border-t flex-col sm:flex-row gap-2">
|
||||
|
||||
@@ -44,8 +44,8 @@ interface TemplateFieldDialogProps {
|
||||
setTemplateFieldColumnCount: (count: number) => void;
|
||||
templateFieldColumnNames: string[];
|
||||
setTemplateFieldColumnNames: (names: string[]) => void;
|
||||
handleAddTemplateField: () => void;
|
||||
// 마스터 항목 관련 props
|
||||
handleAddTemplateField: () => void | Promise<void>;
|
||||
// 항목 관련 props
|
||||
itemMasterFields?: ItemMasterField[];
|
||||
templateFieldInputMode?: 'custom' | 'master';
|
||||
setTemplateFieldInputMode?: (mode: 'custom' | 'master') => void;
|
||||
@@ -79,7 +79,7 @@ export function TemplateFieldDialog({
|
||||
templateFieldColumnNames,
|
||||
setTemplateFieldColumnNames,
|
||||
handleAddTemplateField,
|
||||
// 마스터 항목 관련 props (optional)
|
||||
// 항목 관련 props (optional)
|
||||
itemMasterFields = [],
|
||||
templateFieldInputMode = 'custom',
|
||||
setTemplateFieldInputMode,
|
||||
@@ -107,7 +107,7 @@ export function TemplateFieldDialog({
|
||||
setTemplateFieldMultiColumn(false);
|
||||
setTemplateFieldColumnCount(2);
|
||||
setTemplateFieldColumnNames(['컬럼1', '컬럼2']);
|
||||
// 마스터 항목 관련 상태 초기화
|
||||
// 항목 관련 상태 초기화
|
||||
setTemplateFieldInputMode?.('custom');
|
||||
setShowMasterFieldList?.(false);
|
||||
setSelectedMasterFieldId?.('');
|
||||
@@ -137,7 +137,7 @@ export function TemplateFieldDialog({
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingTemplateFieldId ? '항목 수정' : '항목 추가'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
재사용 가능한 마스터 항목을 선택하거나 직접 입력하세요
|
||||
재사용 가능한 항목을 선택하거나 직접 입력하세요
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
@@ -161,16 +161,16 @@ export function TemplateFieldDialog({
|
||||
}}
|
||||
className="flex-1"
|
||||
>
|
||||
마스터 항목 선택
|
||||
항목 선택
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 마스터 항목 목록 */}
|
||||
{/* 항목 목록 */}
|
||||
{templateFieldInputMode === 'master' && !editingTemplateFieldId && showMasterFieldList && (
|
||||
<div className="border rounded p-3 space-y-2 max-h-[400px] overflow-y-auto">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Label>마스터 항목 목록</Label>
|
||||
<Label>항목 목록</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -181,7 +181,7 @@ export function TemplateFieldDialog({
|
||||
</div>
|
||||
{itemMasterFields.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
등록된 마스터 항목이 없습니다
|
||||
등록된 항목이 없습니다
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -73,10 +73,36 @@ export function useAttributeManagement(): UseAttributeManagementReturn {
|
||||
updateItemMasterField
|
||||
} = useItemMaster();
|
||||
|
||||
// 속성 옵션 상태
|
||||
const [unitOptions, setUnitOptions] = useState<MasterOption[]>([]);
|
||||
const [materialOptions, setMaterialOptions] = useState<MasterOption[]>([]);
|
||||
const [surfaceTreatmentOptions, setSurfaceTreatmentOptions] = useState<MasterOption[]>([]);
|
||||
// 속성 옵션 상태 (기본값 하드코딩 - TODO: 나중에 백엔드 API로 대체)
|
||||
const [unitOptions, setUnitOptions] = useState<MasterOption[]>([
|
||||
{ id: 'unit-1', value: 'EA', label: 'EA (개)', isActive: true },
|
||||
{ id: 'unit-2', value: 'KG', label: 'KG (킬로그램)', isActive: true },
|
||||
{ id: 'unit-3', value: 'M', label: 'M (미터)', isActive: true },
|
||||
{ id: 'unit-4', value: 'MM', label: 'MM (밀리미터)', isActive: true },
|
||||
{ id: 'unit-5', value: 'L', label: 'L (리터)', isActive: true },
|
||||
{ id: 'unit-6', value: 'SET', label: 'SET (세트)', isActive: true },
|
||||
{ id: 'unit-7', value: 'BOX', label: 'BOX (박스)', isActive: true },
|
||||
{ id: 'unit-8', value: 'ROLL', label: 'ROLL (롤)', isActive: true },
|
||||
]);
|
||||
const [materialOptions, setMaterialOptions] = useState<MasterOption[]>([
|
||||
{ id: 'mat-1', value: 'SUS304', label: 'SUS304 (스테인리스)', isActive: true },
|
||||
{ id: 'mat-2', value: 'SUS316', label: 'SUS316 (스테인리스)', isActive: true },
|
||||
{ id: 'mat-3', value: 'AL6061', label: 'AL6061 (알루미늄)', isActive: true },
|
||||
{ id: 'mat-4', value: 'AL5052', label: 'AL5052 (알루미늄)', isActive: true },
|
||||
{ id: 'mat-5', value: 'SS400', label: 'SS400 (일반강)', isActive: true },
|
||||
{ id: 'mat-6', value: 'S45C', label: 'S45C (탄소강)', isActive: true },
|
||||
{ id: 'mat-7', value: 'POM', label: 'POM (폴리아세탈)', isActive: true },
|
||||
{ id: 'mat-8', value: 'PEEK', label: 'PEEK (폴리에테르에테르케톤)', isActive: true },
|
||||
]);
|
||||
const [surfaceTreatmentOptions, setSurfaceTreatmentOptions] = useState<MasterOption[]>([
|
||||
{ id: 'surf-1', value: 'NONE', label: '없음', isActive: true },
|
||||
{ id: 'surf-2', value: 'ANODIZE', label: '아노다이징', isActive: true },
|
||||
{ id: 'surf-3', value: 'PLATING', label: '도금', isActive: true },
|
||||
{ id: 'surf-4', value: 'PAINTING', label: '도장', isActive: true },
|
||||
{ id: 'surf-5', value: 'PASSIVATION', label: '부동태처리', isActive: true },
|
||||
{ id: 'surf-6', value: 'SANDBLAST', label: '샌드블라스트', isActive: true },
|
||||
{ id: 'surf-7', value: 'POLISHING', label: '폴리싱', isActive: true },
|
||||
]);
|
||||
const [customAttributeOptions, setCustomAttributeOptions] = useState<Record<string, MasterOption[]>>({});
|
||||
|
||||
// 옵션 다이얼로그 상태
|
||||
|
||||
@@ -62,7 +62,7 @@ export interface UseFieldManagementReturn {
|
||||
setTempConditionValue: (value: string) => void;
|
||||
|
||||
// 핸들러
|
||||
handleAddField: (selectedPage: ItemPage | undefined) => void;
|
||||
handleAddField: (selectedPage: ItemPage | undefined) => Promise<void>;
|
||||
handleEditField: (sectionId: string, field: ItemField) => void;
|
||||
handleDeleteField: (pageId: string, sectionId: string, fieldId: string) => void;
|
||||
resetFieldForm: () => void;
|
||||
@@ -74,7 +74,7 @@ export function useFieldManagement(): UseFieldManagementReturn {
|
||||
addFieldToSection,
|
||||
updateField,
|
||||
deleteField,
|
||||
addItemMasterField,
|
||||
addItemMasterField: _addItemMasterField, // 2025-11-27: 중복 필드 생성 방지로 사용 안함
|
||||
updateItemMasterField,
|
||||
} = useItemMaster();
|
||||
|
||||
@@ -135,8 +135,8 @@ export function useFieldManagement(): UseFieldManagementReturn {
|
||||
}
|
||||
}, [fieldInputMode, selectedMasterFieldId, itemMasterFields]);
|
||||
|
||||
// 필드 추가
|
||||
const handleAddField = (selectedPage: ItemPage | undefined) => {
|
||||
// 필드 추가 (2025-11-27: async/await 추가 - 다른 탭 실시간 동기화)
|
||||
const handleAddField = async (selectedPage: ItemPage | undefined) => {
|
||||
if (!selectedPage || !selectedSectionForField || !newFieldName.trim() || !newFieldKey.trim()) {
|
||||
toast.error('모든 필수 항목을 입력해주세요');
|
||||
return;
|
||||
@@ -187,59 +187,41 @@ export function useFieldManagement(): UseFieldManagementReturn {
|
||||
updated_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
if (editingFieldId) {
|
||||
console.log('Updating field:', { pageId: selectedPage.id, sectionId: selectedSectionForField, fieldId: editingFieldId, fieldName: newField.field_name });
|
||||
updateField(Number(editingFieldId), newField);
|
||||
try {
|
||||
if (editingFieldId) {
|
||||
console.log('Updating field:', { pageId: selectedPage.id, sectionId: selectedSectionForField, fieldId: editingFieldId, fieldName: newField.field_name });
|
||||
await updateField(Number(editingFieldId), newField);
|
||||
|
||||
// 항목관리 탭의 마스터 항목도 업데이트 (동일한 fieldKey가 있으면)
|
||||
const existingMasterField = itemMasterFields.find(mf => mf.id.toString() === newField.field_name);
|
||||
if (existingMasterField) {
|
||||
const updatedMasterField: Partial<ItemMasterField> = {
|
||||
field_name: newField.field_name,
|
||||
description: newField.placeholder ?? null,
|
||||
properties: newField.properties,
|
||||
updated_at: new Date().toISOString()
|
||||
};
|
||||
updateItemMasterField(existingMasterField.id, updatedMasterField);
|
||||
}
|
||||
// 항목관리 탭의 마스터 항목도 업데이트 (동일한 fieldKey가 있으면)
|
||||
const existingMasterField = itemMasterFields.find(mf => mf.id.toString() === newField.field_name);
|
||||
if (existingMasterField) {
|
||||
const updatedMasterField: Partial<ItemMasterField> = {
|
||||
field_name: newField.field_name,
|
||||
description: newField.placeholder ?? null,
|
||||
properties: newField.properties,
|
||||
updated_at: new Date().toISOString()
|
||||
};
|
||||
await updateItemMasterField(existingMasterField.id, updatedMasterField);
|
||||
}
|
||||
|
||||
toast.success('항목이 섹션에 수정되었습니다!');
|
||||
} else {
|
||||
console.log('Adding field to section:', { pageId: selectedPage.id, sectionId: selectedSectionForField, fieldName: newField.field_name });
|
||||
|
||||
// 1. 섹션에 항목 추가
|
||||
addFieldToSection(Number(selectedSectionForField), newField);
|
||||
|
||||
// 2. 마스터 항목 선택이 아닌 경우에만 새 마스터 항목 자동 생성
|
||||
const isFromMasterField = masterFieldId !== null;
|
||||
const existingMasterField = itemMasterFields.find(mf => mf.id.toString() === newField.field_name);
|
||||
if (!isFromMasterField && !existingMasterField) {
|
||||
// ItemMasterField 타입에 맞게 필수 필드 포함
|
||||
const newMasterFieldData: Omit<ItemMasterField, 'id' | 'tenant_id' | 'created_by' | 'updated_by' | 'created_at' | 'updated_at'> = {
|
||||
field_name: newField.field_name,
|
||||
field_type: newField.field_type,
|
||||
description: newField.placeholder ?? null,
|
||||
category: selectedPage.item_type,
|
||||
is_common: false,
|
||||
default_value: null,
|
||||
options: newField.options ?? null,
|
||||
validation_rules: null,
|
||||
properties: newField.properties ?? null,
|
||||
};
|
||||
addItemMasterField(newMasterFieldData as any);
|
||||
|
||||
console.log('Field added to both section and master fields:', {
|
||||
fieldId: newField.id,
|
||||
fieldName: newMasterFieldData.field_name
|
||||
});
|
||||
|
||||
toast.success('항목이 섹션에 추가되고 마스터 항목으로도 등록되었습니다!');
|
||||
toast.success('항목이 섹션에 수정되었습니다!');
|
||||
} else {
|
||||
console.log('Adding field to section:', { pageId: selectedPage.id, sectionId: selectedSectionForField, fieldName: newField.field_name });
|
||||
|
||||
// 섹션에 항목 추가 (await로 완료 대기)
|
||||
// 2025-11-27: addItemMasterField 호출 제거 - 중복 필드 생성 방지
|
||||
// 계층구조에서 만든 필드는 섹션에만 연결됨 (section_id = X)
|
||||
// 항목탭에는 독립 필드(section_id = null)만 표시
|
||||
await addFieldToSection(Number(selectedSectionForField), newField);
|
||||
|
||||
toast.success('항목이 섹션에 추가되었습니다!');
|
||||
}
|
||||
}
|
||||
|
||||
resetFieldForm();
|
||||
resetFieldForm();
|
||||
} catch (error) {
|
||||
console.error('필드 처리 실패:', error);
|
||||
toast.error('항목 처리에 실패했습니다');
|
||||
}
|
||||
};
|
||||
|
||||
// 필드 수정
|
||||
@@ -249,9 +231,10 @@ export function useFieldManagement(): UseFieldManagementReturn {
|
||||
setNewFieldName(field.field_name);
|
||||
setNewFieldKey(field.id.toString());
|
||||
setNewFieldInputType(field.field_type);
|
||||
setNewFieldRequired(field.is_required);
|
||||
// 2025-11-27: is_required와 properties.required 둘 다 체크
|
||||
setNewFieldRequired(field.is_required || field.properties?.required || false);
|
||||
setNewFieldOptions(field.options?.map(opt => opt.value).join(', ') || '');
|
||||
setNewFieldDescription('');
|
||||
setNewFieldDescription(field.placeholder || '');
|
||||
|
||||
// 조건부 표시 설정 로드
|
||||
if (field.display_condition) {
|
||||
|
||||
@@ -5,6 +5,12 @@ import { toast } from 'sonner';
|
||||
import { useItemMaster } from '@/contexts/ItemMasterContext';
|
||||
import type { ItemMasterField } from '@/contexts/ItemMasterContext';
|
||||
|
||||
/**
|
||||
* @deprecated 2025-11-27: item_fields로 통합됨.
|
||||
* - itemMasterFields → item_fields WHERE section_id IS NULL
|
||||
* - 내부적으로 fields.* API를 사용하도록 마이그레이션 완료
|
||||
* - 향후 독립 필드 관리용 훅으로 리네임 예정
|
||||
*/
|
||||
export interface UseMasterFieldManagementReturn {
|
||||
// 다이얼로그 상태
|
||||
isMasterFieldDialogOpen: boolean;
|
||||
@@ -99,7 +105,7 @@ export function useMasterFieldManagement(): UseMasterFieldManagementReturn {
|
||||
|
||||
addItemMasterField(newMasterFieldData as any);
|
||||
resetMasterFieldForm();
|
||||
toast.success('마스터 항목이 추가되었습니다 (저장 필요)');
|
||||
toast.success('항목이 추가되었습니다');
|
||||
};
|
||||
|
||||
// 마스터 항목 수정 시작
|
||||
@@ -145,14 +151,14 @@ export function useMasterFieldManagement(): UseMasterFieldManagementReturn {
|
||||
|
||||
updateItemMasterField(editingMasterFieldId, updateData);
|
||||
resetMasterFieldForm();
|
||||
toast.success('마스터 항목이 수정되었습니다 (저장 필요)');
|
||||
toast.success('항목이 수정되었습니다');
|
||||
};
|
||||
|
||||
// 마스터 항목 삭제
|
||||
// 항목 삭제 (2025-11-27: 마스터 항목 → 항목으로 통합)
|
||||
const handleDeleteMasterField = (id: number) => {
|
||||
if (confirm('이 마스터 항목을 삭제하시겠습니까?')) {
|
||||
if (confirm('이 항목을 삭제하시겠습니까?\n(섹션에서 사용 중인 경우 연결도 함께 해제됩니다)')) {
|
||||
deleteItemMasterField(id);
|
||||
toast.success('마스터 항목이 삭제되었습니다');
|
||||
toast.success('항목이 삭제되었습니다');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -27,11 +27,12 @@ export interface UseSectionManagementReturn {
|
||||
setExpandedSections: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
|
||||
|
||||
// 핸들러
|
||||
handleAddSection: (selectedPage: ItemPage | undefined) => void;
|
||||
handleLinkTemplate: (template: SectionTemplate, selectedPage: ItemPage | undefined) => void;
|
||||
handleAddSection: (selectedPage: ItemPage | undefined) => Promise<void>;
|
||||
handleLinkTemplate: (template: SectionTemplate, selectedPage: ItemPage | undefined) => Promise<void>;
|
||||
handleEditSectionTitle: (sectionId: number, currentTitle: string) => void;
|
||||
handleSaveSectionTitle: (selectedPage: ItemPage | undefined) => void;
|
||||
handleDeleteSection: (pageId: number, sectionId: number) => void;
|
||||
handleUnlinkSection: (pageId: number, sectionId: number) => void; // 계층구조 탭용 - 연결 해제
|
||||
handleDeleteSection: (pageId: number, sectionId: number) => void; // 섹션 탭용 - 실제 삭제
|
||||
toggleSection: (sectionId: string) => void;
|
||||
resetSectionForm: () => void;
|
||||
}
|
||||
@@ -42,8 +43,8 @@ export function useSectionManagement(): UseSectionManagementReturn {
|
||||
addSectionToPage,
|
||||
updateSection,
|
||||
deleteSection,
|
||||
addSectionTemplate,
|
||||
tenantId,
|
||||
linkSectionToPage, // 2025-11-26: 기존 섹션을 페이지에 연결 (entity_relationships)
|
||||
unlinkSectionFromPage, // 2025-11-26: EntityRelationship API 사용
|
||||
} = useItemMaster();
|
||||
|
||||
// 상태
|
||||
@@ -58,112 +59,84 @@ export function useSectionManagement(): UseSectionManagementReturn {
|
||||
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({});
|
||||
|
||||
// 섹션 추가
|
||||
const handleAddSection = (selectedPage: ItemPage | undefined) => {
|
||||
const handleAddSection = async (selectedPage: ItemPage | undefined) => {
|
||||
if (!selectedPage || !newSectionTitle.trim()) {
|
||||
toast.error('하위섹션 제목을 입력해주세요');
|
||||
return;
|
||||
}
|
||||
|
||||
const sectionType: 'BASIC' | 'BOM' | 'CUSTOM' = newSectionType === 'bom' ? 'BOM' : 'BASIC';
|
||||
const newSection: ItemSection = {
|
||||
id: Date.now(),
|
||||
const newSection: Omit<ItemSection, 'id' | 'created_at' | 'updated_at'> = {
|
||||
page_id: selectedPage.id,
|
||||
section_name: newSectionTitle,
|
||||
title: newSectionTitle,
|
||||
section_type: sectionType,
|
||||
description: newSectionDescription || undefined,
|
||||
order_no: selectedPage.sections.length + 1,
|
||||
is_template: false,
|
||||
is_default: false,
|
||||
is_collapsible: true,
|
||||
is_default_open: true,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
fields: [],
|
||||
bomItems: sectionType === 'BOM' ? [] : undefined
|
||||
bom_items: sectionType === 'BOM' ? [] : undefined
|
||||
};
|
||||
|
||||
console.log('Adding section to page:', {
|
||||
pageId: selectedPage.id,
|
||||
page_name: selectedPage.page_name,
|
||||
sectionTitle: newSection.section_name,
|
||||
sectionTitle: newSection.title,
|
||||
sectionType: newSection.section_type,
|
||||
currentSectionCount: selectedPage.sections.length,
|
||||
newSection: newSection
|
||||
});
|
||||
|
||||
// 1. 페이지에 섹션 추가
|
||||
addSectionToPage(selectedPage.id, newSection);
|
||||
try {
|
||||
// 페이지에 섹션 추가 (API 호출)
|
||||
// 2025-11-26: sectionsAsTemplates가 itemPages에서 useMemo로 파생되므로
|
||||
// 별도의 addSectionTemplate 호출 불필요 (자동 동기화)
|
||||
await addSectionToPage(selectedPage.id, newSection);
|
||||
|
||||
// 2. 섹션관리 탭에도 템플릿으로 자동 추가
|
||||
const newTemplateData = {
|
||||
tenant_id: tenantId ?? 0,
|
||||
template_name: newSection.section_name,
|
||||
section_type: newSection.section_type as 'BASIC' | 'BOM' | 'CUSTOM',
|
||||
description: newSection.description ?? null,
|
||||
default_fields: null,
|
||||
created_by: null,
|
||||
updated_by: null,
|
||||
};
|
||||
addSectionTemplate(newTemplateData);
|
||||
console.log('Section added to page:', {
|
||||
sectionTitle: newSection.title
|
||||
});
|
||||
|
||||
console.log('Section added to both page and template:', {
|
||||
sectionId: newSection.id,
|
||||
templateTitle: newTemplateData.template_name
|
||||
});
|
||||
|
||||
resetSectionForm();
|
||||
toast.success(`${newSectionType === 'bom' ? 'BOM' : '일반'} 섹션이 페이지에 추가되고 템플릿으로도 등록되었습니다!`);
|
||||
resetSectionForm();
|
||||
toast.success(`${newSectionType === 'bom' ? 'BOM' : '일반'} 섹션이 추가되었습니다!`);
|
||||
} catch (error) {
|
||||
console.error('섹션 추가 실패:', error);
|
||||
toast.error('섹션 추가에 실패했습니다. 다시 시도해주세요.');
|
||||
}
|
||||
};
|
||||
|
||||
// 섹션 템플릿을 페이지에 연결
|
||||
const handleLinkTemplate = (template: SectionTemplate, selectedPage: ItemPage | undefined) => {
|
||||
// 기존 섹션을 페이지에 연결 (entity_relationships 테이블 사용)
|
||||
// 2025-11-26: 새 섹션 생성이 아닌, 기존 섹션을 연결만 함
|
||||
const handleLinkTemplate = async (template: SectionTemplate, selectedPage: ItemPage | undefined) => {
|
||||
if (!selectedPage) {
|
||||
toast.error('페이지를 먼저 선택해주세요');
|
||||
return;
|
||||
}
|
||||
|
||||
// 템플릿을 섹션으로 변환하여 페이지에 추가
|
||||
const newSection: Omit<ItemSection, 'id' | 'created_at' | 'updated_at'> = {
|
||||
page_id: selectedPage.id,
|
||||
section_name: template.template_name,
|
||||
section_type: template.section_type,
|
||||
description: template.description || undefined,
|
||||
order_no: selectedPage.sections.length + 1,
|
||||
is_collapsible: true,
|
||||
is_default_open: true,
|
||||
fields: template.fields ? template.fields.map((field, idx) => ({
|
||||
id: Date.now() + idx,
|
||||
section_id: 0, // 추후 업데이트됨
|
||||
field_name: field.name,
|
||||
field_type: field.property.inputType,
|
||||
order_no: idx + 1,
|
||||
is_required: field.property.required,
|
||||
placeholder: field.description || null,
|
||||
default_value: null,
|
||||
display_condition: null,
|
||||
validation_rules: null,
|
||||
options: field.property.options
|
||||
? field.property.options.map(opt => ({ label: opt, value: opt }))
|
||||
: null,
|
||||
properties: field.property.multiColumn ? {
|
||||
multiColumn: true,
|
||||
columnCount: field.property.columnCount,
|
||||
columnNames: field.property.columnNames
|
||||
} : null,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
})) : [],
|
||||
bomItems: template.section_type === 'BOM' ? (template.bomItems || []) : undefined
|
||||
};
|
||||
// 이미 연결된 섹션인지 확인
|
||||
const isAlreadyLinked = selectedPage.sections.some(s => s.id === template.id);
|
||||
if (isAlreadyLinked) {
|
||||
toast.error('이미 페이지에 연결된 섹션입니다');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Linking template to page:', {
|
||||
templateId: template.id,
|
||||
templateName: template.template_name,
|
||||
console.log('Linking existing section to page:', {
|
||||
sectionId: template.id,
|
||||
sectionName: template.template_name,
|
||||
pageId: selectedPage.id,
|
||||
newSection
|
||||
orderNo: selectedPage.sections.length + 1,
|
||||
});
|
||||
|
||||
addSectionToPage(selectedPage.id, newSection);
|
||||
resetSectionForm();
|
||||
toast.success(`"${template.template_name}" 템플릿이 페이지에 연결되었습니다!`);
|
||||
try {
|
||||
// 기존 섹션을 페이지에 연결 (entity_relationships에 레코드 추가)
|
||||
await linkSectionToPage(selectedPage.id, template.id, selectedPage.sections.length + 1);
|
||||
resetSectionForm();
|
||||
toast.success(`"${template.template_name}" 섹션이 페이지에 연결되었습니다!`);
|
||||
} catch (error) {
|
||||
console.error('섹션 연결 실패:', error);
|
||||
toast.error('섹션 연결에 실패했습니다. 다시 시도해주세요.');
|
||||
}
|
||||
};
|
||||
|
||||
// 섹션 제목 수정 시작
|
||||
@@ -173,30 +146,53 @@ export function useSectionManagement(): UseSectionManagementReturn {
|
||||
};
|
||||
|
||||
// 섹션 제목 저장
|
||||
const handleSaveSectionTitle = (selectedPage: ItemPage | undefined) => {
|
||||
const handleSaveSectionTitle = async (selectedPage: ItemPage | undefined) => {
|
||||
if (!selectedPage || !editingSectionId || !editingSectionTitle.trim()) {
|
||||
toast.error('하위섹션 제목을 입력해주세요');
|
||||
return;
|
||||
}
|
||||
|
||||
updateSection(editingSectionId, { section_name: editingSectionTitle });
|
||||
setEditingSectionId(null);
|
||||
setEditingSectionTitle('');
|
||||
toast.success('하위섹션 제목이 수정되었습니다 (저장 필요)');
|
||||
try {
|
||||
await updateSection(editingSectionId, { title: editingSectionTitle });
|
||||
setEditingSectionId(null);
|
||||
setEditingSectionTitle('');
|
||||
toast.success('섹션 제목이 수정되었습니다!');
|
||||
} catch (error) {
|
||||
console.error('섹션 제목 수정 실패:', error);
|
||||
toast.error('섹션 제목 수정에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
// 섹션 삭제
|
||||
const handleDeleteSection = (pageId: number, sectionId: number) => {
|
||||
// 섹션 연결 해제 (계층구조 탭용 - 페이지에서만 분리, 섹션 데이터는 유지)
|
||||
// 2025-11-26: EntityRelationship API 사용 (DELETE /pages/{pageId}/unlink-section/{sectionId})
|
||||
const handleUnlinkSection = async (pageId: number, sectionId: number) => {
|
||||
try {
|
||||
await unlinkSectionFromPage(pageId, sectionId);
|
||||
console.log('섹션 연결 해제 완료:', { pageId, sectionId });
|
||||
toast.success('섹션 연결이 해제되었습니다');
|
||||
} catch (error) {
|
||||
console.error('섹션 연결 해제 실패:', error);
|
||||
toast.error('섹션 연결 해제에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
// 섹션 삭제 (섹션 탭용 - 실제 데이터 삭제)
|
||||
const handleDeleteSection = async (pageId: number, sectionId: number) => {
|
||||
const page = itemPages.find(p => p.id === pageId);
|
||||
const sectionToDelete = page?.sections.find(s => s.id === sectionId);
|
||||
const fieldIds = sectionToDelete?.fields?.map(f => f.id) || [];
|
||||
|
||||
deleteSection(sectionId);
|
||||
|
||||
console.log('섹션 삭제 완료:', {
|
||||
sectionId,
|
||||
removedFields: fieldIds.length
|
||||
});
|
||||
try {
|
||||
await deleteSection(sectionId);
|
||||
console.log('섹션 삭제 완료:', {
|
||||
sectionId,
|
||||
removedFields: fieldIds.length
|
||||
});
|
||||
toast.success('섹션이 삭제되었습니다!');
|
||||
} catch (error) {
|
||||
console.error('섹션 삭제 실패:', error);
|
||||
toast.error('섹션 삭제에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
// 섹션 확장/축소 토글
|
||||
@@ -240,6 +236,7 @@ export function useSectionManagement(): UseSectionManagementReturn {
|
||||
handleLinkTemplate,
|
||||
handleEditSectionTitle,
|
||||
handleSaveSectionTitle,
|
||||
handleUnlinkSection,
|
||||
handleDeleteSection,
|
||||
toggleSection,
|
||||
resetSectionForm,
|
||||
|
||||
@@ -100,8 +100,13 @@ export function useTabManagement(): UseTabManagementReturn {
|
||||
]);
|
||||
const [activeTab, setActiveTab] = useState('hierarchy');
|
||||
|
||||
// 속성 하위 탭 상태
|
||||
const [attributeSubTabs, setAttributeSubTabs] = useState<AttributeSubTab[]>([]);
|
||||
// 속성 하위 탭 상태 (기본 탭: 단위, 재질, 표면처리)
|
||||
// TODO: 나중에 백엔드에서 기준값 로드로 대체 예정
|
||||
const [attributeSubTabs, setAttributeSubTabs] = useState<AttributeSubTab[]>([
|
||||
{ id: 'units', label: '단위', key: 'units', isDefault: true, order: 0 },
|
||||
{ id: 'materials', label: '재질', key: 'materials', isDefault: true, order: 1 },
|
||||
{ id: 'surface', label: '표면처리', key: 'surface', isDefault: true, order: 2 },
|
||||
]);
|
||||
const [activeAttributeTab, setActiveAttributeTab] = useState('units');
|
||||
|
||||
// 메인 탭 다이얼로그 상태
|
||||
@@ -123,7 +128,7 @@ export function useTabManagement(): UseTabManagementReturn {
|
||||
// 이전 필드 상태 추적용 ref (무한 루프 방지)
|
||||
const prevFieldsRef = useRef<string>('');
|
||||
|
||||
// 마스터 항목이 추가/수정될 때 속성 탭 자동 생성
|
||||
// 마스터 항목이 추가/수정/삭제될 때 속성 탭 자동 동기화
|
||||
useEffect(() => {
|
||||
// 현재 필드 상태를 문자열로 직렬화
|
||||
const currentFieldsState = JSON.stringify(
|
||||
@@ -136,15 +141,30 @@ export function useTabManagement(): UseTabManagementReturn {
|
||||
}
|
||||
prevFieldsRef.current = currentFieldsState;
|
||||
|
||||
// 현재 마스터 필드 ID 목록
|
||||
const currentFieldIds = new Set(itemMasterFields.map(f => f.id.toString()));
|
||||
|
||||
setAttributeSubTabs(prev => {
|
||||
const newTabs: AttributeSubTab[] = [];
|
||||
const updates: { key: string; label: string }[] = [];
|
||||
|
||||
// 삭제된 마스터 항목에 해당하는 탭 제거 (숫자 key만 체크 - 마스터 항목 ID)
|
||||
const filteredTabs = prev.filter(tab => {
|
||||
// 숫자로만 이루어진 key는 마스터 항목 ID
|
||||
const isNumericKey = /^\d+$/.test(tab.key);
|
||||
if (isNumericKey && !currentFieldIds.has(tab.key)) {
|
||||
// 삭제된 마스터 항목의 탭
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// 새로운 마스터 항목 추가 또는 기존 항목 라벨 업데이트
|
||||
itemMasterFields.forEach(field => {
|
||||
const existingTab = prev.find(tab => tab.key === field.id.toString());
|
||||
const existingTab = filteredTabs.find(tab => tab.key === field.id.toString());
|
||||
|
||||
if (!existingTab) {
|
||||
const maxOrder = Math.max(...prev.map(t => t.order), ...newTabs.map(t => t.order), -1);
|
||||
const maxOrder = Math.max(...filteredTabs.map(t => t.order), ...newTabs.map(t => t.order), -1);
|
||||
newTabs.push({
|
||||
id: `attr-${field.id.toString()}`,
|
||||
label: field.field_name,
|
||||
@@ -157,12 +177,17 @@ export function useTabManagement(): UseTabManagementReturn {
|
||||
}
|
||||
});
|
||||
|
||||
// 탭 삭제, 추가, 업데이트 여부 확인
|
||||
const hasRemovals = filteredTabs.length !== prev.length;
|
||||
const hasAdditions = newTabs.length > 0;
|
||||
const hasUpdates = updates.length > 0;
|
||||
|
||||
// 변경사항 없으면 이전 상태 그대로 반환
|
||||
if (newTabs.length === 0 && updates.length === 0) {
|
||||
if (!hasRemovals && !hasAdditions && !hasUpdates) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
let result = prev.map(tab => {
|
||||
let result = filteredTabs.map(tab => {
|
||||
const update = updates.find(u => u.key === tab.key);
|
||||
return update ? { ...tab, label: update.label } : tab;
|
||||
});
|
||||
@@ -173,6 +198,12 @@ export function useTabManagement(): UseTabManagementReturn {
|
||||
index === self.findIndex(t => t.key === tab.key)
|
||||
);
|
||||
});
|
||||
|
||||
// 현재 활성 탭이 삭제된 마스터 항목인 경우 기본 탭으로 전환
|
||||
const isNumericKey = /^\d+$/.test(activeAttributeTab);
|
||||
if (isNumericKey && !currentFieldIds.has(activeAttributeTab)) {
|
||||
setActiveAttributeTab('units');
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [itemMasterFields]);
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ export interface UseTemplateManagementReturn {
|
||||
handleUpdateSectionTemplate: () => void;
|
||||
handleDeleteSectionTemplate: (id: number) => void;
|
||||
handleLoadTemplate: (selectedPage: ItemPage | undefined) => void;
|
||||
handleAddTemplateField: () => void;
|
||||
handleAddTemplateField: () => Promise<void>;
|
||||
handleEditTemplateField: (templateId: number, field: TemplateField) => void;
|
||||
handleDeleteTemplateField: (templateId: number, fieldId: string) => void;
|
||||
handleAddBOMItemToTemplate: (templateId: number, item: Omit<BOMItem, 'id' | 'created_at' | 'updated_at' | 'tenant_id' | 'section_id'>) => void;
|
||||
@@ -89,7 +89,20 @@ export function useTemplateManagement(): UseTemplateManagementReturn {
|
||||
addSectionToPage,
|
||||
addItemMasterField,
|
||||
itemMasterFields,
|
||||
tenantId
|
||||
tenantId,
|
||||
// 2025-11-26: sectionsAsTemplates가 itemPages에서 파생되므로
|
||||
// 섹션 탭에서 수정/삭제 시 실제 섹션 API를 호출해야 함
|
||||
updateSection,
|
||||
deleteSection,
|
||||
itemPages,
|
||||
// 2025-11-26: 섹션 탭에서 새 섹션 추가 시 독립 섹션으로 생성
|
||||
createIndependentSection,
|
||||
// 2025-11-27: entity_relationships 기반 필드 연결/해제
|
||||
linkFieldToSection,
|
||||
unlinkFieldFromSection,
|
||||
independentFields,
|
||||
// 2025-11-27: 필드 수정 API
|
||||
updateField,
|
||||
} = useItemMaster();
|
||||
|
||||
// 섹션 템플릿 다이얼로그 상태
|
||||
@@ -127,28 +140,33 @@ export function useTemplateManagement(): UseTemplateManagementReturn {
|
||||
const [templateFieldShowMasterFieldList, setTemplateFieldShowMasterFieldList] = useState(false);
|
||||
const [templateFieldSelectedMasterFieldId, setTemplateFieldSelectedMasterFieldId] = useState('');
|
||||
|
||||
// 섹션 템플릿 추가
|
||||
const handleAddSectionTemplate = () => {
|
||||
// 섹션 템플릿 추가 (2025-11-26: 독립 섹션으로 생성하여 sectionsAsTemplates에 반영)
|
||||
const handleAddSectionTemplate = async () => {
|
||||
if (!newSectionTemplateTitle.trim()) {
|
||||
toast.error('섹션 제목을 입력해주세요');
|
||||
return;
|
||||
}
|
||||
|
||||
const newTemplateData = {
|
||||
tenant_id: tenantId ?? 0,
|
||||
template_name: newSectionTemplateTitle,
|
||||
section_type: (newSectionTemplateType === 'bom' ? 'BOM' : 'BASIC') as 'BASIC' | 'BOM' | 'CUSTOM',
|
||||
description: newSectionTemplateDescription || null,
|
||||
default_fields: null,
|
||||
category: newSectionTemplateCategory,
|
||||
created_by: null,
|
||||
updated_by: null,
|
||||
// 2025-11-26: sectionsAsTemplates가 itemPages + independentSections에서 파생되므로
|
||||
// 독립 섹션으로 생성해야 화면에 바로 반영됨
|
||||
const newSectionData = {
|
||||
title: newSectionTemplateTitle,
|
||||
type: newSectionTemplateType as 'fields' | 'bom',
|
||||
description: newSectionTemplateDescription || undefined,
|
||||
is_template: true, // 섹션 탭에서 생성된 섹션은 템플릿으로 표시
|
||||
is_default: false,
|
||||
};
|
||||
|
||||
console.log('Adding section template:', newTemplateData);
|
||||
addSectionTemplate(newTemplateData);
|
||||
resetSectionTemplateForm();
|
||||
toast.success('섹션 템플릿이 추가되었습니다!');
|
||||
console.log('Adding independent section (from section tab):', newSectionData);
|
||||
|
||||
try {
|
||||
await createIndependentSection(newSectionData);
|
||||
resetSectionTemplateForm();
|
||||
toast.success('섹션이 추가되었습니다!');
|
||||
} catch (error) {
|
||||
console.error('섹션 추가 실패:', error);
|
||||
toast.error('섹션 추가에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
// 섹션 템플릿 수정 시작
|
||||
@@ -161,31 +179,43 @@ export function useTemplateManagement(): UseTemplateManagementReturn {
|
||||
setIsSectionTemplateDialogOpen(true);
|
||||
};
|
||||
|
||||
// 섹션 템플릿 업데이트
|
||||
const handleUpdateSectionTemplate = () => {
|
||||
// 섹션 템플릿 업데이트 (2025-11-26: sectionsAsTemplates 사용으로 실제 섹션 API 호출)
|
||||
const handleUpdateSectionTemplate = async () => {
|
||||
if (!editingSectionTemplateId || !newSectionTemplateTitle.trim()) {
|
||||
toast.error('섹션 제목을 입력해주세요');
|
||||
return;
|
||||
}
|
||||
|
||||
// sectionsAsTemplates가 itemPages에서 파생되므로, 실제 섹션을 업데이트해야 함
|
||||
const updateData = {
|
||||
template_name: newSectionTemplateTitle,
|
||||
title: newSectionTemplateTitle,
|
||||
description: newSectionTemplateDescription || undefined,
|
||||
category: newSectionTemplateCategory.length > 0 ? newSectionTemplateCategory : undefined,
|
||||
section_type: (newSectionTemplateType === 'bom' ? 'BOM' : 'BASIC') as 'BASIC' | 'BOM' | 'CUSTOM'
|
||||
};
|
||||
|
||||
console.log('Updating section template:', { id: editingSectionTemplateId, updateData });
|
||||
updateSectionTemplate(editingSectionTemplateId, updateData);
|
||||
resetSectionTemplateForm();
|
||||
toast.success('섹션이 수정되었습니다 (저장 필요)');
|
||||
console.log('Updating section (from template handler):', { id: editingSectionTemplateId, updateData });
|
||||
try {
|
||||
// updateSection 호출 (템플릿이 아닌 실제 섹션 API)
|
||||
await updateSection(editingSectionTemplateId, updateData);
|
||||
resetSectionTemplateForm();
|
||||
toast.success('섹션이 수정되었습니다!');
|
||||
} catch (error) {
|
||||
console.error('섹션 수정 실패:', error);
|
||||
toast.error('섹션 수정에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
// 섹션 템플릿 삭제
|
||||
const handleDeleteSectionTemplate = (id: number) => {
|
||||
// 섹션 템플릿 삭제 (2025-11-26: sectionsAsTemplates 사용으로 실제 섹션 API 호출)
|
||||
const handleDeleteSectionTemplate = async (id: number) => {
|
||||
if (confirm('이 섹션을 삭제하시겠습니까?')) {
|
||||
deleteSectionTemplate(id);
|
||||
toast.success('섹션이 삭제되었습니다');
|
||||
try {
|
||||
// deleteSection 호출 (템플릿이 아닌 실제 섹션 API)
|
||||
await deleteSection(id);
|
||||
toast.success('섹션이 삭제되었습니다!');
|
||||
} catch (error) {
|
||||
console.error('섹션 삭제 실패:', error);
|
||||
toast.error('섹션 삭제에 실패했습니다.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -204,14 +234,16 @@ export function useTemplateManagement(): UseTemplateManagementReturn {
|
||||
|
||||
const newSection = {
|
||||
page_id: selectedPage.id,
|
||||
section_name: template.template_name,
|
||||
title: template.template_name,
|
||||
section_type: template.section_type === 'BOM' ? 'BOM' as const : 'BASIC' as const,
|
||||
description: template.description || undefined,
|
||||
order_no: selectedPage.sections.length + 1,
|
||||
is_template: false,
|
||||
is_default: false,
|
||||
is_collapsible: true,
|
||||
is_default_open: true,
|
||||
fields: [],
|
||||
bomItems: template.section_type === 'BOM' ? [] : undefined
|
||||
bom_items: template.section_type === 'BOM' ? [] : undefined
|
||||
};
|
||||
|
||||
console.log('Loading template to section:', template.template_name, 'newSection:', newSection);
|
||||
@@ -221,77 +253,57 @@ export function useTemplateManagement(): UseTemplateManagementReturn {
|
||||
toast.success('섹션이 불러와졌습니다');
|
||||
};
|
||||
|
||||
// 템플릿 필드 추가
|
||||
const handleAddTemplateField = () => {
|
||||
// 템플릿 필드 추가/수정 (2025-11-27: API 사용으로 변경)
|
||||
// sectionsAsTemplates가 itemPages + independentSections에서 파생되므로
|
||||
// entity_relationships 기반 연결 API를 사용해야 실시간 반영됨
|
||||
const handleAddTemplateField = async () => {
|
||||
if (!currentTemplateId || !templateFieldName.trim() || !templateFieldKey.trim()) {
|
||||
toast.error('모든 필수 항목을 입력해주세요');
|
||||
return;
|
||||
}
|
||||
|
||||
const template = sectionTemplates.find(t => t.id === currentTemplateId);
|
||||
if (!template) return;
|
||||
try {
|
||||
// 수정 모드: 기존 필드 속성 업데이트
|
||||
if (editingTemplateFieldId) {
|
||||
const updateData = {
|
||||
field_name: templateFieldName,
|
||||
field_type: templateFieldInputType,
|
||||
is_required: templateFieldRequired,
|
||||
placeholder: templateFieldDescription || null,
|
||||
options: templateFieldInputType === 'dropdown' && templateFieldOptions.trim()
|
||||
? templateFieldOptions.split(',').map(o => ({ label: o.trim(), value: o.trim() }))
|
||||
: null,
|
||||
properties: {
|
||||
inputType: templateFieldInputType,
|
||||
required: templateFieldRequired,
|
||||
multiColumn: (templateFieldInputType === 'textbox' || templateFieldInputType === 'textarea') ? templateFieldMultiColumn : undefined,
|
||||
columnCount: (templateFieldInputType === 'textbox' || templateFieldInputType === 'textarea') && templateFieldMultiColumn ? templateFieldColumnCount : undefined,
|
||||
columnNames: (templateFieldInputType === 'textbox' || templateFieldInputType === 'textarea') && templateFieldMultiColumn ? templateFieldColumnNames : undefined,
|
||||
},
|
||||
};
|
||||
|
||||
// 마스터 필드에 없으면 자동 추가
|
||||
const existingMasterField = itemMasterFields.find(f => f.id.toString() === templateFieldKey);
|
||||
if (!existingMasterField && !editingTemplateFieldId) {
|
||||
const newMasterFieldData: Omit<ItemMasterField, 'id' | 'tenant_id' | 'created_by' | 'updated_by' | 'created_at' | 'updated_at'> = {
|
||||
field_name: templateFieldName,
|
||||
field_type: templateFieldInputType,
|
||||
category: '공통',
|
||||
description: templateFieldDescription || null,
|
||||
is_common: false,
|
||||
default_value: null,
|
||||
options: templateFieldInputType === 'dropdown' && templateFieldOptions.trim()
|
||||
? templateFieldOptions.split(',').map(o => ({ label: o.trim(), value: o.trim() }))
|
||||
: null,
|
||||
validation_rules: null,
|
||||
properties: {
|
||||
inputType: templateFieldInputType,
|
||||
required: templateFieldRequired,
|
||||
multiColumn: (templateFieldInputType === 'textbox' || templateFieldInputType === 'textarea') ? templateFieldMultiColumn : undefined,
|
||||
columnCount: (templateFieldInputType === 'textbox' || templateFieldInputType === 'textarea') && templateFieldMultiColumn ? templateFieldColumnCount : undefined,
|
||||
columnNames: (templateFieldInputType === 'textbox' || templateFieldInputType === 'textarea') && templateFieldMultiColumn ? templateFieldColumnNames : undefined
|
||||
},
|
||||
};
|
||||
addItemMasterField(newMasterFieldData as any);
|
||||
toast.success('항목 탭에 자동으로 추가되었습니다');
|
||||
await updateField(editingTemplateFieldId, updateData);
|
||||
toast.success('항목이 수정되었습니다');
|
||||
resetTemplateFieldForm();
|
||||
return;
|
||||
}
|
||||
|
||||
// 추가 모드: 기존 필드를 섹션에 연결
|
||||
const existingField = independentFields.find(f => f.id.toString() === templateFieldKey);
|
||||
|
||||
if (existingField) {
|
||||
await linkFieldToSection(currentTemplateId, existingField.id);
|
||||
toast.success('항목이 섹션에 연결되었습니다');
|
||||
} else {
|
||||
toast.error('항목 탭에서 먼저 항목을 생성해주세요');
|
||||
return;
|
||||
}
|
||||
|
||||
resetTemplateFieldForm();
|
||||
} catch (error) {
|
||||
console.error('항목 처리 실패:', error);
|
||||
toast.error('항목 처리에 실패했습니다');
|
||||
}
|
||||
|
||||
// TemplateField 형식으로 생성
|
||||
const newField: TemplateField = {
|
||||
id: String(editingTemplateFieldId || Date.now()),
|
||||
name: templateFieldName,
|
||||
fieldKey: templateFieldKey,
|
||||
property: {
|
||||
inputType: templateFieldInputType,
|
||||
required: templateFieldRequired,
|
||||
options: templateFieldInputType === 'dropdown' && templateFieldOptions.trim()
|
||||
? templateFieldOptions.split(',').map(o => o.trim())
|
||||
: undefined,
|
||||
multiColumn: (templateFieldInputType === 'textbox' || templateFieldInputType === 'textarea') ? templateFieldMultiColumn : undefined,
|
||||
columnCount: (templateFieldInputType === 'textbox' || templateFieldInputType === 'textarea') && templateFieldMultiColumn ? templateFieldColumnCount : undefined,
|
||||
columnNames: (templateFieldInputType === 'textbox' || templateFieldInputType === 'textarea') && templateFieldMultiColumn ? templateFieldColumnNames : undefined
|
||||
},
|
||||
description: templateFieldDescription || undefined
|
||||
};
|
||||
|
||||
let updatedFields;
|
||||
const currentFields = template.default_fields
|
||||
? (typeof template.default_fields === 'string' ? JSON.parse(template.default_fields) : template.default_fields)
|
||||
: [];
|
||||
|
||||
if (editingTemplateFieldId) {
|
||||
updatedFields = Array.isArray(currentFields)
|
||||
? currentFields.map((f: any) => String(f.id) === String(editingTemplateFieldId) ? newField : f)
|
||||
: [];
|
||||
toast.success('항목이 수정되었습니다');
|
||||
} else {
|
||||
updatedFields = Array.isArray(currentFields) ? [...currentFields, newField] : [newField];
|
||||
toast.success('항목이 추가되었습니다');
|
||||
}
|
||||
|
||||
updateSectionTemplate(currentTemplateId, { default_fields: updatedFields });
|
||||
resetTemplateFieldForm();
|
||||
};
|
||||
|
||||
// 템플릿 필드 수정 시작
|
||||
@@ -310,21 +322,20 @@ export function useTemplateManagement(): UseTemplateManagementReturn {
|
||||
setIsTemplateFieldDialogOpen(true);
|
||||
};
|
||||
|
||||
// 템플릿 필드 삭제
|
||||
const handleDeleteTemplateField = (templateId: number, fieldId: string) => {
|
||||
if (!confirm('이 항목을 삭제하시겠습니까?')) return;
|
||||
// 템플릿 필드 연결 해제 (2025-11-27: entity_relationships 기반으로 변경)
|
||||
// sectionId = templateId (sectionsAsTemplates에서 섹션 ID로 사용)
|
||||
// fieldId = 실제 item_fields의 ID
|
||||
const handleDeleteTemplateField = async (templateId: number, fieldId: string) => {
|
||||
if (!confirm('이 항목의 연결을 해제하시겠습니까?\n(항목 자체는 삭제되지 않고 항목 탭에 유지됩니다)')) return;
|
||||
|
||||
const template = sectionTemplates.find(t => t.id === templateId);
|
||||
if (!template) return;
|
||||
|
||||
const currentFields = template.default_fields
|
||||
? (typeof template.default_fields === 'string' ? JSON.parse(template.default_fields) : template.default_fields)
|
||||
: [];
|
||||
const updatedFields = Array.isArray(currentFields)
|
||||
? currentFields.filter((f: any) => String(f.id) !== String(fieldId))
|
||||
: [];
|
||||
updateSectionTemplate(templateId, { default_fields: updatedFields });
|
||||
toast.success('항목이 삭제되었습니다');
|
||||
try {
|
||||
// entity_relationships 기반 연결 해제 API 호출
|
||||
await unlinkFieldFromSection(templateId, Number(fieldId));
|
||||
toast.success('항목 연결이 해제되었습니다');
|
||||
} catch (error) {
|
||||
console.error('항목 연결 해제 실패:', error);
|
||||
toast.error('항목 연결 해제에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
// BOM 항목 추가
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import type { ItemPage, ItemSection } from '@/contexts/ItemMasterContext';
|
||||
import type { ItemPage, ItemSection, BOMItem } from '@/contexts/ItemMasterContext';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Plus, Edit, Trash2, Link, Copy } from 'lucide-react';
|
||||
import { Plus, Edit, Trash2, Link, Copy, Download } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { DraggableSection, DraggableField } from '../../components';
|
||||
import { BOMManagementSection } from '@/components/items/BOMManagementSection';
|
||||
@@ -13,6 +13,7 @@ interface HierarchyTabProps {
|
||||
itemPages: ItemPage[];
|
||||
selectedPage: ItemPage | undefined;
|
||||
ITEM_TYPE_OPTIONS: Array<{ value: string; label: string }>;
|
||||
unitOptions?: Array<{ value: string; label: string }>;
|
||||
|
||||
// State
|
||||
editingPageId: number | null;
|
||||
@@ -54,17 +55,26 @@ interface HierarchyTabProps {
|
||||
handleEditSectionTitle: (sectionId: number, title: string) => void;
|
||||
handleSaveSectionTitle: () => void;
|
||||
moveSection: (dragIndex: number, hoverIndex: number) => void;
|
||||
deleteSection: (pageId: number, sectionId: number) => void;
|
||||
unlinkSection: (pageId: number, sectionId: number) => void; // 연결 해제 (삭제 아님)
|
||||
updateSection: (sectionId: number, updates: Partial<ItemSection>) => Promise<void>;
|
||||
deleteField: (pageId: string, sectionId: string, fieldId: string) => void;
|
||||
deleteField: (pageId: string, sectionId: string, fieldId: string) => void; // 2025-11-27: 연결 해제로 변경 (삭제 아님, 항목 탭에 유지)
|
||||
handleEditField: (sectionId: string, field: any) => void;
|
||||
moveField: (sectionId: number, dragIndex: number, hoverIndex: number) => void;
|
||||
// 2025-11-26 추가: 섹션/필드 불러오기
|
||||
setIsImportSectionDialogOpen?: (open: boolean) => void;
|
||||
setIsImportFieldDialogOpen?: (open: boolean) => void;
|
||||
setImportFieldTargetSectionId?: (id: number | null) => void;
|
||||
// 2025-11-27 추가: BOM 항목 API 함수
|
||||
addBOMItem?: (sectionId: number, bomData: Omit<BOMItem, 'id' | 'created_at' | 'updated_at'>) => Promise<void>;
|
||||
updateBOMItem?: (bomId: number, updates: Partial<BOMItem>) => Promise<void>;
|
||||
deleteBOMItem?: (bomId: number) => Promise<void>;
|
||||
}
|
||||
|
||||
export function HierarchyTab({
|
||||
itemPages,
|
||||
selectedPage,
|
||||
ITEM_TYPE_OPTIONS,
|
||||
unitOptions = [],
|
||||
editingPageId,
|
||||
setEditingPageId,
|
||||
editingPageName,
|
||||
@@ -95,11 +105,19 @@ export function HierarchyTab({
|
||||
handleEditSectionTitle,
|
||||
handleSaveSectionTitle,
|
||||
moveSection,
|
||||
deleteSection,
|
||||
unlinkSection,
|
||||
updateSection,
|
||||
deleteField,
|
||||
handleEditField,
|
||||
moveField
|
||||
moveField,
|
||||
// 2025-11-26 추가: 섹션/필드 불러오기
|
||||
setIsImportSectionDialogOpen,
|
||||
setIsImportFieldDialogOpen,
|
||||
setImportFieldTargetSectionId,
|
||||
// 2025-11-27 추가: BOM 항목 API 함수
|
||||
addBOMItem,
|
||||
updateBOMItem,
|
||||
deleteBOMItem,
|
||||
}: HierarchyTabProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
@@ -326,7 +344,7 @@ export function HierarchyTab({
|
||||
selectedPage.sections
|
||||
.map((section, index) => (
|
||||
<DraggableSection
|
||||
key={section.id}
|
||||
key={`section-${section.id}-${index}`}
|
||||
section={section}
|
||||
index={index}
|
||||
moveSection={(dragIndex, hoverIndex) => {
|
||||
@@ -334,8 +352,7 @@ export function HierarchyTab({
|
||||
}}
|
||||
onDelete={() => {
|
||||
if (confirm('이 섹션을 페이지에서 연결 해제하시겠습니까?\n(섹션 데이터는 섹션 탭에 유지됩니다)')) {
|
||||
deleteSection(selectedPage.id, section.id);
|
||||
toast.success('섹션 연결이 해제되었습니다');
|
||||
unlinkSection(selectedPage.id, section.id);
|
||||
}
|
||||
}}
|
||||
onEditTitle={handleEditSectionTitle}
|
||||
@@ -350,31 +367,69 @@ export function HierarchyTab({
|
||||
<BOMManagementSection
|
||||
title=""
|
||||
description=""
|
||||
bomItems={section.bomItems || []}
|
||||
onAddItem={(item) => {
|
||||
const now = new Date().toISOString();
|
||||
const newBomItems = [...(section.bomItems || []), {
|
||||
...item,
|
||||
id: Date.now(),
|
||||
section_id: section.id,
|
||||
created_at: now,
|
||||
updated_at: now
|
||||
}];
|
||||
updateSection(section.id, { bomItems: newBomItems });
|
||||
toast.success('BOM 항목이 추가되었습니다');
|
||||
bomItems={section.bom_items || []}
|
||||
onAddItem={async (item) => {
|
||||
// 2025-11-27: API 함수로 BOM 항목 추가
|
||||
console.log('[HierarchyTab] BOM 추가 시작:', { sectionId: section.id, item, addBOMItemExists: !!addBOMItem });
|
||||
if (addBOMItem) {
|
||||
try {
|
||||
await addBOMItem(section.id, {
|
||||
section_id: section.id,
|
||||
item_name: item.item_name,
|
||||
item_code: item.item_code,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
spec: item.spec,
|
||||
});
|
||||
console.log('[HierarchyTab] BOM 추가 성공');
|
||||
toast.success('BOM 항목이 추가되었습니다');
|
||||
} catch (error) {
|
||||
console.error('[HierarchyTab] BOM 추가 실패:', error);
|
||||
toast.error('BOM 항목 추가에 실패했습니다. 백엔드 API를 확인하세요.');
|
||||
}
|
||||
} else {
|
||||
// Fallback: 로컬 상태만 업데이트 (API 함수 없을 경우)
|
||||
console.warn('[HierarchyTab] addBOMItem 함수가 없어 로컬 저장만 수행합니다');
|
||||
const now = new Date().toISOString();
|
||||
const newBomItems = [...(section.bom_items || []), {
|
||||
...item,
|
||||
id: Date.now(),
|
||||
section_id: section.id,
|
||||
created_at: now,
|
||||
updated_at: now
|
||||
}];
|
||||
updateSection(section.id, { bom_items: newBomItems });
|
||||
toast.success('BOM 항목이 추가되었습니다 (로컬 - 새로고침 시 사라짐)');
|
||||
}
|
||||
}}
|
||||
onUpdateItem={(id, updatedItem) => {
|
||||
const newBomItems = (section.bomItems || []).map(item =>
|
||||
item.id === id ? { ...item, ...updatedItem } : item
|
||||
);
|
||||
updateSection(section.id, { bomItems: newBomItems });
|
||||
toast.success('BOM 항목이 수정되었습니다');
|
||||
onUpdateItem={async (id, updatedItem) => {
|
||||
// 2025-11-27: API 함수로 BOM 항목 수정
|
||||
if (updateBOMItem) {
|
||||
await updateBOMItem(id, updatedItem);
|
||||
toast.success('BOM 항목이 수정되었습니다');
|
||||
} else {
|
||||
// Fallback: 로컬 상태만 업데이트
|
||||
const newBomItems = (section.bom_items || []).map(item =>
|
||||
item.id === id ? { ...item, ...updatedItem } : item
|
||||
);
|
||||
updateSection(section.id, { bom_items: newBomItems });
|
||||
toast.success('BOM 항목이 수정되었습니다 (로컬)');
|
||||
}
|
||||
}}
|
||||
onDeleteItem={(itemId) => {
|
||||
const newBomItems = (section.bomItems || []).filter(item => item.id !== itemId);
|
||||
updateSection(section.id, { bomItems: newBomItems });
|
||||
toast.success('BOM 항목이 삭제되었습니다');
|
||||
onDeleteItem={async (itemId) => {
|
||||
// 2025-11-27: API 함수로 BOM 항목 삭제
|
||||
if (deleteBOMItem) {
|
||||
await deleteBOMItem(itemId);
|
||||
toast.success('BOM 항목이 삭제되었습니다');
|
||||
} else {
|
||||
// Fallback: 로컬 상태만 업데이트
|
||||
const newBomItems = (section.bom_items || []).filter(item => item.id !== itemId);
|
||||
updateSection(section.id, { bom_items: newBomItems });
|
||||
toast.success('BOM 항목이 삭제되었습니다 (로컬)');
|
||||
}
|
||||
}}
|
||||
unitOptions={unitOptions}
|
||||
itemTypeOptions={ITEM_TYPE_OPTIONS}
|
||||
/>
|
||||
) : (
|
||||
/* 일반 필드 타입 섹션 */
|
||||
@@ -386,31 +441,44 @@ export function HierarchyTab({
|
||||
.sort((a, b) => (a.order_no ?? 0) - (b.order_no ?? 0))
|
||||
.map((field, fieldIndex) => (
|
||||
<DraggableField
|
||||
key={field.id}
|
||||
key={`field-${field.id}-${fieldIndex}`}
|
||||
field={field}
|
||||
index={fieldIndex}
|
||||
moveField={(dragIndex, hoverIndex) => moveField(section.id, dragIndex, hoverIndex)}
|
||||
onDelete={() => {
|
||||
if (confirm('이 항목을 삭제하시겠습니까?')) {
|
||||
if (confirm('이 항목을 섹션에서 연결 해제하시겠습니까?\n(항목 데이터는 항목 탭에 유지됩니다)')) {
|
||||
deleteField(String(selectedPage.id), String(section.id), String(field.id));
|
||||
toast.success('항목이 삭제되었습니다');
|
||||
toast.success('항목 연결이 해제되었습니다');
|
||||
}
|
||||
}}
|
||||
onEdit={() => handleEditField(String(section.id), field)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full mt-3"
|
||||
onClick={() => {
|
||||
setSelectedSectionForField(section.id);
|
||||
setIsFieldDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />항목 추가
|
||||
</Button>
|
||||
<div className="flex gap-2 mt-3">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
setImportFieldTargetSectionId?.(section.id);
|
||||
setIsImportFieldDialogOpen?.(true);
|
||||
}}
|
||||
>
|
||||
<Download className="h-3 w-3 mr-1" />불러오기
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
setSelectedSectionForField(section.id);
|
||||
setIsFieldDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />항목 추가
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DraggableSection>
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
/**
|
||||
* 항목 탭 컴포넌트
|
||||
*
|
||||
* @deprecated 2025-11-27: "마스터 항목" → "항목"으로 명칭 변경
|
||||
* - item_master_fields 테이블 삭제됨
|
||||
* - item_fields WHERE section_id IS NULL로 통합
|
||||
* - 향후 FieldTab으로 리네임 예정
|
||||
*/
|
||||
import type { ItemMasterField } from '@/contexts/ItemMasterContext';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -47,8 +55,8 @@ export function MasterFieldTab({
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div>
|
||||
<CardTitle>마스터 항목 관리</CardTitle>
|
||||
<CardDescription>재사용 가능한 항목 템플릿을 관리합니다</CardDescription>
|
||||
<CardTitle>항목 관리</CardTitle>
|
||||
<CardDescription>재사용 가능한 항목을 관리합니다. 섹션에 연결하여 사용할 수 있습니다.</CardDescription>
|
||||
</div>
|
||||
{/* 변경사항 배지 - 나중에 사용 예정으로 임시 숨김 */}
|
||||
{false && hasUnsavedChanges && pendingChanges.masterFields.length > 0 && (
|
||||
@@ -65,7 +73,7 @@ export function MasterFieldTab({
|
||||
<CardContent>
|
||||
{itemMasterFields.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-muted-foreground mb-2">등록된 마스터 항목이 없습니다</p>
|
||||
<p className="text-muted-foreground mb-2">등록된 항목이 없습니다</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
항목 추가 버튼을 눌러 재사용 가능한 항목을 등록하세요.
|
||||
</p>
|
||||
@@ -80,7 +88,7 @@ export function MasterFieldTab({
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{INPUT_TYPE_OPTIONS.find(t => t.value === field.field_type)?.label}
|
||||
</Badge>
|
||||
{field.properties?.required && (
|
||||
{(field.is_required || field.properties?.required) && (
|
||||
<Badge variant="destructive" className="text-xs">필수</Badge>
|
||||
)}
|
||||
{field.category && (
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Plus, Edit, Trash2, Folder, Package, FileText, GripVertical } from 'lucide-react';
|
||||
import { Plus, Edit, Trash2, Folder, Package, FileText, GripVertical, Copy, Download, Unlink } from 'lucide-react';
|
||||
import type { SectionTemplate, BOMItem, TemplateField } from '@/contexts/ItemMasterContext';
|
||||
import { BOMManagementSection } from '../../BOMManagementSection';
|
||||
|
||||
@@ -33,12 +34,18 @@ interface SectionsTabProps {
|
||||
// 옵션
|
||||
ITEM_TYPE_OPTIONS: Array<{ value: string; label: string }>;
|
||||
INPUT_TYPE_OPTIONS: Array<{ value: string; label: string }>;
|
||||
unitOptions?: Array<{ value: string; label: string }>;
|
||||
|
||||
// 변경사항 추적 (나중에 사용 예정)
|
||||
hasUnsavedChanges?: boolean;
|
||||
pendingChanges?: {
|
||||
sectionTemplates: any[];
|
||||
};
|
||||
|
||||
// 2025-11-26 추가: 복제 및 필드 불러오기
|
||||
onCloneSection?: (sectionId: number) => Promise<void>;
|
||||
setIsImportFieldDialogOpen?: (open: boolean) => void;
|
||||
setImportFieldTargetSectionId?: (id: number | null) => void;
|
||||
}
|
||||
|
||||
export function SectionsTab({
|
||||
@@ -55,9 +62,25 @@ export function SectionsTab({
|
||||
handleDeleteBOMItemFromTemplate,
|
||||
ITEM_TYPE_OPTIONS,
|
||||
INPUT_TYPE_OPTIONS,
|
||||
unitOptions = [],
|
||||
hasUnsavedChanges = false,
|
||||
pendingChanges = { sectionTemplates: [] },
|
||||
onCloneSection,
|
||||
setIsImportFieldDialogOpen,
|
||||
setImportFieldTargetSectionId,
|
||||
}: SectionsTabProps) {
|
||||
// 2025-11-27: prop 변경 추적 (디버깅용)
|
||||
useEffect(() => {
|
||||
console.log('[SectionsTab] 📥 sectionTemplates prop changed:', {
|
||||
count: sectionTemplates.length,
|
||||
sections: sectionTemplates.map(s => ({
|
||||
id: s.id,
|
||||
name: s.template_name,
|
||||
fieldsCount: s.fields?.length || 0
|
||||
}))
|
||||
});
|
||||
}, [sectionTemplates]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -95,10 +118,15 @@ export function SectionsTab({
|
||||
{/* 일반 섹션 탭 */}
|
||||
<TabsContent value="general">
|
||||
{(() => {
|
||||
console.log('Rendering section templates:', {
|
||||
console.log('[SectionsTab] 🔄 Rendering section templates:', {
|
||||
totalTemplates: sectionTemplates.length,
|
||||
generalTemplates: sectionTemplates.filter(t => t.section_type !== 'BOM').length,
|
||||
templates: sectionTemplates.map(t => ({ id: t.id, template_name: t.template_name, section_type: t.section_type }))
|
||||
templates: sectionTemplates.map(t => ({
|
||||
id: t.id,
|
||||
template_name: t.template_name,
|
||||
section_type: t.section_type,
|
||||
fieldsCount: t.fields?.length || 0 // 필드 개수 추가
|
||||
}))
|
||||
});
|
||||
return null;
|
||||
})()}
|
||||
@@ -139,13 +167,25 @@ export function SectionsTab({
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleEditSectionTemplate(template)}
|
||||
title="수정"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
{onCloneSection && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => onCloneSection(template.id)}
|
||||
title="복제"
|
||||
>
|
||||
<Copy className="h-4 w-4 text-green-500" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleDeleteSectionTemplate(template.id)}
|
||||
title="삭제"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
@@ -157,16 +197,31 @@ export function SectionsTab({
|
||||
<p className="text-sm text-muted-foreground">
|
||||
이 템플릿과 관련되는 항목 목록을 조회합니다
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setCurrentTemplateId(template.id);
|
||||
setIsTemplateFieldDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
항목 추가
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
{setIsImportFieldDialogOpen && setImportFieldTargetSectionId && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setImportFieldTargetSectionId(template.id);
|
||||
setIsImportFieldDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
불러오기
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setCurrentTemplateId(template.id);
|
||||
setIsTemplateFieldDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
항목 추가
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(!template.fields || template.fields.length === 0) ? (
|
||||
@@ -213,6 +268,7 @@ export function SectionsTab({
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleEditTemplateField(template.id, field)}
|
||||
title="수정"
|
||||
>
|
||||
<Edit className="h-4 w-4 text-blue-500" />
|
||||
</Button>
|
||||
@@ -220,8 +276,9 @@ export function SectionsTab({
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleDeleteTemplateField(template.id, field.id)}
|
||||
title="연결 해제"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
<Unlink className="h-4 w-4 text-orange-500" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -274,13 +331,25 @@ export function SectionsTab({
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleEditSectionTemplate(template)}
|
||||
title="수정"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
{onCloneSection && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => onCloneSection(template.id)}
|
||||
title="복제"
|
||||
>
|
||||
<Copy className="h-4 w-4 text-green-500" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleDeleteSectionTemplate(template.id)}
|
||||
title="삭제"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
@@ -295,6 +364,8 @@ export function SectionsTab({
|
||||
onAddItem={(item) => handleAddBOMItemToTemplate(template.id, item)}
|
||||
onUpdateItem={(itemId, item) => handleUpdateBOMItemInTemplate(template.id, itemId, item)}
|
||||
onDeleteItem={(itemId) => handleDeleteBOMItemFromTemplate(template.id, itemId)}
|
||||
unitOptions={unitOptions}
|
||||
itemTypeOptions={ITEM_TYPE_OPTIONS}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
Reference in New Issue
Block a user