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:
2025-12-04 14:00:24 +09:00
parent 477779f3ac
commit dac02f120b
28 changed files with 6489 additions and 0 deletions

View File

@@ -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,
]);
}
}

View 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,
]);
}
}

View 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');
}
}

View 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 이상이어야 합니다.',
];
}
}

View 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' => '유효하지 않은 출력 유형입니다.',
];
}
}

View File

@@ -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 이상이어야 합니다.',
];
}
}

View 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' => '유효하지 않은 출력 유형입니다.',
];
}
}

View 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,
};
}
}

View 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');
}
}

View 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;
}
}

View 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}'";
}
}

View 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}";
}
}

View 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 = [];
}
}

View 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(),
];
}
}

View 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,
]);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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;">

View 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">
&larr; 목록으로
</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

View 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">
&larr; 목록으로
</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

View 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

View File

@@ -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

View 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">
&larr; 목록으로
</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

View 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">
&larr; 목록으로
</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

View 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

View 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

View 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">
&larr; 수식 목록
</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

View File

@@ -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');
});
});
});

View File

@@ -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');