feat:품목관리 3-Panel 페이지 신규 구현 + FormulaEvaluatorService 연동
- 품목관리 3-Panel 레이아웃 (좌:목록, 중:BOM/수식산출, 우:상세) - FormulaApiService로 API 견적수식 엔진 연동 - FG 품목 선택 시 기본값(W:1000, H:1000, QTY:1) 자동 산출 - 수식 산출 결과 트리 렌더링 (그룹별/소계/합계) - 중앙 패널 클릭 시 우측 상세만 변경 (skipCenterUpdate) - API 인증 버튼 전역 헤더로 이동 (모든 페이지에서 사용 가능) - FormulaApiService에 Bearer 토큰 지원 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Items\Item;
|
||||
use App\Services\FormulaApiService;
|
||||
use App\Services\ItemManagementService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ItemManagementApiController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ItemManagementService $service
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 품목 목록 (HTML partial - 좌측 패널)
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$items = $this->service->getItemList([
|
||||
'search' => $request->input('search'),
|
||||
'item_type' => $request->input('item_type'),
|
||||
'per_page' => $request->input('per_page', 50),
|
||||
]);
|
||||
|
||||
return view('item-management.partials.item-list', compact('items'));
|
||||
}
|
||||
|
||||
/**
|
||||
* BOM 재귀 트리 (JSON - 중앙 패널, JS 렌더링)
|
||||
*/
|
||||
public function bomTree(int $id, Request $request): JsonResponse
|
||||
{
|
||||
$maxDepth = $request->input('max_depth', 10);
|
||||
$tree = $this->service->getBomTree($id, $maxDepth);
|
||||
|
||||
return response()->json($tree);
|
||||
}
|
||||
|
||||
/**
|
||||
* 품목 상세 (HTML partial - 우측 패널)
|
||||
*/
|
||||
public function detail(int $id): View
|
||||
{
|
||||
$data = $this->service->getItemDetail($id);
|
||||
|
||||
return view('item-management.partials.item-detail', [
|
||||
'item' => $data['item'],
|
||||
'bomChildren' => $data['bom_children'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 수식 기반 BOM 산출 (API 서버의 FormulaEvaluatorService HTTP 호출)
|
||||
*/
|
||||
public function calculateFormula(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$item = Item::withoutGlobalScopes()
|
||||
->where('tenant_id', session('selected_tenant_id'))
|
||||
->findOrFail($id);
|
||||
|
||||
$width = (int) $request->input('width', 1000);
|
||||
$height = (int) $request->input('height', 1000);
|
||||
$qty = (int) $request->input('qty', 1);
|
||||
|
||||
$variables = [
|
||||
'W0' => $width,
|
||||
'H0' => $height,
|
||||
'QTY' => $qty,
|
||||
];
|
||||
|
||||
$formulaService = new FormulaApiService();
|
||||
$result = $formulaService->calculateBom(
|
||||
$item->code,
|
||||
$variables,
|
||||
(int) session('selected_tenant_id')
|
||||
);
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
}
|
||||
23
app/Http/Controllers/ItemManagementController.php
Normal file
23
app/Http/Controllers/ItemManagementController.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ItemManagementController extends Controller
|
||||
{
|
||||
/**
|
||||
* 품목관리 3-Panel 페이지
|
||||
* JS가 @push('scripts')에 있으므로 HTMX 네비게이션 시 HX-Redirect 필요
|
||||
*/
|
||||
public function index(Request $request): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('item-management.index'));
|
||||
}
|
||||
|
||||
return view('item-management.index');
|
||||
}
|
||||
}
|
||||
21
app/Models/Commons/File.php
Normal file
21
app/Models/Commons/File.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Commons;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class File extends Model
|
||||
{
|
||||
protected $table = 'files';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'document_id',
|
||||
'document_type',
|
||||
'original_name',
|
||||
'stored_name',
|
||||
'path',
|
||||
'mime_type',
|
||||
'size',
|
||||
];
|
||||
}
|
||||
@@ -2,25 +2,98 @@
|
||||
|
||||
namespace App\Models\Items;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Item extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
use BelongsToTenant, SoftDeletes;
|
||||
|
||||
protected $table = 'items';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'item_type',
|
||||
'item_category',
|
||||
'code',
|
||||
'name',
|
||||
'unit',
|
||||
'is_active',
|
||||
'tenant_id', 'item_type', 'item_category', 'code', 'name', 'unit',
|
||||
'category_id', 'bom', 'attributes', 'attributes_archive', 'options',
|
||||
'description', 'is_active', 'created_by', 'updated_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean',
|
||||
'bom' => 'array',
|
||||
'attributes' => 'array',
|
||||
'attributes_archive' => 'array',
|
||||
'options' => 'array',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
// 유형 상수
|
||||
const TYPE_FG = 'FG'; // 완제품
|
||||
const TYPE_PT = 'PT'; // 부품
|
||||
const TYPE_SM = 'SM'; // 부자재
|
||||
const TYPE_RM = 'RM'; // 원자재
|
||||
const TYPE_CS = 'CS'; // 소모품
|
||||
|
||||
const PRODUCT_TYPES = ['FG', 'PT'];
|
||||
const MATERIAL_TYPES = ['SM', 'RM', 'CS'];
|
||||
|
||||
// ── 관계 ──
|
||||
|
||||
public function details()
|
||||
{
|
||||
return $this->hasOne(ItemDetail::class, 'item_id');
|
||||
}
|
||||
|
||||
public function category()
|
||||
{
|
||||
return $this->belongsTo(Category::class, 'category_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 (document_id/document_type 기반)
|
||||
* document_id = items.id, document_type = '1' (ITEM_GROUP_ID)
|
||||
*/
|
||||
public function files()
|
||||
{
|
||||
return $this->hasMany(\App\Models\Commons\File::class, 'document_id')
|
||||
->where('document_type', '1');
|
||||
}
|
||||
|
||||
// ── 스코프 ──
|
||||
|
||||
public function scopeType($query, string $type)
|
||||
{
|
||||
return $query->where('items.item_type', strtoupper($type));
|
||||
}
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
public function scopeSearch($query, ?string $search)
|
||||
{
|
||||
if (!$search) return $query;
|
||||
return $query->where(function ($q) use ($search) {
|
||||
$q->where('code', 'like', "%{$search}%")
|
||||
->orWhere('name', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
// ── 헬퍼 ──
|
||||
|
||||
public function isProduct(): bool
|
||||
{
|
||||
return in_array($this->item_type, self::PRODUCT_TYPES);
|
||||
}
|
||||
|
||||
public function isMaterial(): bool
|
||||
{
|
||||
return in_array($this->item_type, self::MATERIAL_TYPES);
|
||||
}
|
||||
|
||||
public function getBomChildIds(): array
|
||||
{
|
||||
return collect($this->bom ?? [])->pluck('child_item_id')->filter()->toArray();
|
||||
}
|
||||
}
|
||||
|
||||
39
app/Models/Items/ItemDetail.php
Normal file
39
app/Models/Items/ItemDetail.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Items;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ItemDetail extends Model
|
||||
{
|
||||
protected $table = 'item_details';
|
||||
|
||||
protected $fillable = [
|
||||
'item_id',
|
||||
// Products 전용
|
||||
'is_sellable', 'is_purchasable', 'is_producible',
|
||||
'safety_stock', 'lead_time', 'is_variable_size',
|
||||
'product_category', 'part_type',
|
||||
'bending_diagram', 'bending_details',
|
||||
'specification_file', 'specification_file_name',
|
||||
'certification_file', 'certification_file_name',
|
||||
'certification_number', 'certification_start_date', 'certification_end_date',
|
||||
// Materials 전용
|
||||
'is_inspection', 'item_name', 'specification', 'search_tag', 'remarks',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_sellable' => 'boolean',
|
||||
'is_purchasable' => 'boolean',
|
||||
'is_producible' => 'boolean',
|
||||
'is_variable_size' => 'boolean',
|
||||
'bending_details' => 'array',
|
||||
'certification_start_date' => 'date',
|
||||
'certification_end_date' => 'date',
|
||||
];
|
||||
|
||||
public function item()
|
||||
{
|
||||
return $this->belongsTo(Item::class);
|
||||
}
|
||||
}
|
||||
83
app/Services/FormulaApiService.php
Normal file
83
app/Services/FormulaApiService.php
Normal file
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class FormulaApiService
|
||||
{
|
||||
/**
|
||||
* API 서버의 FormulaEvaluatorService를 HTTP로 호출하여 BOM 산출
|
||||
*
|
||||
* Docker 내부 통신 패턴:
|
||||
* - URL: https://nginx/api/v1/quotes/calculate/bom (Docker nginx 컨테이너)
|
||||
* - Host 헤더: api.sam.kr (nginx가 올바른 서버 블록으로 라우팅)
|
||||
* - SSL 우회: withoutVerifying() (내부 자체 서명 인증서)
|
||||
* - 인증: X-API-KEY 헤더 (FLOW_TESTER_API_KEY 환경변수)
|
||||
*
|
||||
* @param string $finishedGoodsCode 완제품 코드 (예: FG-KQTS01)
|
||||
* @param array $variables 입력 변수 ['W0' => 3000, 'H0' => 3000, 'QTY' => 1]
|
||||
* @param int $tenantId 테넌트 ID
|
||||
* @return array 성공 시 API 응답, 실패 시 ['success' => false, 'error' => '...']
|
||||
*/
|
||||
public function calculateBom(string $finishedGoodsCode, array $variables, int $tenantId): array
|
||||
{
|
||||
try {
|
||||
$apiKey = config('api-explorer.default_environments.0.api_key')
|
||||
?: env('FLOW_TESTER_API_KEY', '');
|
||||
|
||||
// Bearer token: 세션에 저장된 API 토큰 사용 (헤더 인증 UI에서 발급)
|
||||
$bearerToken = session('api_explorer_token');
|
||||
|
||||
$headers = [
|
||||
'Host' => 'api.sam.kr',
|
||||
'Accept' => 'application/json',
|
||||
'Content-Type' => 'application/json',
|
||||
'X-API-KEY' => $apiKey,
|
||||
'X-TENANT-ID' => (string) $tenantId,
|
||||
];
|
||||
|
||||
$http = Http::timeout(30)->withoutVerifying()->withHeaders($headers);
|
||||
|
||||
if ($bearerToken) {
|
||||
$http = $http->withToken($bearerToken);
|
||||
}
|
||||
|
||||
// API의 QuoteBomCalculateRequest는 W0, H0, QTY 등을 최상위 레벨에서 기대
|
||||
$payload = array_merge(
|
||||
['finished_goods_code' => $finishedGoodsCode],
|
||||
$variables // W0, H0, QTY 등을 풀어서 전송
|
||||
);
|
||||
|
||||
$response = $http->post('https://nginx/api/v1/quotes/calculate/bom', $payload);
|
||||
|
||||
if ($response->successful()) {
|
||||
$json = $response->json();
|
||||
// ApiResponse::handle()는 {success, message, data} 구조로 래핑
|
||||
return $json['data'] ?? $json;
|
||||
}
|
||||
|
||||
Log::warning('FormulaApiService: API 호출 실패', [
|
||||
'status' => $response->status(),
|
||||
'body' => $response->body(),
|
||||
'code' => $finishedGoodsCode,
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'API 응답 오류: HTTP ' . $response->status(),
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
Log::error('FormulaApiService: 예외 발생', [
|
||||
'message' => $e->getMessage(),
|
||||
'code' => $finishedGoodsCode,
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => '수식 계산 서버 연결 실패: ' . $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
136
app/Services/ItemManagementService.php
Normal file
136
app/Services/ItemManagementService.php
Normal file
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Items\Item;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
|
||||
class ItemManagementService
|
||||
{
|
||||
/**
|
||||
* 품목 목록 조회 (검색, 유형 필터, 페이지네이션)
|
||||
*/
|
||||
public function getItemList(array $filters): LengthAwarePaginator
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
$search = $filters['search'] ?? null;
|
||||
$itemType = $filters['item_type'] ?? null;
|
||||
$perPage = $filters['per_page'] ?? 50;
|
||||
|
||||
return Item::withoutGlobalScopes()
|
||||
->where('tenant_id', $tenantId)
|
||||
->search($search)
|
||||
->active()
|
||||
->when($itemType, function ($query, $types) {
|
||||
$typeList = explode(',', $types);
|
||||
$query->whereIn('item_type', $typeList);
|
||||
})
|
||||
->orderBy('code')
|
||||
->paginate($perPage);
|
||||
}
|
||||
|
||||
/**
|
||||
* BOM 재귀 트리 조회
|
||||
*/
|
||||
public function getBomTree(int $itemId, int $maxDepth = 10): array
|
||||
{
|
||||
$item = Item::withoutGlobalScopes()
|
||||
->where('tenant_id', session('selected_tenant_id'))
|
||||
->with('details')
|
||||
->findOrFail($itemId);
|
||||
|
||||
return $this->buildBomNode($item, 0, $maxDepth, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* 품목 상세 조회 (1depth BOM + 파일 + 절곡정보)
|
||||
*/
|
||||
public function getItemDetail(int $itemId): array
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
$item = Item::withoutGlobalScopes()
|
||||
->where('tenant_id', $tenantId)
|
||||
->with(['details', 'category', 'files'])
|
||||
->findOrFail($itemId);
|
||||
|
||||
// BOM 1depth: 직접 연결된 자식 품목만
|
||||
$bomChildren = [];
|
||||
$bomData = $item->bom ?? [];
|
||||
if (!empty($bomData)) {
|
||||
$childIds = array_column($bomData, 'child_item_id');
|
||||
$children = Item::withoutGlobalScopes()
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereIn('id', $childIds)
|
||||
->get(['id', 'code', 'name', 'item_type', 'unit'])
|
||||
->keyBy('id');
|
||||
|
||||
foreach ($bomData as $bom) {
|
||||
$child = $children->get($bom['child_item_id']);
|
||||
if ($child) {
|
||||
$bomChildren[] = [
|
||||
'id' => $child->id,
|
||||
'code' => $child->code,
|
||||
'name' => $child->name,
|
||||
'item_type' => $child->item_type,
|
||||
'unit' => $child->unit,
|
||||
'quantity' => $bom['quantity'] ?? 1,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'item' => $item,
|
||||
'bom_children' => $bomChildren,
|
||||
];
|
||||
}
|
||||
|
||||
// ── Private ──
|
||||
|
||||
private function buildBomNode(Item $item, int $depth, int $maxDepth, array $visited): array
|
||||
{
|
||||
// 순환 참조 방지: visited 배열 + maxDepth 이중 안전장치
|
||||
if (in_array($item->id, $visited) || $depth >= $maxDepth) {
|
||||
return $this->formatNode($item, $depth, []);
|
||||
}
|
||||
|
||||
$visited[] = $item->id;
|
||||
$children = [];
|
||||
|
||||
$bomData = $item->bom ?? [];
|
||||
if (!empty($bomData)) {
|
||||
$childIds = array_column($bomData, 'child_item_id');
|
||||
$childItems = Item::withoutGlobalScopes()
|
||||
->where('tenant_id', session('selected_tenant_id'))
|
||||
->whereIn('id', $childIds)
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
foreach ($bomData as $bom) {
|
||||
$childItem = $childItems->get($bom['child_item_id']);
|
||||
if ($childItem) {
|
||||
$childNode = $this->buildBomNode($childItem, $depth + 1, $maxDepth, $visited);
|
||||
$childNode['quantity'] = $bom['quantity'] ?? 1;
|
||||
$children[] = $childNode;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $this->formatNode($item, $depth, $children);
|
||||
}
|
||||
|
||||
private function formatNode(Item $item, int $depth, array $children): array
|
||||
{
|
||||
return [
|
||||
'id' => $item->id,
|
||||
'code' => $item->code,
|
||||
'name' => $item->name,
|
||||
'item_type' => $item->item_type,
|
||||
'unit' => $item->unit,
|
||||
'depth' => $depth,
|
||||
'has_children' => count($children) > 0,
|
||||
'children' => $children,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -293,8 +293,7 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 공유 인증 모달 -->
|
||||
@include('dev-tools.partials.auth-modal')
|
||||
{{-- 인증 모달: 전역 레이아웃(app.blade.php)에서 포함 --}}
|
||||
|
||||
<!-- 히스토리 서랍 (오버레이) -->
|
||||
<div id="history-drawer" class="fixed inset-y-0 right-0 w-96 bg-white shadow-xl transform translate-x-full transition-transform duration-300 z-50">
|
||||
@@ -319,8 +318,7 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
{{-- 공유 인증 스크립트 --}}
|
||||
@include('dev-tools.partials.auth-scripts')
|
||||
{{-- 인증 스크립트: 전역 레이아웃(app.blade.php)에서 포함 --}}
|
||||
|
||||
<script>
|
||||
// 전체 엔드포인트 데이터 (클라이언트 사이드 필터링용)
|
||||
|
||||
@@ -431,13 +431,10 @@ class="p-2 text-red-600 hover:bg-red-50 rounded-lg transition"
|
||||
@include('dev-tools.flow-tester.partials.guide-modal')
|
||||
@include('dev-tools.flow-tester.partials.example-flows')
|
||||
|
||||
<!-- 공유 인증 모달 -->
|
||||
@include('dev-tools.partials.auth-modal')
|
||||
{{-- 인증 모달/스크립트: 전역 레이아웃(app.blade.php)에서 포함 --}}
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
{{-- 공유 인증 스크립트 --}}
|
||||
@include('dev-tools.partials.auth-scripts')
|
||||
|
||||
<script>
|
||||
// 플로우 스텝 상세 토글
|
||||
|
||||
511
resources/views/item-management/index.blade.php
Normal file
511
resources/views/item-management/index.blade.php
Normal file
@@ -0,0 +1,511 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', '품목관리')
|
||||
|
||||
@section('content')
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-4">
|
||||
<h1 class="text-2xl font-bold text-gray-800">품목관리</h1>
|
||||
</div>
|
||||
|
||||
<!-- 3-Panel 레이아웃 -->
|
||||
<div class="flex gap-4" style="height: calc(100vh - 180px);">
|
||||
|
||||
<!-- 좌측 패널: 품목 리스트 -->
|
||||
<div class="w-72 flex-shrink-0 bg-white rounded-lg shadow-sm flex flex-col overflow-hidden">
|
||||
<!-- 검색 -->
|
||||
<div class="p-3 border-b border-gray-200">
|
||||
<input type="text"
|
||||
id="item-search"
|
||||
placeholder="코드 또는 품목명 검색..."
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<!-- 유형 필터 -->
|
||||
<div class="flex flex-wrap gap-1 mt-2">
|
||||
<button type="button" data-type="" class="item-type-filter active px-2 py-0.5 text-xs rounded-full bg-blue-100 text-blue-800 font-medium">전체</button>
|
||||
<button type="button" data-type="FG" class="item-type-filter px-2 py-0.5 text-xs rounded-full bg-gray-100 text-gray-600 hover:bg-blue-50">FG</button>
|
||||
<button type="button" data-type="PT" class="item-type-filter px-2 py-0.5 text-xs rounded-full bg-gray-100 text-gray-600 hover:bg-green-50">PT</button>
|
||||
<button type="button" data-type="SM" class="item-type-filter px-2 py-0.5 text-xs rounded-full bg-gray-100 text-gray-600 hover:bg-yellow-50">SM</button>
|
||||
<button type="button" data-type="RM" class="item-type-filter px-2 py-0.5 text-xs rounded-full bg-gray-100 text-gray-600 hover:bg-orange-50">RM</button>
|
||||
<button type="button" data-type="CS" class="item-type-filter px-2 py-0.5 text-xs rounded-full bg-gray-100 text-gray-600 hover:bg-gray-200">CS</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 품목 리스트 (HTMX) -->
|
||||
<div id="item-list"
|
||||
class="flex-1 overflow-y-auto"
|
||||
hx-get="/api/admin/items?per_page=50"
|
||||
hx-trigger="load"
|
||||
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'>
|
||||
<div class="flex justify-center items-center p-8">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 중앙 패널: BOM 트리 + 수식 산출 -->
|
||||
<div class="flex-1 bg-white rounded-lg shadow-sm flex flex-col overflow-hidden">
|
||||
<div class="px-4 py-3 border-b border-gray-200">
|
||||
<div class="flex items-center gap-1">
|
||||
<button type="button" id="tab-static-bom"
|
||||
class="bom-tab px-3 py-1.5 text-xs font-medium rounded-md bg-blue-100 text-blue-800"
|
||||
onclick="switchBomTab('static')">
|
||||
정적 BOM
|
||||
</button>
|
||||
<button type="button" id="tab-formula-bom"
|
||||
class="bom-tab px-3 py-1.5 text-xs font-medium rounded-md bg-gray-100 text-gray-600 hover:bg-gray-200"
|
||||
onclick="switchBomTab('formula')"
|
||||
style="display:none;">
|
||||
수식 산출
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 수식 산출 입력 폼 (가변사이즈 품목 선택 시에만 표시) -->
|
||||
<div id="formula-input-panel" style="display:none;" class="p-4 bg-gray-50 border-b border-gray-200">
|
||||
<div class="flex items-end gap-3">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">폭 W (mm)</label>
|
||||
<input type="number" id="input-width" value="1000" min="100" max="10000" step="1"
|
||||
class="w-24 px-2 py-1.5 text-sm border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">높이 H (mm)</label>
|
||||
<input type="number" id="input-height" value="1000" min="100" max="10000" step="1"
|
||||
class="w-24 px-2 py-1.5 text-sm border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">수량</label>
|
||||
<input type="number" id="input-qty" value="1" min="1" max="100" step="1"
|
||||
class="w-16 px-2 py-1.5 text-sm border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none">
|
||||
</div>
|
||||
<button type="button" id="btn-calculate" onclick="calculateFormula()"
|
||||
class="px-4 py-1.5 text-sm font-medium text-white bg-blue-600 rounded hover:bg-blue-700 transition-colors">
|
||||
산출
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 정적 BOM 영역 -->
|
||||
<div id="bom-tree-container" class="flex-1 overflow-y-auto p-4">
|
||||
<p class="text-gray-400 text-center py-10">좌측에서 품목을 선택하세요.</p>
|
||||
</div>
|
||||
|
||||
<!-- 수식 산출 결과 영역 (초기 숨김) -->
|
||||
<div id="formula-result-container" class="flex-1 overflow-y-auto p-4" style="display:none;">
|
||||
<p class="text-gray-400 text-center py-10">오픈사이즈를 입력하고 산출 버튼을 클릭하세요.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 우측 패널: 품목 상세 -->
|
||||
<div class="w-96 flex-shrink-0 bg-white rounded-lg shadow-sm flex flex-col overflow-hidden">
|
||||
<div class="px-4 py-3 border-b border-gray-200">
|
||||
<h2 class="text-sm font-semibold text-gray-700">품목 상세</h2>
|
||||
</div>
|
||||
<div id="item-detail" class="flex-1 overflow-y-auto p-4">
|
||||
<p class="text-gray-400 text-center py-10">품목을 선택하면 상세정보가 표시됩니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
(function() {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]').content;
|
||||
let searchTimer = null;
|
||||
let currentTypeFilter = '';
|
||||
let currentBomTab = 'static';
|
||||
let currentItemId = null;
|
||||
let currentItemCode = null;
|
||||
let skipCenterUpdate = false;
|
||||
|
||||
// ── 좌측 검색 (debounce 300ms) ──
|
||||
const searchInput = document.getElementById('item-search');
|
||||
searchInput.addEventListener('input', function() {
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => {
|
||||
loadItemList();
|
||||
}, 300);
|
||||
});
|
||||
|
||||
// ── 유형 필터 ──
|
||||
document.querySelectorAll('.item-type-filter').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
document.querySelectorAll('.item-type-filter').forEach(b => {
|
||||
b.classList.remove('active', 'bg-blue-100', 'text-blue-800', 'font-medium');
|
||||
b.classList.add('bg-gray-100', 'text-gray-600');
|
||||
});
|
||||
this.classList.add('active', 'bg-blue-100', 'text-blue-800', 'font-medium');
|
||||
this.classList.remove('bg-gray-100', 'text-gray-600');
|
||||
currentTypeFilter = this.dataset.type;
|
||||
loadItemList();
|
||||
});
|
||||
});
|
||||
|
||||
// ── 품목 리스트 로드 ──
|
||||
function loadItemList() {
|
||||
const search = searchInput.value.trim();
|
||||
let url = `/api/admin/items?per_page=50`;
|
||||
if (search) url += `&search=${encodeURIComponent(search)}`;
|
||||
if (currentTypeFilter) url += `&item_type=${currentTypeFilter}`;
|
||||
|
||||
htmx.ajax('GET', url, {
|
||||
target: '#item-list',
|
||||
swap: 'innerHTML',
|
||||
headers: {'X-CSRF-TOKEN': csrfToken}
|
||||
});
|
||||
}
|
||||
|
||||
// ── 품목 선택 (좌측/중앙 공용) ──
|
||||
window.selectItem = function(itemId, updateTree) {
|
||||
if (typeof updateTree === 'undefined') updateTree = true;
|
||||
|
||||
// 좌측 하이라이트
|
||||
document.querySelectorAll('.item-row').forEach(el => {
|
||||
el.classList.remove('bg-blue-50', 'border-l-4', 'border-blue-500');
|
||||
});
|
||||
const selected = document.querySelector(`[data-item-id="${itemId}"]`);
|
||||
if (selected) {
|
||||
selected.classList.add('bg-blue-50', 'border-l-4', 'border-blue-500');
|
||||
}
|
||||
|
||||
// 중앙 트리 갱신 (좌측에서 클릭 시에만)
|
||||
if (updateTree) {
|
||||
const treeContainer = document.getElementById('bom-tree-container');
|
||||
treeContainer.innerHTML = '<div class="flex justify-center py-10"><div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div></div>';
|
||||
|
||||
fetch(`/api/admin/items/${itemId}/bom-tree`, {
|
||||
headers: {'X-CSRF-TOKEN': csrfToken}
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(tree => {
|
||||
treeContainer.innerHTML = '';
|
||||
if (tree.has_children) {
|
||||
const ul = document.createElement('ul');
|
||||
ul.className = 'text-sm';
|
||||
renderBomTree(tree, ul);
|
||||
treeContainer.appendChild(ul);
|
||||
} else {
|
||||
treeContainer.innerHTML = '<p class="text-gray-400 text-center py-10">BOM 구성이 없습니다.</p>';
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
treeContainer.innerHTML = '<p class="text-red-400 text-center py-10">BOM 로드 실패</p>';
|
||||
});
|
||||
}
|
||||
|
||||
// 우측 상세 갱신 (항상)
|
||||
htmx.ajax('GET', `/api/admin/items/${itemId}/detail`, {
|
||||
target: '#item-detail',
|
||||
swap: 'innerHTML',
|
||||
headers: {'X-CSRF-TOKEN': csrfToken}
|
||||
});
|
||||
};
|
||||
|
||||
// 중앙 트리 노드 클릭 (트리 유지, 우측만 갱신)
|
||||
window.selectTreeNode = function(itemId) {
|
||||
selectItem(itemId, false);
|
||||
};
|
||||
|
||||
// ── BOM 트리 렌더링 ──
|
||||
function renderBomTree(node, container) {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'ml-4';
|
||||
|
||||
const nodeEl = document.createElement('div');
|
||||
nodeEl.className = 'flex items-center gap-2 py-1.5 px-2 rounded cursor-pointer hover:bg-blue-50 transition-colors';
|
||||
nodeEl.onclick = () => selectTreeNode(node.id);
|
||||
|
||||
// 펼침/접힘 토글
|
||||
let childList = null;
|
||||
if (node.has_children) {
|
||||
const toggle = document.createElement('span');
|
||||
toggle.className = 'text-gray-400 cursor-pointer select-none text-xs';
|
||||
toggle.textContent = '▼';
|
||||
toggle.onclick = function(e) {
|
||||
e.stopPropagation();
|
||||
if (childList.style.display === 'none') {
|
||||
childList.style.display = '';
|
||||
toggle.textContent = '▼';
|
||||
} else {
|
||||
childList.style.display = 'none';
|
||||
toggle.textContent = '▶';
|
||||
}
|
||||
};
|
||||
nodeEl.appendChild(toggle);
|
||||
} else {
|
||||
const spacer = document.createElement('span');
|
||||
spacer.className = 'w-3 inline-block';
|
||||
nodeEl.appendChild(spacer);
|
||||
}
|
||||
|
||||
// 유형 뱃지
|
||||
const badge = document.createElement('span');
|
||||
badge.className = getTypeBadgeClass(node.item_type);
|
||||
badge.textContent = node.item_type;
|
||||
nodeEl.appendChild(badge);
|
||||
|
||||
// 코드 + 이름
|
||||
const codeSpan = document.createElement('span');
|
||||
codeSpan.className = 'font-mono text-xs text-gray-500';
|
||||
codeSpan.textContent = node.code;
|
||||
nodeEl.appendChild(codeSpan);
|
||||
|
||||
const nameSpan = document.createElement('span');
|
||||
nameSpan.className = 'text-gray-700 text-sm truncate';
|
||||
nameSpan.textContent = node.name;
|
||||
nodeEl.appendChild(nameSpan);
|
||||
|
||||
// 수량
|
||||
if (node.quantity) {
|
||||
const qtySpan = document.createElement('span');
|
||||
qtySpan.className = 'text-blue-600 text-xs font-medium';
|
||||
qtySpan.textContent = `(${node.quantity})`;
|
||||
nodeEl.appendChild(qtySpan);
|
||||
}
|
||||
|
||||
li.appendChild(nodeEl);
|
||||
|
||||
// 자식 노드 재귀 렌더링
|
||||
if (node.children && node.children.length > 0) {
|
||||
childList = document.createElement('ul');
|
||||
childList.className = 'border-l border-gray-200';
|
||||
node.children.forEach(child => renderBomTree(child, childList));
|
||||
li.appendChild(childList);
|
||||
}
|
||||
|
||||
container.appendChild(li);
|
||||
}
|
||||
|
||||
// ── 유형별 뱃지 색상 ──
|
||||
function getTypeBadgeClass(type) {
|
||||
const colors = {
|
||||
'FG': 'bg-blue-100 text-blue-800',
|
||||
'PT': 'bg-green-100 text-green-800',
|
||||
'SM': 'bg-yellow-100 text-yellow-800',
|
||||
'RM': 'bg-orange-100 text-orange-800',
|
||||
'CS': 'bg-gray-100 text-gray-800',
|
||||
};
|
||||
return `inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium ${colors[type] || 'bg-gray-100 text-gray-800'}`;
|
||||
}
|
||||
|
||||
// 전역에서 사용하도록 노출
|
||||
window.getTypeBadgeClass = getTypeBadgeClass;
|
||||
|
||||
// ── 탭 전환 ──
|
||||
window.switchBomTab = function(tab) {
|
||||
currentBomTab = tab;
|
||||
|
||||
// 탭 버튼 스타일
|
||||
document.querySelectorAll('.bom-tab').forEach(btn => {
|
||||
btn.classList.remove('bg-blue-100', 'text-blue-800');
|
||||
btn.classList.add('bg-gray-100', 'text-gray-600');
|
||||
});
|
||||
const activeBtn = document.getElementById(tab === 'static' ? 'tab-static-bom' : 'tab-formula-bom');
|
||||
if (activeBtn) {
|
||||
activeBtn.classList.remove('bg-gray-100', 'text-gray-600');
|
||||
activeBtn.classList.add('bg-blue-100', 'text-blue-800');
|
||||
}
|
||||
|
||||
// 콘텐츠 영역 전환
|
||||
document.getElementById('bom-tree-container').style.display = (tab === 'static') ? '' : 'none';
|
||||
document.getElementById('formula-input-panel').style.display = (tab === 'formula') ? '' : 'none';
|
||||
document.getElementById('formula-result-container').style.display = (tab === 'formula') ? '' : 'none';
|
||||
};
|
||||
|
||||
// ── 가변사이즈 탭 표시/숨김 ──
|
||||
function showFormulaTab() {
|
||||
document.getElementById('tab-formula-bom').style.display = '';
|
||||
switchBomTab('formula');
|
||||
}
|
||||
|
||||
function hideFormulaTab() {
|
||||
document.getElementById('tab-formula-bom').style.display = 'none';
|
||||
document.getElementById('formula-input-panel').style.display = 'none';
|
||||
document.getElementById('formula-result-container').style.display = 'none';
|
||||
switchBomTab('static');
|
||||
}
|
||||
|
||||
// ── 상세 로드 완료 후 FG 품목 감지 → 수식 산출 탭 표시 ──
|
||||
document.body.addEventListener('htmx:afterSwap', function(event) {
|
||||
if (event.detail.target.id === 'item-detail') {
|
||||
// 산출 결과 아이템 클릭 시에는 중앙 패널 유지
|
||||
if (skipCenterUpdate) {
|
||||
skipCenterUpdate = false;
|
||||
return;
|
||||
}
|
||||
const meta = document.getElementById('item-meta-data');
|
||||
if (meta) {
|
||||
currentItemId = meta.dataset.itemId;
|
||||
currentItemCode = meta.dataset.itemCode;
|
||||
if (meta.dataset.itemType === 'FG') {
|
||||
showFormulaTab();
|
||||
calculateFormula();
|
||||
} else {
|
||||
hideFormulaTab();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ── 수식 산출 API 호출 ──
|
||||
window.calculateFormula = function() {
|
||||
if (!currentItemId) return;
|
||||
|
||||
const width = parseInt(document.getElementById('input-width').value) || 1000;
|
||||
const height = parseInt(document.getElementById('input-height').value) || 1000;
|
||||
const qty = parseInt(document.getElementById('input-qty').value) || 1;
|
||||
|
||||
if (width < 100 || width > 10000 || height < 100 || height > 10000) {
|
||||
alert('폭과 높이는 100~10000 범위로 입력하세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const container = document.getElementById('formula-result-container');
|
||||
container.innerHTML = '<div class="flex justify-center py-10"><div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div></div>';
|
||||
|
||||
fetch(`/api/admin/items/${currentItemId}/calculate-formula`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken,
|
||||
},
|
||||
body: JSON.stringify({ width, height, qty }),
|
||||
})
|
||||
.then(res => {
|
||||
if (!res.ok) {
|
||||
return res.text().then(text => {
|
||||
let msg = `HTTP ${res.status}`;
|
||||
try { const j = JSON.parse(text); msg = j.error || j.message || msg; } catch(e) {}
|
||||
throw new Error(msg);
|
||||
});
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (data.success === false) {
|
||||
container.innerHTML = `
|
||||
<div class="text-center py-10">
|
||||
<p class="text-red-500 text-sm mb-2">${data.error || '산출 실패'}</p>
|
||||
<button onclick="calculateFormula()" class="text-blue-600 text-sm hover:underline">다시 시도</button>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
renderFormulaTree(data, container);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('calculateFormula error:', err);
|
||||
container.innerHTML = `
|
||||
<div class="text-center py-10">
|
||||
<p class="text-red-500 text-sm mb-2">${err.message || '서버 연결 실패'}</p>
|
||||
<button onclick="calculateFormula()" class="text-blue-600 text-sm hover:underline">다시 시도</button>
|
||||
</div>`;
|
||||
});
|
||||
};
|
||||
|
||||
// ── 수식 산출 결과 트리 렌더링 ──
|
||||
// API 응답 구조: grouped_items = { CATEGORY_CODE: { name, items: [...], subtotal } }
|
||||
// subtotals = { CATEGORY_CODE: { name, count, subtotal } }
|
||||
function renderFormulaTree(data, container) {
|
||||
container.innerHTML = '';
|
||||
|
||||
const groupedItems = data.grouped_items || {};
|
||||
|
||||
// 합계 영역
|
||||
if (data.grand_total !== undefined) {
|
||||
const totalDiv = document.createElement('div');
|
||||
totalDiv.className = 'mb-4 p-3 bg-blue-50 rounded-lg flex justify-between items-center';
|
||||
totalDiv.innerHTML = `
|
||||
<span class="text-sm font-medium text-blue-800">
|
||||
${data.finished_goods?.name || ''} (${data.finished_goods?.code || ''})
|
||||
<span class="text-xs text-blue-600 ml-2">W:${data.variables?.W0} H:${data.variables?.H0}</span>
|
||||
</span>
|
||||
<span class="text-sm font-bold text-blue-900">합계: ${Number(data.grand_total).toLocaleString()}원</span>
|
||||
`;
|
||||
container.appendChild(totalDiv);
|
||||
}
|
||||
|
||||
// 카테고리 그룹별 렌더링
|
||||
Object.entries(groupedItems).forEach(([groupKey, group]) => {
|
||||
// group = { name: '강재', items: [...], subtotal: 12345 }
|
||||
const groupItems = group.items || [];
|
||||
if (groupItems.length === 0) return;
|
||||
|
||||
const groupDiv = document.createElement('div');
|
||||
groupDiv.className = 'mb-3';
|
||||
|
||||
const groupName = group.name || groupKey;
|
||||
const subtotal = group.subtotal || 0;
|
||||
|
||||
// 그룹 헤더
|
||||
const header = document.createElement('div');
|
||||
header.className = 'flex items-center gap-2 py-2 px-3 bg-gray-50 rounded-t-lg cursor-pointer';
|
||||
header.innerHTML = `
|
||||
<span class="toggle-icon text-xs text-gray-400">▼</span>
|
||||
<span>📦</span>
|
||||
<span class="text-sm font-semibold text-gray-700">${groupName}</span>
|
||||
<span class="text-xs text-gray-500">(${groupItems.length}건)</span>
|
||||
<span class="ml-auto text-xs font-medium text-gray-600">소계: ${Number(subtotal).toLocaleString()}원</span>
|
||||
`;
|
||||
|
||||
const listDiv = document.createElement('div');
|
||||
listDiv.className = 'border border-gray-100 rounded-b-lg divide-y divide-gray-50';
|
||||
|
||||
// 그룹 접기/펼치기
|
||||
header.onclick = function() {
|
||||
const toggle = header.querySelector('.toggle-icon');
|
||||
if (listDiv.style.display === 'none') {
|
||||
listDiv.style.display = '';
|
||||
toggle.textContent = '▼';
|
||||
} else {
|
||||
listDiv.style.display = 'none';
|
||||
toggle.textContent = '▶';
|
||||
}
|
||||
};
|
||||
|
||||
// 아이템 목록
|
||||
groupItems.forEach(item => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'flex items-center gap-2 py-1.5 px-3 hover:bg-gray-50 cursor-pointer text-sm';
|
||||
row.innerHTML = `
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">
|
||||
${item.item_type || 'PT'}
|
||||
</span>
|
||||
<span class="font-mono text-xs text-gray-500 w-32 truncate">${item.item_code || ''}</span>
|
||||
<span class="text-gray-700 flex-1 truncate">${item.item_name || ''}</span>
|
||||
<span class="text-xs text-gray-500 w-16 text-right">${item.quantity || 0} ${item.unit || ''}</span>
|
||||
<span class="text-xs text-blue-600 font-medium w-20 text-right">${Number(item.total_price || 0).toLocaleString()}원</span>
|
||||
`;
|
||||
if (item.item_id) {
|
||||
row.onclick = () => {
|
||||
// 활성 행 표시
|
||||
container.querySelectorAll('.formula-row-active').forEach(el => {
|
||||
el.classList.remove('formula-row-active', 'ring-1', 'ring-blue-300');
|
||||
});
|
||||
row.classList.add('formula-row-active', 'ring-1', 'ring-blue-300');
|
||||
|
||||
// 우측 상세만 갱신, 중앙 패널 유지
|
||||
skipCenterUpdate = true;
|
||||
htmx.ajax('GET', `/api/admin/items/${item.item_id}/detail`, {
|
||||
target: '#item-detail',
|
||||
swap: 'innerHTML',
|
||||
headers: {'X-CSRF-TOKEN': csrfToken}
|
||||
});
|
||||
};
|
||||
}
|
||||
listDiv.appendChild(row);
|
||||
});
|
||||
|
||||
groupDiv.appendChild(header);
|
||||
groupDiv.appendChild(listDiv);
|
||||
container.appendChild(groupDiv);
|
||||
});
|
||||
|
||||
if (Object.keys(groupedItems).length === 0) {
|
||||
container.innerHTML = '<p class="text-gray-400 text-center py-10">산출된 자재가 없습니다.</p>';
|
||||
}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
@endpush
|
||||
@@ -0,0 +1,5 @@
|
||||
{{-- BOM 트리는 JS로 렌더링됨 (JSON → DOM) --}}
|
||||
{{-- 이 파일은 초기 상태 또는 서버사이드 렌더링이 필요할 때 사용 --}}
|
||||
<div id="bom-tree-container" class="p-4">
|
||||
<p class="text-gray-400 text-center py-10">좌측에서 품목을 선택하세요.</p>
|
||||
</div>
|
||||
277
resources/views/item-management/partials/item-detail.blade.php
Normal file
277
resources/views/item-management/partials/item-detail.blade.php
Normal file
@@ -0,0 +1,277 @@
|
||||
<!-- 품목 메타 데이터 (JS에서 FG 감지용) -->
|
||||
<div id="item-meta-data"
|
||||
data-item-id="{{ $item->id }}"
|
||||
data-item-code="{{ $item->code }}"
|
||||
data-item-type="{{ $item->item_type }}"
|
||||
style="display:none;"></div>
|
||||
|
||||
{{-- 기본정보 --}}
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-gray-800 mb-2 pb-1 border-b border-gray-200">기본정보</h3>
|
||||
<table class="w-full text-sm">
|
||||
<tbody>
|
||||
<tr class="border-b border-gray-50">
|
||||
<td class="py-1.5 text-gray-500 w-24">코드</td>
|
||||
<td class="py-1.5 font-mono text-gray-900">{{ $item->code }}</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-50">
|
||||
<td class="py-1.5 text-gray-500">품목명</td>
|
||||
<td class="py-1.5 text-gray-900 font-medium">{{ $item->name }}</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-50">
|
||||
<td class="py-1.5 text-gray-500">유형</td>
|
||||
<td class="py-1.5">
|
||||
@php
|
||||
$badgeColors = [
|
||||
'FG' => 'bg-blue-100 text-blue-800',
|
||||
'PT' => 'bg-green-100 text-green-800',
|
||||
'SM' => 'bg-yellow-100 text-yellow-800',
|
||||
'RM' => 'bg-orange-100 text-orange-800',
|
||||
'CS' => 'bg-gray-100 text-gray-800',
|
||||
];
|
||||
$typeLabels = [
|
||||
'FG' => '완제품',
|
||||
'PT' => '부품',
|
||||
'SM' => '부자재',
|
||||
'RM' => '원자재',
|
||||
'CS' => '소모품',
|
||||
];
|
||||
$color = $badgeColors[$item->item_type] ?? 'bg-gray-100 text-gray-800';
|
||||
@endphp
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium {{ $color }}">
|
||||
{{ $item->item_type }} ({{ $typeLabels[$item->item_type] ?? $item->item_type }})
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
@if($item->item_category)
|
||||
<tr class="border-b border-gray-50">
|
||||
<td class="py-1.5 text-gray-500">카테고리</td>
|
||||
<td class="py-1.5 text-gray-900">{{ $item->item_category }}</td>
|
||||
</tr>
|
||||
@endif
|
||||
<tr class="border-b border-gray-50">
|
||||
<td class="py-1.5 text-gray-500">단위</td>
|
||||
<td class="py-1.5 text-gray-900">{{ $item->unit ?? '-' }}</td>
|
||||
</tr>
|
||||
@if($item->category)
|
||||
<tr class="border-b border-gray-50">
|
||||
<td class="py-1.5 text-gray-500">분류</td>
|
||||
<td class="py-1.5 text-gray-900">{{ $item->category->name }}</td>
|
||||
</tr>
|
||||
@endif
|
||||
<tr class="border-b border-gray-50">
|
||||
<td class="py-1.5 text-gray-500">상태</td>
|
||||
<td class="py-1.5">
|
||||
@if($item->is_active)
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">활성</span>
|
||||
@else
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800">비활성</span>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@if($item->description)
|
||||
<tr class="border-b border-gray-50">
|
||||
<td class="py-1.5 text-gray-500">설명</td>
|
||||
<td class="py-1.5 text-gray-900 text-xs">{{ $item->description }}</td>
|
||||
</tr>
|
||||
@endif
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{{-- Options 정보 --}}
|
||||
@if($item->options)
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-gray-800 mb-2 pb-1 border-b border-gray-200">관리 옵션</h3>
|
||||
<table class="w-full text-sm">
|
||||
<tbody>
|
||||
@if(isset($item->options['lot_managed']))
|
||||
<tr class="border-b border-gray-50">
|
||||
<td class="py-1.5 text-gray-500 w-24">LOT 관리</td>
|
||||
<td class="py-1.5">{{ $item->options['lot_managed'] ? '예' : '아니오' }}</td>
|
||||
</tr>
|
||||
@endif
|
||||
@if(isset($item->options['consumption_method']))
|
||||
<tr class="border-b border-gray-50">
|
||||
<td class="py-1.5 text-gray-500">소진방식</td>
|
||||
<td class="py-1.5">{{ $item->options['consumption_method'] }}</td>
|
||||
</tr>
|
||||
@endif
|
||||
@if(isset($item->options['production_source']))
|
||||
<tr class="border-b border-gray-50">
|
||||
<td class="py-1.5 text-gray-500">조달구분</td>
|
||||
<td class="py-1.5">{{ $item->options['production_source'] }}</td>
|
||||
</tr>
|
||||
@endif
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- BOM 구성 (1depth) --}}
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-gray-800 mb-2 pb-1 border-b border-gray-200">
|
||||
BOM 구성
|
||||
@if(count($bomChildren) > 0)
|
||||
<span class="text-gray-400 font-normal">({{ count($bomChildren) }}건)</span>
|
||||
@endif
|
||||
</h3>
|
||||
@if(count($bomChildren) > 0)
|
||||
<div class="space-y-1">
|
||||
@foreach($bomChildren as $child)
|
||||
<div class="flex items-center gap-2 py-1.5 px-2 rounded hover:bg-gray-50 cursor-pointer transition-colors"
|
||||
onclick="selectTreeNode({{ $child['id'] }})">
|
||||
@php
|
||||
$childColor = $badgeColors[$child['item_type']] ?? 'bg-gray-100 text-gray-800';
|
||||
@endphp
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium {{ $childColor }}">
|
||||
{{ $child['item_type'] }}
|
||||
</span>
|
||||
<span class="font-mono text-xs text-gray-500">{{ $child['code'] }}</span>
|
||||
<span class="text-sm text-gray-700 truncate flex-1">{{ $child['name'] }}</span>
|
||||
<span class="text-xs text-blue-600 font-medium">{{ $child['quantity'] }} {{ $child['unit'] ?? '' }}</span>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<p class="text-gray-400 text-sm py-2">BOM 구성이 없습니다.</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- 상세정보 (ItemDetail) --}}
|
||||
@if($item->details)
|
||||
{{-- 절곡 정보 --}}
|
||||
@if($item->details->bending_details)
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-gray-800 mb-2 pb-1 border-b border-gray-200">절곡 정보</h3>
|
||||
<div class="bg-gray-50 rounded p-2 text-xs">
|
||||
<pre class="whitespace-pre-wrap text-gray-700">{{ json_encode($item->details->bending_details, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- 제품 정보 --}}
|
||||
@if($item->isProduct())
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-gray-800 mb-2 pb-1 border-b border-gray-200">제품 상세</h3>
|
||||
<table class="w-full text-sm">
|
||||
<tbody>
|
||||
<tr class="border-b border-gray-50">
|
||||
<td class="py-1.5 text-gray-500 w-24">판매가능</td>
|
||||
<td class="py-1.5">{{ $item->details->is_sellable ? '예' : '아니오' }}</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-50">
|
||||
<td class="py-1.5 text-gray-500">구매가능</td>
|
||||
<td class="py-1.5">{{ $item->details->is_purchasable ? '예' : '아니오' }}</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-50">
|
||||
<td class="py-1.5 text-gray-500">생산가능</td>
|
||||
<td class="py-1.5">{{ $item->details->is_producible ? '예' : '아니오' }}</td>
|
||||
</tr>
|
||||
@if($item->details->safety_stock)
|
||||
<tr class="border-b border-gray-50">
|
||||
<td class="py-1.5 text-gray-500">안전재고</td>
|
||||
<td class="py-1.5">{{ $item->details->safety_stock }}</td>
|
||||
</tr>
|
||||
@endif
|
||||
@if($item->details->lead_time)
|
||||
<tr class="border-b border-gray-50">
|
||||
<td class="py-1.5 text-gray-500">리드타임</td>
|
||||
<td class="py-1.5">{{ $item->details->lead_time }}일</td>
|
||||
</tr>
|
||||
@endif
|
||||
@if($item->details->product_category)
|
||||
<tr class="border-b border-gray-50">
|
||||
<td class="py-1.5 text-gray-500">제품분류</td>
|
||||
<td class="py-1.5">{{ $item->details->product_category }}</td>
|
||||
</tr>
|
||||
@endif
|
||||
@if($item->details->part_type)
|
||||
<tr class="border-b border-gray-50">
|
||||
<td class="py-1.5 text-gray-500">부품유형</td>
|
||||
<td class="py-1.5">{{ $item->details->part_type }}</td>
|
||||
</tr>
|
||||
@endif
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- 자재 정보 --}}
|
||||
@if($item->isMaterial() && ($item->details->specification || $item->details->remarks))
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-gray-800 mb-2 pb-1 border-b border-gray-200">자재 상세</h3>
|
||||
<table class="w-full text-sm">
|
||||
<tbody>
|
||||
@if($item->details->specification)
|
||||
<tr class="border-b border-gray-50">
|
||||
<td class="py-1.5 text-gray-500 w-24">규격</td>
|
||||
<td class="py-1.5">{{ $item->details->specification }}</td>
|
||||
</tr>
|
||||
@endif
|
||||
@if($item->details->is_inspection)
|
||||
<tr class="border-b border-gray-50">
|
||||
<td class="py-1.5 text-gray-500">검사여부</td>
|
||||
<td class="py-1.5">{{ $item->details->is_inspection === 'Y' ? '예' : '아니오' }}</td>
|
||||
</tr>
|
||||
@endif
|
||||
@if($item->details->remarks)
|
||||
<tr class="border-b border-gray-50">
|
||||
<td class="py-1.5 text-gray-500">비고</td>
|
||||
<td class="py-1.5 text-xs">{{ $item->details->remarks }}</td>
|
||||
</tr>
|
||||
@endif
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- 인증 정보 --}}
|
||||
@if($item->details->certification_number)
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-gray-800 mb-2 pb-1 border-b border-gray-200">인증 정보</h3>
|
||||
<table class="w-full text-sm">
|
||||
<tbody>
|
||||
<tr class="border-b border-gray-50">
|
||||
<td class="py-1.5 text-gray-500 w-24">인증번호</td>
|
||||
<td class="py-1.5">{{ $item->details->certification_number }}</td>
|
||||
</tr>
|
||||
@if($item->details->certification_start_date)
|
||||
<tr class="border-b border-gray-50">
|
||||
<td class="py-1.5 text-gray-500">유효기간</td>
|
||||
<td class="py-1.5">
|
||||
{{ $item->details->certification_start_date->format('Y-m-d') }}
|
||||
~ {{ $item->details->certification_end_date?->format('Y-m-d') ?? '미정' }}
|
||||
</td>
|
||||
</tr>
|
||||
@endif
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
|
||||
{{-- 파일/이미지 --}}
|
||||
@if($item->files->isNotEmpty())
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-gray-800 mb-2 pb-1 border-b border-gray-200">
|
||||
첨부파일
|
||||
<span class="text-gray-400 font-normal">({{ $item->files->count() }}건)</span>
|
||||
</h3>
|
||||
<div class="space-y-1">
|
||||
@foreach($item->files as $file)
|
||||
<div class="flex items-center gap-2 py-1.5 px-2 rounded bg-gray-50">
|
||||
<svg class="w-4 h-4 text-gray-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"></path>
|
||||
</svg>
|
||||
<span class="text-sm text-gray-700 truncate">{{ $file->original_name }}</span>
|
||||
@if($file->size)
|
||||
<span class="text-xs text-gray-400 flex-shrink-0">{{ number_format($file->size / 1024, 1) }}KB</span>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
46
resources/views/item-management/partials/item-list.blade.php
Normal file
46
resources/views/item-management/partials/item-list.blade.php
Normal file
@@ -0,0 +1,46 @@
|
||||
@forelse($items as $item)
|
||||
<div class="item-row px-3 py-2 border-b border-gray-100 cursor-pointer hover:bg-gray-50 transition-colors"
|
||||
data-item-id="{{ $item->id }}"
|
||||
onclick="selectItem({{ $item->id }})">
|
||||
<div class="flex items-center gap-2">
|
||||
{{-- 유형 뱃지 --}}
|
||||
@php
|
||||
$badgeColors = [
|
||||
'FG' => 'bg-blue-100 text-blue-800',
|
||||
'PT' => 'bg-green-100 text-green-800',
|
||||
'SM' => 'bg-yellow-100 text-yellow-800',
|
||||
'RM' => 'bg-orange-100 text-orange-800',
|
||||
'CS' => 'bg-gray-100 text-gray-800',
|
||||
];
|
||||
$color = $badgeColors[$item->item_type] ?? 'bg-gray-100 text-gray-800';
|
||||
@endphp
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium {{ $color }}">
|
||||
{{ $item->item_type }}
|
||||
</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 truncate">{{ $item->name }}</p>
|
||||
<p class="text-xs text-gray-500 font-mono">{{ $item->code }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<div class="p-8 text-center text-gray-400 text-sm">
|
||||
검색 결과가 없습니다.
|
||||
</div>
|
||||
@endforelse
|
||||
|
||||
{{-- 페이지네이션 정보 --}}
|
||||
@if($items->hasPages())
|
||||
<div class="px-3 py-2 border-t border-gray-200 text-xs text-gray-500 text-center">
|
||||
{{ $items->total() }}건 중 {{ $items->firstItem() }}-{{ $items->lastItem() }}
|
||||
@if($items->hasMorePages())
|
||||
<button class="text-blue-600 hover:underline ml-2"
|
||||
hx-get="{{ $items->nextPageUrl() }}"
|
||||
hx-target="#item-list"
|
||||
hx-swap="innerHTML"
|
||||
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'>
|
||||
더보기
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
@@ -306,6 +306,9 @@ class="fixed inset-0 bg-black/50 z-40 hidden lg:hidden"
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API 인증 모달 (전역) -->
|
||||
@include('dev-tools.partials.auth-modal')
|
||||
|
||||
<!-- 전역 컨텍스트 메뉴 -->
|
||||
@include('components.context-menu')
|
||||
|
||||
@@ -529,6 +532,46 @@ function toggleSidebar() {
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- API 인증 스크립트 (전역) -->
|
||||
@include('dev-tools.partials.auth-scripts')
|
||||
|
||||
<!-- 헤더 API 인증 상태 동기화 -->
|
||||
<script>
|
||||
(function() {
|
||||
function updateHeaderAuthBtn() {
|
||||
const btn = document.getElementById('header-api-auth-btn');
|
||||
if (!btn) return;
|
||||
const dot = btn.querySelector('.dev-tools-auth-dot');
|
||||
const label = btn.querySelector('.dev-tools-auth-status');
|
||||
const isAuth = window.DevToolsAuth && DevToolsAuth.isAuthenticated();
|
||||
|
||||
if (dot) {
|
||||
dot.classList.toggle('bg-green-500', isAuth);
|
||||
dot.classList.toggle('bg-gray-300', !isAuth);
|
||||
}
|
||||
if (label) {
|
||||
label.textContent = isAuth ? 'API 인증됨' : 'API 인증';
|
||||
label.classList.toggle('text-green-600', isAuth);
|
||||
label.classList.toggle('text-gray-500', !isAuth);
|
||||
}
|
||||
btn.classList.toggle('border-green-300', isAuth);
|
||||
btn.classList.toggle('border-gray-300', !isAuth);
|
||||
}
|
||||
|
||||
// DevToolsAuth 콜백 등록
|
||||
if (window.DevToolsAuth) {
|
||||
DevToolsAuth.onAuthChange(updateHeaderAuthBtn);
|
||||
}
|
||||
|
||||
// 초기 상태 동기화
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', updateHeaderAuthBtn);
|
||||
} else {
|
||||
updateHeaderAuthBtn();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
@stack('scripts')
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -80,6 +80,21 @@ class="border-gray-300 rounded-lg text-sm focus:ring-primary focus:border-primar
|
||||
|
||||
<!-- Right Side Actions -->
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- API 인증 상태 (전역) -->
|
||||
<button
|
||||
type="button"
|
||||
onclick="DevToolsAuth.openModal()"
|
||||
class="flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-lg border transition-colors hover:bg-gray-50"
|
||||
id="header-api-auth-btn"
|
||||
title="API 인증 설정"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/>
|
||||
</svg>
|
||||
<span class="hidden lg:inline dev-tools-auth-status text-gray-500">API 인증</span>
|
||||
<span class="dev-tools-auth-dot w-2 h-2 rounded-full bg-gray-300"></span>
|
||||
</button>
|
||||
|
||||
<!-- Notifications (추후 추가) -->
|
||||
<button class="p-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
||||
@@ -865,6 +865,12 @@
|
||||
*/
|
||||
Route::middleware(['web', 'auth', 'hq.member'])->prefix('admin/items')->name('api.admin.items.')->group(function () {
|
||||
Route::get('/search', [\App\Http\Controllers\Api\Admin\ItemApiController::class, 'search'])->name('search');
|
||||
|
||||
// 품목관리 페이지 API
|
||||
Route::get('/', [\App\Http\Controllers\Api\Admin\ItemManagementApiController::class, 'index'])->name('index');
|
||||
Route::get('/{id}/bom-tree', [\App\Http\Controllers\Api\Admin\ItemManagementApiController::class, 'bomTree'])->name('bom-tree');
|
||||
Route::get('/{id}/detail', [\App\Http\Controllers\Api\Admin\ItemManagementApiController::class, 'detail'])->name('detail');
|
||||
Route::post('/{id}/calculate-formula', [\App\Http\Controllers\Api\Admin\ItemManagementApiController::class, 'calculateFormula'])->name('calculate-formula');
|
||||
});
|
||||
|
||||
/*
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
use App\Http\Controllers\DocumentTemplateController;
|
||||
use App\Http\Controllers\FcmController;
|
||||
use App\Http\Controllers\ItemFieldController;
|
||||
use App\Http\Controllers\ItemManagementController;
|
||||
use App\Http\Controllers\Lab\StrategyController;
|
||||
use App\Http\Controllers\MenuController;
|
||||
use App\Http\Controllers\MenuSyncController;
|
||||
@@ -335,6 +336,9 @@
|
||||
Route::get('/{id}', [DailyLogController::class, 'show'])->name('show');
|
||||
});
|
||||
|
||||
// 품목관리 (3-Panel: 리스트 + BOM 트리 + 상세)
|
||||
Route::get('/item-management', [ItemManagementController::class, 'index'])->name('item-management.index');
|
||||
|
||||
// 품목기준 필드 관리 (Blade 화면만)
|
||||
Route::prefix('item-fields')->name('item-fields.')->group(function () {
|
||||
Route::get('/', [ItemFieldController::class, 'index'])->name('index');
|
||||
|
||||
Reference in New Issue
Block a user