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`
+- **현재**: `절곡 바라시 작업지시서 — ...` (가이드레일 전용)
+- **문제**: 케이스/하단마감재에서도 "절곡 바라시" 표시
+- **수정**: `$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 = ``;
+ 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
+
+
+ {{-- 헤더 --}}
+
+
+
+
+
+
+ {{ $mode === 'create' ? '기초자료 등록' : ($mode === 'edit' ? '기초자료 수정' : '기초자료 상세') }}
+ @if($item) {{ $itemCode }} @endif
+
+
+
+ @if(!$isCreate)
+
+ @endif
+ @if($isView)
+
수정
+ @endif
+ @if(!$isCreate)
+
+ @endif
+
+
+
+ @if(session('success'))
+
+ @endif
+
+ @if($errors->any())
+
+
+ @foreach($errors->all() as $error)
+ - {{ $error }}
+ @endforeach
+
+
+ @endif
+
+
+
+ @if(!$isView)
+ @include('components.canvas-editor')
+ @endif
+
+ {{-- 이력 모달 --}}
+ @if(!$isCreate)
+
+ @endif
+
+
+@push('scripts')
+
+
+
+@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')
+
+ {{-- 헤더 --}}
+
+
+ {{-- 필터 --}}
+
+
+ {{-- 테이블 --}}
+
+ @include('bending.base.partials.table', ['items' => $items])
+
+
+
+@if(session('success'))
+
+@endif
+
+@push('scripts')
+
+@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
+
+
+
+
+
+ | NO |
+ 코드 |
+ 대분류 |
+ 인정 |
+ 분류 |
+ 품명 |
+ 규격 |
+ 재질 |
+ 이미지 |
+ 모델 |
+ 폭합 |
+ 절곡수 |
+ 등록일 |
+ 수정자 |
+ 작업 |
+
+
+
+ @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
+
+ | {{ $item['id'] }} |
+ {{ $item['code'] }} |
+
+
+ {{ $itemSep }}
+
+ |
+
+ @if($modelUA)
+ {{ $modelUA }}
+ @else
+ -
+ @endif
+ |
+ {{ $item['item_bending'] ?? '-' }} |
+ {{ $item['item_name'] ?? $item['name'] }} |
+ {{ $item['item_spec'] ?? '-' }} |
+ {{ $item['material'] ?? '-' }} |
+
+ @if(!empty($item['image_file_id']))
+
+  }})
+
+  }})
+
+
+ @else
+ -
+ @endif
+ |
+ {{ $item['model_name'] ?? '-' }} |
+ {{ $widthSum ?? '-' }} |
+
+ @if($bendCount > 0)
+ {{ $bendCount }}
+ @else
+ -
+ @endif
+ |
+ {{ $createdAt ? \Illuminate\Support\Str::before($createdAt, ' ') : '-' }} |
+ {{ $item['modified_by'] ?? '-' }} |
+
+ 수정
+ |
+
+ @empty
+
+ | 데이터가 없습니다. |
+
+ @endforelse
+
+
+
+
+
+
+{{-- 페이지네이션 --}}
+@if($lastPage > 1)
+
+
+
전체 {{ $total }}건
+
+
+
+@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
+
+
+ {{-- 헤더 --}}
+
+
+
+
+
+
+ {{ $pageTitle }}
+ @if($item) {{ $opt['model_name'] ?? $opt['code'] ?? '' }} @endif
+
+
+
+ @if(!$isCreate && $itemId)
+
+
+ @endif
+ @if($isView && $itemId)
+
수정
+ @endif
+ @if(!$isCreate && $itemId)
+
+ @endif
+
+
+
+ @if(session('success'))
+
+ @endif
+
+ @if($errors->any())
+
+
+ @foreach($errors->all() as $error)
+ - {{ $error }}
+ @endforeach
+
+
+ @endif
+
+
+
+ {{-- 부품 검색 모달 --}}
+ @if(!$isView)
+
+
+
+
절곡 부품 검색
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
검색 결과가 없습니다.
+
검색 중...
+
+
+
선택: 0건
+
+
+
+
+
+
+
+ @endif
+
+ @if(!$isView)
+ @include('components.canvas-editor')
+ @endif
+
+ {{-- 이력 모달 --}}
+ @if(!$isCreate && $itemId)
+
+ @endif
+
+
+@push('scripts')
+@if(!$isView)
+
+
+@endif
+
+@endpush
+
+{{-- 작업지시서 모달 (iframe 방식) --}}
+@if(!$isCreate && $itemId)
+
+
+@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')
+
+
+
+ {{-- 필터 --}}
+
+
+ {{-- 테이블 --}}
+
+ @include('bending.products.partials.table', ['items' => $items])
+
+
+
+@if(session('success'))
+
+@endif
+
+@push('scripts')
+
+@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
+
+
+
+
+
+ | NO |
+ @if($isCase)
+ 박스(가로×세로) |
+ 점검구 |
+ 전면밑 |
+ 레일폭 |
+ @elseif($isBottom)
+ 모델명 |
+ 대분류 |
+ 인정 |
+ 가로×세로 |
+ 마감 |
+ @else
+ 모델명 |
+ 대분류 |
+ 인정 |
+ 형상 |
+ 레일폭×높이 |
+ 마감 |
+ @endif
+ 이미지 |
+ 부품수 |
+ 소요자재량 |
+ 검색어 |
+ 수정자 |
+ 작업지시서 |
+ 작업 |
+
+
+
+ @forelse($itemList as $item)
+ @php
+ $itemCat = $item['item_category'] ?? 'GUIDERAIL_MODEL';
+ $routePrefix = match($itemCat) { 'SHUTTERBOX_MODEL' => 'cases', 'BOTTOMBAR_MODEL' => 'bottombars', default => 'products' };
+ @endphp
+
+ | {{ $item['id'] }} |
+ @if($isCase)
+
+ {{ $item['box_width'] ?? '-' }}×{{ $item['box_height'] ?? '-' }}
+ |
+ {{ $item['exit_direction'] ?? '-' }} |
+ {{ $item['front_bottom_width'] ?? '-' }} |
+ {{ $item['rail_width'] ?? '-' }} |
+ @elseif($isBottom)
+ {{ $item['model_name'] ?? $item['name'] }} |
+
+ @php $sep = $item['item_sep'] ?? '-'; @endphp
+ {{ $sep }}
+ |
+
+ @if($item['model_UA'] ?? null)
+ {{ $item['model_UA'] }}
+ @else - @endif
+ |
+ {{ ($item['bar_width'] ?? '-') }}×{{ ($item['bar_height'] ?? '-') }} |
+ {{ $item['finishing_type'] ?? '-' }} |
+ @else
+ {{ $item['model_name'] ?? $item['name'] }} |
+
+ @php $sep = $item['item_sep'] ?? '-'; @endphp
+ {{ $sep }}
+ |
+
+ @if($item['model_UA'] ?? null)
+ {{ $item['model_UA'] }}
+ @else - @endif
+ |
+ {{ $item['check_type'] ?? '-' }} |
+ {{ ($item['rail_width'] ?? '-') }}×{{ ($item['rail_length'] ?? '-') }} |
+ {{ $item['finishing_type'] ?? '-' }} |
+ @endif
+
+ @if(!empty($item['image_file_id']))
+
+  }})
+
+  }})
+
+
+ @else
+ -
+ @endif
+ |
+
+ {{ $item['component_count'] ?? 0 }}
+ |
+
+ @foreach(($item['material_summary'] ?? []) as $mat => $total)
+ {{ $mat }}: {{ $total }}
+ @if(!$loop->last) | @endif
+ @endforeach
+ @if(empty($item['material_summary'])) - @endif
+ |
+ {{ $item['search_keyword'] ?? '-' }} |
+ {{ $item['modified_by'] ?? '-' }} |
+
+ @if(!empty($item['id']))
+
+ @endif
+ |
+
+ 수정
+ |
+
+ @empty
+
+ | 데이터가 없습니다. |
+
+ @endforelse
+
+
+
+
+
+
+{{-- 작업지시서 모달 (iframe 방식) --}}
+
+
+
+
+@if($lastPage > 1)
+
+
+
전체 {{ $total }}건
+
+
+
+@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 @@
+
+
+
+
+ 절곡 바라시 작업지시서 — {{ $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
+
+
+
+
+{{-- 부품별 전개도 테이블 (레거시 포맷) --}}
+
+
+
+ | 번호 |
+ 재질 |
+ 절곡치수 |
+ 폭합 |
+ 수량 |
+
+
+
+ @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
+ {{-- 합계 행 --}}
+
+ | {{ $comp['orderNumber'] ?? ($idx + 1) }}. {{ $comp['itemName'] ?? '부품' }} |
+ {{ $comp['material'] ?? '-' }} |
+
+ @foreach($sums as $si => $sum)
+ {{ $sum }}
+ @endforeach
+ |
+ {{ $widthSum }} |
+ {{ $qty }} |
+
+ {{-- A각 행 --}}
+
+ |
+ @foreach($angles as $angle)
+ {{ $angle ? 'A"' : '' }}
+ @endforeach
+ |
+ |
+
+ @endforeach
+
+
+
+{{-- 재질별 폭합 --}}
+@if(!empty($materialSummary))
+
+
+
+
+ | 재질 |
+ 폭합계 (mm) |
+
+
+
+ @foreach($materialSummary as $mat => $total)
+
+ | {{ $mat }} |
+ {{ number_format($total) }} |
+
+ @endforeach
+
+
+
+@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 기반) --}}
+
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')
+
+
+
+
+
+
+
+
+ @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 ?? '-' }}
+
+
+
+
+
+
관리 메뉴
+
+
+@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 (인증 불필요 - 서명자용)