feat(quote-formula): 매핑/품목 관리 UI 구현 (Phase 2, 3)
Phase 2 - 매핑(Mapping) 관리: - QuoteFormulaMappingController, QuoteFormulaMappingService 추가 - mappings-tab.blade.php 뷰 생성 - 매핑 CRUD 및 순서 변경 API Phase 3 - 품목(Item) 관리: - QuoteFormulaItemController, QuoteFormulaItemService 추가 - items-tab.blade.php 뷰 생성 - 품목 CRUD 및 순서 변경 API - 수량식/단가식 입력 지원 공통: - edit.blade.php에 매핑/품목 탭 연동 - routes/api.php에 API 엔드포인트 추가
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin\Quote;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Quote\QuoteFormulaItemService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class QuoteFormulaItemController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly QuoteFormulaItemService $itemService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 품목 목록 조회
|
||||
*/
|
||||
public function index(int $formulaId): JsonResponse
|
||||
{
|
||||
$items = $this->itemService->getItemsByFormula($formulaId);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $items,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 품목 상세 조회
|
||||
*/
|
||||
public function show(int $formulaId, int $itemId): JsonResponse
|
||||
{
|
||||
// 수식 소속 확인
|
||||
if (! $this->itemService->belongsToFormula($itemId, $formulaId)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '해당 수식에 속하지 않는 품목입니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
$item = $this->itemService->getItemById($itemId);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $item,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 품목 생성
|
||||
*/
|
||||
public function store(Request $request, int $formulaId): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'item_code' => 'required|string|max:50',
|
||||
'item_name' => 'required|string|max:200',
|
||||
'specification' => 'nullable|string|max:200',
|
||||
'unit' => 'nullable|string|max:20',
|
||||
'quantity_formula' => 'nullable|string|max:500',
|
||||
'unit_price_formula' => 'nullable|string|max:500',
|
||||
'sort_order' => 'nullable|integer|min:0',
|
||||
]);
|
||||
|
||||
$item = $this->itemService->createItem($formulaId, $validated);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '품목이 추가되었습니다.',
|
||||
'data' => $item,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 품목 수정
|
||||
*/
|
||||
public function update(Request $request, int $formulaId, int $itemId): JsonResponse
|
||||
{
|
||||
// 수식 소속 확인
|
||||
if (! $this->itemService->belongsToFormula($itemId, $formulaId)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '해당 수식에 속하지 않는 품목입니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'item_code' => 'nullable|string|max:50',
|
||||
'item_name' => 'nullable|string|max:200',
|
||||
'specification' => 'nullable|string|max:200',
|
||||
'unit' => 'nullable|string|max:20',
|
||||
'quantity_formula' => 'nullable|string|max:500',
|
||||
'unit_price_formula' => 'nullable|string|max:500',
|
||||
]);
|
||||
|
||||
$item = $this->itemService->updateItem($itemId, $validated);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '품목이 수정되었습니다.',
|
||||
'data' => $item,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 품목 삭제
|
||||
*/
|
||||
public function destroy(int $formulaId, int $itemId): JsonResponse
|
||||
{
|
||||
// 수식 소속 확인
|
||||
if (! $this->itemService->belongsToFormula($itemId, $formulaId)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '해당 수식에 속하지 않는 품목입니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
$this->itemService->deleteItem($itemId);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '품목이 삭제되었습니다.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 순서 변경
|
||||
*/
|
||||
public function reorder(Request $request, int $formulaId): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'item_ids' => 'required|array',
|
||||
'item_ids.*' => 'integer',
|
||||
]);
|
||||
|
||||
// 모든 품목이 해당 수식에 속하는지 확인
|
||||
foreach ($validated['item_ids'] as $itemId) {
|
||||
if (! $this->itemService->belongsToFormula($itemId, $formulaId)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '유효하지 않은 품목 ID가 포함되어 있습니다.',
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
|
||||
$this->itemService->reorder($validated['item_ids']);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '순서가 변경되었습니다.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin\Quote;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Quote\QuoteFormulaMappingService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class QuoteFormulaMappingController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly QuoteFormulaMappingService $mappingService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 매핑 목록 조회
|
||||
*/
|
||||
public function index(int $formulaId): JsonResponse
|
||||
{
|
||||
$mappings = $this->mappingService->getMappingsByFormula($formulaId);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $mappings,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 매핑 상세 조회
|
||||
*/
|
||||
public function show(int $formulaId, int $mappingId): JsonResponse
|
||||
{
|
||||
// 수식 소속 확인
|
||||
if (! $this->mappingService->belongsToFormula($mappingId, $formulaId)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '해당 수식에 속하지 않는 매핑입니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
$mapping = $this->mappingService->getMappingById($mappingId);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $mapping,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 매핑 생성
|
||||
*/
|
||||
public function store(Request $request, int $formulaId): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'source_variable' => 'required|string|max:50',
|
||||
'source_value' => 'required|string|max:100',
|
||||
'result_value' => 'required|string',
|
||||
'result_type' => 'nullable|in:fixed,formula',
|
||||
'sort_order' => 'nullable|integer|min:0',
|
||||
]);
|
||||
|
||||
$mapping = $this->mappingService->createMapping($formulaId, $validated);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '매핑이 추가되었습니다.',
|
||||
'data' => $mapping,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 매핑 수정
|
||||
*/
|
||||
public function update(Request $request, int $formulaId, int $mappingId): JsonResponse
|
||||
{
|
||||
// 수식 소속 확인
|
||||
if (! $this->mappingService->belongsToFormula($mappingId, $formulaId)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '해당 수식에 속하지 않는 매핑입니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'source_variable' => 'nullable|string|max:50',
|
||||
'source_value' => 'nullable|string|max:100',
|
||||
'result_value' => 'nullable|string',
|
||||
'result_type' => 'nullable|in:fixed,formula',
|
||||
]);
|
||||
|
||||
$mapping = $this->mappingService->updateMapping($mappingId, $validated);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '매핑이 수정되었습니다.',
|
||||
'data' => $mapping,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 매핑 삭제
|
||||
*/
|
||||
public function destroy(int $formulaId, int $mappingId): JsonResponse
|
||||
{
|
||||
// 수식 소속 확인
|
||||
if (! $this->mappingService->belongsToFormula($mappingId, $formulaId)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '해당 수식에 속하지 않는 매핑입니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
$this->mappingService->deleteMapping($mappingId);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '매핑이 삭제되었습니다.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 순서 변경
|
||||
*/
|
||||
public function reorder(Request $request, int $formulaId): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'mapping_ids' => 'required|array',
|
||||
'mapping_ids.*' => 'integer',
|
||||
]);
|
||||
|
||||
// 모든 매핑이 해당 수식에 속하는지 확인
|
||||
foreach ($validated['mapping_ids'] as $mappingId) {
|
||||
if (! $this->mappingService->belongsToFormula($mappingId, $formulaId)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '유효하지 않은 매핑 ID가 포함되어 있습니다.',
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
|
||||
$this->mappingService->reorder($validated['mapping_ids']);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '순서가 변경되었습니다.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
97
app/Services/Quote/QuoteFormulaItemService.php
Normal file
97
app/Services/Quote/QuoteFormulaItemService.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Quote;
|
||||
|
||||
use App\Models\Quote\QuoteFormulaItem;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class QuoteFormulaItemService
|
||||
{
|
||||
/**
|
||||
* 수식별 품목 목록 조회
|
||||
*/
|
||||
public function getItemsByFormula(int $formulaId): Collection
|
||||
{
|
||||
return QuoteFormulaItem::where('formula_id', $formulaId)
|
||||
->orderBy('sort_order')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* 품목 상세 조회
|
||||
*/
|
||||
public function getItemById(int $id): ?QuoteFormulaItem
|
||||
{
|
||||
return QuoteFormulaItem::find($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 품목 생성
|
||||
*/
|
||||
public function createItem(int $formulaId, array $data): QuoteFormulaItem
|
||||
{
|
||||
// 순서 자동 설정
|
||||
if (! isset($data['sort_order'])) {
|
||||
$maxOrder = QuoteFormulaItem::where('formula_id', $formulaId)->max('sort_order') ?? 0;
|
||||
$data['sort_order'] = $maxOrder + 1;
|
||||
}
|
||||
|
||||
return QuoteFormulaItem::create([
|
||||
'formula_id' => $formulaId,
|
||||
'item_code' => $data['item_code'],
|
||||
'item_name' => $data['item_name'],
|
||||
'specification' => $data['specification'] ?? null,
|
||||
'unit' => $data['unit'] ?? 'EA',
|
||||
'quantity_formula' => $data['quantity_formula'] ?? '1',
|
||||
'unit_price_formula' => $data['unit_price_formula'] ?? null,
|
||||
'sort_order' => $data['sort_order'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 품목 수정
|
||||
*/
|
||||
public function updateItem(int $itemId, array $data): QuoteFormulaItem
|
||||
{
|
||||
$item = QuoteFormulaItem::findOrFail($itemId);
|
||||
|
||||
$item->update([
|
||||
'item_code' => $data['item_code'] ?? $item->item_code,
|
||||
'item_name' => $data['item_name'] ?? $item->item_name,
|
||||
'specification' => $data['specification'] ?? $item->specification,
|
||||
'unit' => $data['unit'] ?? $item->unit,
|
||||
'quantity_formula' => $data['quantity_formula'] ?? $item->quantity_formula,
|
||||
'unit_price_formula' => $data['unit_price_formula'] ?? $item->unit_price_formula,
|
||||
]);
|
||||
|
||||
return $item->fresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* 품목 삭제
|
||||
*/
|
||||
public function deleteItem(int $itemId): void
|
||||
{
|
||||
QuoteFormulaItem::destroy($itemId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 순서 변경
|
||||
*/
|
||||
public function reorder(array $itemIds): void
|
||||
{
|
||||
foreach ($itemIds as $order => $id) {
|
||||
QuoteFormulaItem::where('id', $id)->update(['sort_order' => $order + 1]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 품목이 수식에 속하는지 확인
|
||||
*/
|
||||
public function belongsToFormula(int $itemId, int $formulaId): bool
|
||||
{
|
||||
return QuoteFormulaItem::where('id', $itemId)
|
||||
->where('formula_id', $formulaId)
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
93
app/Services/Quote/QuoteFormulaMappingService.php
Normal file
93
app/Services/Quote/QuoteFormulaMappingService.php
Normal file
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Quote;
|
||||
|
||||
use App\Models\Quote\QuoteFormulaMapping;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class QuoteFormulaMappingService
|
||||
{
|
||||
/**
|
||||
* 수식별 매핑 목록 조회
|
||||
*/
|
||||
public function getMappingsByFormula(int $formulaId): Collection
|
||||
{
|
||||
return QuoteFormulaMapping::where('formula_id', $formulaId)
|
||||
->orderBy('sort_order')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* 매핑 상세 조회
|
||||
*/
|
||||
public function getMappingById(int $id): ?QuoteFormulaMapping
|
||||
{
|
||||
return QuoteFormulaMapping::find($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 매핑 생성
|
||||
*/
|
||||
public function createMapping(int $formulaId, array $data): QuoteFormulaMapping
|
||||
{
|
||||
// 순서 자동 설정
|
||||
if (! isset($data['sort_order'])) {
|
||||
$maxOrder = QuoteFormulaMapping::where('formula_id', $formulaId)->max('sort_order') ?? 0;
|
||||
$data['sort_order'] = $maxOrder + 1;
|
||||
}
|
||||
|
||||
return QuoteFormulaMapping::create([
|
||||
'formula_id' => $formulaId,
|
||||
'source_variable' => $data['source_variable'],
|
||||
'source_value' => $data['source_value'],
|
||||
'result_value' => $data['result_value'],
|
||||
'result_type' => $data['result_type'] ?? 'fixed',
|
||||
'sort_order' => $data['sort_order'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 매핑 수정
|
||||
*/
|
||||
public function updateMapping(int $mappingId, array $data): QuoteFormulaMapping
|
||||
{
|
||||
$mapping = QuoteFormulaMapping::findOrFail($mappingId);
|
||||
|
||||
$mapping->update([
|
||||
'source_variable' => $data['source_variable'] ?? $mapping->source_variable,
|
||||
'source_value' => $data['source_value'] ?? $mapping->source_value,
|
||||
'result_value' => $data['result_value'] ?? $mapping->result_value,
|
||||
'result_type' => $data['result_type'] ?? $mapping->result_type,
|
||||
]);
|
||||
|
||||
return $mapping->fresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* 매핑 삭제
|
||||
*/
|
||||
public function deleteMapping(int $mappingId): void
|
||||
{
|
||||
QuoteFormulaMapping::destroy($mappingId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 순서 변경
|
||||
*/
|
||||
public function reorder(array $mappingIds): void
|
||||
{
|
||||
foreach ($mappingIds as $order => $id) {
|
||||
QuoteFormulaMapping::where('id', $id)->update(['sort_order' => $order + 1]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 매핑이 수식에 속하는지 확인
|
||||
*/
|
||||
public function belongsToFormula(int $mappingId, int $formulaId): bool
|
||||
{
|
||||
return QuoteFormulaMapping::where('id', $mappingId)
|
||||
->where('formula_id', $formulaId)
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
@@ -47,14 +47,14 @@ class="py-4 px-6 border-b-2 font-medium text-sm transition-colors flex items-cen
|
||||
class="py-4 px-6 border-b-2 font-medium text-sm transition-colors flex items-center gap-2"
|
||||
x-show="formula?.type === 'mapping'">
|
||||
매핑 설정
|
||||
<span class="bg-gray-100 text-gray-600 text-xs px-2 py-0.5 rounded-full" x-text="formula?.mappings_count || 0"></span>
|
||||
<span class="bg-gray-100 text-gray-600 text-xs px-2 py-0.5 rounded-full" x-text="mappings.length"></span>
|
||||
</button>
|
||||
<button @click="activeTab = 'items'"
|
||||
:class="activeTab === 'items' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
|
||||
class="py-4 px-6 border-b-2 font-medium text-sm transition-colors flex items-center gap-2"
|
||||
x-show="formula?.output_type === 'item'">
|
||||
품목 설정
|
||||
<span class="bg-gray-100 text-gray-600 text-xs px-2 py-0.5 rounded-full" x-text="formula?.items_count || 0"></span>
|
||||
<span class="bg-gray-100 text-gray-600 text-xs px-2 py-0.5 rounded-full" x-text="items.length"></span>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
@@ -71,26 +71,14 @@ class="py-4 px-6 border-b-2 font-medium text-sm transition-colors flex items-cen
|
||||
@include('quote-formulas.partials.ranges-tab')
|
||||
</div>
|
||||
|
||||
<!-- 매핑 설정 탭 (Phase 2에서 구현) -->
|
||||
<!-- 매핑 설정 탭 -->
|
||||
<div x-show="activeTab === 'mappings'" x-transition>
|
||||
<div class="text-center py-12 text-gray-500">
|
||||
<svg class="w-16 h-16 mx-auto text-gray-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path>
|
||||
</svg>
|
||||
<p>매핑 관리 UI는 준비 중입니다.</p>
|
||||
<p class="text-sm mt-1">Phase 2에서 구현 예정</p>
|
||||
</div>
|
||||
@include('quote-formulas.partials.mappings-tab')
|
||||
</div>
|
||||
|
||||
<!-- 품목 설정 탭 (Phase 3에서 구현) -->
|
||||
<!-- 품목 설정 탭 -->
|
||||
<div x-show="activeTab === 'items'" x-transition>
|
||||
<div class="text-center py-12 text-gray-500">
|
||||
<svg class="w-16 h-16 mx-auto text-gray-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"></path>
|
||||
</svg>
|
||||
<p>품목 관리 UI는 준비 중입니다.</p>
|
||||
<p class="text-sm mt-1">Phase 3에서 구현 예정</p>
|
||||
</div>
|
||||
@include('quote-formulas.partials.items-tab')
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -107,6 +95,8 @@ function formulaEditor() {
|
||||
categories: [],
|
||||
availableVariables: [],
|
||||
ranges: [],
|
||||
mappings: [],
|
||||
items: [],
|
||||
form: {
|
||||
category_id: '',
|
||||
type: '',
|
||||
@@ -180,6 +170,16 @@ function formulaEditor() {
|
||||
if (result.data.type === 'range') {
|
||||
await this.loadRanges();
|
||||
}
|
||||
|
||||
// 매핑 데이터 로드
|
||||
if (result.data.type === 'mapping') {
|
||||
await this.loadMappings();
|
||||
}
|
||||
|
||||
// 품목 데이터 로드
|
||||
if (result.data.output_type === 'item') {
|
||||
await this.loadItems();
|
||||
}
|
||||
} else {
|
||||
showToast(result.message || '데이터를 불러오는데 실패했습니다.', 'error');
|
||||
window.location.href = '{{ route("quote-formulas.index") }}';
|
||||
@@ -205,6 +205,36 @@ function formulaEditor() {
|
||||
}
|
||||
},
|
||||
|
||||
async loadMappings() {
|
||||
const formulaId = {{ $id }};
|
||||
try {
|
||||
const res = await fetch(`/api/admin/quote-formulas/formulas/${formulaId}/mappings`, {
|
||||
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' }
|
||||
});
|
||||
const result = await res.json();
|
||||
if (result.success) {
|
||||
this.mappings = result.data;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('매핑 로드 실패:', err);
|
||||
}
|
||||
},
|
||||
|
||||
async loadItems() {
|
||||
const formulaId = {{ $id }};
|
||||
try {
|
||||
const res = await fetch(`/api/admin/quote-formulas/formulas/${formulaId}/items`, {
|
||||
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' }
|
||||
});
|
||||
const result = await res.json();
|
||||
if (result.success) {
|
||||
this.items = result.data;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('품목 로드 실패:', err);
|
||||
}
|
||||
},
|
||||
|
||||
async saveFormula() {
|
||||
const formulaId = {{ $id }};
|
||||
const errorDiv = document.getElementById('errorMessage');
|
||||
|
||||
324
resources/views/quote-formulas/partials/items-tab.blade.php
Normal file
324
resources/views/quote-formulas/partials/items-tab.blade.php
Normal file
@@ -0,0 +1,324 @@
|
||||
{{-- 품목 설정 탭 --}}
|
||||
<div x-data="itemsManager()" x-init="init()">
|
||||
<!-- 헤더 -->
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-gray-800">품목 설정</h3>
|
||||
<p class="text-sm text-gray-500 mt-1">수식 결과로 생성되는 출력 품목을 정의합니다.</p>
|
||||
</div>
|
||||
<button @click="openAddModal()" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm transition flex items-center gap-2">
|
||||
<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="M12 4v16m8-8H4"></path>
|
||||
</svg>
|
||||
품목 추가
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 품목 목록 테이블 -->
|
||||
<div x-show="items.length > 0" class="overflow-x-auto">
|
||||
<table class="w-full border border-gray-200 rounded-lg overflow-hidden">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-12">#</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">품목코드</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">품목명</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">규격</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">단위</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">수량식</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">단가식</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider w-32">액션</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<template x-for="(item, index) in items" :key="item.id">
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-3 text-sm text-gray-500" x-text="index + 1"></td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="font-mono text-sm bg-blue-100 text-blue-700 px-2 py-1 rounded" x-text="item.item_code"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-800" x-text="item.item_name"></td>
|
||||
<td class="px-4 py-3 text-sm text-gray-600" x-text="item.specification || '-'"></td>
|
||||
<td class="px-4 py-3 text-sm text-gray-600" x-text="item.unit"></td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="font-mono text-xs bg-gray-100 px-2 py-1 rounded" x-text="item.quantity_formula"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="font-mono text-xs" :class="item.unit_price_formula ? 'bg-green-100 text-green-700 px-2 py-1 rounded' : 'text-gray-400'"
|
||||
x-text="item.unit_price_formula || '마스터 참조'"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<button @click="editItem(item)" class="text-blue-600 hover:text-blue-800 text-sm mr-2">수정</button>
|
||||
<button @click="deleteItem(item.id)" class="text-red-600 hover:text-red-800 text-sm">삭제</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 빈 상태 -->
|
||||
<div x-show="items.length === 0" class="text-center py-12 text-gray-500 border-2 border-dashed border-gray-200 rounded-lg">
|
||||
<svg class="w-12 h-12 mx-auto text-gray-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"></path>
|
||||
</svg>
|
||||
<p class="text-lg font-medium mb-1">설정된 품목이 없습니다</p>
|
||||
<p class="text-sm mb-4">위의 "품목 추가" 버튼을 클릭하여 첫 번째 품목을 추가하세요.</p>
|
||||
</div>
|
||||
|
||||
<!-- 도움말 -->
|
||||
<div class="mt-6 p-4 bg-gray-50 rounded-lg">
|
||||
<h4 class="text-sm font-medium text-gray-700 mb-2">품목 설정 가이드</h4>
|
||||
<ul class="text-sm text-gray-600 space-y-1">
|
||||
<li><span class="font-mono text-blue-600">item_code</span>: 품목 고유 코드 (예: PT-MOTOR-150)</li>
|
||||
<li><span class="font-mono text-blue-600">item_name</span>: 품목명 (예: 개폐전동기 150kg)</li>
|
||||
<li><span class="font-mono text-blue-600">specification</span>: 규격 (예: 150K(S))</li>
|
||||
<li><span class="font-mono text-blue-600">quantity_formula</span>: 수량 계산식 (예: 1, 2, CEIL(H1/3000))</li>
|
||||
<li><span class="font-mono text-blue-600">unit_price_formula</span>: 단가 계산식 (빈 값은 마스터 가격 참조)</li>
|
||||
</ul>
|
||||
<div class="mt-3 p-3 bg-white rounded border border-gray-200">
|
||||
<p class="text-xs font-medium text-gray-500 mb-2">예시: 모터 품목</p>
|
||||
<table class="text-xs w-full">
|
||||
<tr><td class="pr-4 font-mono">품목코드</td><td>PT-MOTOR-150</td></tr>
|
||||
<tr><td class="pr-4 font-mono">품목명</td><td>개폐전동기 150kg</td></tr>
|
||||
<tr><td class="pr-4 font-mono">규격</td><td>150K(S)</td></tr>
|
||||
<tr><td class="pr-4 font-mono">수량식</td><td>1</td></tr>
|
||||
<tr><td class="pr-4 font-mono">단가식</td><td>285000 (또는 마스터 참조)</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 품목 추가/수정 모달 -->
|
||||
<div x-show="showModal" x-cloak
|
||||
class="fixed inset-0 z-50 overflow-y-auto"
|
||||
@keydown.escape.window="closeModal()">
|
||||
<div class="flex items-center justify-center min-h-screen px-4">
|
||||
<!-- 오버레이 -->
|
||||
<div class="fixed inset-0 bg-black bg-opacity-50 transition-opacity" @click="closeModal()"></div>
|
||||
|
||||
<!-- 모달 콘텐츠 -->
|
||||
<div class="relative bg-white rounded-lg shadow-xl max-w-2xl w-full p-6 transform transition-all"
|
||||
@click.stop>
|
||||
<h3 class="text-lg font-bold text-gray-800 mb-4" x-text="editingItem ? '품목 수정' : '품목 추가'"></h3>
|
||||
|
||||
<form @submit.prevent="saveItem()">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<!-- 품목코드 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
품목코드 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="text" x-model="itemForm.item_code"
|
||||
@input="itemForm.item_code = $event.target.value.toUpperCase().replace(/[^A-Z0-9_-]/g, '')"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono"
|
||||
placeholder="예: PT-MOTOR-150"
|
||||
required>
|
||||
</div>
|
||||
|
||||
<!-- 품목명 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
품목명 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="text" x-model="itemForm.item_name"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="예: 개폐전동기 150kg"
|
||||
required>
|
||||
</div>
|
||||
|
||||
<!-- 규격 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
규격
|
||||
</label>
|
||||
<input type="text" x-model="itemForm.specification"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="예: 150K(S)">
|
||||
</div>
|
||||
|
||||
<!-- 단위 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
단위
|
||||
</label>
|
||||
<select x-model="itemForm.unit"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="EA">EA (개)</option>
|
||||
<option value="SET">SET (세트)</option>
|
||||
<option value="M">M (미터)</option>
|
||||
<option value="MM">MM (밀리미터)</option>
|
||||
<option value="KG">KG (킬로그램)</option>
|
||||
<option value="BOX">BOX (박스)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 수량 계산식 -->
|
||||
<div class="mt-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
수량 계산식 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="text" x-model="itemForm.quantity_formula"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono"
|
||||
placeholder="예: 1, 2, CEIL(H1/3000)"
|
||||
required>
|
||||
<p class="text-xs text-gray-500 mt-1">정수 또는 수식 (변수 사용 가능: W0, H0, S, H1, K 등)</p>
|
||||
</div>
|
||||
|
||||
<!-- 단가 계산식 -->
|
||||
<div class="mt-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
단가 계산식
|
||||
</label>
|
||||
<input type="text" x-model="itemForm.unit_price_formula"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono"
|
||||
placeholder="예: 285000, MOTOR_PRICE * 1.1">
|
||||
<p class="text-xs text-gray-500 mt-1">빈 값이면 5130 마스터 가격을 참조합니다.</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 mt-6">
|
||||
<button type="button" @click="closeModal()"
|
||||
class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition">
|
||||
취소
|
||||
</button>
|
||||
<button type="submit"
|
||||
class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition">
|
||||
저장
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function itemsManager() {
|
||||
return {
|
||||
showModal: false,
|
||||
editingItem: null,
|
||||
itemForm: {
|
||||
item_code: '',
|
||||
item_name: '',
|
||||
specification: '',
|
||||
unit: 'EA',
|
||||
quantity_formula: '1',
|
||||
unit_price_formula: '',
|
||||
},
|
||||
|
||||
init() {
|
||||
// 부모 컴포넌트의 items 데이터 사용
|
||||
this.$watch('$root.items', (items) => {
|
||||
// items 변경 시 처리 (필요시)
|
||||
});
|
||||
},
|
||||
|
||||
get items() {
|
||||
return this.$root.items || [];
|
||||
},
|
||||
|
||||
openAddModal() {
|
||||
this.editingItem = null;
|
||||
this.itemForm = {
|
||||
item_code: '',
|
||||
item_name: '',
|
||||
specification: '',
|
||||
unit: 'EA',
|
||||
quantity_formula: '1',
|
||||
unit_price_formula: '',
|
||||
};
|
||||
this.showModal = true;
|
||||
},
|
||||
|
||||
editItem(item) {
|
||||
this.editingItem = item;
|
||||
this.itemForm = {
|
||||
item_code: item.item_code || '',
|
||||
item_name: item.item_name || '',
|
||||
specification: item.specification || '',
|
||||
unit: item.unit || 'EA',
|
||||
quantity_formula: item.quantity_formula || '1',
|
||||
unit_price_formula: item.unit_price_formula || '',
|
||||
};
|
||||
this.showModal = true;
|
||||
},
|
||||
|
||||
closeModal() {
|
||||
this.showModal = false;
|
||||
this.editingItem = null;
|
||||
},
|
||||
|
||||
async saveItem() {
|
||||
const formulaId = {{ $id }};
|
||||
const isEdit = !!this.editingItem;
|
||||
|
||||
const data = {
|
||||
item_code: this.itemForm.item_code,
|
||||
item_name: this.itemForm.item_name,
|
||||
specification: this.itemForm.specification || null,
|
||||
unit: this.itemForm.unit,
|
||||
quantity_formula: this.itemForm.quantity_formula,
|
||||
unit_price_formula: this.itemForm.unit_price_formula || null,
|
||||
};
|
||||
|
||||
const url = isEdit
|
||||
? `/api/admin/quote-formulas/formulas/${formulaId}/items/${this.editingItem.id}`
|
||||
: `/api/admin/quote-formulas/formulas/${formulaId}/items`;
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: isEdit ? 'PUT' : 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
const result = await res.json();
|
||||
|
||||
if (res.ok && result.success) {
|
||||
showToast(result.message, 'success');
|
||||
this.closeModal();
|
||||
// 부모 컴포넌트의 loadItems 호출
|
||||
await this.$root.loadItems();
|
||||
} else {
|
||||
showToast(result.message || '저장에 실패했습니다.', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('품목 저장 실패:', err);
|
||||
showToast('서버 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async deleteItem(itemId) {
|
||||
if (!confirm('이 품목을 삭제하시겠습니까?')) return;
|
||||
|
||||
const formulaId = {{ $id }};
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/admin/quote-formulas/formulas/${formulaId}/items/${itemId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await res.json();
|
||||
|
||||
if (res.ok && result.success) {
|
||||
showToast(result.message, 'success');
|
||||
// 부모 컴포넌트의 loadItems 호출
|
||||
await this.$root.loadItems();
|
||||
} else {
|
||||
showToast(result.message || '삭제에 실패했습니다.', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('품목 삭제 실패:', err);
|
||||
showToast('서버 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
321
resources/views/quote-formulas/partials/mappings-tab.blade.php
Normal file
321
resources/views/quote-formulas/partials/mappings-tab.blade.php
Normal file
@@ -0,0 +1,321 @@
|
||||
{{-- 매핑 설정 탭 --}}
|
||||
<div x-data="mappingsManager()" x-init="init()">
|
||||
<!-- 헤더 -->
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-gray-800">매핑 설정</h3>
|
||||
<p class="text-sm text-gray-500 mt-1">소스 변수의 값에 따라 특정 결과를 반환합니다.</p>
|
||||
</div>
|
||||
<button @click="openAddModal()" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm transition flex items-center gap-2">
|
||||
<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="M12 4v16m8-8H4"></path>
|
||||
</svg>
|
||||
매핑 추가
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 소스 변수 입력 (첫 매핑 추가 시 사용) -->
|
||||
<div x-show="mappings.length === 0" class="mb-4 p-4 bg-gray-50 rounded-lg">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
소스 변수 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="text" x-model="sourceVariable"
|
||||
@input="sourceVariable = $event.target.value.toUpperCase().replace(/[^A-Z0-9_]/g, '')"
|
||||
class="w-full max-w-xs px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono"
|
||||
placeholder="예: CONTROL_TYPE, MOTOR_TYPE">
|
||||
<p class="text-xs text-gray-500 mt-1">매핑에 사용할 변수명을 입력하세요 (예: 제어기 유형 CONTROL_TYPE)</p>
|
||||
</div>
|
||||
|
||||
<!-- 소스 변수 표시 (매핑이 있을 때) -->
|
||||
<div x-show="mappings.length > 0" class="mb-4">
|
||||
<span class="text-sm text-gray-600">소스 변수:</span>
|
||||
<span class="ml-2 font-mono bg-purple-100 text-purple-700 px-3 py-1 rounded" x-text="sourceVariable || '-'"></span>
|
||||
</div>
|
||||
|
||||
<!-- 매핑 목록 테이블 -->
|
||||
<div x-show="mappings.length > 0" class="overflow-x-auto">
|
||||
<table class="w-full border border-gray-200 rounded-lg overflow-hidden">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-12">#</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">소스값</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">결과값</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">유형</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider w-32">액션</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<template x-for="(mapping, index) in mappings" :key="mapping.id">
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-3 text-sm text-gray-500" x-text="index + 1"></td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="font-mono text-sm bg-gray-100 px-2 py-1 rounded" x-text="mapping.source_value"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="font-mono text-sm" x-text="mapping.result_value"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="text-xs px-2 py-1 rounded-full"
|
||||
:class="mapping.result_type === 'fixed' ? 'bg-green-100 text-green-700' : 'bg-purple-100 text-purple-700'"
|
||||
x-text="mapping.result_type === 'fixed' ? '고정값' : '수식'"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<button @click="editMapping(mapping)" class="text-blue-600 hover:text-blue-800 text-sm mr-2">수정</button>
|
||||
<button @click="deleteMapping(mapping.id)" class="text-red-600 hover:text-red-800 text-sm">삭제</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 빈 상태 -->
|
||||
<div x-show="mappings.length === 0" class="text-center py-12 text-gray-500 border-2 border-dashed border-gray-200 rounded-lg">
|
||||
<svg class="w-12 h-12 mx-auto text-gray-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path>
|
||||
</svg>
|
||||
<p class="text-lg font-medium mb-1">설정된 매핑이 없습니다</p>
|
||||
<p class="text-sm mb-4">위의 "매핑 추가" 버튼을 클릭하여 첫 번째 매핑을 추가하세요.</p>
|
||||
</div>
|
||||
|
||||
<!-- 도움말 -->
|
||||
<div class="mt-6 p-4 bg-gray-50 rounded-lg">
|
||||
<h4 class="text-sm font-medium text-gray-700 mb-2">매핑 설정 가이드</h4>
|
||||
<ul class="text-sm text-gray-600 space-y-1">
|
||||
<li><span class="font-mono text-purple-600">source_variable</span>: 매핑 조건에 사용할 변수명</li>
|
||||
<li><span class="font-mono text-purple-600">source_value</span>: 변수의 특정 값 (예: EMB, EXP)</li>
|
||||
<li><span class="font-mono text-purple-600">result_value</span>: 해당 값일 때 반환할 결과</li>
|
||||
</ul>
|
||||
<div class="mt-3 p-3 bg-white rounded border border-gray-200">
|
||||
<p class="text-xs font-medium text-gray-500 mb-2">예시: 제어기 유형 매핑 (소스 변수: CONTROL_TYPE)</p>
|
||||
<table class="text-xs w-full">
|
||||
<tr><td class="pr-4 font-mono">EMB</td><td>매립형</td></tr>
|
||||
<tr><td class="pr-4 font-mono">EXP</td><td>노출형</td></tr>
|
||||
<tr><td class="pr-4 font-mono">BOX_1P</td><td>콘트롤박스</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 매핑 추가/수정 모달 -->
|
||||
<div x-show="showModal" x-cloak
|
||||
class="fixed inset-0 z-50 overflow-y-auto"
|
||||
@keydown.escape.window="closeModal()">
|
||||
<div class="flex items-center justify-center min-h-screen px-4">
|
||||
<!-- 오버레이 -->
|
||||
<div class="fixed inset-0 bg-black bg-opacity-50 transition-opacity" @click="closeModal()"></div>
|
||||
|
||||
<!-- 모달 콘텐츠 -->
|
||||
<div class="relative bg-white rounded-lg shadow-xl max-w-lg w-full p-6 transform transition-all"
|
||||
@click.stop>
|
||||
<h3 class="text-lg font-bold text-gray-800 mb-4" x-text="editingMapping ? '매핑 수정' : '매핑 추가'"></h3>
|
||||
|
||||
<form @submit.prevent="saveMapping()">
|
||||
<!-- 소스 변수 (추가 시에만, 첫 매핑일 때) -->
|
||||
<div x-show="!editingMapping && mappings.length === 0" class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
소스 변수 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="text" x-model="mappingForm.source_variable"
|
||||
@input="mappingForm.source_variable = $event.target.value.toUpperCase().replace(/[^A-Z0-9_]/g, '')"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono"
|
||||
placeholder="예: CONTROL_TYPE">
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
소스값 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="text" x-model="mappingForm.source_value"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono"
|
||||
placeholder="예: EMB, EXP"
|
||||
required>
|
||||
<p class="text-xs text-gray-500 mt-1">변수(<span class="font-mono" x-text="sourceVariable || mappingForm.source_variable || 'CONTROL_TYPE'"></span>)가 이 값일 때 매핑이 적용됩니다.</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
결과 유형
|
||||
</label>
|
||||
<div class="flex gap-4">
|
||||
<label class="flex items-center">
|
||||
<input type="radio" x-model="mappingForm.result_type" value="fixed"
|
||||
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300">
|
||||
<span class="ml-2 text-sm text-gray-700">고정값</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input type="radio" x-model="mappingForm.result_type" value="formula"
|
||||
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300">
|
||||
<span class="ml-2 text-sm text-gray-700">수식</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
결과값 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea x-model="mappingForm.result_value" rows="2"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono"
|
||||
:placeholder="mappingForm.result_type === 'fixed' ? '예: 매립형, PT-CTRL-EMB' : '예: CTRL_BASE_PRICE * 1.2'"
|
||||
required></textarea>
|
||||
<p class="text-xs text-gray-500 mt-1" x-show="mappingForm.result_type === 'formula'">
|
||||
수식에서 다른 변수를 참조할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button" @click="closeModal()"
|
||||
class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition">
|
||||
취소
|
||||
</button>
|
||||
<button type="submit"
|
||||
class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition">
|
||||
저장
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function mappingsManager() {
|
||||
return {
|
||||
showModal: false,
|
||||
editingMapping: null,
|
||||
sourceVariable: '',
|
||||
mappingForm: {
|
||||
source_variable: '',
|
||||
source_value: '',
|
||||
result_value: '',
|
||||
result_type: 'fixed',
|
||||
},
|
||||
|
||||
init() {
|
||||
// 부모 컴포넌트의 mappings 데이터 사용
|
||||
this.$watch('$root.mappings', (mappings) => {
|
||||
if (mappings.length > 0 && mappings[0].source_variable) {
|
||||
this.sourceVariable = mappings[0].source_variable;
|
||||
}
|
||||
});
|
||||
|
||||
// 초기 소스 변수 설정
|
||||
if (this.$root.mappings && this.$root.mappings.length > 0) {
|
||||
this.sourceVariable = this.$root.mappings[0].source_variable;
|
||||
}
|
||||
},
|
||||
|
||||
get mappings() {
|
||||
return this.$root.mappings || [];
|
||||
},
|
||||
|
||||
openAddModal() {
|
||||
this.editingMapping = null;
|
||||
this.mappingForm = {
|
||||
source_variable: this.sourceVariable,
|
||||
source_value: '',
|
||||
result_value: '',
|
||||
result_type: 'fixed',
|
||||
};
|
||||
this.showModal = true;
|
||||
},
|
||||
|
||||
editMapping(mapping) {
|
||||
this.editingMapping = mapping;
|
||||
this.mappingForm = {
|
||||
source_variable: mapping.source_variable,
|
||||
source_value: mapping.source_value || '',
|
||||
result_value: mapping.result_value || '',
|
||||
result_type: mapping.result_type || 'fixed',
|
||||
};
|
||||
this.showModal = true;
|
||||
},
|
||||
|
||||
closeModal() {
|
||||
this.showModal = false;
|
||||
this.editingMapping = null;
|
||||
},
|
||||
|
||||
async saveMapping() {
|
||||
const formulaId = {{ $id }};
|
||||
const isEdit = !!this.editingMapping;
|
||||
|
||||
// 소스 변수 설정 (첫 매핑 추가 시)
|
||||
if (!isEdit && this.mappings.length === 0) {
|
||||
if (!this.mappingForm.source_variable) {
|
||||
showToast('소스 변수를 입력해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
this.sourceVariable = this.mappingForm.source_variable;
|
||||
}
|
||||
|
||||
const data = {
|
||||
source_variable: this.sourceVariable || this.mappingForm.source_variable,
|
||||
source_value: this.mappingForm.source_value,
|
||||
result_value: this.mappingForm.result_value,
|
||||
result_type: this.mappingForm.result_type,
|
||||
};
|
||||
|
||||
const url = isEdit
|
||||
? `/api/admin/quote-formulas/formulas/${formulaId}/mappings/${this.editingMapping.id}`
|
||||
: `/api/admin/quote-formulas/formulas/${formulaId}/mappings`;
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: isEdit ? 'PUT' : 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
const result = await res.json();
|
||||
|
||||
if (res.ok && result.success) {
|
||||
showToast(result.message, 'success');
|
||||
this.closeModal();
|
||||
// 부모 컴포넌트의 loadMappings 호출
|
||||
await this.$root.loadMappings();
|
||||
} else {
|
||||
showToast(result.message || '저장에 실패했습니다.', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('매핑 저장 실패:', err);
|
||||
showToast('서버 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async deleteMapping(mappingId) {
|
||||
if (!confirm('이 매핑을 삭제하시겠습니까?')) return;
|
||||
|
||||
const formulaId = {{ $id }};
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/admin/quote-formulas/formulas/${formulaId}/mappings/${mappingId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await res.json();
|
||||
|
||||
if (res.ok && result.success) {
|
||||
showToast(result.message, 'success');
|
||||
// 부모 컴포넌트의 loadMappings 호출
|
||||
await this.$root.loadMappings();
|
||||
} else {
|
||||
showToast(result.message || '삭제에 실패했습니다.', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('매핑 삭제 실패:', err);
|
||||
showToast('서버 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
@@ -557,6 +557,26 @@
|
||||
Route::delete('/{rangeId}', [\App\Http\Controllers\Api\Admin\Quote\QuoteFormulaRangeController::class, 'destroy'])->name('destroy');
|
||||
Route::post('/reorder', [\App\Http\Controllers\Api\Admin\Quote\QuoteFormulaRangeController::class, 'reorder'])->name('reorder');
|
||||
});
|
||||
|
||||
// 수식별 매핑 관리 API
|
||||
Route::prefix('{formulaId}/mappings')->name('mappings.')->group(function () {
|
||||
Route::get('/', [\App\Http\Controllers\Api\Admin\Quote\QuoteFormulaMappingController::class, 'index'])->name('index');
|
||||
Route::post('/', [\App\Http\Controllers\Api\Admin\Quote\QuoteFormulaMappingController::class, 'store'])->name('store');
|
||||
Route::get('/{mappingId}', [\App\Http\Controllers\Api\Admin\Quote\QuoteFormulaMappingController::class, 'show'])->name('show');
|
||||
Route::put('/{mappingId}', [\App\Http\Controllers\Api\Admin\Quote\QuoteFormulaMappingController::class, 'update'])->name('update');
|
||||
Route::delete('/{mappingId}', [\App\Http\Controllers\Api\Admin\Quote\QuoteFormulaMappingController::class, 'destroy'])->name('destroy');
|
||||
Route::post('/reorder', [\App\Http\Controllers\Api\Admin\Quote\QuoteFormulaMappingController::class, 'reorder'])->name('reorder');
|
||||
});
|
||||
|
||||
// 수식별 품목 관리 API
|
||||
Route::prefix('{formulaId}/items')->name('items.')->group(function () {
|
||||
Route::get('/', [\App\Http\Controllers\Api\Admin\Quote\QuoteFormulaItemController::class, 'index'])->name('index');
|
||||
Route::post('/', [\App\Http\Controllers\Api\Admin\Quote\QuoteFormulaItemController::class, 'store'])->name('store');
|
||||
Route::get('/{itemId}', [\App\Http\Controllers\Api\Admin\Quote\QuoteFormulaItemController::class, 'show'])->name('show');
|
||||
Route::put('/{itemId}', [\App\Http\Controllers\Api\Admin\Quote\QuoteFormulaItemController::class, 'update'])->name('update');
|
||||
Route::delete('/{itemId}', [\App\Http\Controllers\Api\Admin\Quote\QuoteFormulaItemController::class, 'destroy'])->name('destroy');
|
||||
Route::post('/reorder', [\App\Http\Controllers\Api\Admin\Quote\QuoteFormulaItemController::class, 'reorder'])->name('reorder');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user