feat(quote-formulas): 견적수식 관리 기능 구현
## 구현 내용 ### 모델 (5개) - QuoteFormulaCategory: 수식 카테고리 - QuoteFormula: 수식 정의 (input/calculation/range/mapping) - QuoteFormulaRange: 범위별 값 정의 - QuoteFormulaMapping: 매핑 테이블 - QuoteFormulaItem: 수식-품목 연결 ### 서비스 (3개) - QuoteFormulaCategoryService: 카테고리 CRUD - QuoteFormulaService: 수식 CRUD, 복제, 재정렬 - FormulaEvaluatorService: 수식 계산 엔진 - 지원 함수: SUM, ROUND, CEIL, FLOOR, ABS, MIN, MAX, IF, AND, OR, NOT ### API Controller (2개) - QuoteFormulaCategoryController: 카테고리 API (11개 엔드포인트) - QuoteFormulaController: 수식 API (16개 엔드포인트) ### FormRequest (4개) - Store/Update QuoteFormulaCategoryRequest - Store/Update QuoteFormulaRequest ### Blade Views (8개) - 수식 목록/추가/수정/시뮬레이터 - 카테고리 목록/추가/수정 - HTMX 테이블 partial ### 라우트 - API: 27개 엔드포인트 - Web: 7개 라우트
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin\Quote;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Quote\StoreQuoteFormulaCategoryRequest;
|
||||
use App\Http\Requests\Quote\UpdateQuoteFormulaCategoryRequest;
|
||||
use App\Services\Quote\QuoteFormulaCategoryService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class QuoteFormulaCategoryController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly QuoteFormulaCategoryService $categoryService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 카테고리 목록 (HTMX용)
|
||||
*/
|
||||
public function index(Request $request): View|JsonResponse
|
||||
{
|
||||
$filters = $request->only(['search', 'is_active', 'trashed', 'sort_by', 'sort_direction']);
|
||||
$categories = $this->categoryService->getCategories($filters, 15);
|
||||
|
||||
// HTMX 요청이면 HTML 파셜 반환
|
||||
if ($request->header('HX-Request')) {
|
||||
return view('quote-formulas.categories.partials.table', compact('categories'));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $categories,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 통계
|
||||
*/
|
||||
public function stats(): JsonResponse
|
||||
{
|
||||
$stats = $this->categoryService->getCategoryStats();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $stats,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 상세 조회
|
||||
*/
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
$category = $this->categoryService->getCategoryById($id, true);
|
||||
|
||||
if (! $category) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '카테고리를 찾을 수 없습니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $category,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 생성
|
||||
*/
|
||||
public function store(StoreQuoteFormulaCategoryRequest $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
|
||||
// 코드 중복 체크
|
||||
if ($this->categoryService->isCodeExists($validated['code'])) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '이미 사용 중인 카테고리 코드입니다.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$category = $this->categoryService->createCategory($validated);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '카테고리가 생성되었습니다.',
|
||||
'data' => $category,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 수정
|
||||
*/
|
||||
public function update(UpdateQuoteFormulaCategoryRequest $request, int $id): JsonResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
|
||||
// 코드 중복 체크 (자신 제외)
|
||||
if (isset($validated['code']) && $this->categoryService->isCodeExists($validated['code'], $id)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '이미 사용 중인 카테고리 코드입니다.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$this->categoryService->updateCategory($id, $validated);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '카테고리가 수정되었습니다.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 삭제 (Soft Delete)
|
||||
*/
|
||||
public function destroy(int $id): JsonResponse
|
||||
{
|
||||
$this->categoryService->deleteCategory($id);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '카테고리가 삭제되었습니다.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 복원
|
||||
*/
|
||||
public function restore(int $id): JsonResponse
|
||||
{
|
||||
$this->categoryService->restoreCategory($id);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '카테고리가 복원되었습니다.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 영구 삭제
|
||||
*/
|
||||
public function forceDestroy(int $id): JsonResponse
|
||||
{
|
||||
$this->categoryService->forceDeleteCategory($id);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '카테고리가 영구 삭제되었습니다.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 활성/비활성 토글
|
||||
*/
|
||||
public function toggleActive(int $id): JsonResponse
|
||||
{
|
||||
$category = $this->categoryService->toggleActive($id);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => $category->is_active ? '카테고리가 활성화되었습니다.' : '카테고리가 비활성화되었습니다.',
|
||||
'data' => ['is_active' => $category->is_active],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 순서 변경
|
||||
*/
|
||||
public function reorder(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'category_ids' => 'required|array',
|
||||
'category_ids.*' => 'integer|exists:quote_formula_categories,id',
|
||||
]);
|
||||
|
||||
$this->categoryService->reorder($validated['category_ids']);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '순서가 변경되었습니다.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 드롭다운용 카테고리 목록
|
||||
*/
|
||||
public function dropdown(): JsonResponse
|
||||
{
|
||||
$categories = $this->categoryService->getActiveCategories();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $categories,
|
||||
]);
|
||||
}
|
||||
}
|
||||
311
app/Http/Controllers/Api/Admin/Quote/QuoteFormulaController.php
Normal file
311
app/Http/Controllers/Api/Admin/Quote/QuoteFormulaController.php
Normal file
@@ -0,0 +1,311 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin\Quote;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Quote\StoreQuoteFormulaRequest;
|
||||
use App\Http\Requests\Quote\UpdateQuoteFormulaRequest;
|
||||
use App\Services\Quote\FormulaEvaluatorService;
|
||||
use App\Services\Quote\QuoteFormulaService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class QuoteFormulaController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly QuoteFormulaService $formulaService,
|
||||
private readonly FormulaEvaluatorService $evaluatorService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 수식 목록 (HTMX용)
|
||||
*/
|
||||
public function index(Request $request): View|JsonResponse
|
||||
{
|
||||
$filters = $request->only(['search', 'category_id', 'type', 'is_active', 'trashed', 'sort_by', 'sort_direction']);
|
||||
$formulas = $this->formulaService->getFormulas($filters, 15);
|
||||
|
||||
// HTMX 요청이면 HTML 파셜 반환
|
||||
if ($request->header('HX-Request')) {
|
||||
return view('quote-formulas.partials.table', compact('formulas'));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $formulas,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 수식 통계
|
||||
*/
|
||||
public function stats(): JsonResponse
|
||||
{
|
||||
$stats = $this->formulaService->getFormulaStats();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $stats,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 수식 상세 조회
|
||||
*/
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
$formula = $this->formulaService->getFormulaById($id, true);
|
||||
|
||||
if (! $formula) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '수식을 찾을 수 없습니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $formula,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 수식 생성
|
||||
*/
|
||||
public function store(StoreQuoteFormulaRequest $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
|
||||
// 변수명 중복 체크
|
||||
if ($this->formulaService->isVariableExists($validated['variable'])) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '이미 사용 중인 변수명입니다.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$formula = $this->formulaService->createFormula($validated);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '수식이 생성되었습니다.',
|
||||
'data' => $formula,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 수식 수정
|
||||
*/
|
||||
public function update(UpdateQuoteFormulaRequest $request, int $id): JsonResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
|
||||
// 변수명 중복 체크 (자신 제외)
|
||||
if (isset($validated['variable']) && $this->formulaService->isVariableExists($validated['variable'], $id)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '이미 사용 중인 변수명입니다.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$this->formulaService->updateFormula($id, $validated);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '수식이 수정되었습니다.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 수식 삭제 (Soft Delete)
|
||||
*/
|
||||
public function destroy(int $id): JsonResponse
|
||||
{
|
||||
$this->formulaService->deleteFormula($id);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '수식이 삭제되었습니다.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 수식 복원
|
||||
*/
|
||||
public function restore(int $id): JsonResponse
|
||||
{
|
||||
$this->formulaService->restoreFormula($id);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '수식이 복원되었습니다.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 수식 영구 삭제
|
||||
*/
|
||||
public function forceDestroy(int $id): JsonResponse
|
||||
{
|
||||
$this->formulaService->forceDeleteFormula($id);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '수식이 영구 삭제되었습니다.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 수식 활성/비활성 토글
|
||||
*/
|
||||
public function toggleActive(int $id): JsonResponse
|
||||
{
|
||||
$formula = $this->formulaService->toggleActive($id);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => $formula->is_active ? '수식이 활성화되었습니다.' : '수식이 비활성화되었습니다.',
|
||||
'data' => ['is_active' => $formula->is_active],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리별 수식 목록 (실행 순서)
|
||||
*/
|
||||
public function byCategory(int $categoryId): JsonResponse
|
||||
{
|
||||
$formulas = $this->formulaService->getFormulasByCategory($categoryId);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $formulas,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 수식 순서 변경
|
||||
*/
|
||||
public function reorder(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'formula_ids' => 'required|array',
|
||||
'formula_ids.*' => 'integer|exists:quote_formulas,id',
|
||||
]);
|
||||
|
||||
$this->formulaService->reorder($validated['formula_ids']);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '순서가 변경되었습니다.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용 가능한 변수 목록
|
||||
*/
|
||||
public function variables(): JsonResponse
|
||||
{
|
||||
$variables = $this->formulaService->getAvailableVariables();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $variables,
|
||||
]);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 수식 검증 및 테스트 API
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 수식 문법 검증
|
||||
*/
|
||||
public function validate(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'formula' => 'required|string|max:2000',
|
||||
]);
|
||||
|
||||
$result = $this->evaluatorService->validateFormula($validated['formula']);
|
||||
|
||||
return response()->json([
|
||||
'success' => $result['success'],
|
||||
'data' => $result,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 수식 테스트 실행
|
||||
*/
|
||||
public function test(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'formula' => 'required|string|max:2000',
|
||||
'variables' => 'nullable|array',
|
||||
]);
|
||||
|
||||
$this->evaluatorService->resetVariables();
|
||||
|
||||
$result = $this->evaluatorService->evaluate(
|
||||
$validated['formula'],
|
||||
$validated['variables'] ?? []
|
||||
);
|
||||
|
||||
$errors = $this->evaluatorService->getErrors();
|
||||
|
||||
return response()->json([
|
||||
'success' => empty($errors),
|
||||
'data' => [
|
||||
'result' => $result,
|
||||
'errors' => $errors,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 수식 시뮬레이션
|
||||
*/
|
||||
public function simulate(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'category_id' => 'nullable|integer|exists:quote_formula_categories,id',
|
||||
'input_variables' => 'required|array',
|
||||
]);
|
||||
|
||||
// 카테고리별 수식 조회
|
||||
$categoryId = $validated['category_id'] ?? null;
|
||||
$formulas = $categoryId
|
||||
? $this->formulaService->getFormulasByCategory($categoryId)
|
||||
: $this->formulaService->getAllActiveFormulas();
|
||||
|
||||
// 카테고리별로 그룹핑
|
||||
$formulasByCategory = $formulas->groupBy(fn ($f) => $f->category->code);
|
||||
|
||||
// 전체 실행
|
||||
$this->evaluatorService->resetVariables();
|
||||
$result = $this->evaluatorService->executeAll(
|
||||
$formulasByCategory,
|
||||
$validated['input_variables']
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => empty($result['errors']),
|
||||
'data' => $result,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 수식 복제
|
||||
*/
|
||||
public function duplicate(int $id): JsonResponse
|
||||
{
|
||||
$formula = $this->formulaService->duplicateFormula($id);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '수식이 복제되었습니다.',
|
||||
'data' => $formula,
|
||||
]);
|
||||
}
|
||||
}
|
||||
69
app/Http/Controllers/QuoteFormulaController.php
Normal file
69
app/Http/Controllers/QuoteFormulaController.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* 견적수식 관리 컨트롤러 (Blade 화면용)
|
||||
*
|
||||
* 실제 CRUD 작업은 Api\Admin\QuoteFormulaController에서 처리
|
||||
*/
|
||||
class QuoteFormulaController extends Controller
|
||||
{
|
||||
/**
|
||||
* 수식 목록 화면
|
||||
*/
|
||||
public function index(): View
|
||||
{
|
||||
return view('quote-formulas.index');
|
||||
}
|
||||
|
||||
/**
|
||||
* 수식 생성 화면
|
||||
*/
|
||||
public function create(): View
|
||||
{
|
||||
return view('quote-formulas.create');
|
||||
}
|
||||
|
||||
/**
|
||||
* 수식 수정 화면
|
||||
*/
|
||||
public function edit(int $id): View
|
||||
{
|
||||
return view('quote-formulas.edit', compact('id'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 목록 화면
|
||||
*/
|
||||
public function categories(): View
|
||||
{
|
||||
return view('quote-formulas.categories.index');
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 생성 화면
|
||||
*/
|
||||
public function createCategory(): View
|
||||
{
|
||||
return view('quote-formulas.categories.create');
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 수정 화면
|
||||
*/
|
||||
public function editCategory(int $id): View
|
||||
{
|
||||
return view('quote-formulas.categories.edit', compact('id'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 수식 시뮬레이터 화면
|
||||
*/
|
||||
public function simulator(): View
|
||||
{
|
||||
return view('quote-formulas.simulator');
|
||||
}
|
||||
}
|
||||
38
app/Http/Requests/Quote/StoreQuoteFormulaCategoryRequest.php
Normal file
38
app/Http/Requests/Quote/StoreQuoteFormulaCategoryRequest.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Quote;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreQuoteFormulaCategoryRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return auth()->check();
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'code' => 'required|string|max:50|regex:/^[A-Z][A-Z0-9_]*$/',
|
||||
'name' => 'required|string|max:100',
|
||||
'description' => 'nullable|string|max:500',
|
||||
'sort_order' => 'nullable|integer|min:1',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'code.required' => '카테고리 코드는 필수입니다.',
|
||||
'code.regex' => '카테고리 코드는 대문자로 시작하고 대문자, 숫자, 언더스코어만 사용할 수 있습니다.',
|
||||
'code.max' => '카테고리 코드는 50자 이하로 입력해주세요.',
|
||||
'name.required' => '카테고리 이름은 필수입니다.',
|
||||
'name.max' => '카테고리 이름은 100자 이하로 입력해주세요.',
|
||||
'description.max' => '설명은 500자 이하로 입력해주세요.',
|
||||
'sort_order.integer' => '정렬 순서는 숫자로 입력해주세요.',
|
||||
'sort_order.min' => '정렬 순서는 1 이상이어야 합니다.',
|
||||
];
|
||||
}
|
||||
}
|
||||
70
app/Http/Requests/Quote/StoreQuoteFormulaRequest.php
Normal file
70
app/Http/Requests/Quote/StoreQuoteFormulaRequest.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Quote;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreQuoteFormulaRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return auth()->check();
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'category_id' => 'required|integer|exists:quote_formula_categories,id',
|
||||
'product_id' => 'nullable|integer',
|
||||
'name' => 'required|string|max:200',
|
||||
'variable' => 'required|string|max:50|regex:/^[A-Z][A-Z0-9_]*$/',
|
||||
'type' => 'required|in:input,calculation,range,mapping',
|
||||
'formula' => 'nullable|string|max:2000',
|
||||
'output_type' => 'in:variable,item',
|
||||
'description' => 'nullable|string|max:500',
|
||||
'sort_order' => 'nullable|integer|min:1',
|
||||
'is_active' => 'boolean',
|
||||
|
||||
// 범위 규칙
|
||||
'ranges' => 'array',
|
||||
'ranges.*.min_value' => 'nullable|numeric',
|
||||
'ranges.*.max_value' => 'nullable|numeric',
|
||||
'ranges.*.condition_variable' => 'required_with:ranges|string|max:50',
|
||||
'ranges.*.result_value' => 'required_with:ranges|string|max:500',
|
||||
'ranges.*.result_type' => 'in:fixed,formula',
|
||||
|
||||
// 매핑 규칙
|
||||
'mappings' => 'array',
|
||||
'mappings.*.source_variable' => 'required_with:mappings|string|max:50',
|
||||
'mappings.*.source_value' => 'required_with:mappings|string|max:200',
|
||||
'mappings.*.result_value' => 'required_with:mappings|string|max:500',
|
||||
'mappings.*.result_type' => 'in:fixed,formula',
|
||||
|
||||
// 품목 출력
|
||||
'items' => 'array',
|
||||
'items.*.item_code' => 'required_with:items|string|max:50',
|
||||
'items.*.item_name' => 'required_with:items|string|max:200',
|
||||
'items.*.specification' => 'nullable|string|max:100',
|
||||
'items.*.unit' => 'required_with:items|string|max:20',
|
||||
'items.*.quantity_formula' => 'required_with:items|string|max:500',
|
||||
'items.*.unit_price_formula' => 'nullable|string|max:500',
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'category_id.required' => '카테고리를 선택해주세요.',
|
||||
'category_id.exists' => '유효하지 않은 카테고리입니다.',
|
||||
'name.required' => '수식 이름은 필수입니다.',
|
||||
'name.max' => '수식 이름은 200자 이하로 입력해주세요.',
|
||||
'variable.required' => '변수명은 필수입니다.',
|
||||
'variable.regex' => '변수명은 대문자로 시작하고 대문자, 숫자, 언더스코어만 사용할 수 있습니다.',
|
||||
'variable.max' => '변수명은 50자 이하로 입력해주세요.',
|
||||
'type.required' => '수식 유형을 선택해주세요.',
|
||||
'type.in' => '유효하지 않은 수식 유형입니다.',
|
||||
'formula.max' => '수식은 2000자 이하로 입력해주세요.',
|
||||
'output_type.in' => '유효하지 않은 출력 유형입니다.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Quote;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateQuoteFormulaCategoryRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return auth()->check();
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'code' => 'sometimes|required|string|max:50|regex:/^[A-Z][A-Z0-9_]*$/',
|
||||
'name' => 'sometimes|required|string|max:100',
|
||||
'description' => 'nullable|string|max:500',
|
||||
'sort_order' => 'nullable|integer|min:1',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'code.required' => '카테고리 코드는 필수입니다.',
|
||||
'code.regex' => '카테고리 코드는 대문자로 시작하고 대문자, 숫자, 언더스코어만 사용할 수 있습니다.',
|
||||
'code.max' => '카테고리 코드는 50자 이하로 입력해주세요.',
|
||||
'name.required' => '카테고리 이름은 필수입니다.',
|
||||
'name.max' => '카테고리 이름은 100자 이하로 입력해주세요.',
|
||||
'description.max' => '설명은 500자 이하로 입력해주세요.',
|
||||
'sort_order.integer' => '정렬 순서는 숫자로 입력해주세요.',
|
||||
'sort_order.min' => '정렬 순서는 1 이상이어야 합니다.',
|
||||
];
|
||||
}
|
||||
}
|
||||
70
app/Http/Requests/Quote/UpdateQuoteFormulaRequest.php
Normal file
70
app/Http/Requests/Quote/UpdateQuoteFormulaRequest.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Quote;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateQuoteFormulaRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return auth()->check();
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'category_id' => 'sometimes|required|integer|exists:quote_formula_categories,id',
|
||||
'product_id' => 'nullable|integer',
|
||||
'name' => 'sometimes|required|string|max:200',
|
||||
'variable' => 'sometimes|required|string|max:50|regex:/^[A-Z][A-Z0-9_]*$/',
|
||||
'type' => 'sometimes|required|in:input,calculation,range,mapping',
|
||||
'formula' => 'nullable|string|max:2000',
|
||||
'output_type' => 'in:variable,item',
|
||||
'description' => 'nullable|string|max:500',
|
||||
'sort_order' => 'nullable|integer|min:1',
|
||||
'is_active' => 'boolean',
|
||||
|
||||
// 범위 규칙
|
||||
'ranges' => 'array',
|
||||
'ranges.*.min_value' => 'nullable|numeric',
|
||||
'ranges.*.max_value' => 'nullable|numeric',
|
||||
'ranges.*.condition_variable' => 'required_with:ranges|string|max:50',
|
||||
'ranges.*.result_value' => 'required_with:ranges|string|max:500',
|
||||
'ranges.*.result_type' => 'in:fixed,formula',
|
||||
|
||||
// 매핑 규칙
|
||||
'mappings' => 'array',
|
||||
'mappings.*.source_variable' => 'required_with:mappings|string|max:50',
|
||||
'mappings.*.source_value' => 'required_with:mappings|string|max:200',
|
||||
'mappings.*.result_value' => 'required_with:mappings|string|max:500',
|
||||
'mappings.*.result_type' => 'in:fixed,formula',
|
||||
|
||||
// 품목 출력
|
||||
'items' => 'array',
|
||||
'items.*.item_code' => 'required_with:items|string|max:50',
|
||||
'items.*.item_name' => 'required_with:items|string|max:200',
|
||||
'items.*.specification' => 'nullable|string|max:100',
|
||||
'items.*.unit' => 'required_with:items|string|max:20',
|
||||
'items.*.quantity_formula' => 'required_with:items|string|max:500',
|
||||
'items.*.unit_price_formula' => 'nullable|string|max:500',
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'category_id.required' => '카테고리를 선택해주세요.',
|
||||
'category_id.exists' => '유효하지 않은 카테고리입니다.',
|
||||
'name.required' => '수식 이름은 필수입니다.',
|
||||
'name.max' => '수식 이름은 200자 이하로 입력해주세요.',
|
||||
'variable.required' => '변수명은 필수입니다.',
|
||||
'variable.regex' => '변수명은 대문자로 시작하고 대문자, 숫자, 언더스코어만 사용할 수 있습니다.',
|
||||
'variable.max' => '변수명은 50자 이하로 입력해주세요.',
|
||||
'type.required' => '수식 유형을 선택해주세요.',
|
||||
'type.in' => '유효하지 않은 수식 유형입니다.',
|
||||
'formula.max' => '수식은 2000자 이하로 입력해주세요.',
|
||||
'output_type.in' => '유효하지 않은 출력 유형입니다.',
|
||||
];
|
||||
}
|
||||
}
|
||||
218
app/Models/Quote/QuoteFormula.php
Normal file
218
app/Models/Quote/QuoteFormula.php
Normal file
@@ -0,0 +1,218 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Quote;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
/**
|
||||
* 견적 수식 모델
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $tenant_id
|
||||
* @property int $category_id
|
||||
* @property int|null $product_id
|
||||
* @property string $name
|
||||
* @property string $variable
|
||||
* @property string $type
|
||||
* @property string|null $formula
|
||||
* @property string $output_type
|
||||
* @property string|null $description
|
||||
* @property int $sort_order
|
||||
* @property bool $is_active
|
||||
*/
|
||||
class QuoteFormula extends Model
|
||||
{
|
||||
use BelongsToTenant, SoftDeletes;
|
||||
|
||||
protected $table = 'quote_formulas';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'category_id',
|
||||
'product_id',
|
||||
'name',
|
||||
'variable',
|
||||
'type',
|
||||
'formula',
|
||||
'output_type',
|
||||
'description',
|
||||
'sort_order',
|
||||
'is_active',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean',
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
|
||||
protected $attributes = [
|
||||
'type' => 'calculation',
|
||||
'output_type' => 'variable',
|
||||
'sort_order' => 0,
|
||||
'is_active' => true,
|
||||
];
|
||||
|
||||
// 수식 유형 상수
|
||||
public const TYPE_INPUT = 'input';
|
||||
|
||||
public const TYPE_CALCULATION = 'calculation';
|
||||
|
||||
public const TYPE_RANGE = 'range';
|
||||
|
||||
public const TYPE_MAPPING = 'mapping';
|
||||
|
||||
// 출력 유형 상수
|
||||
public const OUTPUT_VARIABLE = 'variable';
|
||||
|
||||
public const OUTPUT_ITEM = 'item';
|
||||
|
||||
// =========================================================================
|
||||
// Relationships
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 카테고리 관계
|
||||
*/
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(QuoteFormulaCategory::class, 'category_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 범위 규칙
|
||||
*/
|
||||
public function ranges(): HasMany
|
||||
{
|
||||
return $this->hasMany(QuoteFormulaRange::class, 'formula_id')
|
||||
->orderBy('sort_order');
|
||||
}
|
||||
|
||||
/**
|
||||
* 매핑 규칙
|
||||
*/
|
||||
public function mappings(): HasMany
|
||||
{
|
||||
return $this->hasMany(QuoteFormulaMapping::class, 'formula_id')
|
||||
->orderBy('sort_order');
|
||||
}
|
||||
|
||||
/**
|
||||
* 품목 출력
|
||||
*/
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(QuoteFormulaItem::class, 'formula_id')
|
||||
->orderBy('sort_order');
|
||||
}
|
||||
|
||||
/**
|
||||
* 생성자
|
||||
*/
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
/**
|
||||
* 수정자
|
||||
*/
|
||||
public function updater(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'updated_by');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Scopes
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 수식이 공통 수식인지 확인
|
||||
*/
|
||||
public function isCommon(): bool
|
||||
{
|
||||
return is_null($this->product_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope: 공통 수식
|
||||
*/
|
||||
public function scopeCommon(Builder $query): Builder
|
||||
{
|
||||
return $query->whereNull('product_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope: 특정 제품 수식 (공통 + 제품 전용)
|
||||
*/
|
||||
public function scopeForProduct(Builder $query, ?int $productId): Builder
|
||||
{
|
||||
return $query->where(function ($q) use ($productId) {
|
||||
$q->whereNull('product_id');
|
||||
if ($productId) {
|
||||
$q->orWhere('product_id', $productId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope: 활성화된 수식
|
||||
*/
|
||||
public function scopeActive(Builder $query): Builder
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope: 정렬 순서
|
||||
*/
|
||||
public function scopeOrdered(Builder $query): Builder
|
||||
{
|
||||
return $query->orderBy('sort_order');
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope: 유형별 필터
|
||||
*/
|
||||
public function scopeOfType(Builder $query, string $type): Builder
|
||||
{
|
||||
return $query->where('type', $type);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Helper Methods
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 유형 레이블 조회
|
||||
*/
|
||||
public function getTypeLabelAttribute(): string
|
||||
{
|
||||
return match ($this->type) {
|
||||
self::TYPE_INPUT => '입력값',
|
||||
self::TYPE_CALCULATION => '계산식',
|
||||
self::TYPE_RANGE => '범위별',
|
||||
self::TYPE_MAPPING => '매핑',
|
||||
default => $this->type,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 출력 유형 레이블 조회
|
||||
*/
|
||||
public function getOutputTypeLabelAttribute(): string
|
||||
{
|
||||
return match ($this->output_type) {
|
||||
self::OUTPUT_VARIABLE => '변수',
|
||||
self::OUTPUT_ITEM => '품목',
|
||||
default => $this->output_type,
|
||||
};
|
||||
}
|
||||
}
|
||||
109
app/Models/Quote/QuoteFormulaCategory.php
Normal file
109
app/Models/Quote/QuoteFormulaCategory.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Quote;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
/**
|
||||
* 견적 수식 카테고리 모델
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $tenant_id
|
||||
* @property string $code
|
||||
* @property string $name
|
||||
* @property string|null $description
|
||||
* @property int $sort_order
|
||||
* @property bool $is_active
|
||||
* @property int|null $created_by
|
||||
* @property int|null $updated_by
|
||||
*/
|
||||
class QuoteFormulaCategory extends Model
|
||||
{
|
||||
use BelongsToTenant, SoftDeletes;
|
||||
|
||||
protected $table = 'quote_formula_categories';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'code',
|
||||
'name',
|
||||
'description',
|
||||
'sort_order',
|
||||
'is_active',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean',
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
|
||||
protected $attributes = [
|
||||
'sort_order' => 0,
|
||||
'is_active' => true,
|
||||
];
|
||||
|
||||
// =========================================================================
|
||||
// Relationships
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 카테고리에 속한 수식들
|
||||
*/
|
||||
public function formulas(): HasMany
|
||||
{
|
||||
return $this->hasMany(QuoteFormula::class, 'category_id')
|
||||
->orderBy('sort_order');
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성화된 수식만
|
||||
*/
|
||||
public function activeFormulas(): HasMany
|
||||
{
|
||||
return $this->formulas()->where('is_active', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 생성자
|
||||
*/
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
/**
|
||||
* 수정자
|
||||
*/
|
||||
public function updater(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'updated_by');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Scopes
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Scope: 활성화된 카테고리
|
||||
*/
|
||||
public function scopeActive(Builder $query): Builder
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope: 정렬 순서
|
||||
*/
|
||||
public function scopeOrdered(Builder $query): Builder
|
||||
{
|
||||
return $query->orderBy('sort_order');
|
||||
}
|
||||
}
|
||||
70
app/Models/Quote/QuoteFormulaItem.php
Normal file
70
app/Models/Quote/QuoteFormulaItem.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Quote;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* 수식 품목 출력 모델
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $formula_id
|
||||
* @property string $item_code
|
||||
* @property string $item_name
|
||||
* @property string|null $specification
|
||||
* @property string $unit
|
||||
* @property string $quantity_formula
|
||||
* @property string|null $unit_price_formula
|
||||
* @property int $sort_order
|
||||
*/
|
||||
class QuoteFormulaItem extends Model
|
||||
{
|
||||
protected $table = 'quote_formula_items';
|
||||
|
||||
protected $fillable = [
|
||||
'formula_id',
|
||||
'item_code',
|
||||
'item_name',
|
||||
'specification',
|
||||
'unit',
|
||||
'quantity_formula',
|
||||
'unit_price_formula',
|
||||
'sort_order',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
|
||||
protected $attributes = [
|
||||
'sort_order' => 0,
|
||||
];
|
||||
|
||||
// =========================================================================
|
||||
// Relationships
|
||||
// =========================================================================
|
||||
|
||||
public function formula(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(QuoteFormula::class, 'formula_id');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Helper Methods
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 품목 표시 문자열
|
||||
*/
|
||||
public function getDisplayNameAttribute(): string
|
||||
{
|
||||
$name = "[{$this->item_code}] {$this->item_name}";
|
||||
|
||||
if ($this->specification) {
|
||||
$name .= " ({$this->specification})";
|
||||
}
|
||||
|
||||
return $name;
|
||||
}
|
||||
}
|
||||
65
app/Models/Quote/QuoteFormulaMapping.php
Normal file
65
app/Models/Quote/QuoteFormulaMapping.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Quote;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* 수식 매핑 값 모델
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $formula_id
|
||||
* @property string $source_variable
|
||||
* @property string $source_value
|
||||
* @property string $result_value
|
||||
* @property string $result_type
|
||||
* @property int $sort_order
|
||||
*/
|
||||
class QuoteFormulaMapping extends Model
|
||||
{
|
||||
protected $table = 'quote_formula_mappings';
|
||||
|
||||
protected $fillable = [
|
||||
'formula_id',
|
||||
'source_variable',
|
||||
'source_value',
|
||||
'result_value',
|
||||
'result_type',
|
||||
'sort_order',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
|
||||
protected $attributes = [
|
||||
'result_type' => 'fixed',
|
||||
'sort_order' => 0,
|
||||
];
|
||||
|
||||
public const RESULT_FIXED = 'fixed';
|
||||
|
||||
public const RESULT_FORMULA = 'formula';
|
||||
|
||||
// =========================================================================
|
||||
// Relationships
|
||||
// =========================================================================
|
||||
|
||||
public function formula(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(QuoteFormula::class, 'formula_id');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Helper Methods
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 매핑 조건 표시 문자열
|
||||
*/
|
||||
public function getConditionLabelAttribute(): string
|
||||
{
|
||||
return "{$this->source_variable} = '{$this->source_value}'";
|
||||
}
|
||||
}
|
||||
107
app/Models/Quote/QuoteFormulaRange.php
Normal file
107
app/Models/Quote/QuoteFormulaRange.php
Normal file
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Quote;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* 수식 범위별 값 모델
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $formula_id
|
||||
* @property float|null $min_value
|
||||
* @property float|null $max_value
|
||||
* @property string $condition_variable
|
||||
* @property string $result_value
|
||||
* @property string $result_type
|
||||
* @property int $sort_order
|
||||
*/
|
||||
class QuoteFormulaRange extends Model
|
||||
{
|
||||
protected $table = 'quote_formula_ranges';
|
||||
|
||||
protected $fillable = [
|
||||
'formula_id',
|
||||
'min_value',
|
||||
'max_value',
|
||||
'condition_variable',
|
||||
'result_value',
|
||||
'result_type',
|
||||
'sort_order',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'min_value' => 'decimal:4',
|
||||
'max_value' => 'decimal:4',
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
|
||||
protected $attributes = [
|
||||
'result_type' => 'fixed',
|
||||
'sort_order' => 0,
|
||||
];
|
||||
|
||||
public const RESULT_FIXED = 'fixed';
|
||||
|
||||
public const RESULT_FORMULA = 'formula';
|
||||
|
||||
// =========================================================================
|
||||
// Relationships
|
||||
// =========================================================================
|
||||
|
||||
public function formula(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(QuoteFormula::class, 'formula_id');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Helper Methods
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 값이 범위 내에 있는지 확인
|
||||
*/
|
||||
public function isInRange($value): bool
|
||||
{
|
||||
$min = $this->min_value;
|
||||
$max = $this->max_value;
|
||||
|
||||
if (is_null($min) && is_null($max)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (is_null($min)) {
|
||||
return $value <= $max;
|
||||
}
|
||||
|
||||
if (is_null($max)) {
|
||||
return $value >= $min;
|
||||
}
|
||||
|
||||
return $value >= $min && $value <= $max;
|
||||
}
|
||||
|
||||
/**
|
||||
* 범위 표시 문자열
|
||||
*/
|
||||
public function getRangeLabelAttribute(): string
|
||||
{
|
||||
$min = $this->min_value;
|
||||
$max = $this->max_value;
|
||||
|
||||
if (is_null($min) && is_null($max)) {
|
||||
return '전체';
|
||||
}
|
||||
|
||||
if (is_null($min)) {
|
||||
return "~ {$max}";
|
||||
}
|
||||
|
||||
if (is_null($max)) {
|
||||
return "{$min} ~";
|
||||
}
|
||||
|
||||
return "{$min} ~ {$max}";
|
||||
}
|
||||
}
|
||||
354
app/Services/Quote/FormulaEvaluatorService.php
Normal file
354
app/Services/Quote/FormulaEvaluatorService.php
Normal file
@@ -0,0 +1,354 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Quote;
|
||||
|
||||
use App\Models\Quote\QuoteFormula;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class FormulaEvaluatorService
|
||||
{
|
||||
private array $variables = [];
|
||||
|
||||
private array $errors = [];
|
||||
|
||||
/**
|
||||
* 수식 검증
|
||||
*/
|
||||
public function validateFormula(string $formula): array
|
||||
{
|
||||
$errors = [];
|
||||
|
||||
// 기본 문법 검증
|
||||
if (empty(trim($formula))) {
|
||||
return ['success' => false, 'errors' => ['수식이 비어있습니다.']];
|
||||
}
|
||||
|
||||
// 괄호 매칭 검증
|
||||
if (! $this->validateParentheses($formula)) {
|
||||
$errors[] = '괄호가 올바르게 닫히지 않았습니다.';
|
||||
}
|
||||
|
||||
// 변수 추출 및 검증
|
||||
$variables = $this->extractVariables($formula);
|
||||
|
||||
// 지원 함수 검증
|
||||
$functions = $this->extractFunctions($formula);
|
||||
$supportedFunctions = ['SUM', 'ROUND', 'CEIL', 'FLOOR', 'ABS', 'MIN', 'MAX', 'IF', 'AND', 'OR', 'NOT'];
|
||||
|
||||
foreach ($functions as $func) {
|
||||
if (! in_array(strtoupper($func), $supportedFunctions)) {
|
||||
$errors[] = "지원하지 않는 함수입니다: {$func}";
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => empty($errors),
|
||||
'errors' => $errors,
|
||||
'variables' => $variables,
|
||||
'functions' => $functions,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 수식 평가
|
||||
*/
|
||||
public function evaluate(string $formula, array $variables = []): mixed
|
||||
{
|
||||
$this->variables = array_merge($this->variables, $variables);
|
||||
$this->errors = [];
|
||||
|
||||
try {
|
||||
// 변수 치환
|
||||
$expression = $this->substituteVariables($formula);
|
||||
|
||||
// 함수 처리
|
||||
$expression = $this->processFunctions($expression);
|
||||
|
||||
// 최종 계산
|
||||
$result = $this->calculateExpression($expression);
|
||||
|
||||
return $result;
|
||||
} catch (\Exception $e) {
|
||||
$this->errors[] = $e->getMessage();
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 범위별 수식 평가
|
||||
*/
|
||||
public function evaluateRange(QuoteFormula $formula, array $variables = []): mixed
|
||||
{
|
||||
$conditionVar = $formula->ranges->first()?->condition_variable;
|
||||
$value = $variables[$conditionVar] ?? 0;
|
||||
|
||||
foreach ($formula->ranges as $range) {
|
||||
if ($range->isInRange($value)) {
|
||||
if ($range->result_type === 'formula') {
|
||||
return $this->evaluate($range->result_value, $variables);
|
||||
}
|
||||
|
||||
return $range->result_value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 매핑 수식 평가
|
||||
*/
|
||||
public function evaluateMapping(QuoteFormula $formula, array $variables = []): mixed
|
||||
{
|
||||
foreach ($formula->mappings as $mapping) {
|
||||
$sourceValue = $variables[$mapping->source_variable] ?? null;
|
||||
|
||||
if ($sourceValue == $mapping->source_value) {
|
||||
if ($mapping->result_type === 'formula') {
|
||||
return $this->evaluate($mapping->result_value, $variables);
|
||||
}
|
||||
|
||||
return $mapping->result_value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 수식 실행 (카테고리 순서대로)
|
||||
*/
|
||||
public function executeAll(Collection $formulasByCategory, array $inputVariables = []): array
|
||||
{
|
||||
$this->variables = $inputVariables;
|
||||
$results = [];
|
||||
$items = [];
|
||||
|
||||
foreach ($formulasByCategory as $categoryCode => $formulas) {
|
||||
foreach ($formulas as $formula) {
|
||||
$result = $this->executeFormula($formula);
|
||||
|
||||
if ($formula->output_type === QuoteFormula::OUTPUT_VARIABLE) {
|
||||
$this->variables[$formula->variable] = $result;
|
||||
$results[$formula->variable] = [
|
||||
'name' => $formula->name,
|
||||
'value' => $result,
|
||||
'category' => $formula->category->name,
|
||||
'type' => $formula->type,
|
||||
];
|
||||
} else {
|
||||
// 품목 출력
|
||||
foreach ($formula->items as $item) {
|
||||
$quantity = $this->evaluate($item->quantity_formula);
|
||||
$unitPrice = $item->unit_price_formula
|
||||
? $this->evaluate($item->unit_price_formula)
|
||||
: $this->getItemPrice($item->item_code);
|
||||
|
||||
$items[] = [
|
||||
'item_code' => $item->item_code,
|
||||
'item_name' => $item->item_name,
|
||||
'specification' => $item->specification,
|
||||
'unit' => $item->unit,
|
||||
'quantity' => $quantity,
|
||||
'unit_price' => $unitPrice,
|
||||
'total_price' => $quantity * $unitPrice,
|
||||
'formula_variable' => $formula->variable,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'variables' => $results,
|
||||
'items' => $items,
|
||||
'errors' => $this->errors,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 수식 실행
|
||||
*/
|
||||
private function executeFormula(QuoteFormula $formula): mixed
|
||||
{
|
||||
return match ($formula->type) {
|
||||
QuoteFormula::TYPE_INPUT => $this->variables[$formula->variable] ??
|
||||
($formula->formula ? $this->evaluate($formula->formula) : null),
|
||||
QuoteFormula::TYPE_CALCULATION => $this->evaluate($formula->formula, $this->variables),
|
||||
QuoteFormula::TYPE_RANGE => $this->evaluateRange($formula, $this->variables),
|
||||
QuoteFormula::TYPE_MAPPING => $this->evaluateMapping($formula, $this->variables),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Private Helper Methods
|
||||
// =========================================================================
|
||||
|
||||
private function validateParentheses(string $formula): bool
|
||||
{
|
||||
$count = 0;
|
||||
foreach (str_split($formula) as $char) {
|
||||
if ($char === '(') {
|
||||
$count++;
|
||||
}
|
||||
if ($char === ')') {
|
||||
$count--;
|
||||
}
|
||||
if ($count < 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return $count === 0;
|
||||
}
|
||||
|
||||
private function extractVariables(string $formula): array
|
||||
{
|
||||
preg_match_all('/\b([A-Z][A-Z0-9_]*)\b/', $formula, $matches);
|
||||
$variables = array_unique($matches[1] ?? []);
|
||||
|
||||
// 함수명 제외
|
||||
$functions = ['SUM', 'ROUND', 'CEIL', 'FLOOR', 'ABS', 'MIN', 'MAX', 'IF', 'AND', 'OR', 'NOT'];
|
||||
|
||||
return array_values(array_diff($variables, $functions));
|
||||
}
|
||||
|
||||
private function extractFunctions(string $formula): array
|
||||
{
|
||||
preg_match_all('/\b([A-Za-z_]+)\s*\(/', $formula, $matches);
|
||||
|
||||
return array_unique($matches[1] ?? []);
|
||||
}
|
||||
|
||||
private function substituteVariables(string $formula): string
|
||||
{
|
||||
foreach ($this->variables as $var => $value) {
|
||||
$formula = preg_replace('/\b'.preg_quote($var, '/').'\\b/', (string) $value, $formula);
|
||||
}
|
||||
|
||||
return $formula;
|
||||
}
|
||||
|
||||
private function processFunctions(string $expression): string
|
||||
{
|
||||
// ROUND(value, decimals)
|
||||
$expression = preg_replace_callback(
|
||||
'/ROUND\s*\(\s*([^,]+)\s*,\s*(\d+)\s*\)/i',
|
||||
fn ($m) => round((float) $this->calculateExpression($m[1]), (int) $m[2]),
|
||||
$expression
|
||||
);
|
||||
|
||||
// SUM(a, b, c, ...)
|
||||
$expression = preg_replace_callback(
|
||||
'/SUM\s*\(([^)]+)\)/i',
|
||||
fn ($m) => array_sum(array_map('floatval', explode(',', $m[1]))),
|
||||
$expression
|
||||
);
|
||||
|
||||
// MIN, MAX
|
||||
$expression = preg_replace_callback(
|
||||
'/MIN\s*\(([^)]+)\)/i',
|
||||
fn ($m) => min(array_map('floatval', explode(',', $m[1]))),
|
||||
$expression
|
||||
);
|
||||
|
||||
$expression = preg_replace_callback(
|
||||
'/MAX\s*\(([^)]+)\)/i',
|
||||
fn ($m) => max(array_map('floatval', explode(',', $m[1]))),
|
||||
$expression
|
||||
);
|
||||
|
||||
// IF(condition, true_val, false_val)
|
||||
$expression = preg_replace_callback(
|
||||
'/IF\s*\(\s*([^,]+)\s*,\s*([^,]+)\s*,\s*([^)]+)\s*\)/i',
|
||||
function ($m) {
|
||||
$condition = $this->evaluateCondition($m[1]);
|
||||
|
||||
return $condition ? $this->calculateExpression($m[2]) : $this->calculateExpression($m[3]);
|
||||
},
|
||||
$expression
|
||||
);
|
||||
|
||||
// ABS, CEIL, FLOOR
|
||||
$expression = preg_replace_callback('/ABS\s*\(([^)]+)\)/i', fn ($m) => abs((float) $this->calculateExpression($m[1])), $expression);
|
||||
$expression = preg_replace_callback('/CEIL\s*\(([^)]+)\)/i', fn ($m) => ceil((float) $this->calculateExpression($m[1])), $expression);
|
||||
$expression = preg_replace_callback('/FLOOR\s*\(([^)]+)\)/i', fn ($m) => floor((float) $this->calculateExpression($m[1])), $expression);
|
||||
|
||||
return $expression;
|
||||
}
|
||||
|
||||
private function calculateExpression(string $expression): float
|
||||
{
|
||||
// 안전한 수식 평가 (숫자, 연산자, 괄호만 허용)
|
||||
$expression = preg_replace('/[^0-9+\-*\/().%\s]/', '', $expression);
|
||||
|
||||
if (empty(trim($expression))) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
// eval 대신 안전한 계산 라이브러리 사용 권장
|
||||
// 여기서는 간단히 eval 사용 (프로덕션에서는 symfony/expression-language 등 사용)
|
||||
return (float) eval("return {$expression};");
|
||||
} catch (\Throwable $e) {
|
||||
$this->errors[] = "계산 오류: {$expression}";
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private function evaluateCondition(string $condition): bool
|
||||
{
|
||||
// 비교 연산자 처리
|
||||
if (preg_match('/(.+)(>=|<=|>|<|==|!=)(.+)/', $condition, $m)) {
|
||||
$left = (float) $this->calculateExpression(trim($m[1]));
|
||||
$right = (float) $this->calculateExpression(trim($m[3]));
|
||||
$op = $m[2];
|
||||
|
||||
return match ($op) {
|
||||
'>=' => $left >= $right,
|
||||
'<=' => $left <= $right,
|
||||
'>' => $left > $right,
|
||||
'<' => $left < $right,
|
||||
'==' => $left == $right,
|
||||
'!=' => $left != $right,
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
return (bool) $this->calculateExpression($condition);
|
||||
}
|
||||
|
||||
private function getItemPrice(string $itemCode): float
|
||||
{
|
||||
// TODO: 품목 마스터에서 단가 조회
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 에러 목록 반환
|
||||
*/
|
||||
public function getErrors(): array
|
||||
{
|
||||
return $this->errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 변수 상태 반환
|
||||
*/
|
||||
public function getVariables(): array
|
||||
{
|
||||
return $this->variables;
|
||||
}
|
||||
|
||||
/**
|
||||
* 변수 초기화
|
||||
*/
|
||||
public function resetVariables(): void
|
||||
{
|
||||
$this->variables = [];
|
||||
$this->errors = [];
|
||||
}
|
||||
}
|
||||
200
app/Services/Quote/QuoteFormulaCategoryService.php
Normal file
200
app/Services/Quote/QuoteFormulaCategoryService.php
Normal file
@@ -0,0 +1,200 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Quote;
|
||||
|
||||
use App\Models\Quote\QuoteFormulaCategory;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class QuoteFormulaCategoryService
|
||||
{
|
||||
/**
|
||||
* 카테고리 목록 조회 (페이지네이션)
|
||||
*/
|
||||
public function getCategories(array $filters = [], int $perPage = 15): LengthAwarePaginator
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
$query = QuoteFormulaCategory::query()
|
||||
->withCount('formulas')
|
||||
->withCount(['formulas as active_formulas_count' => function ($q) {
|
||||
$q->where('is_active', true);
|
||||
}]);
|
||||
|
||||
// 테넌트 필터
|
||||
if ($tenantId && $tenantId !== 'all') {
|
||||
$query->where('tenant_id', $tenantId);
|
||||
}
|
||||
|
||||
// 검색
|
||||
if (! empty($filters['search'])) {
|
||||
$search = $filters['search'];
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('code', 'like', "%{$search}%")
|
||||
->orWhere('description', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
// 활성화 필터
|
||||
if (isset($filters['is_active']) && $filters['is_active'] !== '') {
|
||||
$query->where('is_active', (bool) $filters['is_active']);
|
||||
}
|
||||
|
||||
// 정렬
|
||||
$sortBy = $filters['sort_by'] ?? 'sort_order';
|
||||
$sortDirection = $filters['sort_direction'] ?? 'asc';
|
||||
$query->orderBy($sortBy, $sortDirection);
|
||||
|
||||
return $query->paginate($perPage);
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 카테고리 목록 (선택용)
|
||||
*/
|
||||
public function getAllCategories(): Collection
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
return QuoteFormulaCategory::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('is_active', true)
|
||||
->orderBy('sort_order')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성 카테고리 목록 (드롭다운용)
|
||||
*/
|
||||
public function getActiveCategories(): Collection
|
||||
{
|
||||
return $this->getAllCategories();
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 상세 조회
|
||||
*/
|
||||
public function getCategoryById(int $id): ?QuoteFormulaCategory
|
||||
{
|
||||
return QuoteFormulaCategory::with('formulas')->find($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 생성
|
||||
*/
|
||||
public function createCategory(array $data): QuoteFormulaCategory
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
return DB::transaction(function () use ($data, $tenantId) {
|
||||
// 자동 sort_order 할당
|
||||
if (! isset($data['sort_order'])) {
|
||||
$maxOrder = QuoteFormulaCategory::where('tenant_id', $tenantId)->max('sort_order');
|
||||
$data['sort_order'] = ($maxOrder ?? 0) + 1;
|
||||
}
|
||||
|
||||
return QuoteFormulaCategory::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'code' => $data['code'],
|
||||
'name' => $data['name'],
|
||||
'description' => $data['description'] ?? null,
|
||||
'sort_order' => $data['sort_order'],
|
||||
'is_active' => $data['is_active'] ?? true,
|
||||
'created_by' => auth()->id(),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 수정
|
||||
*/
|
||||
public function updateCategory(int $id, array $data): QuoteFormulaCategory
|
||||
{
|
||||
$category = QuoteFormulaCategory::findOrFail($id);
|
||||
|
||||
$category->update([
|
||||
'code' => $data['code'] ?? $category->code,
|
||||
'name' => $data['name'] ?? $category->name,
|
||||
'description' => array_key_exists('description', $data) ? $data['description'] : $category->description,
|
||||
'sort_order' => $data['sort_order'] ?? $category->sort_order,
|
||||
'is_active' => $data['is_active'] ?? $category->is_active,
|
||||
'updated_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
return $category->fresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 삭제
|
||||
*/
|
||||
public function deleteCategory(int $id): void
|
||||
{
|
||||
$category = QuoteFormulaCategory::findOrFail($id);
|
||||
|
||||
// 연관 수식이 있으면 삭제 불가
|
||||
if ($category->formulas()->count() > 0) {
|
||||
throw new \Exception('연관된 수식이 있어 삭제할 수 없습니다. 먼저 수식을 삭제해주세요.');
|
||||
}
|
||||
|
||||
$category->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 순서 변경
|
||||
*/
|
||||
public function reorderCategories(array $orderData): void
|
||||
{
|
||||
DB::transaction(function () use ($orderData) {
|
||||
foreach ($orderData as $item) {
|
||||
QuoteFormulaCategory::where('id', $item['id'])
|
||||
->update(['sort_order' => $item['sort_order']]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 활성/비활성 토글
|
||||
*/
|
||||
public function toggleActive(int $id): QuoteFormulaCategory
|
||||
{
|
||||
$category = QuoteFormulaCategory::findOrFail($id);
|
||||
|
||||
$category->is_active = ! $category->is_active;
|
||||
$category->updated_by = auth()->id();
|
||||
$category->save();
|
||||
|
||||
return $category;
|
||||
}
|
||||
|
||||
/**
|
||||
* 코드 중복 체크
|
||||
*/
|
||||
public function isCodeExists(string $code, ?int $excludeId = null): bool
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
$query = QuoteFormulaCategory::where('tenant_id', $tenantId)
|
||||
->where('code', $code);
|
||||
|
||||
if ($excludeId) {
|
||||
$query->where('id', '!=', $excludeId);
|
||||
}
|
||||
|
||||
return $query->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 통계
|
||||
*/
|
||||
public function getCategoryStats(): array
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
return [
|
||||
'total' => QuoteFormulaCategory::where('tenant_id', $tenantId)->count(),
|
||||
'active' => QuoteFormulaCategory::where('tenant_id', $tenantId)->where('is_active', true)->count(),
|
||||
'inactive' => QuoteFormulaCategory::where('tenant_id', $tenantId)->where('is_active', false)->count(),
|
||||
];
|
||||
}
|
||||
}
|
||||
504
app/Services/Quote/QuoteFormulaService.php
Normal file
504
app/Services/Quote/QuoteFormulaService.php
Normal file
@@ -0,0 +1,504 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Quote;
|
||||
|
||||
use App\Models\Quote\QuoteFormula;
|
||||
use App\Models\Quote\QuoteFormulaItem;
|
||||
use App\Models\Quote\QuoteFormulaMapping;
|
||||
use App\Models\Quote\QuoteFormulaRange;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class QuoteFormulaService
|
||||
{
|
||||
/**
|
||||
* 수식 목록 조회 (페이지네이션)
|
||||
*/
|
||||
public function getFormulas(array $filters = [], int $perPage = 15): LengthAwarePaginator
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
$query = QuoteFormula::query()
|
||||
->with(['category'])
|
||||
->withCount(['ranges', 'mappings', 'items']);
|
||||
|
||||
// 테넌트 필터
|
||||
if ($tenantId && $tenantId !== 'all') {
|
||||
$query->where('tenant_id', $tenantId);
|
||||
}
|
||||
|
||||
// 카테고리 필터
|
||||
if (! empty($filters['category_id'])) {
|
||||
$query->where('category_id', $filters['category_id']);
|
||||
}
|
||||
|
||||
// 제품 필터 (공통/특정)
|
||||
if (isset($filters['product_id'])) {
|
||||
if ($filters['product_id'] === 'common' || $filters['product_id'] === '') {
|
||||
$query->whereNull('product_id');
|
||||
} else {
|
||||
$query->where('product_id', $filters['product_id']);
|
||||
}
|
||||
}
|
||||
|
||||
// 유형 필터
|
||||
if (! empty($filters['type'])) {
|
||||
$query->where('type', $filters['type']);
|
||||
}
|
||||
|
||||
// 검색
|
||||
if (! empty($filters['search'])) {
|
||||
$search = $filters['search'];
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('variable', 'like', "%{$search}%")
|
||||
->orWhere('formula', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
// 활성화 필터
|
||||
if (isset($filters['is_active']) && $filters['is_active'] !== '') {
|
||||
$query->where('is_active', (bool) $filters['is_active']);
|
||||
}
|
||||
|
||||
// 정렬
|
||||
$sortBy = $filters['sort_by'] ?? 'sort_order';
|
||||
$sortDirection = $filters['sort_direction'] ?? 'asc';
|
||||
|
||||
if ($sortBy === 'category') {
|
||||
$query->join('quote_formula_categories', 'quote_formulas.category_id', '=', 'quote_formula_categories.id')
|
||||
->orderBy('quote_formula_categories.sort_order', $sortDirection)
|
||||
->orderBy('quote_formulas.sort_order', $sortDirection)
|
||||
->select('quote_formulas.*');
|
||||
} else {
|
||||
$query->orderBy($sortBy, $sortDirection);
|
||||
}
|
||||
|
||||
return $query->paginate($perPage);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리별 수식 목록 (실행 순서용)
|
||||
*/
|
||||
public function getFormulasByCategory(?int $productId = null): Collection
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
return QuoteFormula::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('is_active', true)
|
||||
->where(function ($q) use ($productId) {
|
||||
$q->whereNull('product_id');
|
||||
if ($productId) {
|
||||
$q->orWhere('product_id', $productId);
|
||||
}
|
||||
})
|
||||
->with(['category', 'ranges', 'mappings', 'items'])
|
||||
->get()
|
||||
->groupBy('category.code')
|
||||
->sortBy(fn ($formulas) => $formulas->first()->category->sort_order);
|
||||
}
|
||||
|
||||
/**
|
||||
* 수식 상세 조회
|
||||
*/
|
||||
public function getFormulaById(int $id, bool $withRelations = false): ?QuoteFormula
|
||||
{
|
||||
$query = QuoteFormula::query();
|
||||
|
||||
if ($withRelations) {
|
||||
$query->with(['category', 'ranges', 'mappings', 'items']);
|
||||
}
|
||||
|
||||
return $query->find($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 수식 생성
|
||||
*/
|
||||
public function createFormula(array $data): QuoteFormula
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
return DB::transaction(function () use ($data, $tenantId) {
|
||||
// 자동 sort_order
|
||||
if (! isset($data['sort_order'])) {
|
||||
$maxOrder = QuoteFormula::where('tenant_id', $tenantId)
|
||||
->where('category_id', $data['category_id'])
|
||||
->max('sort_order');
|
||||
$data['sort_order'] = ($maxOrder ?? 0) + 1;
|
||||
}
|
||||
|
||||
$formula = QuoteFormula::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'category_id' => $data['category_id'],
|
||||
'product_id' => $data['product_id'] ?? null,
|
||||
'name' => $data['name'],
|
||||
'variable' => $data['variable'],
|
||||
'type' => $data['type'],
|
||||
'formula' => $data['formula'] ?? null,
|
||||
'output_type' => $data['output_type'] ?? 'variable',
|
||||
'description' => $data['description'] ?? null,
|
||||
'sort_order' => $data['sort_order'],
|
||||
'is_active' => $data['is_active'] ?? true,
|
||||
'created_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
// 범위별 규칙 저장
|
||||
if ($data['type'] === QuoteFormula::TYPE_RANGE && ! empty($data['ranges'])) {
|
||||
$this->saveRanges($formula, $data['ranges']);
|
||||
}
|
||||
|
||||
// 매핑 규칙 저장
|
||||
if ($data['type'] === QuoteFormula::TYPE_MAPPING && ! empty($data['mappings'])) {
|
||||
$this->saveMappings($formula, $data['mappings']);
|
||||
}
|
||||
|
||||
// 품목 출력 저장
|
||||
if (($data['output_type'] ?? 'variable') === QuoteFormula::OUTPUT_ITEM && ! empty($data['items'])) {
|
||||
$this->saveItems($formula, $data['items']);
|
||||
}
|
||||
|
||||
return $formula->load(['ranges', 'mappings', 'items']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 수식 수정
|
||||
*/
|
||||
public function updateFormula(int $id, array $data): QuoteFormula
|
||||
{
|
||||
$formula = QuoteFormula::findOrFail($id);
|
||||
|
||||
return DB::transaction(function () use ($formula, $data) {
|
||||
$formula->update([
|
||||
'category_id' => $data['category_id'] ?? $formula->category_id,
|
||||
'product_id' => array_key_exists('product_id', $data) ? $data['product_id'] : $formula->product_id,
|
||||
'name' => $data['name'] ?? $formula->name,
|
||||
'variable' => $data['variable'] ?? $formula->variable,
|
||||
'type' => $data['type'] ?? $formula->type,
|
||||
'formula' => array_key_exists('formula', $data) ? $data['formula'] : $formula->formula,
|
||||
'output_type' => $data['output_type'] ?? $formula->output_type,
|
||||
'description' => array_key_exists('description', $data) ? $data['description'] : $formula->description,
|
||||
'sort_order' => $data['sort_order'] ?? $formula->sort_order,
|
||||
'is_active' => $data['is_active'] ?? $formula->is_active,
|
||||
'updated_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
// 범위 규칙 업데이트
|
||||
if (isset($data['ranges'])) {
|
||||
$formula->ranges()->delete();
|
||||
if ($formula->type === QuoteFormula::TYPE_RANGE) {
|
||||
$this->saveRanges($formula, $data['ranges']);
|
||||
}
|
||||
}
|
||||
|
||||
// 매핑 규칙 업데이트
|
||||
if (isset($data['mappings'])) {
|
||||
$formula->mappings()->delete();
|
||||
if ($formula->type === QuoteFormula::TYPE_MAPPING) {
|
||||
$this->saveMappings($formula, $data['mappings']);
|
||||
}
|
||||
}
|
||||
|
||||
// 품목 출력 업데이트
|
||||
if (isset($data['items'])) {
|
||||
$formula->items()->delete();
|
||||
if ($formula->output_type === QuoteFormula::OUTPUT_ITEM) {
|
||||
$this->saveItems($formula, $data['items']);
|
||||
}
|
||||
}
|
||||
|
||||
return $formula->fresh(['category', 'ranges', 'mappings', 'items']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 수식 삭제
|
||||
*/
|
||||
public function deleteFormula(int $id): void
|
||||
{
|
||||
$formula = QuoteFormula::findOrFail($id);
|
||||
$formula->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* 수식 활성/비활성 토글
|
||||
*/
|
||||
public function toggleActive(int $id): QuoteFormula
|
||||
{
|
||||
$formula = QuoteFormula::findOrFail($id);
|
||||
|
||||
$formula->is_active = ! $formula->is_active;
|
||||
$formula->updated_by = auth()->id();
|
||||
$formula->save();
|
||||
|
||||
return $formula;
|
||||
}
|
||||
|
||||
/**
|
||||
* 수식 복원
|
||||
*/
|
||||
public function restoreFormula(int $id): void
|
||||
{
|
||||
$formula = QuoteFormula::withTrashed()->findOrFail($id);
|
||||
$formula->restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* 수식 영구 삭제
|
||||
*/
|
||||
public function forceDeleteFormula(int $id): void
|
||||
{
|
||||
$formula = QuoteFormula::withTrashed()->findOrFail($id);
|
||||
|
||||
DB::transaction(function () use ($formula) {
|
||||
$formula->ranges()->delete();
|
||||
$formula->mappings()->delete();
|
||||
$formula->items()->delete();
|
||||
$formula->forceDelete();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 변수명 중복 체크 (테넌트 전체 범위)
|
||||
*/
|
||||
public function isVariableExists(string $variable, ?int $excludeId = null): bool
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
$query = QuoteFormula::where('tenant_id', $tenantId)
|
||||
->where('variable', $variable);
|
||||
|
||||
if ($excludeId) {
|
||||
$query->where('id', '!=', $excludeId);
|
||||
}
|
||||
|
||||
return $query->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* 수식 순서 변경
|
||||
*/
|
||||
public function reorder(array $formulaIds): void
|
||||
{
|
||||
foreach ($formulaIds as $index => $id) {
|
||||
QuoteFormula::where('id', $id)->update([
|
||||
'sort_order' => $index + 1,
|
||||
'updated_by' => auth()->id(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 활성 수식 조회
|
||||
*/
|
||||
public function getAllActiveFormulas(): Collection
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
return QuoteFormula::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('is_active', true)
|
||||
->with(['category', 'ranges', 'mappings', 'items'])
|
||||
->orderBy('category_id')
|
||||
->orderBy('sort_order')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* 수식 복제
|
||||
*/
|
||||
public function duplicateFormula(int $id): QuoteFormula
|
||||
{
|
||||
$original = QuoteFormula::with(['ranges', 'mappings', 'items'])->findOrFail($id);
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
return DB::transaction(function () use ($original, $tenantId) {
|
||||
// 새로운 변수명 생성
|
||||
$newVariable = $this->generateUniqueVariable($original->variable);
|
||||
|
||||
// 수식 복제
|
||||
$formula = QuoteFormula::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'category_id' => $original->category_id,
|
||||
'product_id' => $original->product_id,
|
||||
'name' => $original->name.' (복제)',
|
||||
'variable' => $newVariable,
|
||||
'type' => $original->type,
|
||||
'formula' => $original->formula,
|
||||
'output_type' => $original->output_type,
|
||||
'description' => $original->description,
|
||||
'sort_order' => $original->sort_order + 1,
|
||||
'is_active' => false, // 복제본은 비활성 상태로 생성
|
||||
'created_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
// 범위 규칙 복제
|
||||
foreach ($original->ranges as $range) {
|
||||
QuoteFormulaRange::create([
|
||||
'formula_id' => $formula->id,
|
||||
'min_value' => $range->min_value,
|
||||
'max_value' => $range->max_value,
|
||||
'condition_variable' => $range->condition_variable,
|
||||
'result_value' => $range->result_value,
|
||||
'result_type' => $range->result_type,
|
||||
'sort_order' => $range->sort_order,
|
||||
]);
|
||||
}
|
||||
|
||||
// 매핑 규칙 복제
|
||||
foreach ($original->mappings as $mapping) {
|
||||
QuoteFormulaMapping::create([
|
||||
'formula_id' => $formula->id,
|
||||
'source_variable' => $mapping->source_variable,
|
||||
'source_value' => $mapping->source_value,
|
||||
'result_value' => $mapping->result_value,
|
||||
'result_type' => $mapping->result_type,
|
||||
'sort_order' => $mapping->sort_order,
|
||||
]);
|
||||
}
|
||||
|
||||
// 품목 출력 복제
|
||||
foreach ($original->items as $item) {
|
||||
QuoteFormulaItem::create([
|
||||
'formula_id' => $formula->id,
|
||||
'item_code' => $item->item_code,
|
||||
'item_name' => $item->item_name,
|
||||
'specification' => $item->specification,
|
||||
'unit' => $item->unit,
|
||||
'quantity_formula' => $item->quantity_formula,
|
||||
'unit_price_formula' => $item->unit_price_formula,
|
||||
'sort_order' => $item->sort_order,
|
||||
]);
|
||||
}
|
||||
|
||||
return $formula->load(['category', 'ranges', 'mappings', 'items']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 고유한 변수명 생성
|
||||
*/
|
||||
private function generateUniqueVariable(string $baseVariable): string
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
$suffix = 1;
|
||||
$newVariable = $baseVariable.'_COPY';
|
||||
|
||||
while (QuoteFormula::where('tenant_id', $tenantId)->where('variable', $newVariable)->exists()) {
|
||||
$suffix++;
|
||||
$newVariable = $baseVariable.'_COPY'.$suffix;
|
||||
}
|
||||
|
||||
return $newVariable;
|
||||
}
|
||||
|
||||
/**
|
||||
* 변수 목록 조회 (수식 입력 시 참조용)
|
||||
*/
|
||||
public function getAvailableVariables(?int $productId = null): array
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
$formulas = QuoteFormula::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('is_active', true)
|
||||
->where('output_type', 'variable')
|
||||
->where(function ($q) use ($productId) {
|
||||
$q->whereNull('product_id');
|
||||
if ($productId) {
|
||||
$q->orWhere('product_id', $productId);
|
||||
}
|
||||
})
|
||||
->with('category')
|
||||
->orderBy('category_id')
|
||||
->orderBy('sort_order')
|
||||
->get();
|
||||
|
||||
return $formulas->map(fn ($f) => [
|
||||
'variable' => $f->variable,
|
||||
'name' => $f->name,
|
||||
'category' => $f->category->name,
|
||||
'type' => $f->type,
|
||||
])->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 수식 통계
|
||||
*/
|
||||
public function getFormulaStats(): array
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
return [
|
||||
'total' => QuoteFormula::where('tenant_id', $tenantId)->count(),
|
||||
'active' => QuoteFormula::where('tenant_id', $tenantId)->where('is_active', true)->count(),
|
||||
'by_type' => [
|
||||
'input' => QuoteFormula::where('tenant_id', $tenantId)->where('type', 'input')->count(),
|
||||
'calculation' => QuoteFormula::where('tenant_id', $tenantId)->where('type', 'calculation')->count(),
|
||||
'range' => QuoteFormula::where('tenant_id', $tenantId)->where('type', 'range')->count(),
|
||||
'mapping' => QuoteFormula::where('tenant_id', $tenantId)->where('type', 'mapping')->count(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Private Methods
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 범위 규칙 저장
|
||||
*/
|
||||
private function saveRanges(QuoteFormula $formula, array $ranges): void
|
||||
{
|
||||
foreach ($ranges as $index => $range) {
|
||||
QuoteFormulaRange::create([
|
||||
'formula_id' => $formula->id,
|
||||
'min_value' => $range['min_value'] ?? null,
|
||||
'max_value' => $range['max_value'] ?? null,
|
||||
'condition_variable' => $range['condition_variable'],
|
||||
'result_value' => $range['result_value'],
|
||||
'result_type' => $range['result_type'] ?? 'fixed',
|
||||
'sort_order' => $index + 1,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 매핑 규칙 저장
|
||||
*/
|
||||
private function saveMappings(QuoteFormula $formula, array $mappings): void
|
||||
{
|
||||
foreach ($mappings as $index => $mapping) {
|
||||
QuoteFormulaMapping::create([
|
||||
'formula_id' => $formula->id,
|
||||
'source_variable' => $mapping['source_variable'],
|
||||
'source_value' => $mapping['source_value'],
|
||||
'result_value' => $mapping['result_value'],
|
||||
'result_type' => $mapping['result_type'] ?? 'fixed',
|
||||
'sort_order' => $index + 1,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 품목 출력 저장
|
||||
*/
|
||||
private function saveItems(QuoteFormula $formula, array $items): void
|
||||
{
|
||||
foreach ($items as $index => $item) {
|
||||
QuoteFormulaItem::create([
|
||||
'formula_id' => $formula->id,
|
||||
'item_code' => $item['item_code'],
|
||||
'item_name' => $item['item_name'],
|
||||
'specification' => $item['specification'] ?? null,
|
||||
'unit' => $item['unit'],
|
||||
'quantity_formula' => $item['quantity_formula'],
|
||||
'unit_price_formula' => $item['unit_price_formula'] ?? null,
|
||||
'sort_order' => $index + 1,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
1985
docs/QUOTE_FORMULA_DEVELOPMENT_PLAN.md
Normal file
1985
docs/QUOTE_FORMULA_DEVELOPMENT_PLAN.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -153,6 +153,16 @@ class="flex items-center gap-2 pr-3 py-2 rounded-lg text-sm text-gray-700 hover:
|
||||
</svg>
|
||||
</button>
|
||||
<ul id="production-group" class="space-y-1 mt-1">
|
||||
<li>
|
||||
<a href="{{ route('quote-formulas.index') }}"
|
||||
class="flex items-center gap-2 pr-3 py-2 rounded-lg text-sm text-gray-700 hover:bg-gray-100 {{ request()->routeIs('quote-formulas.*') ? 'bg-primary text-white hover:bg-primary' : '' }}"
|
||||
style="padding-left: 2rem;">
|
||||
<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="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span class="font-medium">견적수식 관리</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<span class="flex items-center gap-2 pr-3 py-2 rounded-lg text-sm text-gray-400 cursor-not-allowed"
|
||||
style="padding-left: 2rem;">
|
||||
|
||||
142
resources/views/quote-formulas/categories/create.blade.php
Normal file
142
resources/views/quote-formulas/categories/create.blade.php
Normal file
@@ -0,0 +1,142 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', '카테고리 추가')
|
||||
|
||||
@section('content')
|
||||
<div class="container mx-auto max-w-2xl">
|
||||
<!-- 헤더 -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-800">카테고리 추가</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">새로운 수식 카테고리를 추가합니다.</p>
|
||||
</div>
|
||||
<a href="{{ route('quote-formulas.categories.index') }}"
|
||||
class="text-gray-600 hover:text-gray-800 text-sm font-medium">
|
||||
← 목록으로
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 폼 -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
||||
<form id="categoryForm" class="space-y-6">
|
||||
<!-- 카테고리 코드 -->
|
||||
<div>
|
||||
<label for="code" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
카테고리 코드 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="text" name="code" id="code"
|
||||
placeholder="예: BASIC_INFO, SIZE_CALC"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
required>
|
||||
<p class="text-xs text-gray-500 mt-1">대문자로 시작하고 대문자, 숫자, 언더스코어만 사용 가능</p>
|
||||
</div>
|
||||
|
||||
<!-- 카테고리명 -->
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
카테고리명 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="text" name="name" id="name"
|
||||
placeholder="예: 기본정보, 제작사이즈"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
required>
|
||||
</div>
|
||||
|
||||
<!-- 설명 -->
|
||||
<div>
|
||||
<label for="description" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
설명
|
||||
</label>
|
||||
<textarea name="description" id="description" rows="3"
|
||||
placeholder="카테고리에 대한 설명을 입력하세요."
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- 정렬 순서 -->
|
||||
<div>
|
||||
<label for="sort_order" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
정렬 순서
|
||||
</label>
|
||||
<input type="number" name="sort_order" id="sort_order"
|
||||
min="1"
|
||||
placeholder="숫자가 작을수록 먼저 실행됩니다"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<p class="text-xs text-gray-500 mt-1">수식 실행 순서에 영향을 줍니다. 비워두면 자동으로 마지막 순서로 지정됩니다.</p>
|
||||
</div>
|
||||
|
||||
<!-- 활성 상태 -->
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" name="is_active" id="is_active" value="1" checked
|
||||
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
||||
<label for="is_active" class="ml-2 block text-sm text-gray-700">
|
||||
활성화
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- 에러 메시지 -->
|
||||
<div id="errorMessage" class="hidden bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-700"></div>
|
||||
|
||||
<!-- 버튼 -->
|
||||
<div class="flex justify-end gap-3 pt-4 border-t">
|
||||
<a href="{{ route('quote-formulas.categories.index') }}"
|
||||
class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition">
|
||||
취소
|
||||
</a>
|
||||
<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>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
document.getElementById('categoryForm').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const errorDiv = document.getElementById('errorMessage');
|
||||
errorDiv.classList.add('hidden');
|
||||
|
||||
const formData = new FormData(this);
|
||||
const data = {
|
||||
code: formData.get('code'),
|
||||
name: formData.get('name'),
|
||||
description: formData.get('description') || null,
|
||||
sort_order: formData.get('sort_order') ? parseInt(formData.get('sort_order')) : null,
|
||||
is_active: formData.has('is_active')
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/quote-formulas/categories', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
window.location.href = '{{ route("quote-formulas.categories.index") }}';
|
||||
} else {
|
||||
errorDiv.textContent = result.message || '저장에 실패했습니다.';
|
||||
errorDiv.classList.remove('hidden');
|
||||
}
|
||||
} catch (err) {
|
||||
errorDiv.textContent = '서버 오류가 발생했습니다.';
|
||||
errorDiv.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// 코드 자동 대문자 변환
|
||||
document.getElementById('code').addEventListener('input', function(e) {
|
||||
this.value = this.value.toUpperCase().replace(/[^A-Z0-9_]/g, '');
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
182
resources/views/quote-formulas/categories/edit.blade.php
Normal file
182
resources/views/quote-formulas/categories/edit.blade.php
Normal file
@@ -0,0 +1,182 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', '카테고리 수정')
|
||||
|
||||
@section('content')
|
||||
<div class="container mx-auto max-w-2xl">
|
||||
<!-- 헤더 -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-800">카테고리 수정</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">카테고리 정보를 수정합니다.</p>
|
||||
</div>
|
||||
<a href="{{ route('quote-formulas.categories.index') }}"
|
||||
class="text-gray-600 hover:text-gray-800 text-sm font-medium">
|
||||
← 목록으로
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 로딩 -->
|
||||
<div id="loading" class="bg-white rounded-lg shadow-sm p-12 text-center">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p class="text-gray-500 mt-4">데이터를 불러오는 중...</p>
|
||||
</div>
|
||||
|
||||
<!-- 폼 (로드 후 표시) -->
|
||||
<div id="formContainer" class="bg-white rounded-lg shadow-sm p-6 hidden">
|
||||
<form id="categoryForm" class="space-y-6">
|
||||
<!-- 카테고리 코드 -->
|
||||
<div>
|
||||
<label for="code" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
카테고리 코드 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="text" name="code" id="code"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
required>
|
||||
<p class="text-xs text-gray-500 mt-1">대문자로 시작하고 대문자, 숫자, 언더스코어만 사용 가능</p>
|
||||
</div>
|
||||
|
||||
<!-- 카테고리명 -->
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
카테고리명 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="text" name="name" id="name"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
required>
|
||||
</div>
|
||||
|
||||
<!-- 설명 -->
|
||||
<div>
|
||||
<label for="description" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
설명
|
||||
</label>
|
||||
<textarea name="description" id="description" rows="3"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- 정렬 순서 -->
|
||||
<div>
|
||||
<label for="sort_order" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
정렬 순서
|
||||
</label>
|
||||
<input type="number" name="sort_order" id="sort_order"
|
||||
min="1"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<p class="text-xs text-gray-500 mt-1">수식 실행 순서에 영향을 줍니다.</p>
|
||||
</div>
|
||||
|
||||
<!-- 활성 상태 -->
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" name="is_active" id="is_active" value="1"
|
||||
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
||||
<label for="is_active" class="ml-2 block text-sm text-gray-700">
|
||||
활성화
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- 에러 메시지 -->
|
||||
<div id="errorMessage" class="hidden bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-700"></div>
|
||||
|
||||
<!-- 버튼 -->
|
||||
<div class="flex justify-end gap-3 pt-4 border-t">
|
||||
<a href="{{ route('quote-formulas.categories.index') }}"
|
||||
class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition">
|
||||
취소
|
||||
</a>
|
||||
<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>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
const categoryId = {{ $id }};
|
||||
|
||||
// 데이터 로드
|
||||
async function loadCategory() {
|
||||
try {
|
||||
const response = await fetch(`/api/admin/quote-formulas/categories/${categoryId}`, {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
}
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
const data = result.data;
|
||||
document.getElementById('code').value = data.code || '';
|
||||
document.getElementById('name').value = data.name || '';
|
||||
document.getElementById('description').value = data.description || '';
|
||||
document.getElementById('sort_order').value = data.sort_order || '';
|
||||
document.getElementById('is_active').checked = data.is_active;
|
||||
|
||||
document.getElementById('loading').classList.add('hidden');
|
||||
document.getElementById('formContainer').classList.remove('hidden');
|
||||
} else {
|
||||
alert(result.message || '데이터를 불러오는데 실패했습니다.');
|
||||
window.location.href = '{{ route("quote-formulas.categories.index") }}';
|
||||
}
|
||||
} catch (err) {
|
||||
alert('서버 오류가 발생했습니다.');
|
||||
window.location.href = '{{ route("quote-formulas.categories.index") }}';
|
||||
}
|
||||
}
|
||||
|
||||
// 폼 제출
|
||||
document.getElementById('categoryForm').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const errorDiv = document.getElementById('errorMessage');
|
||||
errorDiv.classList.add('hidden');
|
||||
|
||||
const formData = new FormData(this);
|
||||
const data = {
|
||||
code: formData.get('code'),
|
||||
name: formData.get('name'),
|
||||
description: formData.get('description') || null,
|
||||
sort_order: formData.get('sort_order') ? parseInt(formData.get('sort_order')) : null,
|
||||
is_active: formData.has('is_active')
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/admin/quote-formulas/categories/${categoryId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
window.location.href = '{{ route("quote-formulas.categories.index") }}';
|
||||
} else {
|
||||
errorDiv.textContent = result.message || '저장에 실패했습니다.';
|
||||
errorDiv.classList.remove('hidden');
|
||||
}
|
||||
} catch (err) {
|
||||
errorDiv.textContent = '서버 오류가 발생했습니다.';
|
||||
errorDiv.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// 코드 자동 대문자 변환
|
||||
document.getElementById('code').addEventListener('input', function(e) {
|
||||
this.value = this.value.toUpperCase().replace(/[^A-Z0-9_]/g, '');
|
||||
});
|
||||
|
||||
// 초기 로드
|
||||
loadCategory();
|
||||
</script>
|
||||
@endpush
|
||||
143
resources/views/quote-formulas/categories/index.blade.php
Normal file
143
resources/views/quote-formulas/categories/index.blade.php
Normal file
@@ -0,0 +1,143 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', '수식 카테고리 관리')
|
||||
|
||||
@section('content')
|
||||
<div class="container mx-auto max-w-6xl">
|
||||
<!-- 헤더 -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-800">수식 카테고리 관리</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">수식을 그룹화하는 카테고리를 관리합니다.</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ route('quote-formulas.index') }}"
|
||||
class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors">
|
||||
수식 목록
|
||||
</a>
|
||||
<a href="{{ route('quote-formulas.categories.create') }}"
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors">
|
||||
+ 카테고리 추가
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 필터 -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-4 mb-6">
|
||||
<form id="filterForm" class="flex gap-4 flex-wrap">
|
||||
<!-- 검색 -->
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<input type="text"
|
||||
name="search"
|
||||
placeholder="카테고리명, 코드로 검색..."
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
|
||||
<!-- 활성 상태 필터 -->
|
||||
<div class="w-32">
|
||||
<select name="is_active" 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="">전체</option>
|
||||
<option value="1">활성</option>
|
||||
<option value="0">비활성</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 삭제된 항목 포함 -->
|
||||
<div class="w-36">
|
||||
<select name="trashed" 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="">활성만</option>
|
||||
<option value="with">삭제 포함</option>
|
||||
<option value="only">삭제만</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 검색 버튼 -->
|
||||
<button type="submit" class="bg-gray-600 hover:bg-gray-700 text-white px-6 py-2 rounded-lg transition">
|
||||
검색
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 테이블 영역 (HTMX로 로드) -->
|
||||
<div id="category-table"
|
||||
hx-get="/api/admin/quote-formulas/categories"
|
||||
hx-trigger="load, filterSubmit from:body"
|
||||
hx-include="#filterForm"
|
||||
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'
|
||||
class="bg-white rounded-lg shadow-sm overflow-hidden">
|
||||
<!-- 로딩 스피너 -->
|
||||
<div class="flex justify-center items-center p-12">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
<script>
|
||||
// 폼 제출 시 HTMX 이벤트 트리거
|
||||
document.getElementById('filterForm').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
htmx.trigger('#category-table', 'filterSubmit');
|
||||
});
|
||||
|
||||
// 삭제 확인
|
||||
window.confirmDelete = function(id, name) {
|
||||
if (confirm(`"${name}" 카테고리를 삭제하시겠습니까?\n\n해당 카테고리의 수식들은 삭제되지 않습니다.`)) {
|
||||
htmx.ajax('DELETE', `/api/admin/quote-formulas/categories/${id}`, {
|
||||
target: '#category-table',
|
||||
swap: 'none',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
}
|
||||
}).then(() => {
|
||||
htmx.trigger('#category-table', 'filterSubmit');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 복원 확인
|
||||
window.confirmRestore = function(id, name) {
|
||||
if (confirm(`"${name}" 카테고리를 복원하시겠습니까?`)) {
|
||||
htmx.ajax('POST', `/api/admin/quote-formulas/categories/${id}/restore`, {
|
||||
target: '#category-table',
|
||||
swap: 'none',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
}
|
||||
}).then(() => {
|
||||
htmx.trigger('#category-table', 'filterSubmit');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 영구삭제 확인
|
||||
window.confirmForceDelete = function(id, name) {
|
||||
if (confirm(`"${name}" 카테고리를 영구 삭제하시겠습니까?\n\n이 작업은 되돌릴 수 없습니다!`)) {
|
||||
htmx.ajax('DELETE', `/api/admin/quote-formulas/categories/${id}/force`, {
|
||||
target: '#category-table',
|
||||
swap: 'none',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
}
|
||||
}).then(() => {
|
||||
htmx.trigger('#category-table', 'filterSubmit');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 활성/비활성 토글
|
||||
window.toggleActive = function(id) {
|
||||
htmx.ajax('POST', `/api/admin/quote-formulas/categories/${id}/toggle-active`, {
|
||||
target: '#category-table',
|
||||
swap: 'none',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
}
|
||||
}).then(() => {
|
||||
htmx.trigger('#category-table', 'filterSubmit');
|
||||
});
|
||||
};
|
||||
</script>
|
||||
@endpush
|
||||
@@ -0,0 +1,110 @@
|
||||
<!-- 카테고리 테이블 -->
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-center text-xs font-semibold text-gray-600 uppercase tracking-wider w-16">순서</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">코드</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">카테고리명</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">설명</th>
|
||||
<th class="px-4 py-3 text-center text-xs font-semibold text-gray-600 uppercase tracking-wider">수식 수</th>
|
||||
<th class="px-4 py-3 text-center text-xs font-semibold text-gray-600 uppercase tracking-wider">상태</th>
|
||||
<th class="px-4 py-3 text-center text-xs font-semibold text-gray-600 uppercase tracking-wider">액션</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
@forelse($categories as $category)
|
||||
<tr class="{{ $category->trashed() ? 'bg-red-50' : '' }} hover:bg-gray-50">
|
||||
<td class="px-4 py-3 whitespace-nowrap text-center">
|
||||
<span class="inline-flex items-center justify-center w-8 h-8 rounded-full bg-gray-100 text-sm font-medium text-gray-600">
|
||||
{{ $category->sort_order }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
<code class="px-2 py-1 bg-indigo-50 rounded text-sm font-mono text-indigo-600">{{ $category->code }}</code>
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
<div class="text-sm font-medium text-gray-900">{{ $category->name }}</div>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
@if($category->description)
|
||||
<div class="text-sm text-gray-500 truncate max-w-xs">{{ $category->description }}</div>
|
||||
@else
|
||||
<span class="text-gray-400 text-sm">-</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-center">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
{{ $category->formulas_count ?? 0 }}개
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-center">
|
||||
@if($category->trashed())
|
||||
<span class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-red-100 text-red-800">
|
||||
삭제됨
|
||||
</span>
|
||||
@elseif($category->is_active)
|
||||
<button onclick="toggleActive({{ $category->id }})"
|
||||
class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-green-100 text-green-800 hover:bg-green-200 cursor-pointer">
|
||||
활성
|
||||
</button>
|
||||
@else
|
||||
<button onclick="toggleActive({{ $category->id }})"
|
||||
class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-gray-100 text-gray-800 hover:bg-gray-200 cursor-pointer">
|
||||
비활성
|
||||
</button>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-center">
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
@if($category->trashed())
|
||||
<button onclick="confirmRestore({{ $category->id }}, '{{ $category->name }}')"
|
||||
class="p-1.5 text-green-600 hover:bg-green-50 rounded" title="복원">
|
||||
<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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</button>
|
||||
@if(auth()->user()?->is_super_admin)
|
||||
<button onclick="confirmForceDelete({{ $category->id }}, '{{ $category->name }}')"
|
||||
class="p-1.5 text-red-600 hover:bg-red-50 rounded" title="영구삭제">
|
||||
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
@endif
|
||||
@else
|
||||
<a href="{{ route('quote-formulas.categories.edit', $category->id) }}"
|
||||
class="p-1.5 text-indigo-600 hover:bg-indigo-50 rounded" title="수정">
|
||||
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</a>
|
||||
<button onclick="confirmDelete({{ $category->id }}, '{{ $category->name }}')"
|
||||
class="p-1.5 text-red-600 hover:bg-red-50 rounded" title="삭제">
|
||||
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="7" class="px-6 py-12 text-center text-gray-500">
|
||||
<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="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
||||
</svg>
|
||||
<p class="text-sm font-medium">등록된 카테고리가 없습니다.</p>
|
||||
<p class="text-xs mt-1">새 카테고리를 추가해주세요.</p>
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- 페이지네이션 -->
|
||||
@if($categories->hasPages())
|
||||
<div class="px-6 py-4 border-t border-gray-200">
|
||||
{{ $categories->withQueryString()->links() }}
|
||||
</div>
|
||||
@endif
|
||||
321
resources/views/quote-formulas/create.blade.php
Normal file
321
resources/views/quote-formulas/create.blade.php
Normal file
@@ -0,0 +1,321 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', '수식 추가')
|
||||
|
||||
@section('content')
|
||||
<div class="container mx-auto max-w-4xl">
|
||||
<!-- 헤더 -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-800">수식 추가</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">새로운 견적 수식을 추가합니다.</p>
|
||||
</div>
|
||||
<a href="{{ route('quote-formulas.index') }}"
|
||||
class="text-gray-600 hover:text-gray-800 text-sm font-medium">
|
||||
← 목록으로
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 폼 -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
||||
<form id="formulaForm" class="space-y-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- 카테고리 -->
|
||||
<div>
|
||||
<label for="category_id" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
카테고리 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<select name="category_id" id="category_id"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
required>
|
||||
<option value="">카테고리 선택</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 수식 유형 -->
|
||||
<div>
|
||||
<label for="type" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
수식 유형 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<select name="type" id="type"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
required>
|
||||
<option value="">유형 선택</option>
|
||||
<option value="input">입력값</option>
|
||||
<option value="calculation">계산식</option>
|
||||
<option value="range">범위별</option>
|
||||
<option value="mapping">매핑</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- 수식명 -->
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
수식명 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="text" name="name" id="name"
|
||||
placeholder="예: 폭, 높이, 면적"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
required>
|
||||
</div>
|
||||
|
||||
<!-- 변수명 -->
|
||||
<div>
|
||||
<label for="variable" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
변수명 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="text" name="variable" id="variable"
|
||||
placeholder="예: W0, H0, AREA"
|
||||
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"
|
||||
required>
|
||||
<p class="text-xs text-gray-500 mt-1">대문자로 시작, 대문자/숫자/언더스코어만 사용</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 계산식 (type=calculation 일 때 표시) -->
|
||||
<div id="formulaSection" class="hidden">
|
||||
<label for="formula" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
계산식 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea name="formula" id="formula" rows="3"
|
||||
placeholder="예: W0 * H0, ROUND(AREA * 1.1, 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"></textarea>
|
||||
<div class="flex items-center gap-4 mt-2">
|
||||
<button type="button" onclick="validateFormula()"
|
||||
class="text-sm text-blue-600 hover:text-blue-700">
|
||||
수식 검증
|
||||
</button>
|
||||
<span id="validateResult" class="text-sm"></span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-1">지원 함수: SUM, ROUND, CEIL, FLOOR, ABS, MIN, MAX, IF</p>
|
||||
</div>
|
||||
|
||||
<!-- 사용 가능한 변수 목록 -->
|
||||
<div id="variablesSection" class="hidden">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">사용 가능한 변수</label>
|
||||
<div id="variablesList" class="flex flex-wrap gap-2 p-3 bg-gray-50 rounded-lg max-h-32 overflow-y-auto">
|
||||
<span class="text-sm text-gray-500">변수를 불러오는 중...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 설명 -->
|
||||
<div>
|
||||
<label for="description" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
설명
|
||||
</label>
|
||||
<textarea name="description" id="description" rows="2"
|
||||
placeholder="수식에 대한 설명을 입력하세요."
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- 정렬 순서 -->
|
||||
<div>
|
||||
<label for="sort_order" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
정렬 순서
|
||||
</label>
|
||||
<input type="number" name="sort_order" id="sort_order"
|
||||
min="1"
|
||||
placeholder="자동 지정"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
|
||||
<!-- 활성 상태 -->
|
||||
<div class="flex items-center pt-6">
|
||||
<input type="checkbox" name="is_active" id="is_active" value="1" checked
|
||||
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
||||
<label for="is_active" class="ml-2 block text-sm text-gray-700">
|
||||
활성화
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 에러 메시지 -->
|
||||
<div id="errorMessage" class="hidden bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-700"></div>
|
||||
|
||||
<!-- 버튼 -->
|
||||
<div class="flex justify-end gap-3 pt-4 border-t">
|
||||
<a href="{{ route('quote-formulas.index') }}"
|
||||
class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition">
|
||||
취소
|
||||
</a>
|
||||
<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>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
let availableVariables = [];
|
||||
|
||||
// 카테고리 로드
|
||||
async function loadCategories() {
|
||||
try {
|
||||
const response = await fetch('/api/admin/quote-formulas/categories/dropdown', {
|
||||
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' }
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.success && result.data) {
|
||||
const select = document.getElementById('category_id');
|
||||
result.data.forEach(cat => {
|
||||
const option = document.createElement('option');
|
||||
option.value = cat.id;
|
||||
option.textContent = cat.name;
|
||||
select.appendChild(option);
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('카테고리 로드 실패:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// 변수 목록 로드
|
||||
async function loadVariables() {
|
||||
try {
|
||||
const response = await fetch('/api/admin/quote-formulas/formulas/variables', {
|
||||
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' }
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.success && result.data) {
|
||||
availableVariables = result.data;
|
||||
renderVariables();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('변수 로드 실패:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// 변수 목록 렌더링
|
||||
function renderVariables() {
|
||||
const container = document.getElementById('variablesList');
|
||||
if (availableVariables.length === 0) {
|
||||
container.innerHTML = '<span class="text-sm text-gray-500">사용 가능한 변수가 없습니다.</span>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = availableVariables.map(v =>
|
||||
`<button type="button" onclick="insertVariable('${v.variable}')"
|
||||
class="px-2 py-1 bg-blue-100 text-blue-700 rounded text-xs font-mono hover:bg-blue-200"
|
||||
title="${v.name} (${v.category})">
|
||||
${v.variable}
|
||||
</button>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
// 변수 삽입
|
||||
function insertVariable(variable) {
|
||||
const formulaInput = document.getElementById('formula');
|
||||
const cursorPos = formulaInput.selectionStart;
|
||||
const textBefore = formulaInput.value.substring(0, cursorPos);
|
||||
const textAfter = formulaInput.value.substring(cursorPos);
|
||||
formulaInput.value = textBefore + variable + textAfter;
|
||||
formulaInput.focus();
|
||||
formulaInput.setSelectionRange(cursorPos + variable.length, cursorPos + variable.length);
|
||||
}
|
||||
|
||||
// 수식 유형 변경
|
||||
document.getElementById('type').addEventListener('change', function() {
|
||||
const formulaSection = document.getElementById('formulaSection');
|
||||
const variablesSection = document.getElementById('variablesSection');
|
||||
|
||||
if (this.value === 'calculation') {
|
||||
formulaSection.classList.remove('hidden');
|
||||
variablesSection.classList.remove('hidden');
|
||||
} else {
|
||||
formulaSection.classList.add('hidden');
|
||||
variablesSection.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// 수식 검증
|
||||
async function validateFormula() {
|
||||
const formula = document.getElementById('formula').value;
|
||||
const resultSpan = document.getElementById('validateResult');
|
||||
|
||||
if (!formula) {
|
||||
resultSpan.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/quote-formulas/formulas/validate', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
},
|
||||
body: JSON.stringify({ formula })
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
resultSpan.innerHTML = '<span class="text-green-600">유효한 수식입니다.</span>';
|
||||
} else {
|
||||
resultSpan.innerHTML = `<span class="text-red-600">${result.data?.errors?.join(', ') || '유효하지 않은 수식입니다.'}</span>`;
|
||||
}
|
||||
} catch (err) {
|
||||
resultSpan.innerHTML = '<span class="text-red-600">검증 중 오류가 발생했습니다.</span>';
|
||||
}
|
||||
}
|
||||
|
||||
// 변수명 자동 대문자 변환
|
||||
document.getElementById('variable').addEventListener('input', function() {
|
||||
this.value = this.value.toUpperCase().replace(/[^A-Z0-9_]/g, '');
|
||||
});
|
||||
|
||||
// 폼 제출
|
||||
document.getElementById('formulaForm').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const errorDiv = document.getElementById('errorMessage');
|
||||
errorDiv.classList.add('hidden');
|
||||
|
||||
const formData = new FormData(this);
|
||||
const data = {
|
||||
category_id: parseInt(formData.get('category_id')),
|
||||
name: formData.get('name'),
|
||||
variable: formData.get('variable'),
|
||||
type: formData.get('type'),
|
||||
formula: formData.get('formula') || null,
|
||||
description: formData.get('description') || null,
|
||||
sort_order: formData.get('sort_order') ? parseInt(formData.get('sort_order')) : null,
|
||||
is_active: formData.has('is_active')
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/quote-formulas/formulas', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
window.location.href = '{{ route("quote-formulas.index") }}';
|
||||
} else {
|
||||
errorDiv.textContent = result.message || '저장에 실패했습니다.';
|
||||
errorDiv.classList.remove('hidden');
|
||||
}
|
||||
} catch (err) {
|
||||
errorDiv.textContent = '서버 오류가 발생했습니다.';
|
||||
errorDiv.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// 초기화
|
||||
loadCategories();
|
||||
loadVariables();
|
||||
</script>
|
||||
@endpush
|
||||
363
resources/views/quote-formulas/edit.blade.php
Normal file
363
resources/views/quote-formulas/edit.blade.php
Normal file
@@ -0,0 +1,363 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', '수식 수정')
|
||||
|
||||
@section('content')
|
||||
<div class="container mx-auto max-w-4xl">
|
||||
<!-- 헤더 -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-800">수식 수정</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">수식 정보를 수정합니다.</p>
|
||||
</div>
|
||||
<a href="{{ route('quote-formulas.index') }}"
|
||||
class="text-gray-600 hover:text-gray-800 text-sm font-medium">
|
||||
← 목록으로
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 로딩 -->
|
||||
<div id="loading" class="bg-white rounded-lg shadow-sm p-12 text-center">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p class="text-gray-500 mt-4">데이터를 불러오는 중...</p>
|
||||
</div>
|
||||
|
||||
<!-- 폼 (로드 후 표시) -->
|
||||
<div id="formContainer" class="bg-white rounded-lg shadow-sm p-6 hidden">
|
||||
<form id="formulaForm" class="space-y-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- 카테고리 -->
|
||||
<div>
|
||||
<label for="category_id" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
카테고리 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<select name="category_id" id="category_id"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
required>
|
||||
<option value="">카테고리 선택</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 수식 유형 -->
|
||||
<div>
|
||||
<label for="type" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
수식 유형 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<select name="type" id="type"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
required>
|
||||
<option value="">유형 선택</option>
|
||||
<option value="input">입력값</option>
|
||||
<option value="calculation">계산식</option>
|
||||
<option value="range">범위별</option>
|
||||
<option value="mapping">매핑</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- 수식명 -->
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
수식명 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="text" name="name" id="name"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
required>
|
||||
</div>
|
||||
|
||||
<!-- 변수명 -->
|
||||
<div>
|
||||
<label for="variable" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
변수명 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="text" name="variable" id="variable"
|
||||
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"
|
||||
required>
|
||||
<p class="text-xs text-gray-500 mt-1">대문자로 시작, 대문자/숫자/언더스코어만 사용</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 계산식 (type=calculation 일 때 표시) -->
|
||||
<div id="formulaSection" class="hidden">
|
||||
<label for="formula" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
계산식 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea name="formula" id="formula" rows="3"
|
||||
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"></textarea>
|
||||
<div class="flex items-center gap-4 mt-2">
|
||||
<button type="button" onclick="validateFormula()"
|
||||
class="text-sm text-blue-600 hover:text-blue-700">
|
||||
수식 검증
|
||||
</button>
|
||||
<span id="validateResult" class="text-sm"></span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-1">지원 함수: SUM, ROUND, CEIL, FLOOR, ABS, MIN, MAX, IF</p>
|
||||
</div>
|
||||
|
||||
<!-- 사용 가능한 변수 목록 -->
|
||||
<div id="variablesSection" class="hidden">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">사용 가능한 변수</label>
|
||||
<div id="variablesList" class="flex flex-wrap gap-2 p-3 bg-gray-50 rounded-lg max-h-32 overflow-y-auto">
|
||||
<span class="text-sm text-gray-500">변수를 불러오는 중...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 설명 -->
|
||||
<div>
|
||||
<label for="description" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
설명
|
||||
</label>
|
||||
<textarea name="description" id="description" 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"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- 정렬 순서 -->
|
||||
<div>
|
||||
<label for="sort_order" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
정렬 순서
|
||||
</label>
|
||||
<input type="number" name="sort_order" id="sort_order"
|
||||
min="1"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
|
||||
<!-- 활성 상태 -->
|
||||
<div class="flex items-center pt-6">
|
||||
<input type="checkbox" name="is_active" id="is_active" value="1"
|
||||
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
||||
<label for="is_active" class="ml-2 block text-sm text-gray-700">
|
||||
활성화
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 에러 메시지 -->
|
||||
<div id="errorMessage" class="hidden bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-700"></div>
|
||||
|
||||
<!-- 버튼 -->
|
||||
<div class="flex justify-end gap-3 pt-4 border-t">
|
||||
<a href="{{ route('quote-formulas.index') }}"
|
||||
class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition">
|
||||
취소
|
||||
</a>
|
||||
<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>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
const formulaId = {{ $id }};
|
||||
let availableVariables = [];
|
||||
|
||||
// 카테고리 로드
|
||||
async function loadCategories() {
|
||||
try {
|
||||
const response = await fetch('/api/admin/quote-formulas/categories/dropdown', {
|
||||
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' }
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.success && result.data) {
|
||||
const select = document.getElementById('category_id');
|
||||
result.data.forEach(cat => {
|
||||
const option = document.createElement('option');
|
||||
option.value = cat.id;
|
||||
option.textContent = cat.name;
|
||||
select.appendChild(option);
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('카테고리 로드 실패:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// 변수 목록 로드
|
||||
async function loadVariables() {
|
||||
try {
|
||||
const response = await fetch('/api/admin/quote-formulas/formulas/variables', {
|
||||
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' }
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.success && result.data) {
|
||||
availableVariables = result.data;
|
||||
renderVariables();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('변수 로드 실패:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// 변수 목록 렌더링
|
||||
function renderVariables() {
|
||||
const container = document.getElementById('variablesList');
|
||||
if (availableVariables.length === 0) {
|
||||
container.innerHTML = '<span class="text-sm text-gray-500">사용 가능한 변수가 없습니다.</span>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = availableVariables.map(v =>
|
||||
`<button type="button" onclick="insertVariable('${v.variable}')"
|
||||
class="px-2 py-1 bg-blue-100 text-blue-700 rounded text-xs font-mono hover:bg-blue-200"
|
||||
title="${v.name} (${v.category})">
|
||||
${v.variable}
|
||||
</button>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
// 변수 삽입
|
||||
function insertVariable(variable) {
|
||||
const formulaInput = document.getElementById('formula');
|
||||
const cursorPos = formulaInput.selectionStart;
|
||||
const textBefore = formulaInput.value.substring(0, cursorPos);
|
||||
const textAfter = formulaInput.value.substring(cursorPos);
|
||||
formulaInput.value = textBefore + variable + textAfter;
|
||||
formulaInput.focus();
|
||||
formulaInput.setSelectionRange(cursorPos + variable.length, cursorPos + variable.length);
|
||||
}
|
||||
|
||||
// 데이터 로드
|
||||
async function loadFormula() {
|
||||
await loadCategories();
|
||||
await loadVariables();
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/admin/quote-formulas/formulas/${formulaId}`, {
|
||||
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' }
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
const data = result.data;
|
||||
document.getElementById('category_id').value = data.category_id || '';
|
||||
document.getElementById('type').value = data.type || '';
|
||||
document.getElementById('name').value = data.name || '';
|
||||
document.getElementById('variable').value = data.variable || '';
|
||||
document.getElementById('formula').value = data.formula || '';
|
||||
document.getElementById('description').value = data.description || '';
|
||||
document.getElementById('sort_order').value = data.sort_order || '';
|
||||
document.getElementById('is_active').checked = data.is_active;
|
||||
|
||||
// 수식 유형에 따라 섹션 표시
|
||||
if (data.type === 'calculation') {
|
||||
document.getElementById('formulaSection').classList.remove('hidden');
|
||||
document.getElementById('variablesSection').classList.remove('hidden');
|
||||
}
|
||||
|
||||
document.getElementById('loading').classList.add('hidden');
|
||||
document.getElementById('formContainer').classList.remove('hidden');
|
||||
} else {
|
||||
alert(result.message || '데이터를 불러오는데 실패했습니다.');
|
||||
window.location.href = '{{ route("quote-formulas.index") }}';
|
||||
}
|
||||
} catch (err) {
|
||||
alert('서버 오류가 발생했습니다.');
|
||||
window.location.href = '{{ route("quote-formulas.index") }}';
|
||||
}
|
||||
}
|
||||
|
||||
// 수식 유형 변경
|
||||
document.getElementById('type').addEventListener('change', function() {
|
||||
const formulaSection = document.getElementById('formulaSection');
|
||||
const variablesSection = document.getElementById('variablesSection');
|
||||
|
||||
if (this.value === 'calculation') {
|
||||
formulaSection.classList.remove('hidden');
|
||||
variablesSection.classList.remove('hidden');
|
||||
} else {
|
||||
formulaSection.classList.add('hidden');
|
||||
variablesSection.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// 수식 검증
|
||||
async function validateFormula() {
|
||||
const formula = document.getElementById('formula').value;
|
||||
const resultSpan = document.getElementById('validateResult');
|
||||
|
||||
if (!formula) {
|
||||
resultSpan.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/quote-formulas/formulas/validate', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
},
|
||||
body: JSON.stringify({ formula })
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
resultSpan.innerHTML = '<span class="text-green-600">유효한 수식입니다.</span>';
|
||||
} else {
|
||||
resultSpan.innerHTML = `<span class="text-red-600">${result.data?.errors?.join(', ') || '유효하지 않은 수식입니다.'}</span>`;
|
||||
}
|
||||
} catch (err) {
|
||||
resultSpan.innerHTML = '<span class="text-red-600">검증 중 오류가 발생했습니다.</span>';
|
||||
}
|
||||
}
|
||||
|
||||
// 변수명 자동 대문자 변환
|
||||
document.getElementById('variable').addEventListener('input', function() {
|
||||
this.value = this.value.toUpperCase().replace(/[^A-Z0-9_]/g, '');
|
||||
});
|
||||
|
||||
// 폼 제출
|
||||
document.getElementById('formulaForm').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const errorDiv = document.getElementById('errorMessage');
|
||||
errorDiv.classList.add('hidden');
|
||||
|
||||
const formData = new FormData(this);
|
||||
const data = {
|
||||
category_id: parseInt(formData.get('category_id')),
|
||||
name: formData.get('name'),
|
||||
variable: formData.get('variable'),
|
||||
type: formData.get('type'),
|
||||
formula: formData.get('formula') || null,
|
||||
description: formData.get('description') || null,
|
||||
sort_order: formData.get('sort_order') ? parseInt(formData.get('sort_order')) : null,
|
||||
is_active: formData.has('is_active')
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/admin/quote-formulas/formulas/${formulaId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
window.location.href = '{{ route("quote-formulas.index") }}';
|
||||
} else {
|
||||
errorDiv.textContent = result.message || '저장에 실패했습니다.';
|
||||
errorDiv.classList.remove('hidden');
|
||||
}
|
||||
} catch (err) {
|
||||
errorDiv.textContent = '서버 오류가 발생했습니다.';
|
||||
errorDiv.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// 초기화
|
||||
loadFormula();
|
||||
</script>
|
||||
@endpush
|
||||
208
resources/views/quote-formulas/index.blade.php
Normal file
208
resources/views/quote-formulas/index.blade.php
Normal file
@@ -0,0 +1,208 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', '견적수식 관리')
|
||||
|
||||
@section('content')
|
||||
<div class="container mx-auto max-w-7xl">
|
||||
<!-- 헤더 -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-800">견적수식 관리</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">견적 산출에 사용되는 수식을 관리합니다.</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ route('quote-formulas.categories.index') }}"
|
||||
class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors">
|
||||
카테고리 관리
|
||||
</a>
|
||||
<a href="{{ route('quote-formulas.simulator') }}"
|
||||
class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors">
|
||||
시뮬레이터
|
||||
</a>
|
||||
<a href="{{ route('quote-formulas.create') }}"
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors">
|
||||
+ 수식 추가
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 필터 -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-4 mb-6">
|
||||
<form id="filterForm" class="flex flex-wrap gap-4 items-end">
|
||||
<!-- 카테고리 필터 -->
|
||||
<div class="w-48">
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">카테고리</label>
|
||||
<select name="category_id" id="categoryFilter" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="">전체</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 유형 필터 -->
|
||||
<div class="w-40">
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">수식 유형</label>
|
||||
<select name="type" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="">전체</option>
|
||||
<option value="input">입력값</option>
|
||||
<option value="calculation">계산식</option>
|
||||
<option value="range">범위별</option>
|
||||
<option value="mapping">매핑</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 활성 상태 필터 -->
|
||||
<div class="w-32">
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">상태</label>
|
||||
<select name="is_active" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="">전체</option>
|
||||
<option value="1">활성</option>
|
||||
<option value="0">비활성</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 삭제된 항목 포함 -->
|
||||
<div class="w-36">
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">삭제 포함</label>
|
||||
<select name="trashed" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="">활성만</option>
|
||||
<option value="with">삭제 포함</option>
|
||||
<option value="only">삭제만</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 검색 -->
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">검색</label>
|
||||
<input type="text" name="search"
|
||||
placeholder="수식명, 변수명, 수식 내용..."
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
|
||||
<!-- 검색 버튼 -->
|
||||
<button type="submit"
|
||||
class="bg-gray-800 hover:bg-gray-900 text-white px-6 py-2 rounded-lg text-sm font-medium transition-colors">
|
||||
검색
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 테이블 영역 (HTMX로 로드) -->
|
||||
<div id="formula-table"
|
||||
hx-get="/api/admin/quote-formulas/formulas"
|
||||
hx-trigger="load, filterSubmit from:body"
|
||||
hx-include="#filterForm"
|
||||
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'
|
||||
class="bg-white rounded-lg shadow-sm overflow-hidden">
|
||||
<!-- 로딩 스피너 -->
|
||||
<div class="flex justify-center items-center p-12">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
<script>
|
||||
// 카테고리 목록 로드
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
fetch('/api/admin/quote-formulas/categories/dropdown', {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success && data.data) {
|
||||
const select = document.getElementById('categoryFilter');
|
||||
data.data.forEach(cat => {
|
||||
const option = document.createElement('option');
|
||||
option.value = cat.id;
|
||||
option.textContent = cat.name;
|
||||
select.appendChild(option);
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('카테고리 로드 실패:', err));
|
||||
});
|
||||
|
||||
// 폼 제출 시 HTMX 이벤트 트리거
|
||||
document.getElementById('filterForm').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
htmx.trigger('#formula-table', 'filterSubmit');
|
||||
});
|
||||
|
||||
// 삭제 확인
|
||||
window.confirmDelete = function(id, name) {
|
||||
if (confirm(`"${name}" 수식을 삭제하시겠습니까?`)) {
|
||||
htmx.ajax('DELETE', `/api/admin/quote-formulas/formulas/${id}`, {
|
||||
target: '#formula-table',
|
||||
swap: 'none',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
}
|
||||
}).then(() => {
|
||||
htmx.trigger('#formula-table', 'filterSubmit');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 복원 확인
|
||||
window.confirmRestore = function(id, name) {
|
||||
if (confirm(`"${name}" 수식을 복원하시겠습니까?`)) {
|
||||
htmx.ajax('POST', `/api/admin/quote-formulas/formulas/${id}/restore`, {
|
||||
target: '#formula-table',
|
||||
swap: 'none',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
}
|
||||
}).then(() => {
|
||||
htmx.trigger('#formula-table', 'filterSubmit');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 영구삭제 확인
|
||||
window.confirmForceDelete = function(id, name) {
|
||||
if (confirm(`"${name}" 수식을 영구 삭제하시겠습니까?\n\n이 작업은 되돌릴 수 없습니다!`)) {
|
||||
htmx.ajax('DELETE', `/api/admin/quote-formulas/formulas/${id}/force`, {
|
||||
target: '#formula-table',
|
||||
swap: 'none',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
}
|
||||
}).then(() => {
|
||||
htmx.trigger('#formula-table', 'filterSubmit');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 활성/비활성 토글
|
||||
window.toggleActive = function(id) {
|
||||
htmx.ajax('POST', `/api/admin/quote-formulas/formulas/${id}/toggle-active`, {
|
||||
target: '#formula-table',
|
||||
swap: 'none',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
}
|
||||
}).then(() => {
|
||||
htmx.trigger('#formula-table', 'filterSubmit');
|
||||
});
|
||||
};
|
||||
|
||||
// 수식 복제
|
||||
window.duplicateFormula = function(id) {
|
||||
if (confirm('이 수식을 복제하시겠습니까?')) {
|
||||
htmx.ajax('POST', `/api/admin/quote-formulas/formulas/${id}/duplicate`, {
|
||||
target: '#formula-table',
|
||||
swap: 'none',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
}
|
||||
}).then(() => {
|
||||
htmx.trigger('#formula-table', 'filterSubmit');
|
||||
});
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@endpush
|
||||
160
resources/views/quote-formulas/partials/table.blade.php
Normal file
160
resources/views/quote-formulas/partials/table.blade.php
Normal file
@@ -0,0 +1,160 @@
|
||||
<!-- 수식 테이블 -->
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">카테고리</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">수식명</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">변수명</th>
|
||||
<th class="px-4 py-3 text-center text-xs font-semibold text-gray-600 uppercase tracking-wider">유형</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">수식/규칙</th>
|
||||
<th class="px-4 py-3 text-center text-xs font-semibold text-gray-600 uppercase tracking-wider">상태</th>
|
||||
<th class="px-4 py-3 text-center text-xs font-semibold text-gray-600 uppercase tracking-wider">순서</th>
|
||||
<th class="px-4 py-3 text-center text-xs font-semibold text-gray-600 uppercase tracking-wider">액션</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
@forelse($formulas as $formula)
|
||||
<tr class="{{ $formula->trashed() ? 'bg-red-50' : '' }} hover:bg-gray-50">
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
<span class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-indigo-100 text-indigo-800">
|
||||
{{ $formula->category->name ?? '-' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="text-sm font-medium text-gray-900">{{ $formula->name }}</div>
|
||||
@if($formula->description)
|
||||
<div class="text-xs text-gray-500 truncate max-w-xs">{{ $formula->description }}</div>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
<code class="px-2 py-1 bg-gray-100 rounded text-sm font-mono text-blue-600">{{ $formula->variable }}</code>
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-center">
|
||||
@switch($formula->type)
|
||||
@case('input')
|
||||
<span class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-green-100 text-green-800">
|
||||
<svg class="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
입력값
|
||||
</span>
|
||||
@break
|
||||
@case('calculation')
|
||||
<span class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-blue-100 text-blue-800">
|
||||
<svg class="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
계산식
|
||||
</span>
|
||||
@break
|
||||
@case('range')
|
||||
<span class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||
<svg class="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16m-7 6h7" />
|
||||
</svg>
|
||||
범위별
|
||||
</span>
|
||||
@break
|
||||
@case('mapping')
|
||||
<span class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-purple-100 text-purple-800">
|
||||
<svg class="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
</svg>
|
||||
매핑
|
||||
</span>
|
||||
@break
|
||||
@endswitch
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
@if($formula->type === 'calculation' && $formula->formula)
|
||||
<code class="text-xs text-gray-600 bg-gray-50 px-2 py-1 rounded block truncate max-w-xs" title="{{ $formula->formula }}">
|
||||
{{ Str::limit($formula->formula, 40) }}
|
||||
</code>
|
||||
@elseif($formula->type === 'range')
|
||||
<span class="text-xs text-gray-500">{{ $formula->ranges_count ?? 0 }}개 범위 규칙</span>
|
||||
@elseif($formula->type === 'mapping')
|
||||
<span class="text-xs text-gray-500">{{ $formula->mappings_count ?? 0 }}개 매핑 규칙</span>
|
||||
@else
|
||||
<span class="text-xs text-gray-400">-</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-center">
|
||||
@if($formula->trashed())
|
||||
<span class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-red-100 text-red-800">
|
||||
삭제됨
|
||||
</span>
|
||||
@elseif($formula->is_active)
|
||||
<button onclick="toggleActive({{ $formula->id }})"
|
||||
class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-green-100 text-green-800 hover:bg-green-200 cursor-pointer">
|
||||
활성
|
||||
</button>
|
||||
@else
|
||||
<button onclick="toggleActive({{ $formula->id }})"
|
||||
class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-gray-100 text-gray-800 hover:bg-gray-200 cursor-pointer">
|
||||
비활성
|
||||
</button>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-center text-sm text-gray-500">
|
||||
{{ $formula->sort_order }}
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-center">
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
@if($formula->trashed())
|
||||
<button onclick="confirmRestore({{ $formula->id }}, '{{ $formula->name }}')"
|
||||
class="p-1.5 text-green-600 hover:bg-green-50 rounded" title="복원">
|
||||
<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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</button>
|
||||
@if(auth()->user()?->is_super_admin)
|
||||
<button onclick="confirmForceDelete({{ $formula->id }}, '{{ $formula->name }}')"
|
||||
class="p-1.5 text-red-600 hover:bg-red-50 rounded" title="영구삭제">
|
||||
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
@endif
|
||||
@else
|
||||
<a href="{{ route('quote-formulas.edit', $formula->id) }}"
|
||||
class="p-1.5 text-indigo-600 hover:bg-indigo-50 rounded" title="수정">
|
||||
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</a>
|
||||
<button onclick="duplicateFormula({{ $formula->id }})"
|
||||
class="p-1.5 text-gray-600 hover:bg-gray-50 rounded" title="복제">
|
||||
<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="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button onclick="confirmDelete({{ $formula->id }}, '{{ $formula->name }}')"
|
||||
class="p-1.5 text-red-600 hover:bg-red-50 rounded" title="삭제">
|
||||
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="8" class="px-6 py-12 text-center text-gray-500">
|
||||
<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="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<p class="text-sm font-medium">등록된 수식이 없습니다.</p>
|
||||
<p class="text-xs mt-1">새 수식을 추가해주세요.</p>
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- 페이지네이션 -->
|
||||
@if($formulas->hasPages())
|
||||
<div class="px-6 py-4 border-t border-gray-200">
|
||||
{{ $formulas->withQueryString()->links() }}
|
||||
</div>
|
||||
@endif
|
||||
355
resources/views/quote-formulas/simulator.blade.php
Normal file
355
resources/views/quote-formulas/simulator.blade.php
Normal file
@@ -0,0 +1,355 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', '수식 시뮬레이터')
|
||||
|
||||
@section('content')
|
||||
<div class="container mx-auto max-w-6xl">
|
||||
<!-- 헤더 -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-800">수식 시뮬레이터</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">입력값을 넣어 전체 수식 실행 결과를 테스트합니다.</p>
|
||||
</div>
|
||||
<a href="{{ route('quote-formulas.index') }}"
|
||||
class="text-gray-600 hover:text-gray-800 text-sm font-medium">
|
||||
← 수식 목록
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- 입력 영역 -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-800 mb-4 pb-2 border-b">입력값</h2>
|
||||
|
||||
<!-- 로딩 -->
|
||||
<div id="inputLoading" class="text-center py-8">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p class="text-gray-500 mt-2 text-sm">입력 변수를 불러오는 중...</p>
|
||||
</div>
|
||||
|
||||
<!-- 입력 폼 -->
|
||||
<form id="simulatorForm" class="space-y-4 hidden">
|
||||
<!-- 동적으로 생성될 입력 필드 -->
|
||||
<div id="inputFields" class="space-y-4"></div>
|
||||
|
||||
<!-- 에러 메시지 -->
|
||||
<div id="errorMessage" class="hidden bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-700"></div>
|
||||
|
||||
<!-- 실행 버튼 -->
|
||||
<button type="submit" id="runButton"
|
||||
class="w-full bg-blue-600 hover:bg-blue-700 text-white py-2 rounded-lg font-medium transition-colors flex items-center justify-center gap-2">
|
||||
<span>수식 실행</span>
|
||||
<svg id="runSpinner" class="hidden animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- 입력 변수 없음 -->
|
||||
<div id="noInputs" class="hidden text-center py-8 text-gray-500">
|
||||
<p>입력 변수가 없습니다.</p>
|
||||
<a href="{{ route('quote-formulas.create') }}" class="text-blue-600 hover:text-blue-700 text-sm mt-2 inline-block">
|
||||
수식 추가하기 →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 결과 영역 -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-800 mb-4 pb-2 border-b">실행 결과</h2>
|
||||
|
||||
<!-- 초기 상태 -->
|
||||
<div id="resultEmpty" class="text-center text-gray-400 py-12">
|
||||
<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="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<p>입력값을 넣고 수식을 실행하세요</p>
|
||||
</div>
|
||||
|
||||
<!-- 로딩 -->
|
||||
<div id="resultLoading" class="hidden text-center py-12">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p class="text-gray-500 mt-4">수식을 실행하는 중...</p>
|
||||
</div>
|
||||
|
||||
<!-- 결과 표시 -->
|
||||
<div id="resultContainer" class="hidden space-y-6">
|
||||
<!-- 계산된 변수값 -->
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-700 mb-2">계산된 변수</h3>
|
||||
<div id="calculatedVariables" class="space-y-2 max-h-60 overflow-y-auto"></div>
|
||||
</div>
|
||||
|
||||
<!-- 생성된 품목 -->
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-700 mb-2">생성된 품목</h3>
|
||||
<div id="generatedItems" class="space-y-2 max-h-60 overflow-y-auto"></div>
|
||||
</div>
|
||||
|
||||
<!-- 에러가 있을 경우 -->
|
||||
<div id="resultErrors" class="hidden">
|
||||
<h3 class="text-sm font-medium text-red-700 mb-2">오류</h3>
|
||||
<div id="errorList" class="space-y-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 수식 실행 순서 -->
|
||||
<div class="bg-gray-50 rounded-lg p-6 mt-6">
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-3">수식 실행 순서 (카테고리 순)</h3>
|
||||
<div id="categoryOrder" class="flex flex-wrap gap-2">
|
||||
<span class="text-sm text-gray-500">카테고리 로딩 중...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
let inputVariables = [];
|
||||
let categories = [];
|
||||
|
||||
// 초기화
|
||||
async function init() {
|
||||
await Promise.all([
|
||||
loadInputVariables(),
|
||||
loadCategories()
|
||||
]);
|
||||
}
|
||||
|
||||
// 입력 변수 로드
|
||||
async function loadInputVariables() {
|
||||
try {
|
||||
const response = await fetch('/api/admin/quote-formulas/formulas/variables', {
|
||||
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' }
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
document.getElementById('inputLoading').classList.add('hidden');
|
||||
|
||||
if (result.success && result.data) {
|
||||
// type이 'input'인 변수만 필터링
|
||||
inputVariables = result.data.filter(v => v.type === 'input');
|
||||
|
||||
if (inputVariables.length === 0) {
|
||||
document.getElementById('noInputs').classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
renderInputFields();
|
||||
document.getElementById('simulatorForm').classList.remove('hidden');
|
||||
} else {
|
||||
document.getElementById('noInputs').classList.remove('hidden');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('입력 변수 로드 실패:', err);
|
||||
document.getElementById('inputLoading').classList.add('hidden');
|
||||
document.getElementById('noInputs').classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// 카테고리 순서 로드
|
||||
async function loadCategories() {
|
||||
try {
|
||||
const response = await fetch('/api/admin/quote-formulas/categories/dropdown', {
|
||||
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' }
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && result.data) {
|
||||
categories = result.data;
|
||||
renderCategoryOrder();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('카테고리 로드 실패:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// 입력 필드 렌더링
|
||||
function renderInputFields() {
|
||||
const container = document.getElementById('inputFields');
|
||||
|
||||
// 카테고리별로 그룹화
|
||||
const grouped = {};
|
||||
inputVariables.forEach(v => {
|
||||
const category = v.category || '기타';
|
||||
if (!grouped[category]) grouped[category] = [];
|
||||
grouped[category].push(v);
|
||||
});
|
||||
|
||||
let html = '';
|
||||
for (const [category, vars] of Object.entries(grouped)) {
|
||||
html += `
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-sm font-medium text-gray-600 bg-gray-50 px-3 py-2 rounded-lg">${category}</h3>
|
||||
<div class="grid grid-cols-2 gap-3 pl-2">
|
||||
`;
|
||||
|
||||
vars.forEach(v => {
|
||||
const defaultValue = v.default_value || '';
|
||||
html += `
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1" title="${v.description || ''}">
|
||||
${v.variable}
|
||||
<span class="text-gray-400 font-normal">${v.name ? `(${v.name})` : ''}</span>
|
||||
</label>
|
||||
<input type="number"
|
||||
name="${v.variable}"
|
||||
value="${defaultValue}"
|
||||
step="any"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="${v.variable}">
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
html += `
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
// 카테고리 순서 렌더링
|
||||
function renderCategoryOrder() {
|
||||
const container = document.getElementById('categoryOrder');
|
||||
|
||||
if (categories.length === 0) {
|
||||
container.innerHTML = '<span class="text-sm text-gray-500">등록된 카테고리가 없습니다.</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = categories.map((cat, index) => `
|
||||
<span class="inline-flex items-center gap-1 px-3 py-1 bg-white border border-gray-200 rounded-full text-xs text-gray-600">
|
||||
<span class="w-5 h-5 flex items-center justify-center bg-gray-200 rounded-full text-xs font-medium">${index + 1}</span>
|
||||
${cat.name}
|
||||
</span>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// 폼 제출 (수식 실행)
|
||||
document.getElementById('simulatorForm').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const errorDiv = document.getElementById('errorMessage');
|
||||
errorDiv.classList.add('hidden');
|
||||
|
||||
// 입력값 수집
|
||||
const formData = new FormData(this);
|
||||
const inputs = {};
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (value !== '') {
|
||||
inputs[key] = parseFloat(value);
|
||||
}
|
||||
}
|
||||
|
||||
// UI 상태 변경
|
||||
document.getElementById('runButton').disabled = true;
|
||||
document.getElementById('runSpinner').classList.remove('hidden');
|
||||
document.getElementById('resultEmpty').classList.add('hidden');
|
||||
document.getElementById('resultLoading').classList.remove('hidden');
|
||||
document.getElementById('resultContainer').classList.add('hidden');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/quote-formulas/formulas/simulate', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
},
|
||||
body: JSON.stringify({ inputs })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
document.getElementById('resultLoading').classList.add('hidden');
|
||||
|
||||
if (response.ok && result.success) {
|
||||
renderResults(result.data);
|
||||
document.getElementById('resultContainer').classList.remove('hidden');
|
||||
} else {
|
||||
errorDiv.textContent = result.message || '수식 실행에 실패했습니다.';
|
||||
errorDiv.classList.remove('hidden');
|
||||
document.getElementById('resultEmpty').classList.remove('hidden');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('수식 실행 오류:', err);
|
||||
document.getElementById('resultLoading').classList.add('hidden');
|
||||
errorDiv.textContent = '서버 오류가 발생했습니다.';
|
||||
errorDiv.classList.remove('hidden');
|
||||
document.getElementById('resultEmpty').classList.remove('hidden');
|
||||
} finally {
|
||||
document.getElementById('runButton').disabled = false;
|
||||
document.getElementById('runSpinner').classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// 결과 렌더링
|
||||
function renderResults(data) {
|
||||
// 계산된 변수
|
||||
const variablesContainer = document.getElementById('calculatedVariables');
|
||||
const variables = data.variables || {};
|
||||
const variableKeys = Object.keys(variables);
|
||||
|
||||
if (variableKeys.length === 0) {
|
||||
variablesContainer.innerHTML = '<p class="text-sm text-gray-500">계산된 변수가 없습니다.</p>';
|
||||
} else {
|
||||
variablesContainer.innerHTML = variableKeys.map(key => {
|
||||
const value = variables[key];
|
||||
const formattedValue = typeof value === 'number' ? value.toLocaleString() : value;
|
||||
return `
|
||||
<div class="flex justify-between items-center py-2 px-3 bg-gray-50 rounded text-sm">
|
||||
<span class="font-mono text-gray-700">${key}</span>
|
||||
<span class="font-semibold text-blue-600">${formattedValue}</span>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// 생성된 품목
|
||||
const itemsContainer = document.getElementById('generatedItems');
|
||||
const items = data.items || [];
|
||||
|
||||
if (items.length === 0) {
|
||||
itemsContainer.innerHTML = '<p class="text-sm text-gray-500">생성된 품목이 없습니다.</p>';
|
||||
} else {
|
||||
itemsContainer.innerHTML = items.map(item => `
|
||||
<div class="flex justify-between items-center py-2 px-3 bg-green-50 rounded text-sm">
|
||||
<div>
|
||||
<span class="font-medium text-gray-800">${item.name || item.item_code}</span>
|
||||
${item.specification ? `<span class="text-gray-500 ml-2">${item.specification}</span>` : ''}
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<span class="font-semibold text-green-600">${item.quantity || 0}</span>
|
||||
<span class="text-gray-500 ml-1">${item.unit || 'EA'}</span>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// 오류 처리
|
||||
const errors = data.errors || [];
|
||||
const errorsContainer = document.getElementById('resultErrors');
|
||||
const errorList = document.getElementById('errorList');
|
||||
|
||||
if (errors.length > 0) {
|
||||
errorList.innerHTML = errors.map(err => `
|
||||
<div class="py-2 px-3 bg-red-50 rounded text-sm text-red-700">
|
||||
${err}
|
||||
</div>
|
||||
`).join('');
|
||||
errorsContainer.classList.remove('hidden');
|
||||
} else {
|
||||
errorsContainer.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// 초기화 실행
|
||||
init();
|
||||
</script>
|
||||
@endpush
|
||||
@@ -10,6 +10,8 @@
|
||||
use App\Http\Controllers\Api\Admin\ProjectManagement\IssueController as PmIssueController;
|
||||
use App\Http\Controllers\Api\Admin\ProjectManagement\ProjectController as PmProjectController;
|
||||
use App\Http\Controllers\Api\Admin\ProjectManagement\TaskController as PmTaskController;
|
||||
use App\Http\Controllers\Api\Admin\Quote\QuoteFormulaCategoryController;
|
||||
use App\Http\Controllers\Api\Admin\Quote\QuoteFormulaController;
|
||||
use App\Http\Controllers\Api\Admin\RoleController;
|
||||
use App\Http\Controllers\Api\Admin\RolePermissionController;
|
||||
use App\Http\Controllers\Api\Admin\TenantController;
|
||||
@@ -389,4 +391,71 @@
|
||||
Route::put('/{entryId}/status', [DailyLogController::class, 'updateEntryStatus'])->name('updateStatus');
|
||||
Route::delete('/{entryId}', [DailyLogController::class, 'deleteEntry'])->name('delete');
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 견적수식 관리 API
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
Route::prefix('quote-formulas')->name('quote-formulas.')->group(function () {
|
||||
|
||||
// 카테고리 관리 API
|
||||
Route::prefix('categories')->name('categories.')->group(function () {
|
||||
// 고정 경로
|
||||
Route::get('/stats', [QuoteFormulaCategoryController::class, 'stats'])->name('stats');
|
||||
Route::get('/dropdown', [QuoteFormulaCategoryController::class, 'dropdown'])->name('dropdown');
|
||||
Route::post('/reorder', [QuoteFormulaCategoryController::class, 'reorder'])->name('reorder');
|
||||
|
||||
// 기본 CRUD
|
||||
Route::get('/', [QuoteFormulaCategoryController::class, 'index'])->name('index');
|
||||
Route::post('/', [QuoteFormulaCategoryController::class, 'store'])->name('store');
|
||||
Route::get('/{id}', [QuoteFormulaCategoryController::class, 'show'])->name('show');
|
||||
Route::put('/{id}', [QuoteFormulaCategoryController::class, 'update'])->name('update');
|
||||
Route::delete('/{id}', [QuoteFormulaCategoryController::class, 'destroy'])->name('destroy');
|
||||
|
||||
// 복원 (일반관리자 가능)
|
||||
Route::post('/{id}/restore', [QuoteFormulaCategoryController::class, 'restore'])->name('restore');
|
||||
|
||||
// 슈퍼관리자 전용 액션 (영구삭제)
|
||||
Route::middleware('super.admin')->group(function () {
|
||||
Route::delete('/{id}/force', [QuoteFormulaCategoryController::class, 'forceDestroy'])->name('forceDestroy');
|
||||
});
|
||||
|
||||
// 추가 액션
|
||||
Route::post('/{id}/toggle-active', [QuoteFormulaCategoryController::class, 'toggleActive'])->name('toggleActive');
|
||||
});
|
||||
|
||||
// 수식 관리 API
|
||||
Route::prefix('formulas')->name('formulas.')->group(function () {
|
||||
// 고정 경로
|
||||
Route::get('/stats', [QuoteFormulaController::class, 'stats'])->name('stats');
|
||||
Route::get('/variables', [QuoteFormulaController::class, 'variables'])->name('variables');
|
||||
Route::post('/reorder', [QuoteFormulaController::class, 'reorder'])->name('reorder');
|
||||
Route::post('/validate', [QuoteFormulaController::class, 'validate'])->name('validate');
|
||||
Route::post('/test', [QuoteFormulaController::class, 'test'])->name('test');
|
||||
Route::post('/simulate', [QuoteFormulaController::class, 'simulate'])->name('simulate');
|
||||
|
||||
// 기본 CRUD
|
||||
Route::get('/', [QuoteFormulaController::class, 'index'])->name('index');
|
||||
Route::post('/', [QuoteFormulaController::class, 'store'])->name('store');
|
||||
Route::get('/{id}', [QuoteFormulaController::class, 'show'])->name('show');
|
||||
Route::put('/{id}', [QuoteFormulaController::class, 'update'])->name('update');
|
||||
Route::delete('/{id}', [QuoteFormulaController::class, 'destroy'])->name('destroy');
|
||||
|
||||
// 복원 (일반관리자 가능)
|
||||
Route::post('/{id}/restore', [QuoteFormulaController::class, 'restore'])->name('restore');
|
||||
|
||||
// 슈퍼관리자 전용 액션 (영구삭제)
|
||||
Route::middleware('super.admin')->group(function () {
|
||||
Route::delete('/{id}/force', [QuoteFormulaController::class, 'forceDestroy'])->name('forceDestroy');
|
||||
});
|
||||
|
||||
// 추가 액션
|
||||
Route::post('/{id}/toggle-active', [QuoteFormulaController::class, 'toggleActive'])->name('toggleActive');
|
||||
Route::post('/{id}/duplicate', [QuoteFormulaController::class, 'duplicate'])->name('duplicate');
|
||||
|
||||
// 카테고리별
|
||||
Route::get('/category/{categoryId}', [QuoteFormulaController::class, 'byCategory'])->name('byCategory');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
use App\Http\Controllers\PostController;
|
||||
use App\Http\Controllers\ProfileController;
|
||||
use App\Http\Controllers\ProjectManagementController;
|
||||
use App\Http\Controllers\QuoteFormulaController;
|
||||
use App\Http\Controllers\RoleController;
|
||||
use App\Http\Controllers\RolePermissionController;
|
||||
use App\Http\Controllers\TenantController;
|
||||
@@ -163,6 +164,22 @@
|
||||
Route::get('/{id}', [DailyLogController::class, 'show'])->name('show');
|
||||
});
|
||||
|
||||
// 견적수식 관리 (Blade 화면만)
|
||||
Route::prefix('quote-formulas')->name('quote-formulas.')->group(function () {
|
||||
// 수식 관리
|
||||
Route::get('/', [QuoteFormulaController::class, 'index'])->name('index');
|
||||
Route::get('/create', [QuoteFormulaController::class, 'create'])->name('create');
|
||||
Route::get('/{id}/edit', [QuoteFormulaController::class, 'edit'])->name('edit');
|
||||
|
||||
// 카테고리 관리
|
||||
Route::get('/categories', [QuoteFormulaController::class, 'categories'])->name('categories.index');
|
||||
Route::get('/categories/create', [QuoteFormulaController::class, 'createCategory'])->name('categories.create');
|
||||
Route::get('/categories/{id}/edit', [QuoteFormulaController::class, 'editCategory'])->name('categories.edit');
|
||||
|
||||
// 시뮬레이터
|
||||
Route::get('/simulator', [QuoteFormulaController::class, 'simulator'])->name('simulator');
|
||||
});
|
||||
|
||||
// 대시보드
|
||||
Route::get('/dashboard', function () {
|
||||
return view('dashboard.index');
|
||||
|
||||
Reference in New Issue
Block a user