From 728c9c7a2975d7dc94bf5125d76b48b5ce4c802e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Sat, 21 Mar 2026 14:08:08 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[production]=20=EC=A0=88=EA=B3=A1=20?= =?UTF-8?q?=EC=83=9D=EC=82=B0=EA=B4=80=EB=A6=AC=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EC=8B=A0=EA=B7=9C=20=EC=B6=94=EA=B0=80=20(?= =?UTF-8?q?=EC=85=94=ED=84=B0=EB=B0=95=EC=8A=A4,=20=EA=B0=80=EC=9D=B4?= =?UTF-8?q?=EB=93=9C=EB=A0=88=EC=9D=BC,=20=ED=95=98=EB=8B=A8=EB=A7=88?= =?UTF-8?q?=EA=B0=90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../production/bending/[id]/page.tsx | 12 + .../bending/bottombar/[id]/page.tsx | 11 + .../production/bending/bottombar/page.tsx | 13 + .../bending/guiderail/[id]/page.tsx | 11 + .../production/bending/guiderail/page.tsx | 13 + .../(protected)/production/bending/page.tsx | 13 + .../bending/shutterbox/[id]/page.tsx | 11 + .../production/bending/shutterbox/page.tsx | 13 + .../production/bending/BendingBaseForm.tsx | 625 +++++++++++++++ .../production/bending/BendingBaseList.tsx | 410 ++++++++++ .../production/bending/BendingModelForm.tsx | 748 ++++++++++++++++++ .../production/bending/BendingModelList.tsx | 494 ++++++++++++ .../production/bending/BendingSearchModal.tsx | 185 +++++ .../production/bending/BendingTable.tsx | 260 ++++++ .../production/bending/MaterialSummary.tsx | 45 ++ .../production/bending/PartListEditor.tsx | 333 ++++++++ src/components/production/bending/actions.ts | 171 ++++ src/components/production/bending/types.ts | 261 ++++++ 18 files changed, 3629 insertions(+) create mode 100644 src/app/[locale]/(protected)/production/bending/[id]/page.tsx create mode 100644 src/app/[locale]/(protected)/production/bending/bottombar/[id]/page.tsx create mode 100644 src/app/[locale]/(protected)/production/bending/bottombar/page.tsx create mode 100644 src/app/[locale]/(protected)/production/bending/guiderail/[id]/page.tsx create mode 100644 src/app/[locale]/(protected)/production/bending/guiderail/page.tsx create mode 100644 src/app/[locale]/(protected)/production/bending/page.tsx create mode 100644 src/app/[locale]/(protected)/production/bending/shutterbox/[id]/page.tsx create mode 100644 src/app/[locale]/(protected)/production/bending/shutterbox/page.tsx create mode 100644 src/components/production/bending/BendingBaseForm.tsx create mode 100644 src/components/production/bending/BendingBaseList.tsx create mode 100644 src/components/production/bending/BendingModelForm.tsx create mode 100644 src/components/production/bending/BendingModelList.tsx create mode 100644 src/components/production/bending/BendingSearchModal.tsx create mode 100644 src/components/production/bending/BendingTable.tsx create mode 100644 src/components/production/bending/MaterialSummary.tsx create mode 100644 src/components/production/bending/PartListEditor.tsx create mode 100644 src/components/production/bending/actions.ts create mode 100644 src/components/production/bending/types.ts diff --git a/src/app/[locale]/(protected)/production/bending/[id]/page.tsx b/src/app/[locale]/(protected)/production/bending/[id]/page.tsx new file mode 100644 index 00000000..d4ee2f0a --- /dev/null +++ b/src/app/[locale]/(protected)/production/bending/[id]/page.tsx @@ -0,0 +1,12 @@ +'use client'; + +import { useParams, useSearchParams } from 'next/navigation'; +import { BendingBaseForm } from '@/components/production/bending/BendingBaseForm'; + +export default function BendingBaseDetailPage() { + const { id } = useParams(); + const searchParams = useSearchParams(); + const mode = searchParams.get('mode') === 'edit' ? 'edit' : 'view'; + + return ; +} diff --git a/src/app/[locale]/(protected)/production/bending/bottombar/[id]/page.tsx b/src/app/[locale]/(protected)/production/bending/bottombar/[id]/page.tsx new file mode 100644 index 00000000..3c6a08fa --- /dev/null +++ b/src/app/[locale]/(protected)/production/bending/bottombar/[id]/page.tsx @@ -0,0 +1,11 @@ +'use client'; + +import { useParams, useSearchParams } from 'next/navigation'; +import { BendingModelForm } from '@/components/production/bending/BendingModelForm'; + +export default function BottombarDetailPage() { + const { id } = useParams(); + const mode = useSearchParams().get('mode') === 'edit' ? 'edit' : 'view'; + + return ; +} diff --git a/src/app/[locale]/(protected)/production/bending/bottombar/page.tsx b/src/app/[locale]/(protected)/production/bending/bottombar/page.tsx new file mode 100644 index 00000000..999351bd --- /dev/null +++ b/src/app/[locale]/(protected)/production/bending/bottombar/page.tsx @@ -0,0 +1,13 @@ +'use client'; + +import { useSearchParams } from 'next/navigation'; +import { BendingModelList } from '@/components/production/bending/BendingModelList'; +import { BendingModelForm } from '@/components/production/bending/BendingModelForm'; + +export default function BottombarPage() { + const searchParams = useSearchParams(); + const mode = searchParams.get('mode'); + + if (mode === 'new') return ; + return ; +} diff --git a/src/app/[locale]/(protected)/production/bending/guiderail/[id]/page.tsx b/src/app/[locale]/(protected)/production/bending/guiderail/[id]/page.tsx new file mode 100644 index 00000000..c2919e3f --- /dev/null +++ b/src/app/[locale]/(protected)/production/bending/guiderail/[id]/page.tsx @@ -0,0 +1,11 @@ +'use client'; + +import { useParams, useSearchParams } from 'next/navigation'; +import { BendingModelForm } from '@/components/production/bending/BendingModelForm'; + +export default function GuiderailDetailPage() { + const { id } = useParams(); + const mode = useSearchParams().get('mode') === 'edit' ? 'edit' : 'view'; + + return ; +} diff --git a/src/app/[locale]/(protected)/production/bending/guiderail/page.tsx b/src/app/[locale]/(protected)/production/bending/guiderail/page.tsx new file mode 100644 index 00000000..ac82785a --- /dev/null +++ b/src/app/[locale]/(protected)/production/bending/guiderail/page.tsx @@ -0,0 +1,13 @@ +'use client'; + +import { useSearchParams } from 'next/navigation'; +import { BendingModelList } from '@/components/production/bending/BendingModelList'; +import { BendingModelForm } from '@/components/production/bending/BendingModelForm'; + +export default function GuiderailPage() { + const searchParams = useSearchParams(); + const mode = searchParams.get('mode'); + + if (mode === 'new') return ; + return ; +} diff --git a/src/app/[locale]/(protected)/production/bending/page.tsx b/src/app/[locale]/(protected)/production/bending/page.tsx new file mode 100644 index 00000000..b55d62ee --- /dev/null +++ b/src/app/[locale]/(protected)/production/bending/page.tsx @@ -0,0 +1,13 @@ +'use client'; + +import { useSearchParams } from 'next/navigation'; +import { BendingBaseList } from '@/components/production/bending/BendingBaseList'; +import { BendingBaseForm } from '@/components/production/bending/BendingBaseForm'; + +export default function BendingBasePage() { + const searchParams = useSearchParams(); + const mode = searchParams.get('mode'); + + if (mode === 'new') return ; + return ; +} diff --git a/src/app/[locale]/(protected)/production/bending/shutterbox/[id]/page.tsx b/src/app/[locale]/(protected)/production/bending/shutterbox/[id]/page.tsx new file mode 100644 index 00000000..1a990856 --- /dev/null +++ b/src/app/[locale]/(protected)/production/bending/shutterbox/[id]/page.tsx @@ -0,0 +1,11 @@ +'use client'; + +import { useParams, useSearchParams } from 'next/navigation'; +import { BendingModelForm } from '@/components/production/bending/BendingModelForm'; + +export default function ShutterboxDetailPage() { + const { id } = useParams(); + const mode = useSearchParams().get('mode') === 'edit' ? 'edit' : 'view'; + + return ; +} diff --git a/src/app/[locale]/(protected)/production/bending/shutterbox/page.tsx b/src/app/[locale]/(protected)/production/bending/shutterbox/page.tsx new file mode 100644 index 00000000..203eefe0 --- /dev/null +++ b/src/app/[locale]/(protected)/production/bending/shutterbox/page.tsx @@ -0,0 +1,13 @@ +'use client'; + +import { useSearchParams } from 'next/navigation'; +import { BendingModelList } from '@/components/production/bending/BendingModelList'; +import { BendingModelForm } from '@/components/production/bending/BendingModelForm'; + +export default function ShutterboxPage() { + const searchParams = useSearchParams(); + const mode = searchParams.get('mode'); + + if (mode === 'new') return ; + return ; +} diff --git a/src/components/production/bending/BendingBaseForm.tsx b/src/components/production/bending/BendingBaseForm.tsx new file mode 100644 index 00000000..44a1767f --- /dev/null +++ b/src/components/production/bending/BendingBaseForm.tsx @@ -0,0 +1,625 @@ +'use client'; + +/** + * 기초관리 등록/수정 폼 — 별도 페이지 (2열 레이아웃) + * + * 좌측: 기본 정보 (4열 그리드) + 절곡 입력 테이블 + * 우측: 형상 이미지 + 비고 + 저장 버튼 + */ + +import { useState, useEffect, useCallback, useRef } from 'react'; +import { useRouter } from 'next/navigation'; +import { X, Save, Trash2, Loader2, Pencil, History } from 'lucide-react'; +import { DrawingCanvas } from '@/components/items/DrawingCanvas'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Card, CardContent } from '@/components/ui/card'; +import { DatePicker } from '@/components/ui/date-picker'; +import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog'; +import { toast } from 'sonner'; +import { useMenuStore } from '@/stores/menuStore'; +import { BendingTable } from './BendingTable'; +import { + getBendingItem, + getBendingItemFilters, + createBendingItem, + updateBendingItem, + deleteBendingItem, +} from './actions'; +import { + type BendingItem, + type BendingItemFilters, + type BendingData, + type BendingItemFormData, + recalculateSums, + createEmptyBendingData, + getWidthSum, + getBendCount, +} from './types'; + +interface BendingBaseFormProps { + id?: string; + mode: 'new' | 'edit' | 'view'; +} + +const INITIAL_FORM: BendingItemFormData = { + code: '', + name: '', + item_name: '', + item_sep: '', + item_bending: '', + material: '', + item_spec: '120*70', + model_name: '', + model_UA: '', + registration_date: new Date().toISOString().split('T')[0], + search_keyword: '', + memo: '', + exit_direction: '', + front_bottom_width: '', + rail_width: '', + box_width: '', + box_height: '', + bendingData: Array.from({ length: 7 }, (_, i) => createEmptyBendingData(i + 1)), +}; + +export function BendingBaseForm({ id, mode }: BendingBaseFormProps) { + const router = useRouter(); + const isNew = mode === 'new'; + const isView = mode === 'view'; + const isEdit = mode === 'edit'; + + const [formData, setFormData] = useState(INITIAL_FORM); + const [bendingData, setBendingData] = useState(INITIAL_FORM.bendingData); + const [filters, setFilters] = useState(null); + const [isLoading, setIsLoading] = useState(!isNew); + const [isSaving, setIsSaving] = useState(false); + const [isDeleteOpen, setIsDeleteOpen] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [isDrawingOpen, setIsDrawingOpen] = useState(false); + const [drawingImage, setDrawingImage] = useState(null); + const [isHistoryOpen, setIsHistoryOpen] = useState(false); + const [originalItem, setOriginalItem] = useState(null); + const fileInputRef = useRef(null); + + // 파일 선택 → data URL로 미리보기 + const handleFileChange = useCallback((e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = (ev) => { + setDrawingImage(ev.target?.result as string); + }; + reader.readAsDataURL(file); + e.target.value = ''; + }, []); + + // 로그인 사용자 이름 로드 + const [authorName, setAuthorName] = useState(''); + useEffect(() => { + try { + const userStr = localStorage.getItem('user'); + if (userStr) { + const user = JSON.parse(userStr); + if (user?.name) setAuthorName(user.name); + } + } catch { /* ignore */ } + }, []); + + // 데이터 로드 + useEffect(() => { + getBendingItemFilters().then((r) => { + if (r.success && r.data) setFilters(r.data as BendingItemFilters); + }); + + // 다른이름저장: sessionStorage에서 데이터 복사 → 새 등록 폼 + if (isNew) { + try { + const saved = sessionStorage.getItem('bending-save-as'); + if (saved) { + const data = JSON.parse(saved); + sessionStorage.removeItem('bending-save-as'); + setFormData((prev) => ({ + ...prev, + name: data.name || '', + item_name: data.name || '', + material: data.material || '', + })); + if (data.bendingData?.length) { + setBendingData(recalculateSums(data.bendingData)); + } + } + } catch { /* ignore */ } + } + + if (!isNew && id) { + getBendingItem(id).then((r) => { + if (r.success && r.data) { + const item = r.data as BendingItem; + setOriginalItem(item); + setFormData({ + code: item.code || '', + name: item.name || '', + item_name: item.item_name || '', + item_sep: item.item_sep || '', + item_bending: item.item_bending || '', + material: item.material || '', + item_spec: item.item_spec || '', + model_name: item.model_name || '', + model_UA: item.model_UA || '', + registration_date: item.registration_date || '', + search_keyword: item.search_keyword || '', + memo: item.memo || '', + exit_direction: item.exit_direction || '', + front_bottom_width: item.front_bottom_width ? String(item.front_bottom_width) : '', + rail_width: item.rail_width ? String(item.rail_width) : '', + box_width: item.box_width ? String(item.box_width) : '', + box_height: item.box_height ? String(item.box_height) : '', + bendingData: item.bendingData || [], + }); + if (item.author) { + setAuthorName(item.author); + } + if (item.image_url) { + setDrawingImage(item.image_url); + } + setBendingData( + item.bendingData?.length + ? recalculateSums(item.bendingData) + : Array.from({ length: 7 }, (_, i) => createEmptyBendingData(i + 1)) + ); + } else { + toast.error('해당 기초자료가 존재하지 않습니다. (삭제되었거나 연결이 끊어졌을 수 있습니다)'); + router.back(); + } + setIsLoading(false); + }); + } + }, [id, isNew, router]); + + const handleChange = useCallback((field: keyof BendingItemFormData, value: string) => { + setFormData((prev) => ({ ...prev, [field]: value })); + }, []); + + const handleBendingDataChange = useCallback((data: BendingData[]) => { + setBendingData(data); + }, []); + + // 저장 + const handleSubmit = useCallback(async () => { + if (!formData.code || !formData.name || !formData.item_name || !formData.item_sep) { + toast.error('필수 항목을 입력해주세요. (코드, 이름, 품명, 대분류)'); + return; + } + if (!formData.item_bending || !formData.material) { + toast.error('분류와 재질을 입력해주세요.'); + return; + } + + setIsSaving(true); + try { + const payload = { + ...formData, + author: authorName, + bendingData: bendingData.filter((d) => d.input > 0), + width_sum: getWidthSum(bendingData), + bend_count: getBendCount(bendingData), + }; + + const result = isNew + ? await createBendingItem(payload) + : await updateBendingItem(id!, payload); + + if (result.success) { + toast.success(isNew ? '등록되었습니다.' : '저장되었습니다.'); + router.push('/production/bending'); + } else { + toast.error(result.error || '저장에 실패했습니다.'); + } + } finally { + setIsSaving(false); + } + }, [formData, bendingData, isNew, id, router]); + + // 삭제 + const handleDelete = useCallback(async () => { + if (!id) return; + setIsDeleting(true); + try { + const result = await deleteBendingItem(id); + if (result.success) { + toast.success('삭제되었습니다.'); + router.push('/production/bending'); + } else { + toast.error(result.error || '삭제에 실패했습니다.'); + } + } finally { + setIsDeleting(false); + setIsDeleteOpen(false); + } + }, [id, router]); + + const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed); + + if (isLoading) { + return ( +
+ +
+ ); + } + + const disabled = isView; + const isCase = formData.item_bending === '케이스'; + + return ( +
+ {/* 헤더 */} +
+

+ 기초자료 {isNew ? '등록' : '수정'} + {!isNew && formData.code && ( + {formData.code} + )} +

+ +
+ + {/* 2열 레이아웃 */} +
+ {/* 좌측: 기본 정보 + 절곡 테이블 */} +
+ + +

기본 정보

+ {/* 1행 */} +
+
+ + handleChange('code', e.target.value)} disabled={disabled || isEdit} /> +
+
+ + handleChange('name', e.target.value)} disabled={disabled} /> +
+
+ + handleChange('item_name', e.target.value)} disabled={disabled} /> +
+
+ + +
+
+ + {/* 2행 */} +
+
+ + handleChange('item_bending', e.target.value)} + disabled={disabled} + list="bending-list" + /> + {filters && ( + + {filters.item_bending.map((v) => + )} +
+
+ + handleChange('material', e.target.value)} + disabled={disabled} + list="material-list" + /> + {filters && ( + + {filters.material.map((v) => + )} +
+
+ + handleChange('item_spec', e.target.value)} disabled={disabled} /> +
+
+ + handleChange('model_name', e.target.value)} + disabled={disabled} + list="model-list" + /> + {filters && ( + + {filters.model_name.map((v) => + )} +
+
+ + {/* 3행 */} +
+
+ + +
+
+ + handleChange('registration_date', v)} + disabled={disabled} + /> +
+
+ + setAuthorName(e.target.value)} disabled={disabled} /> +
+
+ + handleChange('search_keyword', e.target.value)} disabled={disabled} /> +
+
+ +
+
+ + {/* 케이스 전용 */} + {isCase && ( + + +

케이스 전용

+
+
+ + +
+
+ + handleChange('front_bottom_width', e.target.value)} disabled={disabled} /> +
+
+ + handleChange('rail_width', e.target.value)} disabled={disabled} /> +
+
+ + handleChange('box_width', e.target.value)} disabled={disabled} /> +
+
+ + handleChange('box_height', e.target.value)} disabled={disabled} /> +
+
+
+
+ )} + + {/* 절곡 입력 테이블 */} + + + + + +
+ + {/* 우측: 이미지 + 비고 + 저장 */} +
+ {/* 형상 이미지 */} + + +

형상 이미지

+
+ {drawingImage ? ( + 형상 이미지 + ) : ( + 이미지 없음 + )} +
+ {!disabled && ( +
+
+ + +
+ +

Ctrl+V로 붙여넣기 가능

+
+ )} +
+
+ + {/* 비고 */} + + +

비고

+