diff --git a/app/Helpers/TenantHelper.php b/app/Helpers/TenantHelper.php new file mode 100644 index 00000000..4fd48916 --- /dev/null +++ b/app/Helpers/TenantHelper.php @@ -0,0 +1,141 @@ +attributes->get('tenant_console_id'); + if ($consoleTenantId) { + return (int) $consoleTenantId; + } + + // API 호출 시 Referer 헤더에서 tenant-console 컨텍스트 감지 + $refererTenantId = self::getTenantIdFromReferer(); + if ($refererTenantId) { + return $refererTenantId; + } + + // 메인 관리자 페이지: 세션 기반 + $sessionTenantId = session('selected_tenant_id'); + if ($sessionTenantId && $sessionTenantId !== 'all') { + return (int) $sessionTenantId; + } + + return $default; + } + + /** + * 세션의 raw 값 반환 (all 포함) + * 메인 관리자에서 "전체" 선택 여부 판단 시 사용 + */ + public static function getRawTenantId(): mixed + { + $consoleTenantId = request()->attributes->get('tenant_console_id'); + if ($consoleTenantId) { + return (int) $consoleTenantId; + } + + // API 호출 시 Referer에서 감지 + $refererTenantId = self::getTenantIdFromReferer(); + if ($refererTenantId) { + return $refererTenantId; + } + + return session('selected_tenant_id'); + } + + /** + * 테넌트 콘솔(새창) 컨텍스트인지 확인 + * + * API 호출 시에는 Referer 헤더로 tenant-console 컨텍스트를 감지 + */ + public static function isTenantConsole(): bool + { + if (request()->attributes->get('tenant_console_id')) { + return true; + } + + // API 호출 시 Referer 헤더에서 tenant-console 컨텍스트 감지 + return (bool) self::getTenantIdFromReferer(); + } + + /** + * Referer 헤더에서 tenant-console의 tenantId 추출 + */ + private static function getTenantIdFromReferer(): ?int + { + $referer = request()->header('Referer', ''); + if (preg_match('#/tenant-console/(\d+)#', $referer, $matches)) { + return (int) $matches[1]; + } + + return null; + } + + /** + * 컨텍스트 인식 라우트 URL 생성 + * + * 테넌트 콘솔이면 메인 라우트 URL에 /tenant-console/{tenantId} 프리픽스 추가, + * 메인이면 기존 라우트 URL 그대로 반환 + * + * @param string $name 메인 라우트명 (예: 'common-codes.index') + * @param array $parameters 추가 파라미터 + */ + public static function route(string $name, mixed $parameters = []): string + { + // 메인 라우트 URL 생성 + $mainUrl = route($name, $parameters); + + if (! self::isTenantConsole()) { + return $mainUrl; + } + + // 테넌트 콘솔: 메인 URL의 path에 프리픽스 추가 + $tenantId = self::getEffectiveTenantId(); + $parsed = parse_url($mainUrl); + $path = $parsed['path'] ?? '/'; + $query = isset($parsed['query']) ? '?' . $parsed['query'] : ''; + + $consoleUrl = '/tenant-console/' . $tenantId . $path . $query; + + // 절대 URL이면 호스트 포함 + if (isset($parsed['scheme'])) { + return $parsed['scheme'] . '://' . $parsed['host'] + . (isset($parsed['port']) ? ':' . $parsed['port'] : '') + . $consoleUrl; + } + + return $consoleUrl; + } + + /** + * 컨텍스트 인식 리다이렉트 + */ + public static function redirect(string $name, array $parameters = []): \Illuminate\Http\RedirectResponse + { + return redirect(self::route($name, $parameters)); + } +} diff --git a/app/Http/Controllers/BendingBaseController.php b/app/Http/Controllers/BendingBaseController.php new file mode 100644 index 00000000..e43bb7cd --- /dev/null +++ b/app/Http/Controllers/BendingBaseController.php @@ -0,0 +1,267 @@ +withoutVerifying() + ->withHeaders([ + 'X-API-KEY' => config('services.api.key') ?: '42Jfwc6EaRQ04GNRmLR5kzJp5UudSOzGGqjmdk1a', + 'X-TENANT-ID' => session('selected_tenant_id', 1), + ]) + ->withToken($token) + ->timeout(10); + } + + public function index(Request $request): View|\Illuminate\Http\Response + { + $params = $request->only(['item_sep', 'item_bending', 'material', 'model_UA', 'item_name', 'search', 'page', 'size']); + $params['size'] = $params['size'] ?? 30; + + $response = $this->api()->get('/api/v1/bending-items', $params); + $body = $response->successful() ? $response->json('data', []) : []; + $data = [ + 'data' => $body['data'] ?? [], + 'total' => $body['total'] ?? 0, + 'current_page' => $body['current_page'] ?? 1, + 'last_page' => $body['last_page'] ?? 1, + ]; + + + $filterResponse = $this->api()->get('/api/v1/bending-items/filters'); + $filterOptions = $filterResponse->successful() ? $filterResponse->json('data', []) : []; + + if ($request->header('HX-Request')) { + if ($request->header('HX-Target') === 'items-table') { + return view('bending.base.partials.table', ['items' => $data]); + } + return response('', 200)->header('HX-Redirect', route('bending.base.index', $request->query())); + } + + return view('bending.base.index', [ + 'items' => $data, + 'filterOptions' => $filterOptions, + ]); + } + + public function show(int $id): View + { + $response = $this->api()->get("/api/v1/bending-items/{$id}"); + $item = $response->successful() ? $response->json('data') : null; + abort_unless($item, 404); + + $imageFile = $this->getImageFile($id); + + return view('bending.base.form', ['item' => $item, 'mode' => 'view', 'imageFile' => $imageFile]); + } + + public function create(): View + { + return view('bending.base.form', ['item' => null, 'mode' => 'create', 'imageFile' => null]); + } + + public function edit(int $id): View + { + $response = $this->api()->get("/api/v1/bending-items/{$id}"); + $item = $response->successful() ? $response->json('data') : null; + abort_unless($item, 404); + + $imageFile = $this->getImageFile($id); + + return view('bending.base.form', ['item' => $item, 'mode' => 'edit', 'imageFile' => $imageFile]); + } + + public function store(Request $request) + { + $validated = $request->validate([ + 'code' => 'required|string|max:100', + 'name' => 'required|string|max:200', + 'item_sep' => 'required|in:스크린,철재', + 'item_bending' => 'required|string|max:50', + 'item_name' => 'required|string|max:50', + 'material' => 'required|string|max:50', + 'model_UA' => 'nullable|in:인정,비인정', + ], [ + 'code.required' => '코드를 입력하세요.', + 'name.required' => '이름을 입력하세요.', + 'item_sep.required' => '대분류를 선택하세요.', + 'item_sep.in' => '대분류는 스크린 또는 철재만 선택 가능합니다.', + 'item_bending.required' => '분류를 선택하세요.', + 'item_name.required' => '품명을 입력하세요.', + 'material.required' => '재질을 입력하세요.', + 'model_UA.in' => '인정여부는 인정 또는 비인정만 선택 가능합니다.', + ]); + + $data = $this->prepareApiData($request); + $response = $this->api()->post('/api/v1/bending-items', $data); + + if (! $response->successful()) { + $body = $response->json(); + \Log::error('BendingBase store API error', ['status' => $response->status(), 'body' => $body, 'sent_data' => $data]); + $apiErrors = $body['errors'] ?? $body['error']['details'] ?? $body['data']['errors'] ?? []; + $apiMessage = $body['message'] ?? 'API 오류'; + $errorBag = ['api' => "[{$response->status()}] {$apiMessage}"]; + foreach ($apiErrors as $field => $msgs) { + $fieldLabel = match($field) { + 'code' => '코드', 'name' => '이름', 'item_sep' => '대분류', + 'item_bending' => '분류', 'item_name' => '품명', 'material' => '재질', + default => $field, + }; + $errorBag["api_{$field}"] = "[{$fieldLabel}] " . (is_array($msgs) ? implode(', ', $msgs) : $msgs); + } + return back()->withErrors($errorBag)->withInput(); + } + + $itemId = $response->json('data.id'); + + if ($itemId) { + $this->handleImageUpload($request, $itemId); + } + + return redirect()->route('bending.base.index')->with('success', '절곡품이 등록되었습니다.'); + } + + public function update(Request $request, int $id) + { + $validated = $request->validate([ + 'code' => 'required|string|max:100', + 'name' => 'required|string|max:200', + 'item_sep' => 'required|in:스크린,철재', + 'item_bending' => 'required|string|max:50', + 'item_name' => 'required|string|max:50', + 'material' => 'required|string|max:50', + 'model_UA' => 'nullable|in:인정,비인정', + ], [ + 'code.required' => '코드를 입력하세요.', + 'name.required' => '이름을 입력하세요.', + 'item_sep.required' => '대분류를 선택하세요.', + 'item_sep.in' => '대분류는 스크린 또는 철재만 선택 가능합니다.', + 'item_bending.required' => '분류를 선택하세요.', + 'item_name.required' => '품명을 입력하세요.', + 'material.required' => '재질을 입력하세요.', + 'model_UA.in' => '인정여부는 인정 또는 비인정만 선택 가능합니다.', + ]); + + + $data = $this->prepareApiData($request); + $response = $this->api()->put("/api/v1/bending-items/{$id}", $data); + + if (! $response->successful()) { + return back()->withErrors(['api' => $response->json('message', 'API 오류')])->withInput(); + } + + $this->handleImageUpload($request, $id); + + return redirect()->route('bending.base.show', $id)->with('success', '절곡품이 수정되었습니다.'); + } + + public function destroy(int $id) + { + $this->api()->delete("/api/v1/bending-items/{$id}"); + + return redirect()->route('bending.base.index')->with('success', '절곡품이 삭제되었습니다.'); + } + + private function getImageFile(int $itemId): ?array + { + $response = $this->api()->get("/api/v1/items/{$itemId}/files", ['field_key' => 'bending_diagram']); + + if (! $response->successful()) { + return null; + } + + $files = $response->json('data.bending_diagram', []); + + return ! empty($files) ? $files[0] : null; + } + + private function uploadImage(int $itemId, \Illuminate\Http\UploadedFile $file): ?array + { + $existing = $this->getImageFile($itemId); + + $postData = ['field_key' => 'bending_diagram']; + if ($existing) { + $postData['file_id'] = $existing['id']; + } + + $response = $this->api() + ->attach('file', $file->getContent(), $file->getClientOriginalName()) + ->post("/api/v1/items/{$itemId}/files", $postData); + + if (! $response->successful()) { + \Log::error('Bending image upload failed', [ + 'itemId' => $itemId, + 'status' => $response->status(), + 'body' => $response->json(), + ]); + } + + return $response->successful() ? $response->json('data') : null; + } + + /** + * 파일 업로드 또는 Canvas Base64 이미지 처리 + */ + private function handleImageUpload(Request $request, int $itemId): void + { + if ($request->hasFile('image')) { + $this->uploadImage($itemId, $request->file('image')); + } elseif ($request->filled('canvas_image')) { + $this->uploadCanvasImage($itemId, $request->input('canvas_image')); + } + } + + /** + * Canvas Base64 DataURL → 임시 파일 → API 업로드 + */ + private function uploadCanvasImage(int $itemId, string $dataURL): ?array + { + // data:image/png;base64,... 형식 파싱 + if (! preg_match('/^data:image\/(\w+);base64,/', $dataURL, $matches)) { + return null; + } + + $ext = $matches[1] === 'jpeg' ? 'jpg' : $matches[1]; + $base64 = substr($dataURL, strpos($dataURL, ',') + 1); + $binary = base64_decode($base64); + + if ($binary === false) { + return null; + } + + // 임시 파일 생성 + $tmpPath = tempnam(sys_get_temp_dir(), 'canvas_') . '.' . $ext; + file_put_contents($tmpPath, $binary); + + try { + $file = new \Illuminate\Http\UploadedFile($tmpPath, "canvas.{$ext}", "image/{$ext}", null, true); + + return $this->uploadImage($itemId, $file); + } finally { + @unlink($tmpPath); + } + } + + private function prepareApiData(Request $request): array + { + $data = $request->except(['_token', '_method', 'image', 'canvas_image']); + + if (isset($data['bendingData']) && is_string($data['bendingData'])) { + $decoded = json_decode($data['bendingData'], true); + $data['bendingData'] = is_array($decoded) ? $decoded : null; + } + + // 빈 문자열도 전송 (기존 값 삭제 가능하도록) — null만 제거 + return array_filter($data, fn ($v) => $v !== null); + } +} diff --git a/app/Http/Controllers/BendingProductController.php b/app/Http/Controllers/BendingProductController.php new file mode 100644 index 00000000..9268c5ab --- /dev/null +++ b/app/Http/Controllers/BendingProductController.php @@ -0,0 +1,386 @@ + ['title' => '절곡품 (가이드레일)', 'prefix' => 'products', 'label' => '가이드레일'], + 'SHUTTERBOX_MODEL' => ['title' => '케이스 관리', 'prefix' => 'cases', 'label' => '케이스'], + 'BOTTOMBAR_MODEL' => ['title' => '하단마감재 관리', 'prefix' => 'bottombars', 'label' => '하단마감재'], + ]; + + private function api(): \Illuminate\Http\Client\PendingRequest + { + $baseUrl = config('services.api.base_url', 'https://api.sam.kr'); + $token = session('api_access_token', ''); + + return Http::baseUrl($baseUrl) + ->withoutVerifying() + ->withHeaders([ + 'X-API-KEY' => config('services.api.key') ?: '42Jfwc6EaRQ04GNRmLR5kzJp5UudSOzGGqjmdk1a', + 'X-TENANT-ID' => session('selected_tenant_id', 1), + ]) + ->withToken($token) + ->timeout(10); + } + + private function getConfig(string $category): array + { + return self::TYPE_CONFIG[$category] ?? self::TYPE_CONFIG['GUIDERAIL_MODEL']; + } + + public function index(Request $request, string $category = 'GUIDERAIL_MODEL'): View|\Illuminate\Http\Response + { + $config = $this->getConfig($category); + $params = $request->only(['item_sep', 'model_UA', 'check_type', 'model_name', 'exit_direction', 'search', 'page', 'size']); + $params['size'] = $params['size'] ?? 30; + $params['item_category'] = $category; + + $response = $this->api()->get('/api/v1/guiderail-models', $params); + $body = $response->successful() ? $response->json('data', []) : []; + $data = [ + 'data' => $body['data'] ?? [], + 'total' => $body['total'] ?? 0, + 'current_page' => $body['current_page'] ?? 1, + 'last_page' => $body['last_page'] ?? 1, + ]; + + + $filterResponse = $this->api()->get('/api/v1/guiderail-models/filters'); + $filterOptions = $filterResponse->successful() ? $filterResponse->json('data', []) : []; + + if ($request->header('HX-Request')) { + // 필터/검색 HTMX (hx-target="#items-table") → 파셜 반환 + if ($request->header('HX-Target') === 'items-table') { + return view('bending.products.partials.table', ['items' => $data, 'config' => $config, 'category' => $category]); + } + // 사이드바 등 그 외 HTMX → 전체 페이지 리로드 + return response('', 200)->header('HX-Redirect', route("bending.{$config['prefix']}.index", $request->query())); + } + + return view('bending.products.index', [ + 'items' => $data, + 'filterOptions' => $filterOptions, + 'config' => $config, + 'category' => $category, + ]); + } + + public function show(int $id): View + { + $response = $this->api()->get("/api/v1/guiderail-models/{$id}"); + $item = $response->successful() ? $response->json('data') : null; + abort_unless($item, 404); + + $item = $this->enrichComponentsWithSamIds($item); + $config = $this->getConfig($item['item_category'] ?? 'GUIDERAIL_MODEL'); + $imageFile = $this->getImageFile($id, $item['image_file_id'] ?? null); + + return view('bending.products.form', ['item' => $item, 'mode' => 'view', 'config' => $config, 'imageFile' => $imageFile]); + } + + public function create(string $category = 'GUIDERAIL_MODEL'): View + { + $config = $this->getConfig($category); + + return view('bending.products.form', ['item' => null, 'mode' => 'create', 'config' => $config, 'category' => $category, 'imageFile' => null]); + } + + public function edit(int $id): View + { + $response = $this->api()->get("/api/v1/guiderail-models/{$id}"); + $item = $response->successful() ? $response->json('data') : null; + abort_unless($item, 404); + + $item = $this->enrichComponentsWithSamIds($item); + $config = $this->getConfig($item['item_category'] ?? 'GUIDERAIL_MODEL'); + $imageFile = $this->getImageFile($id, $item['image_file_id'] ?? null); + + return view('bending.products.form', ['item' => $item, 'mode' => 'edit', 'config' => $config, 'imageFile' => $imageFile]); + } + + public function store(Request $request, string $category = 'GUIDERAIL_MODEL') + { + $config = $this->getConfig($category); + + if ($category === 'SHUTTERBOX_MODEL') { + $rules = [ + 'exit_direction' => 'required|string', + 'box_width' => 'required|numeric', + 'box_height' => 'required|numeric', + ]; + $messages = [ + 'exit_direction.required' => '점검구 방향을 선택하세요.', + 'box_width.required' => '가로(폭)를 입력하세요.', + 'box_height.required' => '세로(높이)를 입력하세요.', + ]; + } else { + $rules = [ + 'item_sep' => 'required|string|max:20', + 'model_name' => 'required|string|max:50', + ]; + $messages = [ + 'item_sep.required' => '대분류를 선택하세요.', + 'model_name.required' => '모델을 선택하세요.', + ]; + } + + $request->validate($rules, $messages); + + $data = $this->prepareApiData($request); + $response = $this->api()->post('/api/v1/guiderail-models', $data); + + if ($response->successful()) { + $itemId = $response->json('data.id'); + + if ($itemId) { + $this->handleImageUpload($request, $itemId); + } + + if ($itemId && $request->input('_redirect') === 'edit') { + return redirect()->route("bending.{$config['prefix']}.edit", $itemId)->with('success', '등록 후 편집 모드로 전환되었습니다.'); + } + + return redirect()->route("bending.{$config['prefix']}.index")->with('success', "{$config['label']} 모델이 등록되었습니다."); + } + + $body = $response->json(); + $apiErrors = $body['errors'] ?? $body['error']['details'] ?? $body['data']['errors'] ?? []; + $apiMessage = $body['message'] ?? 'API 오류'; + $errorBag = ['api' => "[{$response->status()}] {$apiMessage}"]; + foreach ($apiErrors as $field => $msgs) { + $errorBag["api_{$field}"] = "[{$field}] " . (is_array($msgs) ? implode(', ', $msgs) : $msgs); + } + return back()->withErrors($errorBag)->withInput(); + } + + public function update(Request $request, int $id) + { + $category = $request->input('item_category', 'GUIDERAIL_MODEL'); + + if ($category === 'SHUTTERBOX_MODEL') { + $rules = [ + 'exit_direction' => 'required|string', + 'box_width' => 'required|numeric', + 'box_height' => 'required|numeric', + ]; + $messages = [ + 'exit_direction.required' => '점검구 방향을 선택하세요.', + 'box_width.required' => '가로(폭)를 입력하세요.', + 'box_height.required' => '세로(높이)를 입력하세요.', + ]; + } else { + $rules = [ + 'item_sep' => 'required|string|max:20', + 'model_name' => 'required|string|max:50', + ]; + $messages = [ + 'item_sep.required' => '대분류를 선택하세요.', + 'model_name.required' => '모델을 선택하세요.', + ]; + } + + $request->validate($rules, $messages); + + $data = $this->prepareApiData($request); + $response = $this->api()->put("/api/v1/guiderail-models/{$id}", $data); + + if ($response->successful()) { + $item = $response->json('data'); + $config = $this->getConfig($item['item_category'] ?? 'GUIDERAIL_MODEL'); + + $this->handleImageUpload($request, $id); + + if ($request->input('_redirect') === 'edit') { + return redirect()->route("bending.{$config['prefix']}.edit", $id)->with('success', '저장되었습니다.'); + } + + return redirect()->route("bending.{$config['prefix']}.show", $id)->with('success', "{$config['label']} 모델이 수정되었습니다."); + } + + $body = $response->json(); + $apiErrors = $body['errors'] ?? $body['error']['details'] ?? $body['data']['errors'] ?? []; + $apiMessage = $body['message'] ?? 'API 오류'; + $errorBag = ['api' => "[{$response->status()}] {$apiMessage}"]; + foreach ($apiErrors as $field => $msgs) { + $errorBag["api_{$field}"] = "[{$field}] " . (is_array($msgs) ? implode(', ', $msgs) : $msgs); + } + return back()->withErrors($errorBag)->withInput(); + } + + public function destroy(Request $request, int $id) + { + $response = $this->api()->get("/api/v1/guiderail-models/{$id}"); + $item = $response->successful() ? $response->json('data') : null; + $config = $this->getConfig($item['item_category'] ?? 'GUIDERAIL_MODEL'); + + $this->api()->delete("/api/v1/guiderail-models/{$id}"); + + return redirect()->route("bending.{$config['prefix']}.index")->with('success', "{$config['label']} 모델이 삭제되었습니다."); + } + + public function print(Request $request, int $id) + { + $response = $this->api()->get("/api/v1/guiderail-models/{$id}"); + $item = $response->successful() ? $response->json('data') : null; + abort_unless($item, 404); + + return view('bending.products.print', ['item' => $item]); + } + + public function searchBendingItems(Request $request) + { + $params = $request->only(['item_sep', 'item_bending', 'material', 'search', 'legacy_bending_num', 'size']); + $params['size'] = $params['size'] ?? 100; + + $response = $this->api()->get('/api/v1/bending-items', $params); + $body = $response->successful() ? $response->json('data', []) : []; + + return response()->json([ + 'data' => $body['data'] ?? [], + 'total' => $body['total'] ?? 0, + ]); + } + + private function enrichComponentsWithSamIds(array $item): array + { + if (empty($item['components'])) { + return $item; + } + + $legacyNums = array_filter(array_column($item['components'], 'legacy_bending_num')); + if (empty($legacyNums)) { + return $item; + } + + $response = $this->api()->get('/api/v1/bending-items', ['size' => 200]); + $allItems = $response->successful() ? ($response->json('data.data') ?? []) : []; + + $numToId = []; + foreach ($allItems as $samItem) { + $lbn = $samItem['legacy_bending_num'] ?? null; + if ($lbn !== null) { + $numToId[(string) $lbn] = $samItem['id']; + } + } + + foreach ($item['components'] as &$comp) { + $lbn = $comp['legacy_bending_num'] ?? null; + $comp['sam_item_id'] = $lbn !== null ? ($numToId[(string) $lbn] ?? null) : null; + } + unset($comp); + + return $item; + } + + private function getImageFile(int $itemId, ?int $imageFileId = null): ?array + { + // API Resource에서 image_file_id를 이미 반환 → 그대로 사용 + if ($imageFileId) { + return ['id' => $imageFileId]; + } + + // fallback: fileable 기반 조회 + $response = $this->api()->get("/api/v1/items/{$itemId}/files", ['field_key' => 'bending_diagram']); + if ($response->successful()) { + $files = $response->json('data.bending_diagram', []); + if (! empty($files)) { + return $files[0]; + } + } + + return null; + } + + private function uploadImage(int $itemId, \Illuminate\Http\UploadedFile $file): ?array + { + $existing = $this->getImageFile($itemId); + + $postData = ['field_key' => 'bending_diagram']; + if ($existing) { + $postData['file_id'] = $existing['id']; + } + + $response = $this->api() + ->attach('file', $file->getContent(), $file->getClientOriginalName()) + ->post("/api/v1/items/{$itemId}/files", $postData); + + if (! $response->successful()) { + \Log::error('Model image upload failed', [ + 'itemId' => $itemId, + 'status' => $response->status(), + 'body' => $response->json(), + ]); + } + + return $response->successful() ? $response->json('data') : null; + } + + private function handleImageUpload(Request $request, int $itemId): void + { + if ($request->hasFile('image')) { + $this->uploadImage($itemId, $request->file('image')); + } elseif ($request->filled('canvas_image')) { + $this->uploadCanvasImage($itemId, $request->input('canvas_image')); + } + } + + private function uploadCanvasImage(int $itemId, string $dataURL): ?array + { + if (! preg_match('/^data:image\/(\w+);base64,/', $dataURL, $matches)) { + return null; + } + + $ext = $matches[1] === 'jpeg' ? 'jpg' : $matches[1]; + $binary = base64_decode(substr($dataURL, strpos($dataURL, ',') + 1)); + + if ($binary === false) { + return null; + } + + $tmpPath = tempnam(sys_get_temp_dir(), 'canvas_') . '.' . $ext; + file_put_contents($tmpPath, $binary); + + try { + $file = new \Illuminate\Http\UploadedFile($tmpPath, "canvas.{$ext}", "image/{$ext}", null, true); + + return $this->uploadImage($itemId, $file); + } finally { + @unlink($tmpPath); + } + } + + private function prepareApiData(Request $request): array + { + $data = $request->except(['_token', '_method', '_redirect', 'image', 'canvas_image']); + + if (isset($data['components']) && is_string($data['components'])) { + $decoded = json_decode($data['components'], true); + $data['components'] = is_array($decoded) ? $decoded : null; + } + if (isset($data['material_summary']) && is_string($data['material_summary'])) { + $decoded = json_decode($data['material_summary'], true); + $data['material_summary'] = is_array($decoded) ? $decoded : null; + } + + // 빈 문자열도 전송 (기존 값 삭제 가능하도록) — null만 제거 + $data = array_filter($data, fn ($v) => $v !== null); + + if (empty($data['code'])) { + $modelName = $data['model_name'] ?? ''; + $itemSep = $data['item_sep'] ?? ''; + $data['code'] = trim("{$itemSep}_{$modelName}_" . date('ymd_His')); + } + if (empty($data['name'])) { + $data['name'] = $data['model_name'] ?? $data['code']; + } + + return $data; + } +} diff --git a/app/Http/Controllers/FileViewController.php b/app/Http/Controllers/FileViewController.php new file mode 100644 index 00000000..5730ada5 --- /dev/null +++ b/app/Http/Controllers/FileViewController.php @@ -0,0 +1,41 @@ +withoutVerifying() + ->withHeaders([ + 'X-API-KEY' => $apiKey, + 'X-TENANT-ID' => 287, // TODO: session('selected_tenant_id', 1) 로 복원 + ]) + ->withToken($token) + ->timeout(15) + ->get("/api/v1/files/{$id}/view"); + + if (! $response->successful()) { + abort(404); + } + + return response($response->body(), 200, [ + 'Content-Type' => $response->header('Content-Type', 'image/png'), + 'Content-Disposition' => 'inline', + 'Cache-Control' => 'private, max-age=3600', + ]); + } +} diff --git a/app/Http/Controllers/TenantConsoleController.php b/app/Http/Controllers/TenantConsoleController.php new file mode 100644 index 00000000..2431acce --- /dev/null +++ b/app/Http/Controllers/TenantConsoleController.php @@ -0,0 +1,62 @@ +attributes->get('tenant_console'); + + return view('tenant-console.index', [ + 'tenant' => $tenant, + 'tenantId' => $tenantId, + ]); + } + + /** + * Catch-all: 메인 라우트의 컨트롤러를 찾아서 실행 + * /tenant-console/{tenantId}/{path} → /{path} 에 매칭되는 메인 라우트 컨트롤러 호출 + */ + public function dispatch(Request $request, int $tenantId, string $path) + { + $url = '/' . ltrim($path, '/'); + $method = $request->method(); + + // 메인 라우트에서 매칭되는 라우트 찾기 + $fakeRequest = Request::create($url, $method, $request->all()); + $fakeRequest->headers->replace($request->headers->all()); + + try { + $route = app(Router::class)->getRoutes()->match($fakeRequest); + } catch (\Symfony\Component\HttpKernel\Exception\NotFoundHttpException $e) { + abort(404, "라우트를 찾을 수 없습니다: {$url}"); + } catch (\Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException $e) { + abort(405, "허용되지 않는 메서드: {$method} {$url}"); + } + + $action = $route->getAction(); + + if (! isset($action['uses']) || ! is_string($action['uses'])) { + abort(404); + } + + // 라우트 파라미터 바인딩 + $route->bind($fakeRequest); + $params = $route->parameters(); + + // 컨트롤러 실행 + [$controllerClass, $controllerMethod] = explode('@', $action['uses']); + $controller = app($controllerClass); + + return app()->call([$controller, $controllerMethod], $params); + } +} diff --git a/app/Http/Middleware/SetTenantContext.php b/app/Http/Middleware/SetTenantContext.php new file mode 100644 index 00000000..832b2d69 --- /dev/null +++ b/app/Http/Middleware/SetTenantContext.php @@ -0,0 +1,131 @@ +route('tenantId'); + + if (! $tenantId) { + abort(404, '테넌트 ID가 필요합니다.'); + } + + $tenant = Tenant::find($tenantId); + + if (! $tenant) { + abort(404, '테넌트를 찾을 수 없습니다.'); + } + + // 요청 범위에서만 테넌트 컨텍스트 설정 (세션 변경 없음) + $request->attributes->set('tenant_console_id', $tenantId); + $request->attributes->set('tenant_console', $tenant); + $request->attributes->set('tenant_id', (int) $tenantId); + + // 뷰에서 사용할 수 있도록 공유 + view()->share('consoleTenant', $tenant); + view()->share('consoleTenantId', $tenantId); + view()->share('isTenantConsole', true); + + // layouts.app → layouts.tenant-console 자동 전환 + // tenant-override 디렉토리를 뷰 경로 최우선으로 추가 + app('view')->getFinder()->prependLocation(resource_path('views/tenant-override')); + + $response = $next($request); + + // 리다이렉트 응답: URL을 tenant-console 경로로 재작성 + if ($response instanceof RedirectResponse) { + $response = $this->rewriteRedirect($response, $tenantId); + } + + // JSON 응답: redirect 키의 URL 재작성 + if ($response instanceof JsonResponse) { + $response = $this->rewriteJsonRedirect($response, $tenantId); + } + + // HX-Redirect 헤더 재작성 + if ($response->headers->has('HX-Redirect')) { + $hxUrl = $response->headers->get('HX-Redirect'); + $response->headers->set('HX-Redirect', $this->prefixUrl($hxUrl, $tenantId)); + } + + return $response; + } + + /** + * 리다이렉트 URL에 tenant-console 프리픽스 추가 + */ + private function rewriteRedirect(RedirectResponse $response, int $tenantId): RedirectResponse + { + $targetUrl = $response->getTargetUrl(); + $response->setTargetUrl($this->prefixUrl($targetUrl, $tenantId)); + + return $response; + } + + /** + * JSON 응답의 redirect 키 URL 재작성 + */ + private function rewriteJsonRedirect(JsonResponse $response, int $tenantId): JsonResponse + { + $data = $response->getData(true); + + if (isset($data['redirect']) && is_string($data['redirect'])) { + $data['redirect'] = $this->prefixUrl($data['redirect'], $tenantId); + $response->setData($data); + } + + return $response; + } + + /** + * URL에 /tenant-console/{tenantId} 프리픽스 추가 + * 이미 tenant-console 경로면 그대로 반환 + */ + private function prefixUrl(string $url, int $tenantId): string + { + $consolePrefix = "/tenant-console/{$tenantId}"; + + // 이미 tenant-console 경로면 그대로 + if (str_contains($url, '/tenant-console/')) { + return $url; + } + + // 절대 URL (https://...) 에서 path 추출 + $parsed = parse_url($url); + $path = $parsed['path'] ?? '/'; + + // /api/ 경로는 재작성하지 않음 + if (str_starts_with($path, '/api/')) { + return $url; + } + + // 프리픽스 추가 + $newPath = $consolePrefix . $path; + $query = isset($parsed['query']) ? '?' . $parsed['query'] : ''; + $fragment = isset($parsed['fragment']) ? '#' . $parsed['fragment'] : ''; + + // 호스트가 있으면 포함 + if (isset($parsed['scheme'])) { + $host = $parsed['scheme'] . '://' . $parsed['host']; + $port = isset($parsed['port']) ? ':' . $parsed['port'] : ''; + + return $host . $port . $newPath . $query . $fragment; + } + + return $newPath . $query . $fragment; + } +} diff --git a/app/Http/Middleware/SetTenantFromApiRequest.php b/app/Http/Middleware/SetTenantFromApiRequest.php new file mode 100644 index 00000000..81a9d0d7 --- /dev/null +++ b/app/Http/Middleware/SetTenantFromApiRequest.php @@ -0,0 +1,44 @@ +input('tenant_id'); + $consoleTenantId = $request->input('tenant_console_id'); + + // 테넌트 콘솔에서 온 요청이면 세션을 임시로 설정 + if ($consoleTenantId && $requestTenantId) { + $originalTenantId = session('selected_tenant_id'); + + // 요청 범위에서만 세션 덮어쓰기 + session(['selected_tenant_id' => (int) $requestTenantId]); + + // 요청 속성에도 테넌트 콘솔 컨텍스트 설정 + $request->attributes->set('tenant_console_id', $consoleTenantId); + + $response = $next($request); + + // 원래 세션 값 복원 + if ($originalTenantId !== null) { + session(['selected_tenant_id' => $originalTenantId]); + } else { + session()->forget('selected_tenant_id'); + } + + return $response; + } + + return $next($request); + } +} diff --git a/claudedocs/legacy-mng-gap-checklist.md b/claudedocs/legacy-mng-gap-checklist.md new file mode 100644 index 00000000..620d6023 --- /dev/null +++ b/claudedocs/legacy-mng-gap-checklist.md @@ -0,0 +1,260 @@ +# 절곡 레거시 → MNG 마이그레이션 Gap 체크리스트 + +> **작성일**: 2026-03-18 +> **대상**: 절곡품 관리 시스템 (bending) +> **상태**: 마이그레이션 진행 중 + +--- + +## 📋 파일 구조 + +| 영역 | 파일 | 비고 | +|------|------|------| +| **컨트롤러** | `BendingBaseController.php` (237줄) | 기초관리 | +| **컨트롤러** | `BendingProductController.php` (372줄) | 제품(3종) | +| **리스트** | `bending/products/index.blade.php` | 3종 공유 | +| **테이블** | `bending/products/partials/table.blade.php` | 3종 공유 | +| **상세/수정** | `bending/products/form.blade.php` (69.6KB) | 3종 공유 | +| **인쇄** | `bending/products/print.blade.php` | 3종 공유 | +| **기초 리스트** | `bending/base/index.blade.php` | | +| **기초 테이블** | `bending/base/partials/table.blade.php` | | +| **기초 폼** | `bending/base/form.blade.php` | | +| **캔버스 에디터** | `public/js/canvas-editor.js` (381줄) | Fabric.js | + +--- + +## ✅ 완료 작업 + +### Git 커밋 이력 +| 커밋 | 내용 | +|------|------| +| `4a7cc620` | feat: 절곡품 기초관리 MNG 화면 구현 | +| `0501d90b` | feat: 절곡품 관리 MNG 화면 전체 구현 | +| `efab4c6b` | fix: 사이드바 HTMX 네비게이션 시 데이터 미로드 수정 | +| `d8429d39` | fix: 신규 등록 시 부품 추가 오류 수정 | +| `cf65ca90` | refactor: 서버 validate 추가 및 에러 표시 개선 | +| `d861d177` | fix: 사이드바 네비게이션 시 빈 화면 수정 | +| `9cc9c868` | fix: HX-Target 기반 파셜/리디렉트 분기 | +| `4a4e39d0` | fix: X-TENANT-ID 287 고정 복원 + 디버그 로그 제거 | +| `fa4e05bb` | fix: 절곡품 수정 시 성공 토스트 미표시 수정 | +| `e8fa15c2` | fix: Toastify→showToast 수정 | + +### 미커밋 작업 (2026-03-18, 9파일 +983/-328줄) +- [x] 리스트 테이블 헤더: "지시서" → "작업지시서" +- [x] 리스트 테이블 버튼: "지시서" → "보기" +- [x] 상세/수정 페이지: `` → 모달창(`openPrintModal`)으로 변경 +- [x] 상세/수정 페이지: printModal dialog + iframe 추가 +- [x] 상세/수정 모달 크기 확대: 1100×80vh → 1400×90vh + +--- + +## 🔴 수정 필요 (Critical) + +### C1. Tenant ID 하드코딩 +- **위치**: `BendingBaseController.php:20`, `BendingProductController.php:26` +- **현재**: `'X-TENANT-ID' => 287` +- **수정**: `session('selected_tenant_id', 287)` — TODO 주석 있음 +- **상태**: [ ] 미수정 + +### C2. API 키 하드코딩 +- **위치**: 두 컨트롤러 +- **현재**: `'42Jfwc6EaRQ04GNRmLR5kzJp5UudSOzGGqjmdk1a'` 직접 노출 +- **수정**: `config('services.api.key')` 만 사용, fallback 제거 +- **상태**: [ ] 미수정 + +### C3. print.blade.php `` 하드코딩 +- **위치**: `print.blade.php:5` +- **현재**: `<title>절곡 바라시 작업지시서 — ...` (가이드레일 전용) +- **문제**: 케이스/하단마감재에서도 "절곡 바라시" 표시 +- **수정**: `$docTitle` 변수 사용 (본문은 이미 분기됨) +- **상태**: [ ] 미수정 + +### C4. 사이드바 메뉴 미등록 +- **위치**: SIDEBAR_MENU_GUIDE.md, sidebar.blade.php +- **현재**: "생산 관리" 그룹에 절곡 메뉴 없음 (전부 # 미구현) +- **필요**: `/bending/products`, `/bending/cases`, `/bending/bottombars`, `/bending/base` 등록 +- **상태**: [ ] 미수정 + +--- + +## 🟡 개선 필요 (Important) + +### I1. api() 메서드 중복 +- **위치**: BendingBaseController, BendingProductController에 동일 코드 +- **수정**: 부모 클래스 또는 Trait로 통합 +- **상태**: [ ] 미수정 + +### I2. 이미지 업로드 코드 중복 +- **메서드**: `getImageFile()`, `uploadImage()`, `uploadCanvasImage()`, `handleImageUpload()` +- **위치**: 두 컨트롤러에 동일 구현 +- **수정**: Trait 또는 서비스 클래스로 추출 +- **상태**: [ ] 미수정 + +### I3. form.blade.php 크기 (69.6KB) +- **위치**: `products/form.blade.php` +- **문제**: 폼, 모달, 캔버스 에디터, 컴포넌트 검색 등 한 파일 +- **수정**: partials 분리 (`_component-search`, `_canvas-editor` 등) +- **상태**: [ ] 미수정 + +### I4. 리스트↔상세 모달 크기 불일치 +- **리스트 모달**: `width:1100px; height:80vh` +- **상세 모달**: `width:1400px; height:90vh` +- **상태**: [ ] 통일 여부 확인 필요 + +### I5. 에러 핸들링 미흡 +- **현재**: API 실패 시 빈 배열만 반환, 사용자 알림 없음 +- **수정**: API 오류 시 토스트 메시지 표시 +- **상태**: [ ] 미수정 + +### I6. FormRequest 미사용 +- **현재**: 유효성 검증이 컨트롤러에 직접 구현 +- **수정**: `StoreBendingItemRequest`, `StoreBendingProductRequest` 등 분리 +- **상태**: [ ] 미수정 + +--- + +## 🟢 개선 권장 (Nice to have) + +### N1. 기초관리에 작업지시서 연동 +- **현재**: base/form.blade.php에 작업지시서 버튼/모달 없음 +- **확인 필요**: 기초관리에서도 작업지시서가 필요한지? +- **상태**: [ ] 확인 대기 + +### N2. 기초관리 리스트에 작업지시서 컬럼 +- **현재**: base/partials/table.blade.php에 "보기" 버튼 없음 +- **제품 테이블과 불일치** +- **상태**: [ ] 확인 대기 + +### N3. 케이스 필드 표시 조건 차이 +- **base**: `$itemBending === '케이스'` (문자열 비교) +- **products**: `$itemCategory === 'SHUTTERBOX_MODEL'` (상수 비교) +- **수정**: 판단 기준 통일 +- **상태**: [ ] 미수정 + +### N4. 삭제 confirm → 모달 확인창 +- **현재**: `return confirm('삭제하시겠습니까?')` — 브라우저 기본 +- **수정**: 다른 페이지와 통일된 모달 확인창 +- **상태**: [ ] 미수정 + +### N5. 검색 결과 하이라이트 +- **현재**: 리스트 테이블에서 검색어 매칭 하이라이트 없음 +- **상태**: [ ] 미수정 + +### N6. 코드 자동 생성 로직 +- **현재**: `{item_sep}_{model_name}_{date_ymdHis}` 인라인 생성 +- **수정**: 서비스 또는 번호 규칙(NumberingRule) 적용 +- **상태**: [ ] 미수정 + +--- + +## 📊 필드 매핑 (레거시 → MNG) + +### 기초관리 (bending-items) + +| 필드 | API 키 | UI 표시명 | 필수 | 비고 | +|------|--------|----------|:---:|------| +| 코드 | code | 코드 | | 자동생성 | +| 이름 | name | 이름 | | | +| 품명 | item_name | 품명 | ✅ | | +| 대분류 | item_sep | 대분류 | ✅ | 스크린/철재 | +| 분류 | item_bending | 분류 | ✅ | 가이드레일/케이스/... | +| 재질 | material | 재질 | | | +| 규격 | item_spec | 규격 | | | +| 모델 | model_name | 모델 | | datalist | +| 인정 | model_UA | 인정 | | 인정/비인정 | +| 등록일 | registration_date | 등록일 | | | +| 작성자 | author | 작성자 | | | +| 검색어 | search_keyword | 검색어 | | | +| 수정자 | modified_by | 수정자 | | 자동 | +| 메모 | memo | 메모 | | textarea | +| 절곡 데이터 | bendingData | 절곡표 | | JSON array | +| 이미지 | image_file_id | 이미지 | | file/canvas | + +### 제품 (guiderail-models) — 공통 필드 + +| 필드 | API 키 | UI 표시명 | 필수 | 비고 | +|------|--------|----------|:---:|------| +| 코드 | code | 코드 | | 자동생성 | +| 이름 | name | 이름 | | 자동생성 | +| 카테고리 | item_category | - | ✅ | GUIDERAIL/SHUTTERBOX/BOTTOMBAR | +| 검색어 | search_keyword | 검색어 | | | +| 수정자 | modified_by | 수정자 | | | +| 부품 | components | 부품 테이블 | | JSON array | +| 자재량 | material_summary | 소요자재량 | | JSON object | +| 이미지 | image_file_id | 이미지 | | file/canvas | + +### 제품 — 가이드레일 전용 + +| 필드 | API 키 | 필수 | +|------|--------|:---:| +| 모델명 | model_name | ✅ | +| 대분류 | item_sep | ✅ | +| 인정 | model_UA | | +| 형상 | check_type | | +| 레일폭 | rail_width | | +| 레일높이 | rail_length | | +| 마감 | finishing_type | | + +### 제품 — 케이스 전용 + +| 필드 | API 키 | 필수 | +|------|--------|:---:| +| 점검구 | exit_direction | ✅ | +| 박스가로 | box_width | ✅ | +| 박스세로 | box_height | ✅ | +| 전면밑 | front_bottom_width | | +| 레일폭 | rail_width | | + +### 제품 — 하단마감재 전용 + +| 필드 | API 키 | 필수 | +|------|--------|:---:| +| 모델명 | model_name | ✅ | +| 대분류 | item_sep | ✅ | +| 인정 | model_UA | | +| 가로 | bar_width | | +| 세로 | bar_height | | +| 마감 | finishing_type | | + +--- + +## 🔗 API 엔드포인트 + +| 용도 | Method | Endpoint | +|------|--------|----------| +| 기초 목록 | GET | `/api/v1/bending-items` | +| 기초 필터 | GET | `/api/v1/bending-items/filters` | +| 기초 상세 | GET | `/api/v1/bending-items/{id}` | +| 기초 생성 | POST | `/api/v1/bending-items` | +| 기초 수정 | PUT | `/api/v1/bending-items/{id}` | +| 기초 삭제 | DELETE | `/api/v1/bending-items/{id}` | +| 제품 목록 | GET | `/api/v1/guiderail-models` | +| 제품 필터 | GET | `/api/v1/guiderail-models/filters` | +| 제품 상세 | GET | `/api/v1/guiderail-models/{id}` | +| 제품 생성 | POST | `/api/v1/guiderail-models` | +| 제품 수정 | PUT | `/api/v1/guiderail-models/{id}` | +| 제품 삭제 | DELETE | `/api/v1/guiderail-models/{id}` | +| 이미지 조회 | GET | `/api/v1/items/{id}/files` | +| 이미지 업로드 | POST | `/api/v1/items/{id}/files` | +| 부품 검색 | GET | `/bending/base/api-search` (MNG 라우트) | + +--- + +## 📊 진행률 요약 + +| 카테고리 | 완료 | 미완 | 진행률 | +|----------|:---:|:---:|:---:| +| CRUD 기능 | 8/8 | 0 | 100% | +| 뷰 템플릿 | 7/7 | 0 | 100% | +| 라우트 | 16/16 | 0 | 100% | +| UI 통일 (명칭/모달) | 5/5 | 0 | 100% | +| 코드 품질 (중복제거) | 0/3 | 3 | 0% | +| 보안 (하드코딩 제거) | 0/2 | 2 | 0% | +| 사이드바 등록 | 0/1 | 1 | 0% | +| 에러 핸들링 | 0/1 | 1 | 0% | +| 기타 개선 | 0/6 | 6 | 0% | +| **전체** | **20/49** | **13** | **~60%** | + +--- + +**최종 업데이트**: 2026-03-18 diff --git a/claudedocs/tenant-console-context-fix-report.md b/claudedocs/tenant-console-context-fix-report.md new file mode 100644 index 00000000..c11ad28f --- /dev/null +++ b/claudedocs/tenant-console-context-fix-report.md @@ -0,0 +1,136 @@ +# 테넌트 콘솔 컨텍스트 수정 리포트 + +**작성일**: 2026-03-12 +**브랜치**: sam-kkk + +--- + +## 1. 문제 요약 + +테넌트 콘솔(`/tenant-console/{tenantId}/*`)에서: +- API 라우트 호출 시 `session('selected_tenant_id')`가 메인창 값으로 폴백 → 다른 테넌트 데이터 표시/생성 +- 라우트 파라미터 충돌: `{tenantId}`가 `{id}` 대신 컨트롤러에 전달 +- `TenantScope` 글로벌 스코프가 테넌트 콘솔 컨텍스트를 인식하지 못함 +- 일부 뷰에서 레이아웃/링크가 테넌트 콘솔 미대응 + +--- + +## 2. 솔루션 아키텍처 (5-Layer) + +### Layer 1: Frontend 글로벌 인터셉터 +**파일**: `resources/views/layouts/tenant-console.blade.php` + +| 호출 방식 | 처리 | +|----------|------| +| HTMX | `htmx:configRequest`에서 `tenant_id` + `tenant_console_id` 자동 추가 | +| fetch GET | URL 쿼리스트링에 추가 | +| fetch POST/PUT/DELETE | JSON body에 추가 | + +### Layer 2: API 미들웨어 +**파일**: `app/Http/Middleware/SetTenantFromApiRequest.php` + +`tenant_console_id` 감지 → 세션 임시 덮어쓰기 → 컨트롤러 실행 → 세션 복원 + +### Layer 3: 개별 컨트롤러/서비스 보강 +명시적 `$request->input('tenant_id')` > `session()` 우선순위 적용. + +### Layer 4: 라우트 파라미터 충돌 수정 +`$id = (int) (request()->route('id') ?? $id);` 로 named parameter 명시적 추출. + +| 컨트롤러 | 메서드 | +|----------|--------| +| `PermissionController` | `edit()` | +| `RoleController` | `edit()` | +| `BoardController` | `edit()` | +| `AuditLogController` | `show()` | +| `CommonCodeController` | `update()`, `toggle()`, `copy()`, `promoteToGlobal()`, `destroy()` | + +### Layer 5: TenantScope 글로벌 스코프 연동 +**파일**: `app/Http/Middleware/SetTenantContext.php` + +`$request->attributes->set('tenant_id', (int) $tenantId)` 추가. +→ `TenantScope`가 `$request->attributes->get('tenant_id')`로 올바른 tenantId 사용. +→ `BelongsToTenant` trait 사용 모델(Category 등)이 테넌트 콘솔에서 정상 동작. + +--- + +## 3. 수정 파일 목록 + +### 신규 파일 +| 파일 | 설명 | +|------|------| +| `app/Http/Middleware/SetTenantFromApiRequest.php` | API 요청 시 세션 임시 설정 | +| `app/Helpers/TenantHelper.php` | 테넌트 콘솔 컨텍스트 헬퍼 | +| `app/Http/Controllers/TenantConsoleController.php` | 테넌트 콘솔 컨트롤러 | +| `app/Http/Middleware/SetTenantContext.php` | 테넌트 콘솔 URL에서 컨텍스트 설정 | +| `resources/views/layouts/tenant-console.blade.php` | 테넌트 콘솔 레이아웃 | + +### 미들웨어/인프라 +| 파일 | 변경 | +|------|------| +| `bootstrap/app.php` | `SetTenantFromApiRequest` 미들웨어 등록 | +| `app/Http/Middleware/SetTenantContext.php` | `tenant_id` attribute 추가 (Layer 5) | + +### 웹 컨트롤러 (Layer 4) +| 파일 | 변경 | +|------|------| +| `PermissionController.php` | `edit()`: route('id') 추출 | +| `RoleController.php` | `edit()`: route('id') 추출 | +| `BoardController.php` | `edit()`: route('id') 추출 | +| `AuditLogController.php` | `show()`: route('id') 추출 + `index()`: 테넌트 필터링 | +| `CommonCodeController.php` | 5개 메서드: route('id') 추출 | + +### API 컨트롤러 (Layer 3) +| 파일 | 변경 | +|------|------| +| `Api/Admin/BoardController.php` | HTMX 응답에 `$isTenantConsole` 전달 | +| `Api/Admin/PermissionController.php` | HTMX 응답에 `$isTenantConsole` 전달 | +| `Api/Admin/RoleController.php` | HTMX 응답에 `$isTenantConsole` 전달 | +| `Api/Admin/RolePermissionController.php` | 명시적 `tenant_id` 우선 | +| `Api/Admin/DepartmentPermissionController.php` | 명시적 `tenant_id` 우선 | +| `Api/Admin/CategoryApiController.php` | 4개 메서드에 명시적 `tenant_id` 추가 | + +### 서비스 레이어 +| 파일 | 변경 | +|------|------| +| `BoardService.php` | 명시적 `tenant_id` > TenantHelper > 세션 | +| `PermissionService.php` | 명시적 `tenant_id` > 세션 | +| `RoleService.php` | 명시적 `tenant_id` > 세션 | + +### 뷰 +| 파일 | 변경 | +|------|------| +| `boards/index.blade.php` | fetch에 tenant 파라미터 추가 | +| `boards/create.blade.php` | 리다이렉트 URL + 자동 테넌트 선택 | +| `permissions/index.blade.php` | hidden input 추가 | +| `roles/index.blade.php` | hidden input 추가 | +| `audit-logs/index.blade.php` | 라우트 파라미터명 수정, `TenantHelper::route()` | +| `audit-logs/show.blade.php` | 테넌트 콘솔 레이아웃 적용 + 뒤로가기 링크 수정 | +| `quote-formulas/index.blade.php` | 테넌트 콘솔 시 `target="_blank"` 추가 | +| `system/alerts/index.blade.php` | `TenantHelper::route()` 변환 (4개소) | + +--- + +## 4. 현재 처리 상태 + +| 항목 | 상태 | 설명 | +|------|------|------| +| FormData POST | ✅ 해결됨 | Layer 1 fetch 래퍼에서 JSON body 자동 처리. FormData 사용 페이지는 현재 없음 | +| 견적수식 하위 | ✅ 대응완료 | categories/simulator/create → `target="_blank"` 새창 처리로 정상 동작 | +| TenantScope 캐시 | ✅ 정상동작 | Layer 5에서 `tenant_id` attribute 설정 → static 캐시가 올바른 tenantId로 초기화됨 | + +--- + +## 5. 자동 처리 현황 (Layer 2) + +`SetTenantFromApiRequest` 미들웨어로 **80+ API 컨트롤러**의 `session('selected_tenant_id')` 호출이 자동 처리됨. +개별 수정 불필요한 주요 컨트롤러: `ItemFieldController`(18회), `DocumentApiController`(11회), `ApprovalApiController`(10회), `DocumentTemplateApiController`(7회) 등. + +--- + +## 6. 설계 확인 사항 + +| 항목 | 동작 | 비고 | +|------|------|------| +| 공용/시스템 게시판 | 테넌트 콘솔에 미표시 | 의도된 설계 — `BoardService::getAllBoards()`에서 `where('tenant_id', $consoleTenantId)`로 해당 테넌트 게시판만 조회 | +| 카테고리 빈 목록 | 테넌트별 카테고리 없으면 빈 목록 정상 | DB에 해당 테넌트 데이터 없는 경우 (버그 아님) | diff --git a/public/js/canvas-editor.js b/public/js/canvas-editor.js new file mode 100644 index 00000000..d2910353 --- /dev/null +++ b/public/js/canvas-editor.js @@ -0,0 +1,380 @@ +/** + * Canvas Editor - Fabric.js 기반 이미지 편집기 + * 5130 레거시 imageEditor.js → MNG 이식 + * + * 사용법: + * CanvasEditor.open(existingImageUrl) + * .then(dataURL => { ... }) // 적용 시 + * .catch(() => { ... }); // 취소 시 + */ +const CanvasEditor = (() => { + let canvas = null; + let initialized = false; + + // 상태 + let mode = 'polyline'; + let selectMode = false; + let isLine = false; + let lineObj = null; + let polyPoints = []; + let previewLine = null; + let isPreview = true; + let isRight = true; + let currentColor = '#000000'; + let freeBrush, eraserBrush; + + // Promise resolve/reject + let _resolve, _reject; + + function getEl(id) { return document.getElementById(id); } + + // ── 지우개 SVG 커서 ── + function updateEraserCursor(r) { + const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${r}" height="${r}"><circle cx="${r/2}" cy="${r/2}" r="${r/2}" stroke="black" fill="none"/></svg>`; + canvas.freeDrawingCursor = `url("data:image/svg+xml,${encodeURIComponent(svg)}") ${r/2} ${r/2}, auto`; + } + + // ── 툴바 하이라이트 ── + function highlight() { + document.querySelectorAll('#ce-dialog .ce-tool-btn').forEach(b => b.classList.remove('ring-2', 'ring-blue-500', 'bg-blue-100')); + const map = { + polyline: 'ce-polyBtn', free: 'ce-freeBtn', line: 'ce-lineBtn', + text: 'ce-textBtn', eraser: 'ce-eraserBtn', select: 'ce-selectBtn' + }; + const btn = getEl(map[mode]); + if (btn) btn.classList.add('ring-2', 'ring-blue-500', 'bg-blue-100'); + } + + // ── 모드 전환 ── + function setMode(m) { + if (m === 'polyline') { + polyPoints = []; + isPreview = true; + if (previewLine) { canvas.remove(previewLine); previewLine = null; } + isLine = false; lineObj = null; + } else if (mode === 'polyline') { + if (previewLine) { canvas.remove(previewLine); previewLine = null; } + polyPoints = []; + } + mode = m; + canvas.isDrawingMode = (m === 'free' || m === 'eraser'); + if (m === 'free') canvas.freeDrawingBrush = freeBrush; + if (m === 'eraser') canvas.freeDrawingBrush = eraserBrush; + selectMode = (m === 'select'); + canvas.selection = selectMode; + canvas.upperCanvasEl.style.cursor = (m === 'eraser' ? canvas.freeDrawingCursor : 'crosshair'); + canvas.getObjects().forEach(o => { + if (o !== canvas.backgroundImage) { + o.selectable = selectMode; + o.evented = selectMode; + } + }); + highlight(); + } + + // ── 캔버스 초기화 ── + function initCanvas() { + const canvasEl = getEl('ce-canvas'); + canvas = new fabric.Canvas(canvasEl, { selection: false }); + fabric.Text.prototype.textBaseline = 'alphabetic'; + if (fabric.IText) fabric.IText.prototype.textBaseline = 'alphabetic'; + + freeBrush = canvas.freeDrawingBrush; + freeBrush.width = 2; + freeBrush.color = currentColor; + eraserBrush = new fabric.PencilBrush(canvas); + eraserBrush.width = 20; + eraserBrush.color = '#ffffff'; + updateEraserCursor(20); + + // ── 마우스 이벤트 ── + canvas.on('mouse:down', opt => { + const p = canvas.getPointer(opt.e); + + if (mode === 'line') { + isLine = true; + lineObj = new fabric.Line([p.x, p.y, p.x, p.y], { + stroke: currentColor, strokeWidth: 2, + originX: 'center', originY: 'center', selectable: selectMode + }); + canvas.add(lineObj); + + } else if (mode === 'polyline') { + if (polyPoints.length === 0) isPreview = true; + let x = p.x, y = p.y; + if (isRight && polyPoints.length) { + const prev = polyPoints[polyPoints.length - 1]; + if (Math.abs(x - prev.x) > Math.abs(y - prev.y)) y = prev.y; + else x = prev.x; + } + if (polyPoints.length) { + const prev = polyPoints[polyPoints.length - 1]; + canvas.add(new fabric.Line([prev.x, prev.y, x, y], { + stroke: currentColor, strokeWidth: 2, + originX: 'center', originY: 'center', selectable: selectMode + })); + } + polyPoints.push({ x, y }); + if (previewLine) { canvas.remove(previewLine); previewLine = null; } + + } else if (mode === 'text') { + document.querySelectorAll('.ce-text-input').forEach(el => el.remove()); + const ta = document.createElement('textarea'); + ta.className = 'ce-text-input'; + Object.assign(ta.style, { + position: 'absolute', left: p.x + 'px', top: p.y + 'px', + fontSize: '14px', zIndex: '100', border: '1px solid #3b82f6', + borderRadius: '2px', padding: '2px 4px', outline: 'none', + background: 'rgba(255,255,255,0.9)', minWidth: '60px' + }); + ta.rows = 1; + getEl('ce-body').appendChild(ta); + setTimeout(() => ta.focus(), 0); + ta.addEventListener('keydown', ev => { + if (ev.key === 'Enter') { + ev.preventDefault(); + const txt = ta.value.trim(); + if (txt) canvas.add(new fabric.Text(txt, { left: p.x, top: p.y, fontSize: 14, fill: currentColor })); + ta.remove(); + } else if (ev.key === 'Escape') { ta.remove(); } + }); + } + }); + + canvas.on('mouse:move', opt => { + const p = canvas.getPointer(opt.e); + if (mode === 'line' && isLine) { + let x2 = p.x, y2 = p.y; + if (isRight) { + if (Math.abs(x2 - lineObj.x1) > Math.abs(y2 - lineObj.y1)) y2 = lineObj.y1; + else x2 = lineObj.x1; + } + lineObj.set({ x2, y2 }); + canvas.requestRenderAll(); + + } else if (mode === 'polyline' && polyPoints.length > 0 && isPreview) { + const L = polyPoints[polyPoints.length - 1]; + let x2 = p.x, y2 = p.y; + if (isRight) { + if (Math.abs(x2 - L.x) > Math.abs(y2 - L.y)) y2 = L.y; + else x2 = L.x; + } + if (previewLine) { + previewLine.set({ x1: L.x, y1: L.y, x2, y2 }); + } else { + previewLine = new fabric.Line([L.x, L.y, x2, y2], { + stroke: 'gray', strokeWidth: 1, strokeDashArray: [5, 5], selectable: false + }); + canvas.add(previewLine); + } + canvas.requestRenderAll(); + } + }); + + canvas.on('mouse:up', () => { + if (mode === 'line') { isLine = false; lineObj = null; } + }); + + canvas.on('path:created', e => { + e.path.selectable = selectMode; + e.path.stroke = (mode === 'eraser') ? '#ffffff' : currentColor; + }); + + initialized = true; + } + + // ── 이벤트 바인딩 ── + function bindEvents() { + const dialog = getEl('ce-dialog'); + + // 도구 버튼 + const modeMap = { + 'ce-polyBtn': 'polyline', 'ce-freeBtn': 'free', 'ce-lineBtn': 'line', + 'ce-textBtn': 'text', 'ce-eraserBtn': 'eraser', 'ce-selectBtn': 'select' + }; + Object.entries(modeMap).forEach(([id, m]) => { + const el = getEl(id); + if (el) el.onclick = () => setMode(m); + }); + + // 전체 지우기 + const clearBtn = getEl('ce-clearBtn'); + if (clearBtn) clearBtn.onclick = () => { + canvas.clear(); + canvas.setBackgroundColor('#ffffff', canvas.renderAll.bind(canvas)); + polyPoints = []; + if (previewLine) { canvas.remove(previewLine); previewLine = null; } + isLine = false; lineObj = null; isPreview = true; + setMode('polyline'); + }; + + // 색상 + document.querySelectorAll('#ce-colors .ce-color-btn').forEach(btn => { + btn.onclick = () => { + document.querySelectorAll('#ce-colors .ce-color-btn').forEach(b => b.classList.remove('ring-2', 'ring-offset-1', 'ring-gray-800')); + btn.classList.add('ring-2', 'ring-offset-1', 'ring-gray-800'); + currentColor = btn.dataset.color; + freeBrush.color = currentColor; + }; + }); + + // 지우개 크기 + const eraserRange = getEl('ce-eraserRange'); + if (eraserRange) eraserRange.oninput = e => { + const r = +e.target.value; + getEl('ce-eraserSize').textContent = r; + eraserBrush.width = r; + updateEraserCursor(r); + if (mode === 'eraser') canvas.upperCanvasEl.style.cursor = canvas.freeDrawingCursor; + }; + + // 직각 고정 + const rightAngle = getEl('ce-rightAngle'); + if (rightAngle) rightAngle.onchange = () => { isRight = rightAngle.checked; }; + + // 적용 + getEl('ce-applyBtn').onclick = (e) => { + e.preventDefault(); + e.stopPropagation(); + try { + const dataURL = canvas.toDataURL('image/png'); + dialog.close(); + if (_resolve) { const r = _resolve; _resolve = null; _reject = null; r(dataURL); } + } catch (err) { + console.error('Canvas toDataURL failed:', err); + dialog.close(); + if (_reject) { const r = _reject; _resolve = null; _reject = null; r(err); } + } + }; + + // 닫기 + getEl('ce-closeBtn').onclick = (e) => { + e.preventDefault(); + e.stopPropagation(); + dialog.close(); + if (_reject) { const r = _reject; _resolve = null; _reject = null; r(new Error('User cancelled')); } + }; + + // ESC 처리 + dialog.addEventListener('cancel', e => e.preventDefault()); + window.addEventListener('keydown', e => { + if (!dialog.open || e.key !== 'Escape') return; + e.preventDefault(); + // 텍스트 입력 중이면 제거 + const texts = document.querySelectorAll('.ce-text-input'); + if (texts.length) { texts.forEach(el => el.remove()); return; } + // polyline 프리뷰 취소 + if (mode === 'polyline') { + if (previewLine) { canvas.remove(previewLine); previewLine = null; } + polyPoints = []; + isPreview = true; + setMode('polyline'); + canvas.requestRenderAll(); + } + }); + + // Delete 키로 선택 객체 삭제 + window.addEventListener('keydown', e => { + if (!dialog.open) return; + if (e.key === 'Delete' && selectMode) { + const active = canvas.getActiveObject(); + if (active) { canvas.remove(active); canvas.discardActiveObject(); canvas.requestRenderAll(); } + } + if (e.key === 'l' || e.key === 'L') { + if (document.activeElement.tagName === 'TEXTAREA') return; + setMode('line'); + } + }); + + // 외부 클릭 방지 + dialog.addEventListener('mousedown', e => { if (e.target === dialog) e.preventDefault(); }); + } + + // ── 공개 API ── + function open(imageSrc) { + return new Promise((resolve, reject) => { + _resolve = resolve; + _reject = reject; + + const dialog = getEl('ce-dialog'); + if (!dialog) { reject(new Error('ce-dialog not found')); return; } + + if (!initialized) { + initCanvas(); + bindEvents(); + } + + // 상태 리셋 + mode = 'polyline'; + currentColor = '#000000'; + polyPoints = []; + previewLine = null; + isPreview = true; + isLine = false; + lineObj = null; + + // 색상 초기화 + document.querySelectorAll('#ce-colors .ce-color-btn').forEach(b => b.classList.remove('ring-2', 'ring-offset-1', 'ring-gray-800')); + const firstColor = document.querySelector('#ce-colors .ce-color-btn'); + if (firstColor) firstColor.classList.add('ring-2', 'ring-offset-1', 'ring-gray-800'); + if (freeBrush) freeBrush.color = currentColor; + + // 직각 고정 초기화 + const rightAngle = getEl('ce-rightAngle'); + if (rightAngle) { rightAngle.checked = true; isRight = true; } + + // 캔버스 초기화 + canvas.clear(); + + // 최대 허용 크기 (뷰포트 기준) + const vpW = window.innerWidth * 0.85; + const vpH = window.innerHeight * 0.75; + + if (imageSrc && !imageSrc.includes('placeholder')) { + // 기존 이미지 배경 로드 + fabric.Image.fromURL(imageSrc, img => { + if (!img || !img.width) { + canvas.setWidth(500); + canvas.setHeight(350); + canvas.setBackgroundColor('#ffffff', canvas.renderAll.bind(canvas)); + } else { + // 이미지를 뷰포트에 맞춤 (축소만, 확대 안 함) + const pad = 40; // 상하좌우 여백 + const fitRatio = Math.min(vpW / (img.width + pad * 2), vpH / (img.height + pad * 2), 1); + const sw = Math.round(img.width * fitRatio); + const sh = Math.round(img.height * fitRatio); + // 캔버스 = 이미지 + 여백 + const cw = sw + pad * 2; + const ch = sh + pad * 2; + + img.selectable = false; + canvas.setWidth(cw); + canvas.setHeight(ch); + // 이미지를 여백만큼 offset해서 중앙 배치 + canvas.setBackgroundColor('#ffffff', () => {}); + canvas.setBackgroundImage(img, canvas.renderAll.bind(canvas), { + originX: 'left', originY: 'top', left: pad, top: pad, + scaleX: fitRatio, scaleY: fitRatio + }); + } + setMode('polyline'); + // dialog 크기 = 캔버스 + 10% 여유 + dialog.style.width = Math.round(canvas.width * 1.1) + 'px'; + dialog.showModal(); + dialog.focus(); + }); + } else { + // 빈 캔버스 — 적당한 기본 크기 + canvas.setWidth(500); + canvas.setHeight(350); + canvas.setBackgroundColor('#ffffff', canvas.renderAll.bind(canvas)); + setMode('polyline'); + dialog.style.width = '600px'; + dialog.showModal(); + dialog.focus(); + } + }); + } + + return { open }; +})(); diff --git a/resources/views/bending/base/form.blade.php b/resources/views/bending/base/form.blade.php new file mode 100644 index 00000000..74029f51 --- /dev/null +++ b/resources/views/bending/base/form.blade.php @@ -0,0 +1,545 @@ +@extends('layouts.app') +@section('title', ($mode === 'create' ? '기초자료 등록' : ($mode === 'edit' ? '기초자료 수정' : '기초자료 상세'))) + +@section('content') +@php + $opt = is_array($item) ? $item : ($item?->options ?? []); + $isView = $mode === 'view'; + $isCreate = $mode === 'create'; + $bendingData = $opt['bendingData'] ?? []; + $itemId = is_array($item) ? ($item['id'] ?? null) : $item?->id; + $itemCode = is_array($item) ? ($item['code'] ?? '') : ($itemCode ?? ''); + $itemName = is_array($item) ? ($item['name'] ?? '') : ($itemName ?? ''); + $itemBending = $opt['item_bending'] ?? ''; + $isCase = in_array($itemBending, ['케이스']); +@endphp + +<div class="container-fluid px-4 py-3"> + {{-- 헤더 --}} + <div class="flex items-center justify-between mb-4"> + <div class="flex items-center gap-3"> + <a href="{{ route('bending.base.index') }}" class="text-gray-500 hover:text-gray-700"> + <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg> + </a> + <h1 class="text-xl font-bold text-gray-800"> + {{ $mode === 'create' ? '기초자료 등록' : ($mode === 'edit' ? '기초자료 수정' : '기초자료 상세') }} + @if($item) <span class="text-sm font-normal text-gray-500 ml-2">{{ $itemCode }}</span> @endif + </h1> + </div> + <div class="flex gap-2"> + @if(!$isCreate) + <button type="button" onclick="showHistory()" class="px-3 py-2 bg-gray-500 text-white rounded-lg hover:bg-gray-600 text-sm" title="수정 이력">H</button> + @endif + @if($isView) + <a href="{{ route('bending.base.edit', $itemId) }}" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm">수정</a> + @endif + @if(!$isCreate) + <form method="POST" action="{{ route('bending.base.destroy', $itemId) }}" onsubmit="return confirm('삭제하시겠습니까?')"> + @csrf @method('DELETE') + <button type="submit" class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 text-sm">삭제</button> + </form> + @endif + </div> + </div> + + @if(session('success')) + <script> + document.addEventListener('DOMContentLoaded', () => { + showToast("{{ session('success') }}", 'success'); + }); + </script> + @endif + + @if($errors->any()) + <div class="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700"> + <ul class="list-disc list-inside"> + @foreach($errors->all() as $error) + <li>{{ $error }}</li> + @endforeach + </ul> + </div> + @endif + + <form method="POST" action="{{ $isCreate ? route('bending.base.store') : route('bending.base.update', $itemId) }}" id="bendingForm" enctype="multipart/form-data"> + @csrf + @if(!$isCreate) @method('PUT') @endif + + <div class="flex gap-4" style="align-items: flex-start;"> + {{-- 좌: 기본정보 + 케이스 전용 + 절곡 테이블 --}} + <div style="flex: 1 1 0; min-width: 0;"> + {{-- 기본 정보 --}} + <div class="bg-white rounded-lg shadow p-4 mb-4"> + <h2 class="text-sm font-bold text-gray-700 mb-3 border-b pb-2">기본 정보</h2> + <div class="grid grid-cols-4 gap-3"> + <div> + <label class="block text-xs text-gray-500 mb-1">코드 <span class="text-red-500">*</span></label> + <input type="text" name="code" value="{{ old('code', $itemCode) }}" {{ $isView ? 'disabled' : 'required' }} + class="w-full border border-gray-300 rounded px-3 py-1.5 text-sm {{ $isView ? 'bg-gray-100' : '' }}"> + </div> + <div> + <label class="block text-xs text-gray-500 mb-1">이름 <span class="text-red-500">*</span></label> + <input type="text" name="name" value="{{ old('name', $itemName) }}" {{ $isView ? 'disabled' : 'required' }} + class="w-full border border-gray-300 rounded px-3 py-1.5 text-sm {{ $isView ? 'bg-gray-100' : '' }}"> + </div> + <div> + <label class="block text-xs text-gray-500 mb-1">품명 <span class="text-red-500">*</span></label> + <input type="text" name="item_name" value="{{ old('item_name', $opt['item_name'] ?? '') }}" {{ $isView ? 'disabled' : 'required' }} + class="w-full border border-gray-300 rounded px-3 py-1.5 text-sm {{ $isView ? 'bg-gray-100' : '' }}"> + </div> + <div> + <label class="block text-xs text-gray-500 mb-1">대분류 <span class="text-red-500">*</span></label> + <select name="item_sep" {{ $isView ? 'disabled' : 'required' }} + class="w-full border border-gray-300 rounded px-3 py-1.5 text-sm {{ $isView ? 'bg-gray-100' : '' }}"> + <option value="">선택</option> + <option value="스크린" {{ ($opt['item_sep'] ?? '') === '스크린' ? 'selected' : '' }}>스크린</option> + <option value="철재" {{ ($opt['item_sep'] ?? '') === '철재' ? 'selected' : '' }}>철재</option> + </select> + </div> + <div> + <label class="block text-xs text-gray-500 mb-1">분류 <span class="text-red-500">*</span></label> + <input type="text" name="item_bending" id="itemBendingInput" value="{{ old('item_bending', $itemBending) }}" {{ $isView ? 'disabled' : 'required' }} + class="w-full border border-gray-300 rounded px-3 py-1.5 text-sm {{ $isView ? 'bg-gray-100' : '' }}" + list="item_bending_list" onchange="toggleCaseFields()"> + <datalist id="item_bending_list"> + <option value="가이드레일"><option value="케이스"><option value="하단마감재"> + <option value="마구리"><option value="L-BAR"><option value="보강평철"><option value="연기차단재"> + </datalist> + </div> + <div> + <label class="block text-xs text-gray-500 mb-1">재질 <span class="text-red-500">*</span></label> + <input type="text" name="material" value="{{ old('material', $opt['material'] ?? '') }}" {{ $isView ? 'disabled' : 'required' }} + class="w-full border border-gray-300 rounded px-3 py-1.5 text-sm {{ $isView ? 'bg-gray-100' : '' }}" + list="material_list"> + <datalist id="material_list"> + <option value="SUS 1.2T"><option value="SUS 1.5T"><option value="EGI 1.55T"><option value="EGI 1.15T"><option value="화이바원단"> + </datalist> + </div> + <div> + <label class="block text-xs text-gray-500 mb-1">규격</label> + <input type="text" name="item_spec" value="{{ old('item_spec', $opt['item_spec'] ?? '') }}" {{ $isView ? 'disabled' : '' }} + class="w-full border border-gray-300 rounded px-3 py-1.5 text-sm {{ $isView ? 'bg-gray-100' : '' }}" placeholder="120*70"> + </div> + <div> + <label class="block text-xs text-gray-500 mb-1">모델</label> + <input type="text" name="model_name" value="{{ old('model_name', $opt['model_name'] ?? '') }}" {{ $isView ? 'disabled' : '' }} + class="w-full border border-gray-300 rounded px-3 py-1.5 text-sm {{ $isView ? 'bg-gray-100' : '' }}" + list="model_list"> + <datalist id="model_list"> + <option value="KSS01"><option value="KSS02"><option value="KSE01"> + <option value="KWE01"><option value="KTE01"><option value="KQTS01"><option value="KDSS01"> + </datalist> + </div> + <div> + <label class="block text-xs text-gray-500 mb-1">인정여부</label> + <select name="model_UA" {{ $isView ? 'disabled' : '' }} + class="w-full border border-gray-300 rounded px-3 py-1.5 text-sm {{ $isView ? 'bg-gray-100' : '' }}"> + <option value="">선택</option> + <option value="인정" {{ ($opt['model_UA'] ?? '') === '인정' ? 'selected' : '' }}>인정</option> + <option value="비인정" {{ ($opt['model_UA'] ?? '') === '비인정' ? 'selected' : '' }}>비인정</option> + </select> + </div> + <div> + <label class="block text-xs text-gray-500 mb-1">등록일</label> + <input type="date" name="registration_date" value="{{ old('registration_date', $opt['registration_date'] ?? date('Y-m-d')) }}" {{ $isView ? 'disabled' : '' }} + class="w-full border border-gray-300 rounded px-3 py-1.5 text-sm {{ $isView ? 'bg-gray-100' : '' }}"> + </div> + <div> + <label class="block text-xs text-gray-500 mb-1">작성자</label> + <input type="text" name="author" value="{{ old('author', $opt['author'] ?? Auth::user()?->name ?? '') }}" {{ $isView ? 'disabled' : '' }} + class="w-full border border-gray-300 rounded px-3 py-1.5 text-sm {{ $isView ? 'bg-gray-100' : '' }}"> + </div> + <div> + <label class="block text-xs text-gray-500 mb-1">검색어</label> + <input type="text" name="search_keyword" value="{{ old('search_keyword', $opt['search_keyword'] ?? '') }}" {{ $isView ? 'disabled' : '' }} + class="w-full border border-gray-300 rounded px-3 py-1.5 text-sm {{ $isView ? 'bg-gray-100' : '' }}"> + </div> + </div> + </div> + + {{-- 케이스 전용 필드 --}} + <div id="caseFields" class="bg-white rounded-lg shadow p-4 mb-4 {{ $isCase ? '' : 'hidden' }}"> + <h2 class="text-sm font-bold text-gray-700 mb-3 border-b pb-2">케이스 전용</h2> + <div class="grid grid-cols-5 gap-3"> + <div> + <label class="block text-xs text-gray-500 mb-1">점검구 방향</label> + <select name="exit_direction" {{ $isView ? 'disabled' : '' }} + class="w-full border border-gray-300 rounded px-3 py-1.5 text-sm {{ $isView ? 'bg-gray-100' : '' }}"> + <option value="">선택</option> + @foreach(['양면', '밑면', '후면 점검구'] as $dir) + <option value="{{ $dir }}" {{ ($opt['exit_direction'] ?? '') === $dir ? 'selected' : '' }}>{{ $dir }}</option> + @endforeach + </select> + </div> + <div> + <label class="block text-xs text-gray-500 mb-1">전면밑 (mm)</label> + <input type="number" name="front_bottom_width" value="{{ old('front_bottom_width', $opt['front_bottom_width'] ?? '') }}" {{ $isView ? 'disabled' : '' }} + class="w-full border border-gray-300 rounded px-3 py-1.5 text-sm {{ $isView ? 'bg-gray-100' : '' }}"> + </div> + <div> + <label class="block text-xs text-gray-500 mb-1">레일폭 (mm)</label> + <input type="number" name="rail_width" value="{{ old('rail_width', $opt['rail_width'] ?? '') }}" {{ $isView ? 'disabled' : '' }} + class="w-full border border-gray-300 rounded px-3 py-1.5 text-sm {{ $isView ? 'bg-gray-100' : '' }}"> + </div> + <div> + <label class="block text-xs text-gray-500 mb-1">케이스 너비 (mm)</label> + <input type="number" name="box_width" value="{{ old('box_width', $opt['box_width'] ?? '') }}" {{ $isView ? 'disabled' : '' }} + class="w-full border border-gray-300 rounded px-3 py-1.5 text-sm {{ $isView ? 'bg-gray-100' : '' }}"> + </div> + <div> + <label class="block text-xs text-gray-500 mb-1">케이스 높이 (mm)</label> + <input type="number" name="box_height" value="{{ old('box_height', $opt['box_height'] ?? '') }}" {{ $isView ? 'disabled' : '' }} + class="w-full border border-gray-300 rounded px-3 py-1.5 text-sm {{ $isView ? 'bg-gray-100' : '' }}"> + </div> + </div> + </div> + + {{-- 절곡 입력 테이블 --}} + <div class="bg-white rounded-lg shadow p-4 mb-4"> + <div class="flex items-center justify-between mb-3 border-b pb-2"> + <h2 class="text-sm font-bold text-gray-700">절곡 입력 테이블</h2> + @if(!$isView) + <div class="flex gap-2"> + <button type="button" onclick="addColumn()" class="px-3 py-1 bg-green-600 text-white rounded text-xs">열 추가</button> + <button type="button" onclick="removeColumn()" class="px-3 py-1 bg-red-600 text-white rounded text-xs">열 삭제</button> + <button type="button" onclick="clearTable()" class="px-3 py-1 bg-gray-600 text-white rounded text-xs">비우기</button> + </div> + @endif + </div> + <div class="overflow-x-auto"> + <table class="text-xs border-collapse w-full" id="bendTable"> + <thead> + <tr class="bg-gray-100" id="headerRow"> + <th class="border px-2 py-1 text-gray-600" style="min-width:55px;">구분</th> + </tr> + </thead> + <tbody> + <tr id="inputRow"> + <td class="border px-2 py-1 bg-gray-50 font-medium">입력</td> + </tr> + <tr id="rateRow"> + <td class="border px-2 py-1 bg-gray-50 font-medium">연신율</td> + </tr> + <tr id="adjustedRow"> + <td class="border px-2 py-1 bg-gray-50 font-medium">연신율후</td> + </tr> + <tr id="sumRow"> + <td class="border px-2 py-1 bg-yellow-50 font-medium">합계</td> + </tr> + <tr id="colorRow"> + <td class="border px-2 py-1 bg-gray-50 font-medium">음영</td> + </tr> + <tr id="angleRow"> + <td class="border px-2 py-1 bg-gray-50 font-medium">A각</td> + </tr> + </tbody> + </table> + </div> + <div class="mt-2 text-sm text-gray-600"> + 폭합계: <span id="widthSumDisplay" class="font-bold text-blue-700">0</span> + | 절곡횟수: <span id="bendCountDisplay" class="font-bold text-blue-700">0</span> + </div> + </div> + </div> + + {{-- 우: 이미지 + 메모 --}} + <div class="shrink-0" style="width: 280px; min-width: 200px; max-width: 320px;"> + <div class="bg-white rounded-lg shadow p-4 mb-4"> + <h2 class="text-sm font-bold text-gray-700 mb-3 border-b pb-2">형상 이미지</h2> + <div class="border-2 border-dashed border-gray-300 rounded-lg p-3 text-center min-h-[200px] flex items-center justify-center" id="imageContainer"> + @if(!empty($imageFile)) + <img src="{{ route('files.view', $imageFile['id']) }}" alt="전개도" class="max-w-full rounded" id="currentImage"> + @else + <span class="text-gray-400 text-sm" id="noImageText">이미지 없음</span> + @endif + </div> + @if(!$isView) + <div class="flex gap-1 mt-2"> + <input type="file" name="image" accept="image/*" onchange="previewImage(this)" class="text-xs flex-1 min-w-0"> + <button type="button" onclick="openCanvasEditor()" class="px-2 py-1 bg-indigo-600 text-white rounded text-xs hover:bg-indigo-700 whitespace-nowrap"> + <i class="ri-edit-line"></i> 그리기 + </button> + </div> + <img id="image-preview" class="hidden max-w-full rounded mt-2"> + <input type="hidden" name="canvas_image" id="canvasImageData"> + <p class="text-xs text-gray-400 mt-1">Ctrl+V로 붙여넣기 가능</p> + @endif + </div> + <div class="bg-white rounded-lg shadow p-4"> + <h2 class="text-sm font-bold text-gray-700 mb-3 border-b pb-2">비고</h2> + <textarea name="memo" rows="4" {{ $isView ? 'disabled' : '' }} + class="w-full border border-gray-300 rounded px-3 py-1.5 text-sm {{ $isView ? 'bg-gray-100' : '' }}">{{ old('memo', $opt['memo'] ?? '') }}</textarea> + </div> + + @if(!$isView) + <div class="mt-4"> + <button type="submit" class="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm font-medium"> + {{ $isCreate ? '등록' : '저장' }} + </button> + </div> + @endif + </div> + </div> + + {{-- 절곡 데이터 hidden --}} + <input type="hidden" name="modified_by" value="{{ Auth::user()?->name ?? '' }}"> + <input type="hidden" name="bendingData" id="bendingDataInput"> + </form> + + @if(!$isView) + @include('components.canvas-editor') + @endif + + {{-- 이력 모달 --}} + @if(!$isCreate) + <dialog id="historyDialog" class="rounded-lg shadow-xl p-0 backdrop:bg-black/50" style="max-width:500px; border:none;"> + <div class="p-4"> + <div class="flex items-center justify-between mb-3"> + <h3 class="font-bold text-gray-800">수정 이력</h3> + <button type="button" onclick="document.getElementById('historyDialog').close()" class="text-gray-400 hover:text-gray-600 text-xl">×</button> + </div> + <table class="w-full text-sm"> + <tbody> + <tr class="border-b"><td class="py-2 text-gray-500 pr-4">코드</td><td class="py-2 font-mono">{{ $itemCode }}</td></tr> + <tr class="border-b"><td class="py-2 text-gray-500 pr-4">등록일</td><td class="py-2">{{ $opt['registration_date'] ?? '-' }}</td></tr> + <tr class="border-b"><td class="py-2 text-gray-500 pr-4">작성자</td><td class="py-2">{{ $opt['author'] ?? '-' }}</td></tr> + <tr class="border-b"><td class="py-2 text-gray-500 pr-4">최종수정자</td><td class="py-2">{{ $opt['modified_by'] ?? '-' }}</td></tr> + <tr class="border-b"><td class="py-2 text-gray-500 pr-4">현재 수정자</td><td class="py-2 text-blue-600 font-medium">{{ Auth::user()?->name ?? '-' }}</td></tr> + <tr class="border-b"><td class="py-2 text-gray-500 pr-4">생성일시</td><td class="py-2">{{ is_array($item) ? ($item['created_at'] ?? '-') : '-' }}</td></tr> + <tr class="border-b"><td class="py-2 text-gray-500 pr-4">수정일시</td><td class="py-2">{{ is_array($item) ? ($item['updated_at'] ?? '-') : '-' }}</td></tr> + @if(!empty($opt['change_log'])) + <tr><td colspan="2" class="py-2"> + <div class="text-gray-500 mb-1">변경 기록</div> + <div class="bg-gray-50 rounded p-2 text-xs max-h-[200px] overflow-y-auto"> + @foreach(array_reverse($opt['change_log']) as $log) + <div class="mb-1 pb-1 border-b border-gray-200 last:border-0"> + <span class="text-gray-400">{{ $log['date'] ?? '' }}</span> + <span class="text-gray-700">{{ $log['memo'] ?? '' }}</span> + </div> + @endforeach + </div> + </td></tr> + @endif + </tbody> + </table> + </div> + </dialog> + @endif +</div> + +@push('scripts') +<script src="https://cdnjs.cloudflare.com/ajax/libs/fabric.js/5.3.0/fabric.min.js"></script> +<script src="{{ asset('js/canvas-editor.js') }}"></script> +<script> +const isView = {{ $isView ? 'true' : 'false' }}; +let bendingData = @json($bendingData ?: []); + +document.addEventListener('DOMContentLoaded', () => { + // 절곡품 폼에서 "다른이름 저장"으로 넘어온 경우 데이터 자동 로드 + if (!isView && new URLSearchParams(location.search).get('from') === 'product') { + try { + const saved = sessionStorage.getItem('newPartData'); + if (saved) { + const data = JSON.parse(saved); + sessionStorage.removeItem('newPartData'); + if (data.item_name) { const el = document.querySelector('input[name="item_name"]'); if (el) el.value = data.item_name; } + if (data.material) { const el = document.querySelector('input[name="material"]'); if (el) el.value = data.material; } + if (data.bendingData?.length) { bendingData = data.bendingData; } + } + } catch(e) { console.error('newPartData load error:', e); } + } + + if (bendingData.length > 0) { + bendingData.forEach(col => addColumnUI(col)); + } else if (!isView) { + for (let i = 0; i < 7; i++) addColumnUI(null); + } + recalcAll(); + toggleCaseFields(); +}); + +function toggleCaseFields() { + const val = document.getElementById('itemBendingInput')?.value ?? ''; + const caseDiv = document.getElementById('caseFields'); + if (caseDiv) caseDiv.classList.toggle('hidden', val !== '케이스'); +} + +function addColumnUI(data) { + const idx = document.querySelectorAll('#headerRow th').length; + const dis = isView ? 'disabled' : ''; + + document.getElementById('headerRow').insertAdjacentHTML('beforeend', + `<th class="border px-1 py-1 text-center min-w-[50px]">${idx}</th>`); + document.getElementById('inputRow').insertAdjacentHTML('beforeend', + `<td class="border px-1 py-1"><input type="number" class="w-full text-center border-0 bg-transparent bend-input" data-col="${idx}" value="${data?.input ?? ''}" ${dis} step="any" oninput="recalcAll()"></td>`); + document.getElementById('rateRow').insertAdjacentHTML('beforeend', + `<td class="border px-1 py-1"><input type="number" class="w-full text-center border-0 bg-transparent bend-rate" step="1" data-col="${idx}" value="${data?.rate ?? ''}" ${dis} placeholder="" oninput="recalcAll()"></td>`); + document.getElementById('adjustedRow').insertAdjacentHTML('beforeend', + `<td class="border px-1 py-1 text-center bend-adjusted" data-col="${idx}">-</td>`); + document.getElementById('sumRow').insertAdjacentHTML('beforeend', + `<td class="border px-1 py-1 text-center font-bold bg-yellow-50 bend-sum" data-col="${idx}">-</td>`); + document.getElementById('colorRow').insertAdjacentHTML('beforeend', + `<td class="border px-1 py-1 text-center"><input type="checkbox" class="bend-color" data-col="${idx}" ${data?.color ? 'checked' : ''} ${dis}></td>`); + document.getElementById('angleRow').insertAdjacentHTML('beforeend', + `<td class="border px-1 py-1 text-center"><input type="checkbox" class="bend-angle" data-col="${idx}" ${data?.aAngle ? 'checked' : ''} ${dis}></td>`); +} + +function addColumn() { addColumnUI(null); } + +function removeColumn() { + const cols = document.querySelectorAll('#headerRow th').length; + if (cols <= 1) return; + ['headerRow','inputRow','rateRow','adjustedRow','sumRow','colorRow','angleRow'].forEach(id => { + document.getElementById(id).lastElementChild.remove(); + }); + recalcAll(); +} + +function clearTable() { + document.querySelectorAll('.bend-input').forEach(el => el.value = ''); + document.querySelectorAll('.bend-rate').forEach(el => el.value = ''); + document.querySelectorAll('.bend-color').forEach(el => el.checked = false); + document.querySelectorAll('.bend-angle').forEach(el => el.checked = false); + recalcAll(); +} + +function recalcAll() { + const inputs = document.querySelectorAll('.bend-input'); + let cumSum = 0; + let bendCount = 0; + + inputs.forEach((inp, i) => { + const val = parseFloat(inp.value) || 0; + const rateEl = document.querySelectorAll('.bend-rate')[i]; + const rate = rateEl?.value?.trim() ?? ''; + + let adjusted = val; + if (rate === '-1') { adjusted = val - 1; bendCount++; } + else if (rate === '1') { adjusted = val + 1; bendCount++; } + else if (rate !== '' && rate !== '0') { bendCount++; } + + const adjEl = document.querySelectorAll('.bend-adjusted')[i]; + if (adjEl) adjEl.textContent = val ? adjusted + (rate ? ` (${rate})` : '') : '-'; + + cumSum += adjusted; + const sumEl = document.querySelectorAll('.bend-sum')[i]; + if (sumEl) sumEl.textContent = val ? cumSum : '-'; + }); + + document.getElementById('widthSumDisplay').textContent = cumSum || 0; + document.getElementById('bendCountDisplay').textContent = bendCount; + serializeBendingData(); +} + +function serializeBendingData() { + const inputs = document.querySelectorAll('.bend-input'); + const data = []; + let cumSum = 0; + + inputs.forEach((inp, i) => { + const val = parseFloat(inp.value) || 0; + const rate = document.querySelectorAll('.bend-rate')[i]?.value?.trim() ?? ''; + let adjusted = val; + if (rate === '-1') adjusted = val - 1; + else if (rate === '1') adjusted = val + 1; + cumSum += adjusted; + + data.push({ + no: i + 1, + input: val, + rate: rate, + sum: cumSum, + color: document.querySelectorAll('.bend-color')[i]?.checked ?? false, + aAngle: document.querySelectorAll('.bend-angle')[i]?.checked ?? false, + }); + }); + + document.getElementById('bendingDataInput').value = JSON.stringify(data); +} + +// 이미지 미리보기 +function previewImage(input) { + const file = input.files[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = e => { + const preview = document.getElementById('image-preview'); + preview.src = e.target.result; + preview.classList.remove('hidden'); + }; + reader.readAsDataURL(file); +} + +// Ctrl+V 클립보드 이미지 붙여넣기 +document.addEventListener('paste', function(e) { + if (isView) return; + const items = e.clipboardData?.items; + if (!items) return; + for (const item of items) { + if (item.type.startsWith('image/')) { + const file = item.getAsFile(); + const dt = new DataTransfer(); + dt.items.add(file); + const input = document.querySelector('input[name="image"]'); + if (input) { + input.files = dt.files; + previewImage(input); + } + break; + } + } +}); + +document.getElementById('bendingForm')?.addEventListener('submit', () => serializeBendingData()); + +// 이력 보기 +function showHistory() { + const dialog = document.getElementById('historyDialog'); + if (dialog) dialog.showModal(); +} + +// Canvas Editor +function openCanvasEditor() { + // 현재 이미지 src 가져오기 (미리보기 > 기존 이미지 > 없음) + const preview = document.getElementById('image-preview'); + const current = document.getElementById('currentImage'); + let imgSrc = null; + + if (preview && !preview.classList.contains('hidden') && preview.src) { + imgSrc = preview.src; + } else if (current && current.src) { + imgSrc = current.src; + } + + CanvasEditor.open(imgSrc) + .then(dataURL => { + // hidden input에 Base64 저장 (폼 submit 시 전송) + document.getElementById('canvasImageData').value = dataURL; + + // 파일 input 초기화 (canvas 이미지가 우선) + const fileInput = document.querySelector('input[name="image"]'); + if (fileInput) fileInput.value = ''; + + // image-preview 숨기기 (중복 방지) + const previewEl = document.getElementById('image-preview'); + if (previewEl) previewEl.classList.add('hidden'); + + // 기존 이미지 컨테이너에 바로 교체 표시 + const container = document.getElementById('imageContainer'); + const noText = document.getElementById('noImageText'); + if (noText) noText.remove(); + + let img = document.getElementById('currentImage'); + if (!img) { + img = document.createElement('img'); + img.id = 'currentImage'; + img.alt = '전개도'; + img.className = 'max-w-full rounded'; + container.appendChild(img); + } + img.src = dataURL; + }) + .catch(() => {}); +} +</script> +@endpush +@endsection diff --git a/resources/views/bending/base/index.blade.php b/resources/views/bending/base/index.blade.php new file mode 100644 index 00000000..89dd4582 --- /dev/null +++ b/resources/views/bending/base/index.blade.php @@ -0,0 +1,89 @@ +@extends('layouts.app') +@section('title', '절곡 기초관리') + +@section('content') +<div class="container-fluid px-4 py-3"> + {{-- 헤더 --}} + <div class="flex items-center justify-between mb-6"> + <h1 class="text-2xl font-bold text-gray-800">절곡 바라시 기초자료</h1> + <a href="{{ route('bending.base.create') }}" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm"> + + 신규 등록 + </a> + </div> + + {{-- 필터 --}} + <form id="filterForm" class="flex flex-wrap gap-2 mb-4 items-center"> + {{-- 대분류 (2개 → 그룹버튼) --}} + <input type="hidden" name="item_sep" value="{{ request('item_sep') }}"> + <div class="inline-flex rounded border border-gray-300 text-sm overflow-hidden"> + <button type="button" onclick="setFilter('item_sep','')" class="px-3 py-1.5 {{ !request('item_sep') ? 'bg-gray-700 text-white' : 'bg-white hover:bg-gray-50' }}">전체</button> + @foreach(['스크린', '철재'] as $v) + <button type="button" onclick="setFilter('item_sep','{{ $v }}')" class="px-3 py-1.5 border-l {{ request('item_sep') == $v ? 'bg-gray-700 text-white' : 'bg-white hover:bg-gray-50' }}">{{ $v }}</button> + @endforeach + </div> + + {{-- 인정/비인정 --}} + <input type="hidden" name="model_UA" value="{{ request('model_UA') }}"> + <div class="inline-flex rounded border border-gray-300 text-sm overflow-hidden"> + <button type="button" onclick="setFilter('model_UA','')" class="px-3 py-1.5 {{ !request('model_UA') ? 'bg-gray-700 text-white' : 'bg-white hover:bg-gray-50' }}">전체</button> + @foreach(['인정', '비인정'] as $v) + <button type="button" onclick="setFilter('model_UA','{{ $v }}')" class="px-3 py-1.5 border-l {{ request('model_UA') == $v ? 'bg-gray-700 text-white' : 'bg-white hover:bg-gray-50' }}">{{ $v }}</button> + @endforeach + </div> + + {{-- 중분류(그룹) --}} + <select name="item_bending" class="border border-gray-300 rounded px-3 py-1.5 text-sm" + onchange="this.form.dispatchEvent(new Event('submit'))"> + <option value="">전체(분류)</option> + @foreach(($filterOptions['item_bending'] ?? []) as $bending) + <option value="{{ $bending }}" {{ request('item_bending') == $bending ? 'selected' : '' }}>{{ $bending }}</option> + @endforeach + </select> + + {{-- 재질 --}} + <select name="material" class="border border-gray-300 rounded px-3 py-1.5 text-sm" + onchange="this.form.dispatchEvent(new Event('submit'))"> + <option value="">전체(재질)</option> + @foreach(($filterOptions['material'] ?? []) as $mat) + <option value="{{ $mat }}" {{ request('material') == $mat ? 'selected' : '' }}>{{ $mat }}</option> + @endforeach + </select> + + {{-- 검색 --}} + <input type="text" name="search" value="{{ request('search') }}" placeholder="코드/이름/품명/규격 검색" + class="border border-gray-300 rounded px-3 py-1.5 text-sm" style="width: 200px;" + hx-get="{{ route('bending.base.index') }}" hx-target="#items-table" hx-include="#filterForm" + hx-trigger="keyup changed delay:400ms"> + + <button type="submit" class="px-3 py-1.5 bg-gray-700 text-white rounded text-sm" + hx-get="{{ route('bending.base.index') }}" hx-target="#items-table" hx-include="#filterForm"> + 검색 + </button> + + <span class="text-sm text-gray-500 ml-2">총 {{ $items['total'] ?? count($items['data'] ?? []) }}건</span> + </form> + + {{-- 테이블 --}} + <div id="items-table"> + @include('bending.base.partials.table', ['items' => $items]) + </div> +</div> + +@if(session('success')) +<script> + document.addEventListener('DOMContentLoaded', () => { + showToast("{{ session('success') }}", 'success'); + }); +</script> +@endif + +@push('scripts') +<script> +function setFilter(name, value) { + const input = document.querySelector(`#filterForm input[name="${name}"]`); + if (input) input.value = value; + document.getElementById('filterForm').submit(); +} +</script> +@endpush +@endsection diff --git a/resources/views/bending/base/partials/table.blade.php b/resources/views/bending/base/partials/table.blade.php new file mode 100644 index 00000000..0d87a219 --- /dev/null +++ b/resources/views/bending/base/partials/table.blade.php @@ -0,0 +1,168 @@ +@php + $itemList = $items['data'] ?? $items ?? []; + $total = $items['total'] ?? count($itemList); + $currentPage = $items['current_page'] ?? 1; + $lastPage = $items['last_page'] ?? 1; +@endphp + +<div class="overflow-x-auto bg-white rounded-lg shadow-sm mt-4"> + <table class="min-w-full text-sm"> + <thead class="bg-gray-50 border-b"> + <tr> + <th class="px-2 py-2 text-left text-sm font-semibold text-gray-700">NO</th> + <th class="px-2 py-2 text-left text-sm font-semibold text-gray-700">코드</th> + <th class="px-2 py-2 text-center text-sm font-semibold text-gray-700">대분류</th> + <th class="px-2 py-2 text-center text-sm font-semibold text-gray-700">인정</th> + <th class="px-2 py-2 text-left text-sm font-semibold text-gray-700">분류</th> + <th class="px-2 py-2 text-left text-sm font-semibold text-gray-700">품명</th> + <th class="px-2 py-2 text-left text-sm font-semibold text-gray-700">규격</th> + <th class="px-2 py-2 text-left text-sm font-semibold text-gray-700">재질</th> + <th class="px-2 py-2 text-center text-sm font-semibold text-gray-700">이미지</th> + <th class="px-2 py-2 text-left text-sm font-semibold text-gray-700">모델</th> + <th class="px-2 py-2 text-right text-sm font-semibold text-gray-700">폭합</th> + <th class="px-2 py-2 text-center text-sm font-semibold text-gray-700">절곡수</th> + <th class="px-2 py-2 text-left text-sm font-semibold text-gray-700">등록일</th> + <th class="px-2 py-2 text-center text-sm font-semibold text-gray-700">수정자</th> + <th class="px-2 py-2 text-center text-sm font-semibold text-gray-700">작업</th> + </tr> + </thead> + <tbody class="divide-y divide-gray-200"> + @forelse($itemList as $item) + @php + $itemSep = $item['item_sep'] ?? '-'; + $widthSum = $item['width_sum'] ?? null; + $bendCount = $item['bend_count'] ?? 0; + $modelUA = $item['model_UA'] ?? null; + $createdAt = $item['created_at'] ?? null; + @endphp + <tr class="hover:bg-blue-50 cursor-pointer" onclick="window.location='{{ route('bending.base.show', $item['id']) }}'"> + <td class="px-2 py-2 text-gray-500 text-xs">{{ $item['id'] }}</td> + <td class="px-2 py-2 font-mono text-xs">{{ $item['code'] }}</td> + <td class="px-2 py-2 text-center"> + <span class="px-1.5 py-0.5 rounded text-xs {{ $itemSep === '스크린' ? 'bg-blue-100 text-blue-700' : 'bg-orange-100 text-orange-700' }}"> + {{ $itemSep }} + </span> + </td> + <td class="px-2 py-2 text-center text-xs"> + @if($modelUA) + <span class="{{ $modelUA === '인정' ? 'text-green-600' : 'text-gray-400' }}">{{ $modelUA }}</span> + @else + <span class="text-gray-300">-</span> + @endif + </td> + <td class="px-2 py-2 text-xs">{{ $item['item_bending'] ?? '-' }}</td> + <td class="px-2 py-2 font-medium text-xs">{{ $item['item_name'] ?? $item['name'] }}</td> + <td class="px-2 py-2 text-gray-600 text-xs">{{ $item['item_spec'] ?? '-' }}</td> + <td class="px-2 py-2 text-gray-600 text-xs">{{ $item['material'] ?? '-' }}</td> + <td class="px-2 py-2 text-center"> + @if(!empty($item['image_file_id'])) + <div class="relative inline-block img-preview-wrap"> + <img src="{{ route('files.view', $item['image_file_id']) }}" width="24" height="24" style="width:24px; height:24px; object-fit:contain;" class="rounded inline-block" alt=""> + <div class="img-preview-popup hidden absolute z-50 bg-white border shadow-xl rounded-lg p-1" style="left:50%; transform:translateX(-50%); width:300px;"> + <img src="{{ route('files.view', $item['image_file_id']) }}" class="w-full rounded" alt=""> + </div> + </div> + @else + <span class="text-gray-300">-</span> + @endif + </td> + <td class="px-2 py-2 text-gray-600 text-xs">{{ $item['model_name'] ?? '-' }}</td> + <td class="px-2 py-2 text-right font-mono text-xs">{{ $widthSum ?? '-' }}</td> + <td class="px-2 py-2 text-center text-xs"> + @if($bendCount > 0) + <span class="text-blue-600 font-medium">{{ $bendCount }}</span> + @else + <span class="text-gray-300">-</span> + @endif + </td> + <td class="px-2 py-2 text-gray-500 text-xs">{{ $createdAt ? \Illuminate\Support\Str::before($createdAt, ' ') : '-' }}</td> + <td class="px-2 py-2 text-center text-xs text-gray-500">{{ $item['modified_by'] ?? '-' }}</td> + <td class="px-2 py-2 text-center"> + <a href="{{ route('bending.base.edit', $item['id']) }}" class="text-blue-600 hover:underline text-xs" + onclick="event.stopPropagation()">수정</a> + </td> + </tr> + @empty + <tr> + <td colspan="15" class="px-3 py-8 text-center text-gray-400">데이터가 없습니다.</td> + </tr> + @endforelse + </tbody> + </table> +</div> + +<script> +document.querySelectorAll('.img-preview-wrap').forEach(wrap => { + const popup = wrap.querySelector('.img-preview-popup'); + if (!popup) return; + popup.style.position = 'fixed'; + wrap.addEventListener('mouseenter', () => { + const rect = wrap.getBoundingClientRect(); + const popW = 300; + let left = rect.left + rect.width / 2 - popW / 2; + if (left < 4) left = 4; + if (left + popW > window.innerWidth - 4) left = window.innerWidth - popW - 4; + popup.style.left = left + 'px'; + popup.style.width = popW + 'px'; + popup.style.transform = 'none'; + if (rect.top > window.innerHeight / 2) { + popup.style.bottom = (window.innerHeight - rect.top + 4) + 'px'; + popup.style.top = 'auto'; + } else { + popup.style.top = (rect.bottom + 4) + 'px'; + popup.style.bottom = 'auto'; + } + popup.classList.remove('hidden'); + }); + wrap.addEventListener('mouseleave', () => { popup.classList.add('hidden'); }); +}); +</script> + +{{-- 페이지네이션 --}} +@if($lastPage > 1) +<div class="bg-white px-4 py-3 mt-4 rounded-lg shadow-sm"> + <div class="flex items-center justify-between"> + <p class="text-sm text-gray-700">전체 <span class="font-medium">{{ $total }}</span>건</p> + <nav class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px"> + @if($currentPage > 1) + <a href="{{ request()->fullUrlWithQuery(['page' => 1]) }}" class="relative inline-flex items-center px-3 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50">처음</a> + <a href="{{ request()->fullUrlWithQuery(['page' => $currentPage - 1]) }}" class="relative inline-flex items-center px-2 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"> + <svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd"/></svg> + </a> + @else + <span class="relative inline-flex items-center px-3 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-300 cursor-not-allowed">처음</span> + <span class="relative inline-flex items-center px-2 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-300 cursor-not-allowed"> + <svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd"/></svg> + </span> + @endif + + @php + $maxPages = 10; + $startPage = max(1, $currentPage - floor($maxPages / 2)); + $endPage = min($lastPage, $startPage + $maxPages - 1); + if ($endPage - $startPage + 1 < $maxPages) { $startPage = max(1, $endPage - $maxPages + 1); } + @endphp + + @for($p = $startPage; $p <= $endPage; $p++) + @if($p == $currentPage) + <span class="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-blue-50 text-sm font-medium text-blue-600">{{ $p }}</span> + @else + <a href="{{ request()->fullUrlWithQuery(['page' => $p]) }}" class="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700 hover:bg-gray-50">{{ $p }}</a> + @endif + @endfor + + @if($currentPage < $lastPage) + <a href="{{ request()->fullUrlWithQuery(['page' => $currentPage + 1]) }}" class="relative inline-flex items-center px-2 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"> + <svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/></svg> + </a> + <a href="{{ request()->fullUrlWithQuery(['page' => $lastPage]) }}" class="relative inline-flex items-center px-3 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50">끝</a> + @else + <span class="relative inline-flex items-center px-2 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-300 cursor-not-allowed"> + <svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/></svg> + </span> + <span class="relative inline-flex items-center px-3 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-300 cursor-not-allowed">끝</span> + @endif + </nav> + </div> +</div> +@endif diff --git a/resources/views/bending/products/form.blade.php b/resources/views/bending/products/form.blade.php new file mode 100644 index 00000000..4b51f921 --- /dev/null +++ b/resources/views/bending/products/form.blade.php @@ -0,0 +1,1153 @@ +@extends('layouts.app') +@section('title', ($config['title'] ?? '절곡품') . ' - ' . ($mode === 'create' ? '등록' : ($mode === 'edit' ? '수정' : '상세'))) + +@section('content') +@php + $opt = is_array($item) ? $item : []; + $isView = $mode === 'view'; + $isCreate = $mode === 'create'; + $components = $opt['components'] ?? []; + $materialSummary = $opt['material_summary'] ?? []; + $itemId = $opt['id'] ?? null; + $prefix = $config['prefix'] ?? 'products'; + $itemCategory = $opt['item_category'] ?? ($category ?? 'GUIDERAIL_MODEL'); + $isGuiderail = $itemCategory === 'GUIDERAIL_MODEL'; + $isCase = $itemCategory === 'SHUTTERBOX_MODEL'; + $isBottom = $itemCategory === 'BOTTOMBAR_MODEL'; + $typeLabel = $config['label'] ?? ($isCase ? '케이스' : ($isBottom ? '하단마감재' : '가이드레일')); + $pageTitle = ($isCreate ? "{$typeLabel} 등록" : ($mode === 'edit' ? "{$typeLabel} 수정" : "{$typeLabel} 상세")); +@endphp + +<div class="container-fluid px-4 py-3"> + {{-- 헤더 --}} + <div class="flex items-center justify-between mb-4"> + <div class="flex items-center gap-3"> + <a href="{{ route("bending.{$prefix}.index") }}" class="text-gray-500 hover:text-gray-700"> + <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg> + </a> + <h1 class="text-xl font-bold text-gray-800"> + {{ $pageTitle }} + @if($item) <span class="text-sm font-normal text-gray-500 ml-2">{{ $opt['model_name'] ?? $opt['code'] ?? '' }}</span> @endif + </h1> + </div> + <div class="flex gap-2"> + @if(!$isCreate && $itemId) + <button type="button" onclick="showHistory()" class="px-3 py-2 bg-gray-500 text-white rounded-lg hover:bg-gray-600 text-sm" title="수정 이력">H</button> + <button type="button" onclick="openPrintModal('{{ route("bending.{$prefix}.print", $itemId) }}')" + class="px-4 py-2 bg-gray-700 text-white rounded-lg hover:bg-gray-800 text-sm">작업지시서</button> + @endif + @if($isView && $itemId) + <a href="{{ route("bending.{$prefix}.edit", $itemId) }}" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm no-print">수정</a> + @endif + @if(!$isCreate && $itemId) + <form method="POST" action="{{ route("bending.{$prefix}.destroy", $itemId) }}" onsubmit="return confirm('삭제하시겠습니까?')"> + @csrf @method('DELETE') + <button type="submit" class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 text-sm">삭제</button> + </form> + @endif + </div> + </div> + + @if(session('success')) + <script> + document.addEventListener('DOMContentLoaded', () => { + showToast("{{ session('success') }}", 'success'); + }); + </script> + @endif + + @if($errors->any()) + <div class="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700"> + <ul class="list-disc list-inside"> + @foreach($errors->all() as $error) + <li>{{ $error }}</li> + @endforeach + </ul> + </div> + @endif + + <form method="POST" action="{{ $isCreate ? route("bending.{$prefix}.store") : route("bending.{$prefix}.update", $itemId) }}" id="productForm" enctype="multipart/form-data"> + @csrf + @if(!$isCreate) @method('PUT') @endif + + <div class="flex gap-4" style="align-items: flex-start;"> + {{-- 좌: 기본정보 + 부품 목록 --}} + <div style="flex: 1 1 0; min-width: 0;"> + {{-- 기본 정보 (타입별 레이아웃) --}} + <div class="bg-white rounded-lg shadow p-4 mb-4"> + <h2 class="text-sm font-bold text-gray-700 mb-3 border-b pb-2">기본 정보</h2> + {{-- 1행: 공통 — 등록일/작성자/검색어/비고 --}} + <div class="grid grid-cols-4 gap-3 mb-3"> + <div> + <label class="block text-xs text-gray-500 mb-1">등록일</label> + <input type="date" name="registration_date" value="{{ old('registration_date', $opt['registration_date'] ?? date('Y-m-d')) }}" {{ $isView ? 'disabled' : '' }} + class="w-full border border-gray-300 rounded px-3 py-1.5 text-sm {{ $isView ? 'bg-gray-100' : '' }}"> + </div> + <div> + <label class="block text-xs text-gray-500 mb-1">작성자</label> + <input type="text" name="author" value="{{ old('author', $opt['author'] ?? Auth::user()?->name ?? '') }}" {{ $isView ? 'disabled' : '' }} + class="w-full border border-gray-300 rounded px-3 py-1.5 text-sm {{ $isView ? 'bg-gray-100' : '' }}"> + </div> + <div> + <label class="block text-xs text-gray-500 mb-1">검색어</label> + <input type="text" name="search_keyword" value="{{ old('search_keyword', $opt['search_keyword'] ?? '') }}" {{ $isView ? 'disabled' : '' }} + class="w-full border border-gray-300 rounded px-3 py-1.5 text-sm {{ $isView ? 'bg-gray-100' : '' }}"> + </div> + <div> + <label class="block text-xs text-gray-500 mb-1">비고</label> + <input type="text" name="memo" value="{{ old('memo', $opt['memo'] ?? '') }}" {{ $isView ? 'disabled' : '' }} + class="w-full border border-gray-300 rounded px-3 py-1.5 text-sm {{ $isView ? 'bg-gray-100' : '' }}"> + </div> + </div> + + @if($isCase) + {{-- 2행: 케이스 — 치수 --}} + <div class="grid grid-cols-4 gap-3 mb-3"> + <div> + <label class="block text-xs text-blue-600 mb-1">가로(폭) <span class="text-red-500">*</span></label> + <input type="number" name="box_width" value="{{ old('box_width', $opt['box_width'] ?? '') }}" {{ $isView ? 'disabled' : 'required' }} + class="w-full border border-gray-300 rounded px-3 py-1.5 text-sm text-blue-600 font-bold {{ $isView ? 'bg-gray-100' : '' }}"> + </div> + <div> + <label class="block text-xs text-red-600 mb-1">세로(높이) <span class="text-red-500">*</span></label> + <input type="number" name="box_height" value="{{ old('box_height', $opt['box_height'] ?? '') }}" {{ $isView ? 'disabled' : 'required' }} + class="w-full border border-gray-300 rounded px-3 py-1.5 text-sm text-red-600 font-bold {{ $isView ? 'bg-gray-100' : '' }}"> + </div> + <div> + <label class="block text-xs text-green-600 mb-1">전면밑</label> + <input type="number" name="front_bottom_width" value="{{ old('front_bottom_width', $opt['front_bottom_width'] ?? '') }}" {{ $isView ? 'disabled' : '' }} + class="w-full border border-gray-300 rounded px-3 py-1.5 text-sm {{ $isView ? 'bg-gray-100' : '' }}"> + </div> + <div> + <label class="block text-xs text-red-600 mb-1">레일폭</label> + <input type="number" name="rail_width" value="{{ old('rail_width', $opt['rail_width'] ?? '') }}" {{ $isView ? 'disabled' : '' }} + class="w-full border border-gray-300 rounded px-3 py-1.5 text-sm {{ $isView ? 'bg-gray-100' : '' }}"> + </div> + </div> + {{-- 3행: 케이스 — 점검구 --}} + <div class="grid grid-cols-1 gap-3"> + <div class="flex items-center gap-2 bg-gray-50 rounded px-3 py-2"> + <span class="text-xs font-bold text-gray-500 shrink-0">점검구 <span class="text-red-500">*</span></span> + @foreach(['양면 점검구', '밑면 점검구', '후면 점검구'] as $v) + <label class="flex items-center gap-1 text-sm"> + <input type="radio" name="exit_direction" value="{{ $v }}" {{ ($opt['exit_direction'] ?? '') === $v ? 'checked' : '' }} {{ $isView ? 'disabled' : 'required' }}> + {{ $v }} + </label> + @endforeach + </div> + </div> + @else + {{-- 2행: 가이드레일/하단 — 외형치수 + 분류 --}} + <div class="grid grid-cols-4 gap-3 mb-3"> + @if($isBottom) + <div> + <label class="block text-xs text-blue-600 mb-1">가로(폭)</label> + <input type="number" name="bar_width" value="{{ old('bar_width', $opt['bar_width'] ?? '') }}" {{ $isView ? 'disabled' : '' }} + class="w-full border border-gray-300 rounded px-3 py-1.5 text-sm text-blue-600 font-bold {{ $isView ? 'bg-gray-100' : '' }}"> + </div> + <div> + <label class="block text-xs text-red-600 mb-1">세로(높이)</label> + <input type="number" name="bar_height" value="{{ old('bar_height', $opt['bar_height'] ?? '') }}" {{ $isView ? 'disabled' : '' }} + class="w-full border border-gray-300 rounded px-3 py-1.5 text-sm text-red-600 font-bold {{ $isView ? 'bg-gray-100' : '' }}"> + </div> + @else + <div> + <label class="block text-xs text-blue-600 mb-1">가로(너비)</label> + <input type="number" name="rail_length" value="{{ old('rail_length', $opt['rail_length'] ?? '') }}" {{ $isView ? 'disabled' : '' }} + class="w-full border border-gray-300 rounded px-3 py-1.5 text-sm text-blue-600 font-bold {{ $isView ? 'bg-gray-100' : '' }}"> + </div> + <div> + <label class="block text-xs text-red-600 mb-1">세로(폭)</label> + <input type="number" name="rail_width" value="{{ old('rail_width', $opt['rail_width'] ?? '') }}" {{ $isView ? 'disabled' : '' }} + class="w-full border border-gray-300 rounded px-3 py-1.5 text-sm text-red-600 font-bold {{ $isView ? 'bg-gray-100' : '' }}"> + </div> + @endif + <div> + <label class="block text-xs text-gray-500 mb-1">모델 <span class="text-red-500">*</span></label> + <select name="model_name" {{ $isView ? 'disabled' : 'required' }} + class="w-full border border-gray-300 rounded px-3 py-1.5 text-sm {{ $isView ? 'bg-gray-100' : '' }}"> + <option value="">선택</option> + @foreach(['KSS01','KSS02','KSE01','KWE01','KTE01','KQTS01','KDSS01'] as $m) + <option value="{{ $m }}" {{ ($opt['model_name'] ?? '') === $m ? 'selected' : '' }}>{{ $m }}</option> + @endforeach + </select> + </div> + <div> + <label class="block text-xs text-gray-500 mb-1">마감</label> + <select name="finishing_type" {{ $isView ? 'disabled' : '' }} + class="w-full border border-gray-300 rounded px-3 py-1.5 text-sm {{ $isView ? 'bg-gray-100' : '' }}"> + <option value="">선택</option> + <option value="SUS마감" {{ ($opt['finishing_type'] ?? '') === 'SUS마감' ? 'selected' : '' }}>SUS마감</option> + <option value="EGI마감" {{ ($opt['finishing_type'] ?? '') === 'EGI마감' ? 'selected' : '' }}>EGI마감</option> + </select> + </div> + </div> + {{-- 3행: 가이드레일/하단 — 라디오 --}} + <div class="grid grid-cols-{{ $isGuiderail ? '3' : '2' }} gap-3"> + <div class="flex items-center gap-2 bg-gray-50 rounded px-3 py-2"> + <span class="text-xs font-bold text-gray-500 shrink-0">대분류 <span class="text-red-500">*</span></span> + @foreach(['스크린', '철재'] as $v) + <label class="flex items-center gap-1 text-sm"> + <input type="radio" name="item_sep" value="{{ $v }}" {{ ($opt['item_sep'] ?? '') === $v ? 'checked' : '' }} {{ $isView ? 'disabled' : 'required' }}> + {{ $v }} + </label> + @endforeach + </div> + @if($isGuiderail) + <div class="flex items-center gap-2 bg-gray-50 rounded px-3 py-2"> + <span class="text-xs font-bold text-gray-500 shrink-0">형태</span> + @foreach(['벽면형', '측면형'] as $v) + <label class="flex items-center gap-1 text-sm"> + <input type="radio" name="check_type" value="{{ $v }}" {{ ($opt['check_type'] ?? '') === $v ? 'checked' : '' }} {{ $isView ? 'disabled' : '' }}> + {{ $v }} + </label> + @endforeach + </div> + @endif + <div class="flex items-center gap-2 bg-gray-50 rounded px-3 py-2"> + <span class="text-xs font-bold text-gray-500 shrink-0">모델유형</span> + @foreach(['인정', '비인정'] as $v) + <label class="flex items-center gap-1 text-sm"> + <input type="radio" name="model_UA" value="{{ $v }}" {{ ($opt['model_UA'] ?? '') === $v ? 'checked' : '' }} {{ $isView ? 'disabled' : '' }}> + {{ $v }} + </label> + @endforeach + </div> + </div> + @endif + <input type="hidden" name="code" value="{{ old('code', $opt['code'] ?? '') }}"> + <input type="hidden" name="item_category" value="{{ $itemCategory }}"> + <input type="hidden" name="name" value="{{ old('name', $opt['name'] ?? '') }}"> + </div> + + {{-- 절곡 부품 조립 --}} + <div class="bg-white rounded-lg shadow p-4 mb-4"> + <div class="flex items-center justify-between mb-3 border-b pb-2"> + <h2 class="text-sm font-bold text-gray-700">절곡 부품 조립 (<span id="compCount">{{ count($components) }}</span>개)</h2> + @if(!$isView) + <div class="flex gap-2"> + <button type="button" onclick="addPart()" class="px-3 py-1 bg-blue-600 text-white rounded text-xs">+ 부품 추가</button> + <button type="button" onclick="deleteAllParts()" class="px-3 py-1 bg-red-600 text-white rounded text-xs">전체 삭제</button> + </div> + @endif + </div> + + @if(!$isView) + <div class="flex gap-2 mb-3"> + <button type="button" onclick="resetOrder()" class="px-2 py-1 border border-gray-300 rounded text-xs text-gray-600 hover:bg-gray-50">순서 초기화</button> + <button type="button" onclick="movePart('up')" class="px-2 py-1 border border-gray-300 rounded text-xs text-gray-600 hover:bg-gray-50">↑ 위로</button> + <button type="button" onclick="movePart('down')" class="px-2 py-1 border border-gray-300 rounded text-xs text-gray-600 hover:bg-gray-50">↓ 아래로</button> + <span class="text-xs text-gray-400 ml-2">※ 순서변경은 위/아래 버튼을 사용하세요</span> + </div> + @endif + + <div id="partsContainer"> + @forelse($components as $idx => $comp) + @php + $bd = $comp['bendingData'] ?? []; + $hasSamFormat = !empty($bd) && isset($bd[0]['input']); + if ($hasSamFormat) { + $inputs = array_column($bd, 'input'); + $rates = array_column($bd, 'rate'); + $sums = array_column($bd, 'sum'); + $colors = array_column($bd, 'color'); + $angles = array_column($bd, 'aAngle'); + } else { + $inputs = $comp['inputList'] ?? []; + $rates = $comp['bendingrateList'] ?? []; + $sums = $comp['sumList'] ?? []; + $colors = $comp['colorList'] ?? []; + $angles = $comp['AList'] ?? []; + } + $legacyNum = $comp['legacy_bending_num'] ?? null; + $compSamItemId = $comp['sam_item_id'] ?? null; + $widthSum = !empty($sums) ? end($sums) : 0; + $compFileId = $comp['image_file_id'] ?? null; + @endphp + <div class="mb-4 p-3 bg-gray-50 rounded border part-item" data-part-idx="{{ $idx }}" id="partItem_{{ $idx }}"> + {{-- 파트 헤더 --}} + <div class="flex items-center gap-2 flex-wrap mb-1"> + <label class="flex items-center shrink-0"> + <input type="radio" name="selectedPart" value="{{ $idx }}" class="mr-1" {{ $idx === 0 ? 'checked' : '' }}> + <span class="px-2 py-0.5 bg-blue-600 text-white rounded text-xs font-bold">순서:{{ $comp['orderNumber'] ?? ($idx + 1) }}</span> + </label> + @if($isView) + <span class="text-sm font-medium text-gray-700">{{ $comp['itemName'] ?? '부품' }}</span> + <span class="text-xs text-gray-500">재질: {{ $comp['material'] ?? '-' }}</span> + <span class="text-xs text-gray-500">수량: {{ $comp['quantity'] ?? 1 }}</span> + @else + <input type="text" class="border border-gray-300 rounded px-2 py-0.5 text-sm font-medium part-name" value="{{ $comp['itemName'] ?? '' }}" data-idx="{{ $idx }}" placeholder="부품명" style="width:130px; min-width:80px;"> + <span class="text-xs text-gray-500 whitespace-nowrap">재질:</span> + <input type="text" class="border border-gray-300 rounded px-2 py-0.5 text-xs part-material" value="{{ $comp['material'] ?? '' }}" data-idx="{{ $idx }}" list="mat_list" style="width:90px; min-width:60px;"> + <span class="text-xs text-gray-500 whitespace-nowrap">수량:</span> + <input type="number" class="border border-gray-300 rounded px-1 py-0.5 text-xs text-center part-qty" value="{{ $comp['quantity'] ?? 1 }}" min="1" data-idx="{{ $idx }}" style="width:45px; min-width:35px;"> + @endif + <span class="text-xs text-blue-600 font-mono whitespace-nowrap" id="partWidthSum_{{ $idx }}">폭합: {{ $widthSum }}</span> + @if(!$isView) + <div class="flex gap-1 ml-auto shrink-0"> + @if($compSamItemId) + <a href="/bending/base/{{ $compSamItemId }}/edit" target="_blank" class="px-1.5 py-0.5 bg-indigo-600 text-white rounded text-xs hover:bg-indigo-700 inline-block">원본수정</a> + @else + <button type="button" onclick="editPartOriginal({{ $idx }})" class="px-1.5 py-0.5 bg-indigo-600 text-white rounded text-xs hover:bg-indigo-700">원본수정</button> + @endif + <button type="button" onclick="savePartAsNew({{ $idx }})" class="px-1.5 py-0.5 bg-green-600 text-white rounded text-xs hover:bg-green-700">다른이름저장</button> + <button type="button" onclick="deletePart(this, {{ $idx }})" class="px-1.5 py-0.5 bg-red-600 text-white rounded text-xs hover:bg-red-700">삭제</button> + </div> + @endif + </div> + + {{-- 부품 이미지 --}} + @if($compFileId) + <div class="mb-2"> + <img src="{{ route('files.view', $compFileId) }}" alt="{{ $comp['itemName'] ?? '' }}" width="100" height="60" style="width:100px; height:auto; max-height:60px; object-fit:contain;" class="rounded border"> + </div> + @endif + + {{-- 절곡 테이블 --}} + @if(!empty($inputs) || !$isView) + <div class="overflow-x-auto"> + <table class="text-xs border-collapse w-full" id="bendTable_{{ $idx }}"> + <thead> + <tr class="bg-gray-100"> + <th class="border px-2 py-1 text-gray-600" style="width:65px; min-width:45px;">구분</th> + @for($i = 0; $i < max(count($inputs), 1); $i++) + <th class="border px-1 py-1 text-center min-w-[35px]">{{ $i + 1 }}</th> + @endfor + @if(!$isView) + <th class="border px-1 py-1 text-center min-w-[30px]"> + <button type="button" onclick="addPartCol({{ $idx }})" class="text-green-600 font-bold">+</button> + </th> + @endif + </tr> + </thead> + <tbody> + <tr> + <td class="border px-2 py-1 bg-gray-50 font-medium">입력</td> + @foreach($inputs as $ci => $v) + <td class="border px-1 py-1 text-center {{ ($colors[$ci] ?? false) ? 'bg-orange-100' : '' }}"> + @if($isView) + <span class="font-bold">{{ $v }}</span> + @else + <input type="number" class="w-full text-center border-0 bg-transparent p-input" data-part="{{ $idx }}" data-col="{{ $ci }}" value="{{ $v }}" step="any" oninput="recalcPart({{ $idx }})"> + @endif + </td> + @endforeach + @if(!$isView)<td class="border"></td>@endif + </tr> + <tr> + <td class="border px-2 py-1 bg-gray-50 font-medium">연신율</td> + @foreach($rates as $ci => $v) + <td class="border px-1 py-1 text-center"> + @if($isView) + <span class="{{ $v ? 'text-red-500' : '' }}">{{ $v ?: '' }}</span> + @else + <input type="number" class="w-full text-center border-0 bg-transparent p-rate" step="1" data-part="{{ $idx }}" data-col="{{ $ci }}" value="{{ $v }}" oninput="recalcPart({{ $idx }})"> + @endif + </td> + @endforeach + @if(!$isView)<td class="border"></td>@endif + </tr> + <tr> + <td class="border px-2 py-1 bg-gray-50 font-medium">연신율 후</td> + @foreach($inputs as $ci => $v) + @php + $rate = $rates[$ci] ?? ''; + $adj = (float)$v; + if ($rate === '-1' || $rate === -1) $adj = $v - 1; + elseif ($rate === '1' || $rate === 1) $adj = $v + 1; + @endphp + <td class="border px-1 py-1 text-center text-gray-500 p-adj" data-part="{{ $idx }}" data-col="{{ $ci }}">{{ $adj }}</td> + @endforeach + @if(!$isView)<td class="border"></td>@endif + </tr> + <tr> + <td class="border px-2 py-1 bg-yellow-50 font-medium">합계</td> + @foreach($sums as $ci => $v) + <td class="border px-1 py-1 text-center font-bold bg-yellow-50 p-sum {{ ($colors[$ci] ?? false) ? 'bg-orange-200 text-red-700' : '' }}" data-part="{{ $idx }}" data-col="{{ $ci }}">{{ $v }}</td> + @endforeach + @if(!$isView)<td class="border"></td>@endif + </tr> + <tr> + <td class="border px-2 py-1 bg-gray-50 font-medium">음영</td> + @foreach($colors as $ci => $v) + <td class="border px-1 py-1 text-center"> + @if($isView) + @if($v) <span class="px-1 py-0.5 bg-orange-300 rounded text-xs">음영</span> @endif + @else + <input type="checkbox" class="p-color" data-part="{{ $idx }}" data-col="{{ $ci }}" {{ $v ? 'checked' : '' }}> + @endif + </td> + @endforeach + @if(!$isView)<td class="border"></td>@endif + </tr> + <tr> + <td class="border px-2 py-1 bg-gray-50 font-medium">A각</td> + @foreach($angles as $ci => $v) + <td class="border px-1 py-1 text-center"> + @if($isView) + @if($v) <span class="px-1 py-0.5 bg-blue-400 text-white rounded text-xs">A각</span> @endif + @else + <input type="checkbox" class="p-angle" data-part="{{ $idx }}" data-col="{{ $ci }}" {{ $v ? 'checked' : '' }}> + @endif + </td> + @endforeach + @if(!$isView)<td class="border"></td>@endif + </tr> + </tbody> + </table> + </div> + @else + <p class="text-gray-400 text-xs">절곡 데이터 없음</p> + @endif + </div> + @empty + <p class="text-gray-400 text-sm py-4 text-center" id="noPartsMsg">부품이 없습니다.</p> + @endforelse + </div> + </div> + + {{-- 재질별 폭합 --}} + @if(!empty($materialSummary)) + <div class="bg-white rounded-lg shadow p-4 mb-4"> + <h2 class="text-sm font-bold text-gray-700 mb-3 border-b pb-2">재질별 폭합</h2> + <table class="text-sm"> + <thead> + <tr class="bg-gray-50"> + <th class="px-4 py-2 text-left text-xs font-medium text-gray-500">재질</th> + <th class="px-4 py-2 text-right text-xs font-medium text-gray-500">폭합계 (mm)</th> + </tr> + </thead> + <tbody> + @foreach($materialSummary as $mat => $total) + <tr class="border-t"> + <td class="px-4 py-2">{{ $mat }}</td> + <td class="px-4 py-2 text-right font-mono font-bold text-blue-700">{{ $total }}</td> + </tr> + @endforeach + </tbody> + </table> + </div> + @endif + </div> + + {{-- 우: 이미지 + 저장 --}} + <div class="shrink-0" style="width: 280px; min-width: 200px; max-width: 320px;"> + <div class="bg-white rounded-lg shadow p-4 mb-4"> + <h2 class="text-sm font-bold text-gray-700 mb-3 border-b pb-2">결합형태 이미지</h2> + <div class="border-2 border-dashed border-gray-300 rounded-lg p-3 text-center min-h-[200px] flex items-center justify-center" id="imageContainer"> + @if(!empty($imageFile)) + <img src="{{ route('files.view', $imageFile['id']) }}" alt="결합형태" class="max-w-full rounded" id="currentImage"> + @else + <span class="text-gray-400 text-sm" id="noImageText">이미지 없음</span> + @endif + </div> + @if(!$isView) + <div class="flex gap-1 mt-2"> + <input type="file" name="image" accept="image/*" onchange="previewImage(this)" class="text-xs flex-1 min-w-0"> + <button type="button" onclick="openCanvasEditor()" class="px-2 py-1 bg-indigo-600 text-white rounded text-xs hover:bg-indigo-700 whitespace-nowrap"> + <i class="ri-edit-line"></i> 그리기 + </button> + </div> + <img id="image-preview" class="hidden max-w-full rounded mt-2"> + <input type="hidden" name="canvas_image" id="canvasImageData"> + <p class="text-xs text-gray-400 mt-1">Ctrl+V로 붙여넣기 가능</p> + @endif + </div> + + @if(!$isView) + <div class="mt-4"> + <button type="submit" class="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm font-medium"> + {{ $isCreate ? '등록' : '저장' }} + </button> + </div> + @endif + </div> + </div> + + <input type="hidden" name="modified_by" value="{{ Auth::user()?->name ?? '' }}"> + <input type="hidden" name="components" id="componentsInput"> + <input type="hidden" name="material_summary" id="materialSummaryInput"> + </form> + + {{-- 부품 검색 모달 --}} + @if(!$isView) + <div id="bendingSearchModal" class="fixed inset-0 bg-black bg-opacity-50 hidden z-50 flex items-center justify-center"> + <div class="bg-white rounded-lg shadow-xl" style="width:90%; max-width:1000px; max-height:85vh; display:flex; flex-direction:column;"> + <div class="flex items-center justify-between p-4 border-b"> + <h3 class="text-lg font-bold text-gray-800">절곡 부품 검색</h3> + <button type="button" onclick="closeBendingModal()" class="text-gray-400 hover:text-gray-600 text-2xl">×</button> + </div> + <div class="p-4 border-b"> + <div class="flex flex-wrap gap-2 items-center"> + <select id="modalItemSep" class="border border-gray-300 rounded px-2 py-1 text-xs"> + <option value="">전체(대분류)</option> + <option value="스크린">스크린</option> + <option value="철재">철재</option> + </select> + <select id="modalItemBending" class="border border-gray-300 rounded px-2 py-1 text-xs"> + <option value="">전체(분류)</option> + <option value="가이드레일">가이드레일</option> + <option value="케이스">케이스</option> + <option value="하단마감재">하단마감재</option> + <option value="마구리">마구리</option> + <option value="L-BAR">L-BAR</option> + <option value="보강평철">보강평철</option> + <option value="연기차단재">연기차단재</option> + </select> + <select id="modalMaterial" class="border border-gray-300 rounded px-2 py-1 text-xs"> + <option value="">전체(재질)</option> + <option value="SUS 1.2T">SUS 1.2T</option> + <option value="SUS 1.5T">SUS 1.5T</option> + <option value="EGI 1.55T">EGI 1.55T</option> + </select> + <input type="text" id="modalSearch" placeholder="품명/코드 검색" class="border border-gray-300 rounded px-2 py-1 text-xs" style="width:150px;"> + <button type="button" onclick="searchBendingItems()" class="px-3 py-1 bg-blue-600 text-white rounded text-xs">검색</button> + <button type="button" onclick="resetModalFilters()" class="px-3 py-1 bg-gray-400 text-white rounded text-xs">초기화</button> + </div> + </div> + <div class="overflow-y-auto flex-1 p-4"> + <table class="w-full text-xs"> + <thead class="bg-gray-50 border-b sticky top-0"> + <tr> + <th class="px-2 py-2 text-center w-8"><input type="checkbox" id="modalSelectAll" onchange="toggleSelectAll(this)"></th> + <th class="px-2 py-2 text-left">코드</th> + <th class="px-2 py-2 text-center">대분류</th> + <th class="px-2 py-2 text-left">분류</th> + <th class="px-2 py-2 text-left">품명</th> + <th class="px-2 py-2 text-left">재질</th> + <th class="px-2 py-2 text-center">이미지</th> + <th class="px-2 py-2 text-right">폭합</th> + <th class="px-2 py-2 text-center">절곡수</th> + </tr> + </thead> + <tbody id="modalResults"></tbody> + </table> + <p id="modalNoData" class="text-center text-gray-400 py-4 hidden">검색 결과가 없습니다.</p> + <p id="modalLoading" class="text-center text-gray-400 py-4 hidden">검색 중...</p> + </div> + <div class="flex items-center justify-between p-4 border-t"> + <span class="text-xs text-gray-500">선택: <strong id="modalSelectedCount">0</strong>건</span> + <div class="flex gap-2"> + <button type="button" onclick="closeBendingModal()" class="px-4 py-2 border border-gray-300 rounded text-sm">취소</button> + <button type="button" onclick="applyBendingSelection()" class="px-4 py-2 bg-blue-600 text-white rounded text-sm">선택 적용</button> + </div> + </div> + </div> + </div> + @endif + + @if(!$isView) + @include('components.canvas-editor') + @endif + + {{-- 이력 모달 --}} + @if(!$isCreate && $itemId) + <dialog id="historyDialog" class="rounded-lg shadow-xl p-0 backdrop:bg-black/50" style="max-width:500px; border:none;"> + <div class="p-4"> + <div class="flex items-center justify-between mb-3"> + <h3 class="font-bold text-gray-800">수정 이력</h3> + <button type="button" onclick="document.getElementById('historyDialog').close()" class="text-gray-400 hover:text-gray-600 text-xl">×</button> + </div> + <table class="w-full text-sm"> + <tbody> + <tr class="border-b"><td class="py-2 text-gray-500 pr-4">코드</td><td class="py-2 font-mono">{{ $opt['code'] ?? '' }}</td></tr> + <tr class="border-b"><td class="py-2 text-gray-500 pr-4">모델</td><td class="py-2">{{ $opt['model_name'] ?? '-' }}</td></tr> + <tr class="border-b"><td class="py-2 text-gray-500 pr-4">등록일</td><td class="py-2">{{ $opt['registration_date'] ?? '-' }}</td></tr> + <tr class="border-b"><td class="py-2 text-gray-500 pr-4">작성자</td><td class="py-2">{{ $opt['author'] ?? '-' }}</td></tr> + <tr class="border-b"><td class="py-2 text-gray-500 pr-4">최종수정자</td><td class="py-2">{{ $opt['modified_by'] ?? '-' }}</td></tr> + <tr class="border-b"><td class="py-2 text-gray-500 pr-4">현재 수정자</td><td class="py-2 text-blue-600 font-medium">{{ Auth::user()?->name ?? '-' }}</td></tr> + <tr class="border-b"><td class="py-2 text-gray-500 pr-4">생성일시</td><td class="py-2">{{ $opt['created_at'] ?? '-' }}</td></tr> + <tr class="border-b"><td class="py-2 text-gray-500 pr-4">수정일시</td><td class="py-2">{{ $opt['updated_at'] ?? '-' }}</td></tr> + @if(!empty($opt['change_log'])) + <tr><td colspan="2" class="py-2"> + <div class="text-gray-500 mb-1">변경 기록</div> + <div class="bg-gray-50 rounded p-2 text-xs max-h-[200px] overflow-y-auto"> + @foreach(array_reverse($opt['change_log']) as $log) + <div class="mb-1 pb-1 border-b border-gray-200 last:border-0"> + <span class="text-gray-400">{{ $log['date'] ?? '' }}</span> + <span class="text-gray-700">{{ $log['memo'] ?? '' }}</span> + </div> + @endforeach + </div> + </td></tr> + @endif + </tbody> + </table> + </div> + </dialog> + @endif +</div> + +@push('scripts') +@if(!$isView) +<script src="https://cdnjs.cloudflare.com/ajax/libs/fabric.js/5.3.0/fabric.min.js"></script> +<script src="{{ asset('js/canvas-editor.js') }}"></script> +@endif +<script> +const isView = {{ $isView ? 'true' : 'false' }}; +let components = @json($components ?: []); + +document.addEventListener('DOMContentLoaded', () => { + bindPartInputs(); + serializeComponents(); +}); + +function getSelectedPartIdx() { + const checked = document.querySelector('input[name="selectedPart"]:checked'); + return checked ? parseInt(checked.value) : -1; +} + +function movePart(direction) { + collectAllPartData(); + const idx = getSelectedPartIdx(); + if (idx < 0) return alert('이동할 부품을 선택하세요.'); + const newIdx = direction === 'up' ? idx - 1 : idx + 1; + if (newIdx < 0 || newIdx >= components.length) return; + [components[idx], components[newIdx]] = [components[newIdx], components[idx]]; + components.forEach((c, i) => c.orderNumber = i + 1); + serializeComponents(); + // DOM 순서 스왑 + const container = document.getElementById('partsContainer'); + const items = container.querySelectorAll('.part-item'); + if (items[idx] && items[newIdx]) { + if (direction === 'up') { + container.insertBefore(items[idx], items[newIdx]); + } else { + container.insertBefore(items[newIdx], items[idx]); + } + // 순서 뱃지 업데이트 + container.querySelectorAll('.part-item').forEach((el, i) => { + const badge = el.querySelector('.bg-blue-600'); + if (badge) badge.textContent = `순서:${i + 1}`; + }); + } +} + +function resetOrder() { + collectAllPartData(); + components.forEach((c, i) => c.orderNumber = i + 1); + serializeComponents(); +} + +function deletePart(btn, idx) { + const name = components[idx]?.itemName ?? '부품'; + if (!confirm(`#${idx + 1} ${name}을(를) 삭제하시겠습니까?`)) return; + // DOM 제거 + const el = document.getElementById(`partItem_${idx}`); + if (el) el.remove(); + // 배열 제거 + components.splice(idx, 1); + components.forEach((c, i) => c.orderNumber = i + 1); + document.getElementById('compCount').textContent = components.length; + serializeComponents(); + if (components.length === 0) { + document.getElementById('partsContainer').innerHTML = + '<p class="text-gray-400 text-sm py-4 text-center">부품이 없습니다.</p>'; + } +} + +function deleteAllParts() { + if (!confirm('모든 부품을 삭제하시겠습니까?')) return; + components = []; + serializeComponents(); + document.getElementById('partsContainer').innerHTML = + '<p class="text-gray-400 text-sm py-4 text-center">부품이 없습니다.</p>'; + document.getElementById('compCount').textContent = '0'; +} + +function addPart() { + collectAllPartData(); + document.getElementById('bendingSearchModal').classList.remove('hidden'); + searchBendingItems(); // 초기 로드 +} + +function closeBendingModal() { + document.getElementById('bendingSearchModal').classList.add('hidden'); +} + +let bendingSearchCache = []; + +async function searchBendingItems() { + const params = new URLSearchParams(); + const sep = document.getElementById('modalItemSep').value; + const bending = document.getElementById('modalItemBending').value; + const mat = document.getElementById('modalMaterial').value; + const search = document.getElementById('modalSearch').value; + if (sep) params.set('item_sep', sep); + if (bending) params.set('item_bending', bending); + if (mat) params.set('material', mat); + if (search) params.set('search', search); + params.set('size', '100'); + + document.getElementById('modalLoading').classList.remove('hidden'); + document.getElementById('modalNoData').classList.add('hidden'); + document.getElementById('modalResults').innerHTML = ''; + + try { + const resp = await fetch(`/bending/base/api-search?${params.toString()}`); + if (!resp.ok) { + console.error('api-search failed:', resp.status, resp.statusText); + document.getElementById('modalNoData').classList.remove('hidden'); + return; + } + const data = await resp.json(); + bendingSearchCache = data.data || []; + renderModalResults(bendingSearchCache); + } catch (e) { + console.error('api-search error:', e); + document.getElementById('modalNoData').classList.remove('hidden'); + } + document.getElementById('modalLoading').classList.add('hidden'); +} + +function resetModalFilters() { + document.getElementById('modalItemSep').value = ''; + document.getElementById('modalItemBending').value = ''; + document.getElementById('modalMaterial').value = ''; + document.getElementById('modalSearch').value = ''; + searchBendingItems(); +} + +function renderModalResults(items) { + const tbody = document.getElementById('modalResults'); + if (!items.length) { + tbody.innerHTML = ''; + document.getElementById('modalNoData').classList.remove('hidden'); + return; + } + document.getElementById('modalNoData').classList.add('hidden'); + tbody.innerHTML = items.map((item, i) => ` + <tr class="border-t hover:bg-blue-50 cursor-pointer" onclick="toggleRow(this, ${i})"> + <td class="px-2 py-1 text-center"><input type="checkbox" class="modal-check" data-idx="${i}" onclick="event.stopPropagation(); updateSelectedCount()"></td> + <td class="px-2 py-1 font-mono">${item.code}</td> + <td class="px-2 py-1 text-center"><span class="px-1 py-0.5 rounded text-xs ${item.item_sep === '스크린' ? 'bg-blue-100 text-blue-700' : 'bg-orange-100 text-orange-700'}">${item.item_sep || '-'}</span></td> + <td class="px-2 py-1">${item.item_bending || '-'}</td> + <td class="px-2 py-1 font-medium">${item.item_name || item.name}</td> + <td class="px-2 py-1">${item.material || '-'}</td> + <td class="px-2 py-1 text-center">${item.image_file_id ? `<img src="/files/${item.image_file_id}/view" width="50" height="50" style="width:50px; height:50px; object-fit:contain;" class="inline-block rounded">` : '<span class="text-gray-300">-</span>'}</td> + <td class="px-2 py-1 text-right font-mono">${item.width_sum ?? '-'}</td> + <td class="px-2 py-1 text-center">${item.bend_count || 0}</td> + </tr> + `).join(''); +} + +function toggleRow(tr, idx) { + const cb = tr.querySelector('.modal-check'); + cb.checked = !cb.checked; + updateSelectedCount(); +} + +function toggleSelectAll(master) { + document.querySelectorAll('.modal-check').forEach(cb => cb.checked = master.checked); + updateSelectedCount(); +} + +function updateSelectedCount() { + const count = document.querySelectorAll('.modal-check:checked').length; + document.getElementById('modalSelectedCount').textContent = count; +} + +function applyBendingSelection() { + const checked = document.querySelectorAll('.modal-check:checked'); + if (!checked.length) return alert('부품을 선택하세요.'); + + checked.forEach(cb => { + const item = bendingSearchCache[parseInt(cb.dataset.idx)]; + if (!item) return; + components.push({ + orderNumber: components.length + 1, + itemName: item.item_name || item.name, + material: item.material || '', + quantity: 1, + width_sum: item.width_sum || 0, + bendingData: item.bendingData || [], + legacy_bending_num: item.legacy_bending_num || null, + sam_item_id: item.id || null, + image_file_id: item.image_file_id || null, + }); + }); + + collectAllPartData(); + serializeComponents(); + closeBendingModal(); + + // DOM만 갱신 (submit 안 함 — 사용자가 [저장] 클릭 시 submit) + renderAddedParts(); +} + +function bindPartInputs() { + document.querySelectorAll('.part-name, .part-material, .part-qty').forEach(el => { + el.addEventListener('change', () => { collectAllPartData(); serializeComponents(); }); + }); +} + +// 모든 부품 입력값 수집 → components 배열 갱신 +function collectAllPartData() { + components.forEach((comp, pi) => { + const nameEl = document.querySelector(`.part-name[data-idx="${pi}"]`); + if (nameEl) comp.itemName = nameEl.value; + const matEl = document.querySelector(`.part-material[data-idx="${pi}"]`); + if (matEl) comp.material = matEl.value; + const qtyEl = document.querySelector(`.part-qty[data-idx="${pi}"]`); + if (qtyEl) comp.quantity = parseInt(qtyEl.value) || 1; + + const inputs = document.querySelectorAll(`.p-input[data-part="${pi}"]`); + if (inputs.length === 0) return; + const rates = document.querySelectorAll(`.p-rate[data-part="${pi}"]`); + const bd = []; + let cumSum = 0; + inputs.forEach((inp, ci) => { + const val = parseFloat(inp.value) || 0; + const rate = rates[ci]?.value?.trim() ?? ''; + let adj = val; + if (rate === '-1') adj = val - 1; + else if (rate === '1') adj = val + 1; + cumSum += adj; + bd.push({ + no: ci + 1, input: val, rate: rate, sum: cumSum, + color: document.querySelector(`.p-color[data-part="${pi}"][data-col="${ci}"]`)?.checked ?? false, + aAngle: document.querySelector(`.p-angle[data-part="${pi}"][data-col="${ci}"]`)?.checked ?? false, + }); + }); + comp.bendingData = bd; + comp.width_sum = cumSum; + }); +} + +function serializeComponents() { + document.getElementById('componentsInput').value = JSON.stringify(components); + const summary = {}; + components.forEach(c => { + const mat = c.material; + const ws = c.width_sum || 0; + const qty = c.quantity || 1; + if (mat && ws) summary[mat] = (summary[mat] || 0) + (ws * qty); + }); + document.getElementById('materialSummaryInput').value = JSON.stringify(summary); +} + +// 부품별 절곡 테이블 재계산 +function recalcPart(partIdx) { + const inputs = document.querySelectorAll(`.p-input[data-part="${partIdx}"]`); + const rates = document.querySelectorAll(`.p-rate[data-part="${partIdx}"]`); + let cumSum = 0; + + inputs.forEach((inp, i) => { + const val = parseFloat(inp.value) || 0; + const rate = rates[i]?.value?.trim() ?? ''; + let adj = val; + if (rate === '-1') adj = val - 1; + else if (rate === '1') adj = val + 1; + cumSum += adj; + + // 연신율 후 + const adjEl = document.querySelector(`.p-adj[data-part="${partIdx}"][data-col="${i}"]`); + if (adjEl) adjEl.textContent = val ? adj : '-'; + + // 합계 + const sumEl = document.querySelector(`.p-sum[data-part="${partIdx}"][data-col="${i}"]`); + if (sumEl) sumEl.textContent = val ? cumSum : '-'; + }); + + // components JS 배열 업데이트 + if (components[partIdx]) { + const bd = []; + inputs.forEach((inp, i) => { + bd.push({ + no: i + 1, + input: parseFloat(inp.value) || 0, + rate: rates[i]?.value?.trim() ?? '', + sum: parseFloat(document.querySelector(`.p-sum[data-part="${partIdx}"][data-col="${i}"]`)?.textContent) || 0, + color: document.querySelector(`.p-color[data-part="${partIdx}"][data-col="${i}"]`)?.checked ?? false, + aAngle: document.querySelector(`.p-angle[data-part="${partIdx}"][data-col="${i}"]`)?.checked ?? false, + }); + }); + components[partIdx].bendingData = bd; + components[partIdx].width_sum = cumSum; + } + serializeComponents(); +} + +// create 모드: 부품 추가 후 DOM 동적 갱신 +function renderAddedParts() { + const container = document.getElementById('partsContainer'); + // 기존 "부품이 없습니다" 메시지 제거 + const noMsg = document.getElementById('noPartsMsg'); + if (noMsg) noMsg.remove(); + + // 마지막으로 추가된 부품들만 DOM 생성 (이미 DOM에 있는 것 제외) + const existingCount = container.querySelectorAll('.part-item').length; + + for (let idx = existingCount; idx < components.length; idx++) { + const comp = components[idx]; + const bd = comp.bendingData || []; + const ws = comp.width_sum || 0; + + let colHeaders = ''; + let inputRow = ''; + let rateRow = ''; + let adjRow = ''; + let sumRow = ''; + let colorRow = ''; + let angleRow = ''; + let cumSum = 0; + + bd.forEach((d, ci) => { + const inputVal = d.input ?? 0; + const rateVal = d.rate ?? ''; + const rate = String(rateVal); + const adj = rate === '-1' ? inputVal - 1 : (rate === '1' ? inputVal + 1 : inputVal); + cumSum += adj; + colHeaders += `<th class="border px-1 py-1 text-center min-w-[35px]">${ci + 1}</th>`; + inputRow += `<td class="border px-1 py-1 text-center ${d.color ? 'bg-orange-100' : ''}"><input type="number" class="w-full text-center border-0 bg-transparent p-input" data-part="${idx}" data-col="${ci}" value="${inputVal}" step="any" oninput="recalcPart(${idx})"></td>`; + rateRow += `<td class="border px-1 py-1 text-center"><input type="number" class="w-full text-center border-0 bg-transparent p-rate" step="1" data-part="${idx}" data-col="${ci}" value="${rate}" oninput="recalcPart(${idx})"></td>`; + adjRow += `<td class="border px-1 py-1 text-center text-gray-500 p-adj" data-part="${idx}" data-col="${ci}">${adj}</td>`; + sumRow += `<td class="border px-1 py-1 text-center font-bold bg-yellow-50 p-sum" data-part="${idx}" data-col="${ci}">${cumSum}</td>`; + colorRow += `<td class="border px-1 py-1 text-center"><input type="checkbox" class="p-color" data-part="${idx}" data-col="${ci}" ${d.color ? 'checked' : ''}></td>`; + angleRow += `<td class="border px-1 py-1 text-center"><input type="checkbox" class="p-angle" data-part="${idx}" data-col="${ci}" ${d.aAngle ? 'checked' : ''}></td>`; + }); + + const addBtnTh = `<th class="border px-1 py-1 text-center min-w-[30px]"><button type="button" onclick="addPartCol(${idx})" class="text-green-600 font-bold">+</button></th>`; + const emptyTd = '<td class="border"></td>'; + + const imgHtml = comp.image_file_id + ? `<div class="mb-2"><img src="/files/${comp.image_file_id}/view" alt="${comp.itemName || ''}" width="100" height="60" style="width:100px; height:auto; max-height:60px; object-fit:contain;" class="rounded border"></div>` + : ''; + + const html = ` + <div class="mb-4 p-3 bg-gray-50 rounded border part-item" data-part-idx="${idx}" id="partItem_${idx}"> + <div class="flex items-center gap-2 flex-wrap mb-1"> + <label class="flex items-center shrink-0"> + <input type="radio" name="selectedPart" value="${idx}" class="mr-1"> + <span class="px-2 py-0.5 bg-blue-600 text-white rounded text-xs font-bold">순서:${comp.orderNumber || idx + 1}</span> + </label> + <input type="text" class="border border-gray-300 rounded px-2 py-0.5 text-sm font-medium part-name" value="${comp.itemName || ''}" data-idx="${idx}" placeholder="부품명" style="width:130px; min-width:80px;"> + <span class="text-xs text-gray-500 whitespace-nowrap">재질:</span> + <input type="text" class="border border-gray-300 rounded px-2 py-0.5 text-xs part-material" value="${comp.material || ''}" data-idx="${idx}" list="mat_list" style="width:90px; min-width:60px;"> + <span class="text-xs text-gray-500 whitespace-nowrap">수량:</span> + <input type="number" class="border border-gray-300 rounded px-1 py-0.5 text-xs text-center part-qty" value="${comp.quantity || 1}" min="1" data-idx="${idx}" style="width:45px; min-width:35px;"> + <span class="text-xs text-blue-600 font-mono whitespace-nowrap" id="partWidthSum_${idx}">폭합: ${ws}</span> + <div class="flex gap-1 ml-auto shrink-0"> + ${comp.sam_item_id ? `<button type="button" onclick="editPartOriginal(${idx})" class="px-1.5 py-0.5 bg-indigo-600 text-white rounded text-xs hover:bg-indigo-700">원본수정</button>` : ''} + <button type="button" onclick="savePartAsNew(${idx})" class="px-1.5 py-0.5 bg-green-600 text-white rounded text-xs hover:bg-green-700">다른이름저장</button> + <button type="button" onclick="deletePart(this, ${idx})" class="px-1.5 py-0.5 bg-red-600 text-white rounded text-xs hover:bg-red-700">삭제</button> + </div> + </div> + ${imgHtml} + <div class="overflow-x-auto"> + <table class="text-xs border-collapse w-full" id="bendTable_${idx}"> + <thead><tr class="bg-gray-100"> + <th class="border px-2 py-1 text-gray-600" style="width:65px; min-width:45px;">구분</th> + ${colHeaders}${addBtnTh} + </tr></thead> + <tbody> + <tr><td class="border px-2 py-1 bg-gray-50 font-medium">입력</td>${inputRow}${emptyTd}</tr> + <tr><td class="border px-2 py-1 bg-gray-50 font-medium">연신율</td>${rateRow}${emptyTd}</tr> + <tr><td class="border px-2 py-1 bg-gray-50 font-medium">연신율 후</td>${adjRow}${emptyTd}</tr> + <tr><td class="border px-2 py-1 bg-yellow-50 font-medium">합계</td>${sumRow}${emptyTd}</tr> + <tr><td class="border px-2 py-1 bg-gray-50 font-medium">음영</td>${colorRow}${emptyTd}</tr> + <tr><td class="border px-2 py-1 bg-gray-50 font-medium">A각</td>${angleRow}${emptyTd}</tr> + </tbody> + </table> + </div> + </div>`; + + container.insertAdjacentHTML('beforeend', html); + } + + document.getElementById('compCount').textContent = components.length; + bindPartInputs(); + serializeComponents(); +} + +// 부품에 열 추가 +function addPartCol(partIdx) { + const table = document.getElementById(`bendTable_${partIdx}`); + if (!table) return; + const rows = table.querySelectorAll('tbody tr'); + const headerRow = table.querySelector('thead tr'); + const colCount = headerRow.querySelectorAll('th').length - 2; // 구분 + 추가버튼 제외 + + // 헤더에 새 번호 + const addBtn = headerRow.lastElementChild; + const newTh = document.createElement('th'); + newTh.className = 'border px-1 py-1 text-center min-w-[35px]'; + newTh.textContent = colCount + 1; + headerRow.insertBefore(newTh, addBtn); + + // 각 행에 새 셀 + const newCol = colCount; + const cellTemplates = [ + `<td class="border px-1 py-1 text-center"><input type="number" class="w-full text-center border-0 bg-transparent p-input" data-part="${partIdx}" data-col="${newCol}" value="" step="any" oninput="recalcPart(${partIdx})"></td>`, + `<td class="border px-1 py-1 text-center"><input type="number" class="w-full text-center border-0 bg-transparent p-rate" step="1" data-part="${partIdx}" data-col="${newCol}" value="" oninput="recalcPart(${partIdx})"></td>`, + `<td class="border px-1 py-1 text-center text-gray-500 p-adj" data-part="${partIdx}" data-col="${newCol}">-</td>`, + `<td class="border px-1 py-1 text-center font-bold bg-yellow-50 p-sum" data-part="${partIdx}" data-col="${newCol}">-</td>`, + `<td class="border px-1 py-1 text-center"><input type="checkbox" class="p-color" data-part="${partIdx}" data-col="${newCol}"></td>`, + `<td class="border px-1 py-1 text-center"><input type="checkbox" class="p-angle" data-part="${partIdx}" data-col="${newCol}"></td>`, + ]; + rows.forEach((row, ri) => { + const lastTd = row.lastElementChild; // 빈 + 셀 + const temp = document.createElement('template'); + temp.innerHTML = cellTemplates[ri]; + row.insertBefore(temp.content.firstElementChild, lastTd); + }); +} + +document.getElementById('productForm')?.addEventListener('submit', function() { + collectAllPartData(); + serializeComponents(); +}); + +// ── 부품 원본 수정 (기초관리 편집 페이지로 이동) ── +async function editPartOriginal(partIdx) { + collectAllPartData(); + const comp = components[partIdx]; + if (!comp) return; + + // 1순위: sam_item_id (SAM 기초관리 item ID) + const samId = comp.sam_item_id; + if (samId) { + window.open(`/bending/base/${samId}/edit`, '_blank'); + return; + } + + // 2순위: legacy_bending_num → API 검색으로 SAM ID 찾기 + const legacyNum = comp.legacy_bending_num; + if (legacyNum) { + try { + const resp = await fetch(`/bending/base/api-search?legacy_bending_num=${legacyNum}&size=1`); + if (resp.ok) { + const data = await resp.json(); + if (data.data?.length > 0) { + window.open(`/bending/base/${data.data[0].id}/edit`, '_blank'); + return; + } + } + } catch (e) { + console.error('원본 검색 실패:', e); + } + } + + // 3순위: 부품명으로 검색 페이지 열기 + window.open(`/bending/base?search=${encodeURIComponent(comp.itemName || '')}`, '_blank'); +} + +// ── 부품 다른이름 저장 (현재 데이터로 새 기초관리 부품 등록) ── +function savePartAsNew(partIdx) { + collectAllPartData(); + const comp = components[partIdx]; + if (!comp) return; + + const name = prompt('새 부품명을 입력하세요:', comp.itemName || ''); + if (name === null) return; // 취소 + if (!name.trim()) return alert('부품명을 입력하세요.'); + + // 기초관리 등록 페이지를 새 탭으로 열고, 데이터를 sessionStorage로 전달 + const newPartData = { + item_name: name.trim(), + material: comp.material || '', + bendingData: comp.bendingData || [], + }; + sessionStorage.setItem('newPartData', JSON.stringify(newPartData)); + window.open('/bending/base/create?from=product', '_blank'); +} + +// 이력 보기 +function showHistory() { + const dialog = document.getElementById('historyDialog'); + if (dialog) dialog.showModal(); +} + +// 이미지 미리보기 +function previewImage(input) { + const file = input.files[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = e => { + const preview = document.getElementById('image-preview'); + preview.src = e.target.result; + preview.classList.remove('hidden'); + }; + reader.readAsDataURL(file); +} + +// Ctrl+V 클립보드 이미지 붙여넣기 +document.addEventListener('paste', function(e) { + if (isView) return; + const items = e.clipboardData?.items; + if (!items) return; + for (const item of items) { + if (item.type.startsWith('image/')) { + const file = item.getAsFile(); + const dt = new DataTransfer(); + dt.items.add(file); + const input = document.querySelector('input[name="image"]'); + if (input) { + input.files = dt.files; + previewImage(input); + } + break; + } + } +}); + +// Canvas Editor +function openCanvasEditor() { + const preview = document.getElementById('image-preview'); + const current = document.getElementById('currentImage'); + let imgSrc = null; + + if (preview && !preview.classList.contains('hidden') && preview.src) { + imgSrc = preview.src; + } else if (current && current.src) { + imgSrc = current.src; + } + + CanvasEditor.open(imgSrc) + .then(dataURL => { + document.getElementById('canvasImageData').value = dataURL; + const fileInput = document.querySelector('input[name="image"]'); + if (fileInput) fileInput.value = ''; + const previewEl = document.getElementById('image-preview'); + if (previewEl) previewEl.classList.add('hidden'); + + const container = document.getElementById('imageContainer'); + const noText = document.getElementById('noImageText'); + if (noText) noText.remove(); + + let img = document.getElementById('currentImage'); + if (!img) { + img = document.createElement('img'); + img.id = 'currentImage'; + img.alt = '결합형태'; + img.className = 'max-w-full rounded'; + container.appendChild(img); + } + img.src = dataURL; + }) + .catch(() => {}); +} + +</script> +@endpush + +{{-- 작업지시서 모달 (iframe 방식) --}} +@if(!$isCreate && $itemId) +<dialog id="printModal" class="rounded-lg shadow-2xl p-0 backdrop:bg-black/50" style="max-width:95vw; max-height:95vh; width:1400px; height:90vh; border:none;"> + <div class="flex items-center justify-between px-4 py-2 bg-gray-100 border-b shrink-0"> + <span class="text-sm font-bold text-gray-700">작업지시서</span> + <div class="flex gap-2"> + <button type="button" onclick="document.getElementById('printFrame').contentWindow.print()" class="px-3 py-1 bg-blue-600 text-white rounded text-xs hover:bg-blue-700">PDF 다운로드</button> + <button type="button" onclick="document.getElementById('printModal').close()" class="px-3 py-1 bg-gray-400 text-white rounded text-xs hover:bg-gray-500">닫기</button> + </div> + </div> + <iframe id="printFrame" class="w-full" style="height:calc(90vh - 44px); border:none;"></iframe> +</dialog> +<script> +function openPrintModal(url) { + document.getElementById('printFrame').src = url; + document.getElementById('printModal').showModal(); +} +</script> +@endif +@endsection diff --git a/resources/views/bending/products/index.blade.php b/resources/views/bending/products/index.blade.php new file mode 100644 index 00000000..2abc39d2 --- /dev/null +++ b/resources/views/bending/products/index.blade.php @@ -0,0 +1,112 @@ +@extends('layouts.app') +@section('title', $config['title'] ?? '절곡품 관리') + +@php + $prefix = $config['prefix'] ?? 'products'; + $isCase = ($category ?? '') === 'SHUTTERBOX_MODEL'; + $isBottom = ($category ?? '') === 'BOTTOMBAR_MODEL'; + $isGuiderail = !$isCase && !$isBottom; +@endphp + +@section('content') +<div class="container-fluid px-4 py-3"> + <div class="flex items-center justify-between mb-6"> + <h1 class="text-2xl font-bold text-gray-800">{{ $config['title'] ?? '절곡품 관리' }}</h1> + <a href="{{ route("bending.{$prefix}.create") }}" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm"> + + 신규 등록 + </a> + </div> + + {{-- 필터 --}} + <form id="filterForm" class="flex flex-wrap gap-2 mb-4 items-center"> + @if(!$isCase) + {{-- 대분류 (2개 → 그룹버튼) --}} + <input type="hidden" name="item_sep" value="{{ request('item_sep') }}"> + <div class="inline-flex rounded border border-gray-300 text-sm overflow-hidden"> + <button type="button" onclick="setFilter('item_sep','')" class="px-3 py-1.5 {{ !request('item_sep') ? 'bg-gray-700 text-white' : 'bg-white hover:bg-gray-50' }}">전체</button> + @foreach(['스크린', '철재'] as $v) + <button type="button" onclick="setFilter('item_sep','{{ $v }}')" class="px-3 py-1.5 border-l {{ request('item_sep') == $v ? 'bg-gray-700 text-white' : 'bg-white hover:bg-gray-50' }}">{{ $v }}</button> + @endforeach + </div> + + {{-- 인정 (2개 → 그룹버튼) --}} + <input type="hidden" name="model_UA" value="{{ request('model_UA') }}"> + <div class="inline-flex rounded border border-gray-300 text-sm overflow-hidden"> + <button type="button" onclick="setFilter('model_UA','')" class="px-3 py-1.5 {{ !request('model_UA') ? 'bg-gray-700 text-white' : 'bg-white hover:bg-gray-50' }}">전체</button> + @foreach(['인정', '비인정'] as $v) + <button type="button" onclick="setFilter('model_UA','{{ $v }}')" class="px-3 py-1.5 border-l {{ request('model_UA') == $v ? 'bg-gray-700 text-white' : 'bg-white hover:bg-gray-50' }}">{{ $v }}</button> + @endforeach + </div> + @endif + + @if($isGuiderail) + {{-- 형상 (2개 → 그룹버튼) --}} + <input type="hidden" name="check_type" value="{{ request('check_type') }}"> + <div class="inline-flex rounded border border-gray-300 text-sm overflow-hidden"> + <button type="button" onclick="setFilter('check_type','')" class="px-3 py-1.5 {{ !request('check_type') ? 'bg-gray-700 text-white' : 'bg-white hover:bg-gray-50' }}">전체</button> + @foreach(['벽면형', '측면형'] as $v) + <button type="button" onclick="setFilter('check_type','{{ $v }}')" class="px-3 py-1.5 border-l {{ request('check_type') == $v ? 'bg-gray-700 text-white' : 'bg-white hover:bg-gray-50' }}">{{ $v }}</button> + @endforeach + </div> + @endif + + @if(!$isCase) + {{-- 모델 (7개+ → 드롭다운 유지) --}} + <select name="model_name" class="border border-gray-300 rounded px-3 py-1.5 text-sm" + onchange="this.form.dispatchEvent(new Event('submit'))"> + <option value="">전체(모델)</option> + @foreach(($filterOptions['model_name'] ?? []) as $v) + <option value="{{ $v }}" {{ request('model_name') == $v ? 'selected' : '' }}>{{ $v }}</option> + @endforeach + </select> + @endif + + @if($isCase) + {{-- 점검구 (3개 → 그룹버튼) --}} + <input type="hidden" name="exit_direction" value="{{ request('exit_direction') }}"> + <div class="inline-flex rounded border border-gray-300 text-sm overflow-hidden"> + <button type="button" onclick="setFilter('exit_direction','')" class="px-3 py-1.5 {{ !request('exit_direction') ? 'bg-gray-700 text-white' : 'bg-white hover:bg-gray-50' }}">전체</button> + @foreach(['양면', '밑면', '후면'] as $short) + @php $full = $short . ' 점검구'; @endphp + <button type="button" onclick="setFilter('exit_direction','{{ $full }}')" class="px-3 py-1.5 border-l {{ request('exit_direction') == $full ? 'bg-gray-700 text-white' : 'bg-white hover:bg-gray-50' }}">{{ $short }}</button> + @endforeach + </div> + @endif + + <input type="text" name="search" value="{{ request('search') }}" placeholder="검색" + class="border border-gray-300 rounded px-3 py-1.5 text-sm" style="width: 200px;" + hx-get="{{ route("bending.{$prefix}.index") }}" hx-target="#items-table" hx-include="#filterForm" + hx-trigger="keyup changed delay:400ms"> + + <button type="submit" class="px-3 py-1.5 bg-gray-700 text-white rounded text-sm" + hx-get="{{ route("bending.{$prefix}.index") }}" hx-target="#items-table" hx-include="#filterForm"> + 검색 + </button> + + <span class="text-sm text-gray-500 ml-2">총 {{ $items['total'] ?? 0 }}건</span> + </form> + + {{-- 테이블 --}} + <div id="items-table"> + @include('bending.products.partials.table', ['items' => $items]) + </div> +</div> + +@if(session('success')) +<script> + document.addEventListener('DOMContentLoaded', () => { + showToast("{{ session('success') }}", 'success'); + }); +</script> +@endif + +@push('scripts') +<script> +function setFilter(name, value) { + const input = document.querySelector(`#filterForm input[name="${name}"]`); + if (input) input.value = value; + document.getElementById('filterForm').submit(); +} +</script> +@endpush +@endsection diff --git a/resources/views/bending/products/partials/table.blade.php b/resources/views/bending/products/partials/table.blade.php new file mode 100644 index 00000000..217832a8 --- /dev/null +++ b/resources/views/bending/products/partials/table.blade.php @@ -0,0 +1,224 @@ +@php + $itemList = $items['data'] ?? []; + $total = $items['total'] ?? count($itemList); + $currentPage = $items['current_page'] ?? 1; + $lastPage = $items['last_page'] ?? 1; + $cat = $category ?? ($config['category'] ?? 'GUIDERAIL_MODEL'); + $isCase = $cat === 'SHUTTERBOX_MODEL'; + $isBottom = $cat === 'BOTTOMBAR_MODEL'; + $isGuiderail = !$isCase && !$isBottom; +@endphp + +<div class="overflow-x-auto bg-white rounded-lg shadow-sm mt-4"> + <table class="min-w-full text-sm"> + <thead class="bg-gray-50 border-b"> + <tr> + <th class="px-2 py-2 text-left text-sm font-semibold text-gray-700">NO</th> + @if($isCase) + <th class="px-2 py-2 text-center text-sm font-semibold text-gray-700">박스(가로×세로)</th> + <th class="px-2 py-2 text-center text-sm font-semibold text-gray-700">점검구</th> + <th class="px-2 py-2 text-center text-sm font-semibold text-gray-700">전면밑</th> + <th class="px-2 py-2 text-center text-sm font-semibold text-gray-700">레일폭</th> + @elseif($isBottom) + <th class="px-2 py-2 text-left text-sm font-semibold text-gray-700">모델명</th> + <th class="px-2 py-2 text-center text-sm font-semibold text-gray-700">대분류</th> + <th class="px-2 py-2 text-center text-sm font-semibold text-gray-700">인정</th> + <th class="px-2 py-2 text-center text-sm font-semibold text-gray-700">가로×세로</th> + <th class="px-2 py-2 text-center text-sm font-semibold text-gray-700">마감</th> + @else + <th class="px-2 py-2 text-left text-sm font-semibold text-gray-700">모델명</th> + <th class="px-2 py-2 text-center text-sm font-semibold text-gray-700">대분류</th> + <th class="px-2 py-2 text-center text-sm font-semibold text-gray-700">인정</th> + <th class="px-2 py-2 text-center text-sm font-semibold text-gray-700">형상</th> + <th class="px-2 py-2 text-center text-sm font-semibold text-gray-700">레일폭×높이</th> + <th class="px-2 py-2 text-center text-sm font-semibold text-gray-700">마감</th> + @endif + <th class="px-2 py-2 text-center text-sm font-semibold text-gray-700">이미지</th> + <th class="px-2 py-2 text-center text-sm font-semibold text-gray-700">부품수</th> + <th class="px-2 py-2 text-left text-sm font-semibold text-gray-700">소요자재량</th> + <th class="px-2 py-2 text-left text-sm font-semibold text-gray-700">검색어</th> + <th class="px-2 py-2 text-center text-sm font-semibold text-gray-700">수정자</th> + <th class="px-2 py-2 text-center text-sm font-semibold text-gray-700">작업지시서</th> + <th class="px-2 py-2 text-center text-sm font-semibold text-gray-700">작업</th> + </tr> + </thead> + <tbody class="divide-y divide-gray-200"> + @forelse($itemList as $item) + @php + $itemCat = $item['item_category'] ?? 'GUIDERAIL_MODEL'; + $routePrefix = match($itemCat) { 'SHUTTERBOX_MODEL' => 'cases', 'BOTTOMBAR_MODEL' => 'bottombars', default => 'products' }; + @endphp + <tr class="hover:bg-blue-50 cursor-pointer" onclick="window.location='{{ route("bending.{$routePrefix}.show", $item['id']) }}'"> + <td class="px-2 py-2 text-gray-500 text-xs">{{ $item['id'] }}</td> + @if($isCase) + <td class="px-2 py-2 text-center text-xs font-mono font-bold"> + <span class="text-blue-600">{{ $item['box_width'] ?? '-' }}</span>×<span class="text-red-600">{{ $item['box_height'] ?? '-' }}</span> + </td> + <td class="px-2 py-2 text-center text-xs">{{ $item['exit_direction'] ?? '-' }}</td> + <td class="px-2 py-2 text-center text-xs">{{ $item['front_bottom_width'] ?? '-' }}</td> + <td class="px-2 py-2 text-center text-xs">{{ $item['rail_width'] ?? '-' }}</td> + @elseif($isBottom) + <td class="px-2 py-2 font-medium"><span class="text-blue-700">{{ $item['model_name'] ?? $item['name'] }}</span></td> + <td class="px-2 py-2 text-center"> + @php $sep = $item['item_sep'] ?? '-'; @endphp + <span class="px-1.5 py-0.5 rounded text-xs {{ $sep === '스크린' ? 'bg-blue-100 text-blue-700' : 'bg-orange-100 text-orange-700' }}">{{ $sep }}</span> + </td> + <td class="px-2 py-2 text-center text-xs"> + @if($item['model_UA'] ?? null) + <span class="{{ $item['model_UA'] === '인정' ? 'text-green-600' : 'text-gray-400' }}">{{ $item['model_UA'] }}</span> + @else - @endif + </td> + <td class="px-2 py-2 text-center text-xs font-mono">{{ ($item['bar_width'] ?? '-') }}×{{ ($item['bar_height'] ?? '-') }}</td> + <td class="px-2 py-2 text-center text-xs">{{ $item['finishing_type'] ?? '-' }}</td> + @else + <td class="px-2 py-2 font-medium"><span class="text-blue-700">{{ $item['model_name'] ?? $item['name'] }}</span></td> + <td class="px-2 py-2 text-center"> + @php $sep = $item['item_sep'] ?? '-'; @endphp + <span class="px-1.5 py-0.5 rounded text-xs {{ $sep === '스크린' ? 'bg-blue-100 text-blue-700' : 'bg-orange-100 text-orange-700' }}">{{ $sep }}</span> + </td> + <td class="px-2 py-2 text-center text-xs"> + @if($item['model_UA'] ?? null) + <span class="{{ $item['model_UA'] === '인정' ? 'text-green-600' : 'text-gray-400' }}">{{ $item['model_UA'] }}</span> + @else - @endif + </td> + <td class="px-2 py-2 text-center text-xs">{{ $item['check_type'] ?? '-' }}</td> + <td class="px-2 py-2 text-center text-xs font-mono">{{ ($item['rail_width'] ?? '-') }}×{{ ($item['rail_length'] ?? '-') }}</td> + <td class="px-2 py-2 text-center text-xs">{{ $item['finishing_type'] ?? '-' }}</td> + @endif + <td class="px-2 py-2 text-center"> + @if(!empty($item['image_file_id'])) + <div class="relative inline-block img-preview-wrap"> + <img src="{{ route('files.view', $item['image_file_id']) }}" width="24" height="24" style="width:24px; height:24px; object-fit:contain;" class="rounded inline-block" alt=""> + <div class="img-preview-popup hidden absolute z-50 bg-white border shadow-xl rounded-lg p-1" style="left:50%; transform:translateX(-50%); width:300px;"> + <img src="{{ route('files.view', $item['image_file_id']) }}" class="w-full rounded" alt=""> + </div> + </div> + @else + <span class="text-gray-300">-</span> + @endif + </td> + <td class="px-2 py-2 text-center text-xs"> + <span class="px-1.5 py-0.5 bg-gray-100 rounded">{{ $item['component_count'] ?? 0 }}</span> + </td> + <td class="px-2 py-2 text-xs"> + @foreach(($item['material_summary'] ?? []) as $mat => $total) + <span class="text-gray-600">{{ $mat }}: <strong>{{ $total }}</strong></span> + @if(!$loop->last) | @endif + @endforeach + @if(empty($item['material_summary'])) - @endif + </td> + <td class="px-2 py-2 text-xs text-gray-500">{{ $item['search_keyword'] ?? '-' }}</td> + <td class="px-2 py-2 text-xs text-gray-500">{{ $item['modified_by'] ?? '-' }}</td> + <td class="px-2 py-2 text-center"> + @if(!empty($item['id'])) + <button type="button" onclick="event.stopPropagation(); openPrintModal('{{ route("bending.{$routePrefix}.print", $item['id']) }}')" + class="px-2 py-0.5 bg-gray-600 text-white rounded text-xs hover:bg-gray-700">보기</button> + @endif + </td> + <td class="px-2 py-2 text-center"> + <a href="{{ route("bending.{$routePrefix}.edit", $item['id']) }}" class="text-blue-600 hover:underline text-xs" + onclick="event.stopPropagation()">수정</a> + </td> + </tr> + @empty + <tr> + <td colspan="20" class="px-3 py-8 text-center text-gray-400">데이터가 없습니다.</td> + </tr> + @endforelse + </tbody> + </table> +</div> + +<script> +document.querySelectorAll('.img-preview-wrap').forEach(wrap => { + const popup = wrap.querySelector('.img-preview-popup'); + if (!popup) return; + popup.style.position = 'fixed'; + wrap.addEventListener('mouseenter', () => { + const rect = wrap.getBoundingClientRect(); + const popW = 300; + let left = rect.left + rect.width / 2 - popW / 2; + if (left < 4) left = 4; + if (left + popW > window.innerWidth - 4) left = window.innerWidth - popW - 4; + popup.style.left = left + 'px'; + popup.style.width = popW + 'px'; + popup.style.transform = 'none'; + if (rect.top > window.innerHeight / 2) { + popup.style.bottom = (window.innerHeight - rect.top + 4) + 'px'; + popup.style.top = 'auto'; + } else { + popup.style.top = (rect.bottom + 4) + 'px'; + popup.style.bottom = 'auto'; + } + popup.classList.remove('hidden'); + }); + wrap.addEventListener('mouseleave', () => { popup.classList.add('hidden'); }); +}); +</script> + +{{-- 작업지시서 모달 (iframe 방식) --}} +<dialog id="printModal" class="rounded-lg shadow-2xl p-0 backdrop:bg-black/50" style="max-width:95vw; max-height:95vh; width:1400px; height:90vh; border:none;"> + <div class="flex items-center justify-between px-4 py-2 bg-gray-100 border-b shrink-0"> + <span class="text-sm font-bold text-gray-700">작업지시서</span> + <div class="flex gap-2"> + <button type="button" onclick="document.getElementById('printFrame').contentWindow.print()" class="px-3 py-1 bg-blue-600 text-white rounded text-xs hover:bg-blue-700">PDF 다운로드</button> + <button type="button" onclick="document.getElementById('printModal').close()" class="px-3 py-1 bg-gray-400 text-white rounded text-xs hover:bg-gray-500">닫기</button> + </div> + </div> + <iframe id="printFrame" class="w-full" style="height:calc(90vh - 44px); border:none;"></iframe> +</dialog> + +<script> +function openPrintModal(url) { + document.getElementById('printFrame').src = url; + document.getElementById('printModal').showModal(); +} +</script> + +@if($lastPage > 1) +<div class="bg-white px-4 py-3 mt-4 rounded-lg shadow-sm"> + <div class="flex items-center justify-between"> + <p class="text-sm text-gray-700">전체 <span class="font-medium">{{ $total }}</span>건</p> + <nav class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px"> + @if($currentPage > 1) + <a href="{{ request()->fullUrlWithQuery(['page' => 1]) }}" class="relative inline-flex items-center px-3 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50">처음</a> + <a href="{{ request()->fullUrlWithQuery(['page' => $currentPage - 1]) }}" class="relative inline-flex items-center px-2 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"> + <svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd"/></svg> + </a> + @else + <span class="relative inline-flex items-center px-3 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-300 cursor-not-allowed">처음</span> + <span class="relative inline-flex items-center px-2 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-300 cursor-not-allowed"> + <svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd"/></svg> + </span> + @endif + + @php + $maxPages = 10; + $startPage = max(1, $currentPage - floor($maxPages / 2)); + $endPage = min($lastPage, $startPage + $maxPages - 1); + if ($endPage - $startPage + 1 < $maxPages) { $startPage = max(1, $endPage - $maxPages + 1); } + @endphp + + @for($p = $startPage; $p <= $endPage; $p++) + @if($p == $currentPage) + <span class="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-blue-50 text-sm font-medium text-blue-600">{{ $p }}</span> + @else + <a href="{{ request()->fullUrlWithQuery(['page' => $p]) }}" class="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700 hover:bg-gray-50">{{ $p }}</a> + @endif + @endfor + + @if($currentPage < $lastPage) + <a href="{{ request()->fullUrlWithQuery(['page' => $currentPage + 1]) }}" class="relative inline-flex items-center px-2 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"> + <svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/></svg> + </a> + <a href="{{ request()->fullUrlWithQuery(['page' => $lastPage]) }}" class="relative inline-flex items-center px-3 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50">끝</a> + @else + <span class="relative inline-flex items-center px-2 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-300 cursor-not-allowed"> + <svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/></svg> + </span> + <span class="relative inline-flex items-center px-3 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-300 cursor-not-allowed">끝</span> + @endif + </nav> + </div> +</div> +@endif diff --git a/resources/views/bending/products/print.blade.php b/resources/views/bending/products/print.blade.php new file mode 100644 index 00000000..55c1764a --- /dev/null +++ b/resources/views/bending/products/print.blade.php @@ -0,0 +1,179 @@ +<!DOCTYPE html> +<html lang="ko"> +<head> + <meta charset="UTF-8"> + <title>절곡 바라시 작업지시서 — {{ $opt['model_name'] ?? $opt['code'] ?? '' }} + + + +@php + $opt = is_array($item) ? $item : []; + $components = $opt['components'] ?? []; + $materialSummary = $opt['material_summary'] ?? []; + $modelName = $opt['model_name'] ?? $opt['code'] ?? ''; + $checkType = $opt['check_type'] ?? ''; + $railWidth = $opt['rail_width'] ?? ''; + $railLength = $opt['rail_length'] ?? ''; + $itemSep = $opt['item_sep'] ?? ''; + $modelUA = $opt['model_UA'] ?? ''; + $finishingType = $opt['finishing_type'] ?? ''; + $itemCategory = $opt['item_category'] ?? ''; + $boxWidth = $opt['box_width'] ?? ''; + $boxHeight = $opt['box_height'] ?? ''; + $exitDirection = $opt['exit_direction'] ?? ''; + $barWidth = $opt['bar_width'] ?? ''; + $barHeight = $opt['bar_height'] ?? ''; + + $isCase = $itemCategory === 'SHUTTERBOX_MODEL'; + $isBottom = $itemCategory === 'BOTTOMBAR_MODEL'; + + // 타입별 제목 + if ($isCase) { + $docTitle = '케이스 작업지시서'; + $sizeLabel = "{$boxWidth}*{$boxHeight} {$exitDirection}"; + } elseif ($isBottom) { + $docTitle = '하단마감재 작업지시서'; + $sizeLabel = "{$barWidth}*{$barHeight}"; + } else { + $docTitle = '절곡 바라시 작업지시서'; + $sizeLabel = $railLength && $railWidth ? "{$railLength}x{$railWidth}" : ''; + } +@endphp + +
+

{{ $docTitle }}

+
+ @if(!$isCase) + 모델: {{ $modelName }} + @if($checkType)형태: {{ $checkType }}@endif + @if($sizeLabel)규격: {{ $sizeLabel }}@endif + @if($finishingType)마감: {{ $finishingType }}@endif + @if($itemSep){{ $itemSep }}@endif + @if($modelUA){{ $modelUA }}@endif + @else + 규격: {{ $boxWidth }}*{{ $boxHeight }} + 점검구: {{ $exitDirection }} + @endif +
+
+ + +{{-- 부품별 전개도 테이블 (레거시 포맷) --}} + + + + + + + + + + + + @foreach($components as $idx => $comp) + @php + $bd = $comp['bendingData'] ?? []; + $hasSamFormat = !empty($bd) && isset($bd[0]['input']); + if ($hasSamFormat) { + $sums = array_column($bd, 'sum'); + $colors = array_column($bd, 'color'); + $angles = array_column($bd, 'aAngle'); + } else { + $sums = $comp['sumList'] ?? []; + $colors = $comp['colorList'] ?? []; + $angles = $comp['AList'] ?? []; + } + $widthSum = $comp['width_sum'] ?? (!empty($sums) ? end($sums) : 0); + $qty = $comp['quantity'] ?? 1; + @endphp + {{-- 합계 행 --}} + + + + + + + + {{-- A각 행 --}} + + + + + @endforeach + +
번호재질절곡치수폭합수량
{{ $comp['orderNumber'] ?? ($idx + 1) }}. {{ $comp['itemName'] ?? '부품' }}{{ $comp['material'] ?? '-' }} + @foreach($sums as $si => $sum) + {{ $sum }} + @endforeach + {{ $widthSum }}{{ $qty }}
+ @foreach($angles as $angle) + {{ $angle ? 'A"' : '' }} + @endforeach +
+ +{{-- 재질별 폭합 --}} +@if(!empty($materialSummary)) +
+ + + + + + + + + @foreach($materialSummary as $mat => $total) + + + + + @endforeach + +
재질폭합계 (mm)
{{ $mat }}{{ number_format($total) }}
+
+@endif + + + diff --git a/resources/views/components/canvas-editor.blade.php b/resources/views/components/canvas-editor.blade.php new file mode 100644 index 00000000..7de41be2 --- /dev/null +++ b/resources/views/components/canvas-editor.blade.php @@ -0,0 +1,68 @@ +{{-- Canvas Editor Modal (Fabric.js 기반) --}} + + {{-- 헤더 --}} +
+ 이미지 편집기 +
+ + +
+
+ + {{-- 도구 모음 --}} +
+ + + + + + + + | + + + + | + + +
+ + {{-- 색상 + 지우개 크기 --}} +
+
+ + + + + + + +
+ | + + + 20px +
+ + {{-- 캔버스 영역 --}} +
+ +
+
diff --git a/resources/views/layouts/tenant-console.blade.php b/resources/views/layouts/tenant-console.blade.php new file mode 100644 index 00000000..d674ffaa --- /dev/null +++ b/resources/views/layouts/tenant-console.blade.php @@ -0,0 +1,176 @@ + + + + + + + @yield('title', '테넌트 콘솔') - {{ $consoleTenant->company_name ?? '' }} + @vite(['resources/css/app.css', 'resources/js/app.js']) + + + + + + + + + @stack('styles') + + +
+ + @include('partials.tenant-console-sidebar') + + +
+ +
+
+ + + {{ $consoleTenant->company_name ?? '테넌트' }} + + 테넌트 관리 콘솔 +
+ +
+ + +
+ @yield('content') +
+
+
+ + + + + + + @stack('scripts') + + diff --git a/resources/views/partials/tenant-console-sidebar.blade.php b/resources/views/partials/tenant-console-sidebar.blade.php new file mode 100644 index 00000000..59ac3c44 --- /dev/null +++ b/resources/views/partials/tenant-console-sidebar.blade.php @@ -0,0 +1,70 @@ +{{-- 테넌트 콘솔 전용 사이드바 (DB 동적 메뉴) --}} +@php + $tenantId = $consoleTenantId ?? 0; + $baseUrl = "/tenant-console/{$tenantId}"; +@endphp + +
+ +{{-- 메뉴 그룹 토글 스크립트 --}} + diff --git a/resources/views/tenant-console/index.blade.php b/resources/views/tenant-console/index.blade.php new file mode 100644 index 00000000..c7ed479f --- /dev/null +++ b/resources/views/tenant-console/index.blade.php @@ -0,0 +1,101 @@ +@extends('layouts.tenant-console') + +@section('title', '대시보드') + +@section('content') +
+ +
+
+

+ + {{ $tenant->company_name }} +

+ + {{ $tenant->status_label }} + +
+ +
+
+ 코드: + {{ $tenant->code ?? '-' }} +
+
+ 대표자: + {{ $tenant->ceo_name ?? '-' }} +
+
+ 이메일: + {{ $tenant->email ?? '-' }} +
+
+ 전화: + {{ $tenant->phone_formatted ?? '-' }} +
+
+ 사업자번호: + {{ $tenant->business_num ?? '-' }} +
+
+ 유형: + {{ $tenant->tenant_type ?? '-' }} +
+
+
+ + +

관리 메뉴

+
+ +
+
+ +
+

권한 관리

+
+

역할, 부서, 사용자별 권한을 관리합니다.

+
+ + +
+
+ +
+

시스템 관리

+
+

AI설정, 휴일, 알림, 공통코드를 관리합니다.

+
+ + +
+
+ +
+

생산관리

+
+

품목, BOM, 견적수식을 관리합니다.

+
+ + +
+
+ +
+

게시판관리

+
+

게시판 생성, 수정, 삭제를 관리합니다.

+
+
+
+@endsection diff --git a/resources/views/tenant-override/layouts/app.blade.php b/resources/views/tenant-override/layouts/app.blade.php new file mode 100644 index 00000000..436ce282 --- /dev/null +++ b/resources/views/tenant-override/layouts/app.blade.php @@ -0,0 +1,7 @@ +{{-- + 테넌트 콘솔 모드일 때 layouts.app 대신 사용되는 레이아웃. + SetTenantContext 미들웨어가 view path를 오버라이드하여 + @extends('layouts.app') 요청이 이 파일로 해석됩니다. + 자식 뷰의 @section은 tenant-console 레이아웃으로 자동 전달됩니다. +--}} +@extends('layouts.tenant-console') diff --git a/routes/web.php b/routes/web.php index 4cb5312a..f3950b70 100644 --- a/routes/web.php +++ b/routes/web.php @@ -79,6 +79,7 @@ use App\Http\Controllers\System\SystemAlertController; use App\Http\Controllers\System\SystemGuideController; use App\Http\Controllers\System\TenantMailConfigController; +use App\Http\Controllers\TenantConsoleController; use App\Http\Controllers\TenantController; use App\Http\Controllers\TenantSettingController; use App\Http\Controllers\TriggerAuditController; @@ -466,6 +467,53 @@ Route::get('/', [ItemFieldController::class, 'index'])->name('index'); }); + // 파일 뷰어 (API R2 이미지 프록시) + Route::get('/files/{id}/view', [\App\Http\Controllers\FileViewController::class, 'show'])->whereNumber('id')->name('files.view'); + + // 절곡품 기초관리 + Route::prefix('bending')->name('bending.')->group(function () { + Route::get('/base', [\App\Http\Controllers\BendingBaseController::class, 'index'])->name('base.index'); + Route::get('/base/create', [\App\Http\Controllers\BendingBaseController::class, 'create'])->name('base.create'); + Route::post('/base', [\App\Http\Controllers\BendingBaseController::class, 'store'])->name('base.store'); + Route::get('/base/{id}', [\App\Http\Controllers\BendingBaseController::class, 'show'])->whereNumber('id')->name('base.show'); + Route::get('/base/{id}/edit', [\App\Http\Controllers\BendingBaseController::class, 'edit'])->whereNumber('id')->name('base.edit'); + Route::put('/base/{id}', [\App\Http\Controllers\BendingBaseController::class, 'update'])->whereNumber('id')->name('base.update'); + Route::delete('/base/{id}', [\App\Http\Controllers\BendingBaseController::class, 'destroy'])->whereNumber('id')->name('base.destroy'); + + // 기초관리 부품 검색 (모달용 AJAX) + Route::get('/base/api-search', [\App\Http\Controllers\BendingProductController::class, 'searchBendingItems'])->name('base.api-search'); + + // 절곡품 (가이드레일) + Route::get('/products', [\App\Http\Controllers\BendingProductController::class, 'index'])->name('products.index')->defaults('category', 'GUIDERAIL_MODEL'); + Route::get('/products/create', [\App\Http\Controllers\BendingProductController::class, 'create'])->name('products.create')->defaults('category', 'GUIDERAIL_MODEL'); + Route::post('/products', [\App\Http\Controllers\BendingProductController::class, 'store'])->name('products.store')->defaults('category', 'GUIDERAIL_MODEL'); + Route::get('/products/{id}/print', [\App\Http\Controllers\BendingProductController::class, 'print'])->whereNumber('id')->name('products.print'); + Route::get('/products/{id}', [\App\Http\Controllers\BendingProductController::class, 'show'])->whereNumber('id')->name('products.show'); + Route::get('/products/{id}/edit', [\App\Http\Controllers\BendingProductController::class, 'edit'])->whereNumber('id')->name('products.edit'); + Route::put('/products/{id}', [\App\Http\Controllers\BendingProductController::class, 'update'])->whereNumber('id')->name('products.update'); + Route::delete('/products/{id}', [\App\Http\Controllers\BendingProductController::class, 'destroy'])->whereNumber('id')->name('products.destroy'); + + // 케이스 + Route::get('/cases', [\App\Http\Controllers\BendingProductController::class, 'index'])->name('cases.index')->defaults('category', 'SHUTTERBOX_MODEL'); + Route::get('/cases/create', [\App\Http\Controllers\BendingProductController::class, 'create'])->name('cases.create')->defaults('category', 'SHUTTERBOX_MODEL'); + Route::post('/cases', [\App\Http\Controllers\BendingProductController::class, 'store'])->name('cases.store')->defaults('category', 'SHUTTERBOX_MODEL'); + Route::get('/cases/{id}/print', [\App\Http\Controllers\BendingProductController::class, 'print'])->whereNumber('id')->name('cases.print'); + Route::get('/cases/{id}', [\App\Http\Controllers\BendingProductController::class, 'show'])->whereNumber('id')->name('cases.show'); + Route::get('/cases/{id}/edit', [\App\Http\Controllers\BendingProductController::class, 'edit'])->whereNumber('id')->name('cases.edit'); + Route::put('/cases/{id}', [\App\Http\Controllers\BendingProductController::class, 'update'])->whereNumber('id')->name('cases.update'); + Route::delete('/cases/{id}', [\App\Http\Controllers\BendingProductController::class, 'destroy'])->whereNumber('id')->name('cases.destroy'); + + // 하단마감재 + Route::get('/bottombars', [\App\Http\Controllers\BendingProductController::class, 'index'])->name('bottombars.index')->defaults('category', 'BOTTOMBAR_MODEL'); + Route::get('/bottombars/create', [\App\Http\Controllers\BendingProductController::class, 'create'])->name('bottombars.create')->defaults('category', 'BOTTOMBAR_MODEL'); + Route::post('/bottombars', [\App\Http\Controllers\BendingProductController::class, 'store'])->name('bottombars.store')->defaults('category', 'BOTTOMBAR_MODEL'); + Route::get('/bottombars/{id}/print', [\App\Http\Controllers\BendingProductController::class, 'print'])->whereNumber('id')->name('bottombars.print'); + Route::get('/bottombars/{id}', [\App\Http\Controllers\BendingProductController::class, 'show'])->whereNumber('id')->name('bottombars.show'); + Route::get('/bottombars/{id}/edit', [\App\Http\Controllers\BendingProductController::class, 'edit'])->whereNumber('id')->name('bottombars.edit'); + Route::put('/bottombars/{id}', [\App\Http\Controllers\BendingProductController::class, 'update'])->whereNumber('id')->name('bottombars.update'); + Route::delete('/bottombars/{id}', [\App\Http\Controllers\BendingProductController::class, 'destroy'])->whereNumber('id')->name('bottombars.destroy'); + }); + // 견적수식 관리 (Blade 화면만) Route::prefix('quote-formulas')->name('quote-formulas.')->group(function () { // 수식 관리 @@ -2088,6 +2136,27 @@ Route::get('/guide', [\App\Http\Controllers\EquipmentController::class, 'guide'])->name('guide'); }); +/* +|-------------------------------------------------------------------------- +| Tenant Console Routes (새창 전용 - 테넌트별 독립 관리) +|-------------------------------------------------------------------------- +| /tenant-console/{tenantId}/* 형태의 URL로 접근 +| SetTenantContext 미들웨어가 URL의 tenantId를 컨텍스트로 설정 +| Catch-all 방식으로 메인 라우트 컨트롤러를 자동 재사용 +*/ +Route::prefix('tenant-console/{tenantId}') + ->middleware(['auth', 'hq.member', 'password.changed', 'set.tenant.context']) + ->name('tenant-console.') + ->group(function () { + // 콘솔 대시보드 + Route::get('/', [TenantConsoleController::class, 'index'])->name('index'); + + // Catch-all: 메인 라우트의 컨트롤러를 자동으로 찾아서 실행 + Route::any('/{path}', [TenantConsoleController::class, 'dispatch']) + ->where('path', '.*') + ->name('dispatch'); + }); + /* |-------------------------------------------------------------------------- | SAM E-Sign Public Routes (인증 불필요 - 서명자용)