feat(MNG): 채번 규칙 관리 기능 추가
- NumberingRule 모델, 서비스, 컨트롤러 추가 - API/Blade 라우트 등록 - CRUD + 미리보기 기능 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
101
app/Http/Controllers/Api/Admin/NumberingRuleController.php
Normal file
101
app/Http/Controllers/Api/Admin/NumberingRuleController.php
Normal file
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\StoreNumberingRuleRequest;
|
||||
use App\Http\Requests\UpdateNumberingRuleRequest;
|
||||
use App\Services\NumberingRuleService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class NumberingRuleController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly NumberingRuleService $numberingRuleService
|
||||
) {}
|
||||
|
||||
public function index(Request $request): JsonResponse|\Illuminate\View\View
|
||||
{
|
||||
$rules = $this->numberingRuleService->getRules(
|
||||
$request->all(),
|
||||
$request->integer('per_page', 20)
|
||||
);
|
||||
|
||||
if ($request->header('HX-Request')) {
|
||||
return view('numbering.partials.table', compact('rules'));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $rules->items(),
|
||||
'meta' => [
|
||||
'current_page' => $rules->currentPage(),
|
||||
'last_page' => $rules->lastPage(),
|
||||
'per_page' => $rules->perPage(),
|
||||
'total' => $rules->total(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(StoreNumberingRuleRequest $request): JsonResponse
|
||||
{
|
||||
$rule = $this->numberingRuleService->createRule($request->validated());
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '채번 규칙이 생성되었습니다.',
|
||||
'redirect' => route('numbering-rules.index'),
|
||||
'data' => $rule,
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function update(UpdateNumberingRuleRequest $request, int $id): JsonResponse
|
||||
{
|
||||
$result = $this->numberingRuleService->updateRule($id, $request->validated());
|
||||
|
||||
if (! $result) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '채번 규칙 수정에 실패했습니다.',
|
||||
], 400);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '채번 규칙이 수정되었습니다.',
|
||||
'redirect' => route('numbering-rules.index'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$result = $this->numberingRuleService->deleteRule($id);
|
||||
|
||||
if (! $result) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '채번 규칙 삭제에 실패했습니다.',
|
||||
], 400);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '채번 규칙이 삭제되었습니다.',
|
||||
'action' => 'remove',
|
||||
]);
|
||||
}
|
||||
|
||||
public function preview(Request $request): JsonResponse
|
||||
{
|
||||
$pattern = $request->input('pattern', []);
|
||||
$sequencePadding = $request->integer('sequence_padding', 2);
|
||||
|
||||
$preview = $this->numberingRuleService->generatePreview($pattern, $sequencePadding);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'preview' => $preview,
|
||||
]);
|
||||
}
|
||||
}
|
||||
51
app/Http/Controllers/NumberingRuleController.php
Normal file
51
app/Http/Controllers/NumberingRuleController.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\NumberingRule;
|
||||
use App\Services\NumberingRuleService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class NumberingRuleController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly NumberingRuleService $numberingRuleService
|
||||
) {}
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
return view('numbering.index', [
|
||||
'documentTypes' => NumberingRule::documentTypes(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
$usedTypes = $this->numberingRuleService->getUsedDocumentTypes();
|
||||
|
||||
return view('numbering.create', [
|
||||
'documentTypes' => NumberingRule::documentTypes(),
|
||||
'resetPeriods' => NumberingRule::resetPeriods(),
|
||||
'usedDocumentTypes' => $usedTypes,
|
||||
]);
|
||||
}
|
||||
|
||||
public function edit(int $id): View
|
||||
{
|
||||
$rule = $this->numberingRuleService->getRule($id);
|
||||
|
||||
if (! $rule) {
|
||||
abort(404, '채번 규칙을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
$usedTypes = $this->numberingRuleService->getUsedDocumentTypes($id);
|
||||
|
||||
return view('numbering.edit', [
|
||||
'rule' => $rule,
|
||||
'documentTypes' => NumberingRule::documentTypes(),
|
||||
'resetPeriods' => NumberingRule::resetPeriods(),
|
||||
'usedDocumentTypes' => $usedTypes,
|
||||
]);
|
||||
}
|
||||
}
|
||||
84
app/Http/Requests/StoreNumberingRuleRequest.php
Normal file
84
app/Http/Requests/StoreNumberingRuleRequest.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Models\NumberingRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreNumberingRuleRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
$validTypes = array_keys(NumberingRule::documentTypes());
|
||||
$validResets = array_keys(NumberingRule::resetPeriods());
|
||||
|
||||
return [
|
||||
'document_type' => [
|
||||
'required',
|
||||
'string',
|
||||
Rule::in($validTypes),
|
||||
Rule::unique('numbering_rules', 'document_type')
|
||||
->where('tenant_id', $tenantId),
|
||||
],
|
||||
'rule_name' => 'nullable|string|max:100',
|
||||
'reset_period' => ['required', 'string', Rule::in($validResets)],
|
||||
'sequence_padding' => 'required|integer|min:1|max:10',
|
||||
'is_active' => 'nullable|boolean',
|
||||
|
||||
'pattern' => 'required|array|min:1',
|
||||
'pattern.*.type' => ['required', 'string', Rule::in([
|
||||
'static', 'separator', 'date', 'param', 'mapping', 'sequence',
|
||||
])],
|
||||
'pattern.*.value' => 'required_if:pattern.*.type,static|required_if:pattern.*.type,separator|nullable|string|max:50',
|
||||
'pattern.*.format' => 'required_if:pattern.*.type,date|nullable|string|max:20',
|
||||
'pattern.*.key' => 'required_if:pattern.*.type,param|required_if:pattern.*.type,mapping|nullable|string|max:50',
|
||||
'pattern.*.default' => 'nullable|string|max:50',
|
||||
'pattern.*.map' => 'required_if:pattern.*.type,mapping|nullable|array',
|
||||
'pattern.*.map.*' => 'nullable|string|max:50',
|
||||
];
|
||||
}
|
||||
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'document_type' => '문서유형',
|
||||
'rule_name' => '규칙명',
|
||||
'reset_period' => '리셋 주기',
|
||||
'sequence_padding' => '시퀀스 자릿수',
|
||||
'is_active' => '활성 상태',
|
||||
'pattern' => '패턴',
|
||||
'pattern.*.type' => '세그먼트 타입',
|
||||
'pattern.*.value' => '세그먼트 값',
|
||||
'pattern.*.format' => '날짜 포맷',
|
||||
'pattern.*.key' => '파라미터 키',
|
||||
'pattern.*.default' => '기본값',
|
||||
'pattern.*.map' => '매핑 테이블',
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'document_type.required' => '문서유형은 필수입니다.',
|
||||
'document_type.unique' => '이 문서유형에 대한 규칙이 이미 존재합니다.',
|
||||
'document_type.in' => '유효하지 않은 문서유형입니다.',
|
||||
'pattern.required' => '최소 1개 이상의 세그먼트가 필요합니다.',
|
||||
'pattern.min' => '최소 1개 이상의 세그먼트가 필요합니다.',
|
||||
'pattern.*.type.required' => '세그먼트 타입은 필수입니다.',
|
||||
'pattern.*.type.in' => '유효하지 않은 세그먼트 타입입니다.',
|
||||
'pattern.*.value.required_if' => '이 세그먼트 타입에는 값이 필요합니다.',
|
||||
'pattern.*.format.required_if' => '날짜 타입에는 포맷이 필요합니다.',
|
||||
'pattern.*.key.required_if' => '이 세그먼트 타입에는 키가 필요합니다.',
|
||||
'pattern.*.map.required_if' => '매핑 타입에는 매핑 테이블이 필요합니다.',
|
||||
'sequence_padding.min' => '시퀀스 자릿수는 최소 1 이상이어야 합니다.',
|
||||
'sequence_padding.max' => '시퀀스 자릿수는 최대 10까지입니다.',
|
||||
];
|
||||
}
|
||||
}
|
||||
82
app/Http/Requests/UpdateNumberingRuleRequest.php
Normal file
82
app/Http/Requests/UpdateNumberingRuleRequest.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Models\NumberingRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateNumberingRuleRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
$ruleId = $this->route('id');
|
||||
$validTypes = array_keys(NumberingRule::documentTypes());
|
||||
$validResets = array_keys(NumberingRule::resetPeriods());
|
||||
|
||||
return [
|
||||
'document_type' => [
|
||||
'required',
|
||||
'string',
|
||||
Rule::in($validTypes),
|
||||
Rule::unique('numbering_rules', 'document_type')
|
||||
->where('tenant_id', $tenantId)
|
||||
->ignore($ruleId),
|
||||
],
|
||||
'rule_name' => 'nullable|string|max:100',
|
||||
'reset_period' => ['required', 'string', Rule::in($validResets)],
|
||||
'sequence_padding' => 'required|integer|min:1|max:10',
|
||||
'is_active' => 'nullable|boolean',
|
||||
|
||||
'pattern' => 'required|array|min:1',
|
||||
'pattern.*.type' => ['required', 'string', Rule::in([
|
||||
'static', 'separator', 'date', 'param', 'mapping', 'sequence',
|
||||
])],
|
||||
'pattern.*.value' => 'required_if:pattern.*.type,static|required_if:pattern.*.type,separator|nullable|string|max:50',
|
||||
'pattern.*.format' => 'required_if:pattern.*.type,date|nullable|string|max:20',
|
||||
'pattern.*.key' => 'required_if:pattern.*.type,param|required_if:pattern.*.type,mapping|nullable|string|max:50',
|
||||
'pattern.*.default' => 'nullable|string|max:50',
|
||||
'pattern.*.map' => 'required_if:pattern.*.type,mapping|nullable|array',
|
||||
'pattern.*.map.*' => 'nullable|string|max:50',
|
||||
];
|
||||
}
|
||||
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'document_type' => '문서유형',
|
||||
'rule_name' => '규칙명',
|
||||
'reset_period' => '리셋 주기',
|
||||
'sequence_padding' => '시퀀스 자릿수',
|
||||
'is_active' => '활성 상태',
|
||||
'pattern' => '패턴',
|
||||
'pattern.*.type' => '세그먼트 타입',
|
||||
'pattern.*.value' => '세그먼트 값',
|
||||
'pattern.*.format' => '날짜 포맷',
|
||||
'pattern.*.key' => '파라미터 키',
|
||||
'pattern.*.default' => '기본값',
|
||||
'pattern.*.map' => '매핑 테이블',
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'document_type.required' => '문서유형은 필수입니다.',
|
||||
'document_type.unique' => '이 문서유형에 대한 규칙이 이미 존재합니다.',
|
||||
'document_type.in' => '유효하지 않은 문서유형입니다.',
|
||||
'pattern.required' => '최소 1개 이상의 세그먼트가 필요합니다.',
|
||||
'pattern.min' => '최소 1개 이상의 세그먼트가 필요합니다.',
|
||||
'pattern.*.type.required' => '세그먼트 타입은 필수입니다.',
|
||||
'pattern.*.type.in' => '유효하지 않은 세그먼트 타입입니다.',
|
||||
'sequence_padding.min' => '시퀀스 자릿수는 최소 1 이상이어야 합니다.',
|
||||
'sequence_padding.max' => '시퀀스 자릿수는 최대 10까지입니다.',
|
||||
];
|
||||
}
|
||||
}
|
||||
90
app/Models/NumberingRule.php
Normal file
90
app/Models/NumberingRule.php
Normal file
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class NumberingRule extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'document_type',
|
||||
'rule_name',
|
||||
'pattern',
|
||||
'reset_period',
|
||||
'sequence_padding',
|
||||
'is_active',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'pattern' => 'array',
|
||||
'is_active' => 'boolean',
|
||||
'sequence_padding' => 'integer',
|
||||
];
|
||||
|
||||
// SoftDeletes 없음 (DB에 deleted_at 컬럼 없음) → Hard Delete
|
||||
|
||||
const DOC_QUOTE = 'quote';
|
||||
const DOC_ORDER = 'order';
|
||||
const DOC_SALE = 'sale';
|
||||
const DOC_WORK_ORDER = 'work_order';
|
||||
const DOC_MATERIAL_RECEIPT = 'material_receipt';
|
||||
|
||||
public static function documentTypes(): array
|
||||
{
|
||||
return [
|
||||
self::DOC_QUOTE => '견적',
|
||||
self::DOC_ORDER => '수주',
|
||||
self::DOC_SALE => '매출',
|
||||
self::DOC_WORK_ORDER => '작업지시',
|
||||
self::DOC_MATERIAL_RECEIPT => '원자재수입검사',
|
||||
];
|
||||
}
|
||||
|
||||
public static function resetPeriods(): array
|
||||
{
|
||||
return [
|
||||
'daily' => '일별',
|
||||
'monthly' => '월별',
|
||||
'yearly' => '연별',
|
||||
'never' => '리셋안함',
|
||||
];
|
||||
}
|
||||
|
||||
public function getPreviewAttribute(): string
|
||||
{
|
||||
if (empty($this->pattern) || ! is_array($this->pattern)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$result = '';
|
||||
foreach ($this->pattern as $segment) {
|
||||
$result .= match ($segment['type'] ?? '') {
|
||||
'static' => $segment['value'] ?? '',
|
||||
'separator' => $segment['value'] ?? '',
|
||||
'date' => now()->format($segment['format'] ?? 'ymd'),
|
||||
'param' => $segment['default'] ?? '{' . ($segment['key'] ?? '?') . '}',
|
||||
'mapping' => $segment['default'] ?? '{' . ($segment['key'] ?? '?') . '}',
|
||||
'sequence' => str_pad('1', $this->sequence_padding, '0', STR_PAD_LEFT),
|
||||
default => '',
|
||||
};
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function getDocumentTypeLabelAttribute(): string
|
||||
{
|
||||
return self::documentTypes()[$this->document_type] ?? $this->document_type;
|
||||
}
|
||||
|
||||
public function getResetPeriodLabelAttribute(): string
|
||||
{
|
||||
return self::resetPeriods()[$this->reset_period] ?? $this->reset_period;
|
||||
}
|
||||
}
|
||||
118
app/Services/NumberingRuleService.php
Normal file
118
app/Services/NumberingRuleService.php
Normal file
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\NumberingRule;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
|
||||
class NumberingRuleService
|
||||
{
|
||||
public function getRules(array $filters = [], int $perPage = 20): LengthAwarePaginator
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
$query = NumberingRule::query();
|
||||
|
||||
if ($tenantId) {
|
||||
$query->where('tenant_id', $tenantId);
|
||||
}
|
||||
if (! empty($filters['document_type'])) {
|
||||
$query->where('document_type', $filters['document_type']);
|
||||
}
|
||||
if (isset($filters['is_active']) && $filters['is_active'] !== '') {
|
||||
$query->where('is_active', (bool) $filters['is_active']);
|
||||
}
|
||||
if (! empty($filters['search'])) {
|
||||
$query->where('rule_name', 'like', "%{$filters['search']}%");
|
||||
}
|
||||
|
||||
return $query->orderBy('document_type')->paginate($perPage);
|
||||
}
|
||||
|
||||
public function getRule(int $id): ?NumberingRule
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
$query = NumberingRule::query();
|
||||
|
||||
if ($tenantId) {
|
||||
$query->where('tenant_id', $tenantId);
|
||||
}
|
||||
|
||||
return $query->find($id);
|
||||
}
|
||||
|
||||
public function createRule(array $data): NumberingRule
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
return NumberingRule::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'document_type' => $data['document_type'],
|
||||
'rule_name' => $data['rule_name'] ?? null,
|
||||
'pattern' => $data['pattern'],
|
||||
'reset_period' => $data['reset_period'] ?? 'daily',
|
||||
'sequence_padding' => $data['sequence_padding'] ?? 2,
|
||||
'is_active' => $data['is_active'] ?? true,
|
||||
'created_by' => auth()->id(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateRule(int $id, array $data): bool
|
||||
{
|
||||
$rule = $this->getRule($id);
|
||||
|
||||
if (! $rule) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $rule->update([
|
||||
'document_type' => $data['document_type'] ?? $rule->document_type,
|
||||
'rule_name' => $data['rule_name'] ?? $rule->rule_name,
|
||||
'pattern' => $data['pattern'] ?? $rule->pattern,
|
||||
'reset_period' => $data['reset_period'] ?? $rule->reset_period,
|
||||
'sequence_padding' => $data['sequence_padding'] ?? $rule->sequence_padding,
|
||||
'is_active' => $data['is_active'] ?? $rule->is_active,
|
||||
'updated_by' => auth()->id(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function deleteRule(int $id): bool
|
||||
{
|
||||
$rule = $this->getRule($id);
|
||||
|
||||
if (! $rule) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $rule->delete();
|
||||
}
|
||||
|
||||
public function getUsedDocumentTypes(?int $excludeId = null): array
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
$query = NumberingRule::where('tenant_id', $tenantId);
|
||||
|
||||
if ($excludeId) {
|
||||
$query->where('id', '!=', $excludeId);
|
||||
}
|
||||
|
||||
return $query->pluck('document_type')->toArray();
|
||||
}
|
||||
|
||||
public function generatePreview(array $pattern, int $sequencePadding = 2): string
|
||||
{
|
||||
$result = '';
|
||||
foreach ($pattern as $segment) {
|
||||
$result .= match ($segment['type'] ?? '') {
|
||||
'static' => $segment['value'] ?? '',
|
||||
'separator' => $segment['value'] ?? '',
|
||||
'date' => now()->format($segment['format'] ?? 'ymd'),
|
||||
'param' => $segment['default'] ?? '{' . ($segment['key'] ?? '?') . '}',
|
||||
'mapping' => $segment['default'] ?? '{' . ($segment['key'] ?? '?') . '}',
|
||||
'sequence' => str_pad('1', $sequencePadding, '0', STR_PAD_LEFT),
|
||||
default => '',
|
||||
};
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
107
resources/views/numbering/create.blade.php
Normal file
107
resources/views/numbering/create.blade.php
Normal file
@@ -0,0 +1,107 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', '채번 규칙 생성')
|
||||
|
||||
@section('content')
|
||||
<div class="container mx-auto max-w-4xl">
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-800">채번 규칙 생성</h1>
|
||||
<a href="{{ route('numbering-rules.index') }}" class="text-gray-600 hover:text-gray-800">
|
||||
← 목록으로
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 기본 정보 -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
|
||||
<h2 class="text-lg font-semibold text-gray-800 mb-4 pb-2 border-b">기본 정보</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">규칙명</label>
|
||||
<input type="text" name="rule_name" maxlength="100"
|
||||
placeholder="예: 5130 견적번호"
|
||||
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>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
문서유형 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<select name="document_type" 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>
|
||||
@foreach($documentTypes as $value => $label)
|
||||
<option value="{{ $value }}"
|
||||
{{ in_array($value, $usedDocumentTypes) ? 'disabled' : '' }}>
|
||||
{{ $label }} ({{ $value }})
|
||||
{{ in_array($value, $usedDocumentTypes) ? ' - 사용 중' : '' }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
리셋 주기 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<select name="reset_period" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
@foreach($resetPeriods as $value => $label)
|
||||
<option value="{{ $value }}" {{ $value === 'daily' ? 'selected' : '' }}>
|
||||
{{ $label }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
시퀀스 자릿수 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="number" name="sequence_padding" value="2" min="1" max="10"
|
||||
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">2 → 01,02 / 3 → 001,002</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="checkbox" name="is_active" id="is_active" value="1" checked
|
||||
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500">
|
||||
<label for="is_active" class="text-sm text-gray-700">활성</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 세그먼트 편집 -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
|
||||
<h2 class="text-lg font-semibold text-gray-800 mb-4 pb-2 border-b">패턴 세그먼트</h2>
|
||||
<div id="segmentsContainer"></div>
|
||||
<button type="button" onclick="addSegment()"
|
||||
class="w-full py-2 border-2 border-dashed border-gray-300 rounded-lg text-gray-500 hover:border-blue-400 hover:text-blue-600 transition mt-3">
|
||||
+ 세그먼트 추가
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 미리보기 -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
|
||||
<h2 class="text-lg font-semibold text-gray-800 mb-4 pb-2 border-b">미리보기</h2>
|
||||
<div id="previewArea" class="bg-gray-50 rounded-lg p-4">
|
||||
<p class="text-gray-400">세그먼트를 추가하면 미리보기가 표시됩니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 버튼 -->
|
||||
<div class="flex justify-end gap-3">
|
||||
<a href="{{ route('numbering-rules.index') }}"
|
||||
class="px-6 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition">
|
||||
취소
|
||||
</a>
|
||||
<button type="button" onclick="submitForm('/api/admin/numbering-rules', 'POST')"
|
||||
class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition">
|
||||
생성
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
@include('numbering.partials.segment-editor-js')
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initPatternEditor([], 2);
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
107
resources/views/numbering/edit.blade.php
Normal file
107
resources/views/numbering/edit.blade.php
Normal file
@@ -0,0 +1,107 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', '채번 규칙 수정')
|
||||
|
||||
@section('content')
|
||||
<div class="container mx-auto max-w-4xl">
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-800">채번 규칙 수정</h1>
|
||||
<a href="{{ route('numbering-rules.index') }}" class="text-gray-600 hover:text-gray-800">
|
||||
← 목록으로
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 기본 정보 -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
|
||||
<h2 class="text-lg font-semibold text-gray-800 mb-4 pb-2 border-b">기본 정보</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">규칙명</label>
|
||||
<input type="text" name="rule_name" value="{{ $rule->rule_name }}" maxlength="100"
|
||||
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>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
문서유형 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<select name="document_type" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
@foreach($documentTypes as $value => $label)
|
||||
<option value="{{ $value }}"
|
||||
{{ $rule->document_type === $value ? 'selected' : '' }}
|
||||
{{ in_array($value, $usedDocumentTypes) ? 'disabled' : '' }}>
|
||||
{{ $label }} ({{ $value }})
|
||||
{{ in_array($value, $usedDocumentTypes) ? ' - 사용 중' : '' }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
리셋 주기 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<select name="reset_period" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
@foreach($resetPeriods as $value => $label)
|
||||
<option value="{{ $value }}" {{ $rule->reset_period === $value ? 'selected' : '' }}>
|
||||
{{ $label }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
시퀀스 자릿수 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="number" name="sequence_padding" value="{{ $rule->sequence_padding }}" min="1" max="10"
|
||||
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">2 → 01,02 / 3 → 001,002</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="checkbox" name="is_active" id="is_active" value="1"
|
||||
{{ $rule->is_active ? 'checked' : '' }}
|
||||
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500">
|
||||
<label for="is_active" class="text-sm text-gray-700">활성</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 세그먼트 편집 -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
|
||||
<h2 class="text-lg font-semibold text-gray-800 mb-4 pb-2 border-b">패턴 세그먼트</h2>
|
||||
<div id="segmentsContainer"></div>
|
||||
<button type="button" onclick="addSegment()"
|
||||
class="w-full py-2 border-2 border-dashed border-gray-300 rounded-lg text-gray-500 hover:border-blue-400 hover:text-blue-600 transition mt-3">
|
||||
+ 세그먼트 추가
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 미리보기 -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
|
||||
<h2 class="text-lg font-semibold text-gray-800 mb-4 pb-2 border-b">미리보기</h2>
|
||||
<div id="previewArea" class="bg-gray-50 rounded-lg p-4">
|
||||
<p class="text-gray-400">로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 버튼 -->
|
||||
<div class="flex justify-end gap-3">
|
||||
<a href="{{ route('numbering-rules.index') }}"
|
||||
class="px-6 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition">
|
||||
취소
|
||||
</a>
|
||||
<button type="button" onclick="submitForm('/api/admin/numbering-rules/{{ $rule->id }}', 'PUT')"
|
||||
class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition">
|
||||
저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
@include('numbering.partials.segment-editor-js')
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initPatternEditor(@json($rule->pattern), {{ $rule->sequence_padding }});
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
77
resources/views/numbering/index.blade.php
Normal file
77
resources/views/numbering/index.blade.php
Normal file
@@ -0,0 +1,77 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', '채번 규칙 관리')
|
||||
|
||||
@section('content')
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-800">채번 규칙 관리</h1>
|
||||
<a href="{{ route('numbering-rules.create') }}"
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition text-center w-full sm:w-auto">
|
||||
+ 새 규칙
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 필터 영역 -->
|
||||
<x-filter-collapsible id="filterSection">
|
||||
<form id="filterForm" class="flex flex-wrap gap-2 sm:gap-4">
|
||||
<div class="w-full sm:w-40">
|
||||
<select name="document_type" 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>
|
||||
@foreach($documentTypes as $value => $label)
|
||||
<option value="{{ $value }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div class="w-full sm:w-40">
|
||||
<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="flex-1 min-w-0 w-full sm:w-auto">
|
||||
<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>
|
||||
<button type="submit" class="bg-gray-600 hover:bg-gray-700 text-white px-6 py-2 rounded-lg transition w-full sm:w-auto">
|
||||
검색
|
||||
</button>
|
||||
</form>
|
||||
</x-filter-collapsible>
|
||||
|
||||
<!-- 테이블 영역 (HTMX로 로드) -->
|
||||
<div id="rules-table"
|
||||
hx-get="/api/admin/numbering-rules"
|
||||
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>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
document.getElementById('filterForm').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
htmx.trigger('#rules-table', 'filterSubmit');
|
||||
});
|
||||
|
||||
window.confirmDelete = function(id, name) {
|
||||
showDeleteConfirm(name || '이 채번 규칙', () => {
|
||||
htmx.ajax('DELETE', `/api/admin/numbering-rules/${id}`, {
|
||||
target: '#rules-table',
|
||||
swap: 'none',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
}
|
||||
}).then(() => {
|
||||
htmx.trigger('#rules-table', 'filterSubmit');
|
||||
});
|
||||
});
|
||||
};
|
||||
</script>
|
||||
@endpush
|
||||
364
resources/views/numbering/partials/segment-editor-js.blade.php
Normal file
364
resources/views/numbering/partials/segment-editor-js.blade.php
Normal file
@@ -0,0 +1,364 @@
|
||||
<script>
|
||||
// ========================================
|
||||
// 채번 규칙 세그먼트 에디터 (Vanilla JS)
|
||||
// ========================================
|
||||
|
||||
let segments = [];
|
||||
let sequencePadding = 2;
|
||||
|
||||
const SEGMENT_TYPES = [
|
||||
{ value: 'static', label: '고정 문자열' },
|
||||
{ value: 'separator', label: '구분자' },
|
||||
{ value: 'date', label: '날짜' },
|
||||
{ value: 'param', label: '외부 파라미터' },
|
||||
{ value: 'mapping', label: '값 매핑' },
|
||||
{ value: 'sequence', label: '자동 순번' },
|
||||
];
|
||||
|
||||
const DATE_FORMATS = [
|
||||
{ value: 'ymd', label: 'YYMMDD (260207)' },
|
||||
{ value: 'Ymd', label: 'YYYYMMDD (20260207)' },
|
||||
{ value: 'Ym', label: 'YYYYMM (202602)' },
|
||||
{ value: 'ym', label: 'YYMM (2602)' },
|
||||
{ value: 'Y', label: 'YYYY (2026)' },
|
||||
{ value: 'y', label: 'YY (26)' },
|
||||
];
|
||||
|
||||
// ========================================
|
||||
// 초기화
|
||||
// ========================================
|
||||
function initPatternEditor(initialSegments, initialPadding) {
|
||||
sequencePadding = initialPadding || 2;
|
||||
|
||||
segments = (initialSegments || []).map(function(seg) {
|
||||
var s = Object.assign({ type: '', value: '', format: 'ymd', key: '', default: '', map: {}, _mapEntries: [] }, seg);
|
||||
if (s.type === 'mapping' && s.map && typeof s.map === 'object' && !Array.isArray(s.map)) {
|
||||
s._mapEntries = Object.entries(s.map).map(function(pair) {
|
||||
return { key: pair[0], value: pair[1] };
|
||||
});
|
||||
}
|
||||
return s;
|
||||
});
|
||||
|
||||
renderSegments();
|
||||
updatePreview();
|
||||
|
||||
var paddingInput = document.querySelector('[name="sequence_padding"]');
|
||||
if (paddingInput) {
|
||||
paddingInput.addEventListener('input', function() {
|
||||
sequencePadding = parseInt(this.value) || 2;
|
||||
updatePreview();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 세그먼트 CRUD
|
||||
// ========================================
|
||||
function addSegment() {
|
||||
segments.push({
|
||||
type: 'static', value: '', format: 'ymd',
|
||||
key: '', default: '', map: {}, _mapEntries: [],
|
||||
});
|
||||
renderSegments();
|
||||
updatePreview();
|
||||
}
|
||||
|
||||
function removeSegment(index) {
|
||||
segments.splice(index, 1);
|
||||
renderSegments();
|
||||
updatePreview();
|
||||
}
|
||||
|
||||
function moveSegment(from, direction) {
|
||||
var to = from + direction;
|
||||
if (to < 0 || to >= segments.length) return;
|
||||
var temp = segments.splice(from, 1)[0];
|
||||
segments.splice(to, 0, temp);
|
||||
renderSegments();
|
||||
updatePreview();
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 필드값 변경 핸들러
|
||||
// ========================================
|
||||
function onTypeChange(index, newType) {
|
||||
segments[index].type = newType;
|
||||
segments[index].value = (newType === 'separator') ? '-' : '';
|
||||
segments[index].format = 'ymd';
|
||||
segments[index].key = '';
|
||||
segments[index].default = '';
|
||||
segments[index].map = {};
|
||||
segments[index]._mapEntries = [];
|
||||
renderSegments();
|
||||
updatePreview();
|
||||
}
|
||||
|
||||
function onSegFieldChange(index, field, value) {
|
||||
segments[index][field] = value;
|
||||
updatePreview();
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 매핑 엔트리 관리
|
||||
// ========================================
|
||||
function addMapEntry(segIndex) {
|
||||
if (!segments[segIndex]._mapEntries) segments[segIndex]._mapEntries = [];
|
||||
segments[segIndex]._mapEntries.push({ key: '', value: '' });
|
||||
renderSegments();
|
||||
}
|
||||
|
||||
function removeMapEntry(segIndex, entryIndex) {
|
||||
segments[segIndex]._mapEntries.splice(entryIndex, 1);
|
||||
renderSegments();
|
||||
updatePreview();
|
||||
}
|
||||
|
||||
function onMapEntryChange(segIndex, entryIndex, field, value) {
|
||||
segments[segIndex]._mapEntries[entryIndex][field] = value;
|
||||
updatePreview();
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 타입별 동적 필드 HTML 생성
|
||||
// ========================================
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function getFieldsHtml(seg, index) {
|
||||
switch (seg.type) {
|
||||
case 'static':
|
||||
case 'separator':
|
||||
return '<input type="text" value="' + escapeHtml(seg.value) + '" placeholder="' + (seg.type === 'separator' ? '구분자 (예: -)' : '값') + '" ' +
|
||||
'onchange="onSegFieldChange(' + index + ', \'value\', this.value)" ' +
|
||||
'class="flex-1 min-w-[100px] px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">';
|
||||
case 'date':
|
||||
var opts = DATE_FORMATS.map(function(f) {
|
||||
return '<option value="' + f.value + '"' + (seg.format === f.value ? ' selected' : '') + '>' + f.label + '</option>';
|
||||
}).join('');
|
||||
return '<select onchange="onSegFieldChange(' + index + ', \'format\', this.value)" ' +
|
||||
'class="flex-1 min-w-[180px] px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">' +
|
||||
opts + '</select>';
|
||||
case 'param':
|
||||
return '<input type="text" value="' + escapeHtml(seg.key) + '" placeholder="파라미터 키 (예: pair_code)" ' +
|
||||
'onchange="onSegFieldChange(' + index + ', \'key\', this.value)" ' +
|
||||
'class="flex-1 px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">' +
|
||||
'<input type="text" value="' + escapeHtml(seg.default) + '" placeholder="기본값" ' +
|
||||
'onchange="onSegFieldChange(' + index + ', \'default\', this.value)" ' +
|
||||
'class="w-24 px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">';
|
||||
case 'mapping':
|
||||
var mapHtml = (seg._mapEntries || []).map(function(entry, ei) {
|
||||
return '<div class="flex gap-1 items-center">' +
|
||||
'<input type="text" value="' + escapeHtml(entry.key) + '" placeholder="입력값" ' +
|
||||
'onchange="onMapEntryChange(' + index + ', ' + ei + ', \'key\', this.value)" ' +
|
||||
'class="w-28 px-2 py-1 border border-gray-300 rounded text-xs">' +
|
||||
'<span class="text-gray-400 text-xs">→</span>' +
|
||||
'<input type="text" value="' + escapeHtml(entry.value) + '" placeholder="변환값" ' +
|
||||
'onchange="onMapEntryChange(' + index + ', ' + ei + ', \'value\', this.value)" ' +
|
||||
'class="w-20 px-2 py-1 border border-gray-300 rounded text-xs">' +
|
||||
'<button type="button" onclick="removeMapEntry(' + index + ', ' + ei + ')" ' +
|
||||
'class="text-red-400 hover:text-red-600 text-xs px-1">×</button>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
|
||||
return '<div class="flex-1">' +
|
||||
'<div class="flex gap-2 mb-2">' +
|
||||
'<input type="text" value="' + escapeHtml(seg.key) + '" placeholder="파라미터 키" ' +
|
||||
'onchange="onSegFieldChange(' + index + ', \'key\', this.value)" ' +
|
||||
'class="flex-1 px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">' +
|
||||
'<input type="text" value="' + escapeHtml(seg.default) + '" placeholder="기본값" ' +
|
||||
'onchange="onSegFieldChange(' + index + ', \'default\', this.value)" ' +
|
||||
'class="w-24 px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">' +
|
||||
'</div>' +
|
||||
'<div class="ml-4 space-y-1">' +
|
||||
mapHtml +
|
||||
'<button type="button" onclick="addMapEntry(' + index + ')" ' +
|
||||
'class="text-xs text-blue-600 hover:text-blue-800">+ 매핑 추가</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
case 'sequence':
|
||||
return '<span class="text-sm text-gray-400 pt-2">자동 순번 (설정 없음)</span>';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 세그먼트 전체 렌더링
|
||||
// ========================================
|
||||
function renderSegments() {
|
||||
var container = document.getElementById('segmentsContainer');
|
||||
|
||||
if (segments.length === 0) {
|
||||
container.innerHTML = '<p class="text-gray-400 text-sm py-4 text-center">세그먼트를 추가하세요.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = segments.map(function(seg, index) {
|
||||
var typeOpts = SEGMENT_TYPES.map(function(t) {
|
||||
return '<option value="' + t.value + '"' + (seg.type === t.value ? ' selected' : '') + '>' + t.label + '</option>';
|
||||
}).join('');
|
||||
|
||||
return '<div class="flex items-start gap-2 mb-3 p-3 bg-gray-50 rounded-lg">' +
|
||||
'<span class="text-sm text-gray-400 pt-2 min-w-[24px]">' + (index + 1) + '.</span>' +
|
||||
'<select onchange="onTypeChange(' + index + ', this.value)" ' +
|
||||
'class="w-36 px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">' +
|
||||
typeOpts +
|
||||
'</select>' +
|
||||
'<div class="flex-1 flex flex-wrap gap-2">' +
|
||||
getFieldsHtml(seg, index) +
|
||||
'</div>' +
|
||||
'<div class="flex gap-1 shrink-0">' +
|
||||
'<button type="button" onclick="moveSegment(' + index + ', -1)"' + (index === 0 ? ' disabled' : '') +
|
||||
' class="px-2 py-1 text-gray-400 hover:text-gray-600 disabled:opacity-30" title="위로">↑</button>' +
|
||||
'<button type="button" onclick="moveSegment(' + index + ', 1)"' + (index === segments.length - 1 ? ' disabled' : '') +
|
||||
' class="px-2 py-1 text-gray-400 hover:text-gray-600 disabled:opacity-30" title="아래로">↓</button>' +
|
||||
'<button type="button" onclick="removeSegment(' + index + ')" ' +
|
||||
'class="px-2 py-1 text-red-400 hover:text-red-600" title="삭제">×</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 실시간 미리보기
|
||||
// ========================================
|
||||
function generatePreviewStr(seqNum) {
|
||||
var now = new Date();
|
||||
var pad2 = function(n) { return String(n).padStart(2, '0'); };
|
||||
var yy = String(now.getFullYear()).slice(-2);
|
||||
var yyyy = String(now.getFullYear());
|
||||
var mm = pad2(now.getMonth() + 1);
|
||||
var dd = pad2(now.getDate());
|
||||
|
||||
var formatDate = function(fmt) {
|
||||
switch (fmt) {
|
||||
case 'ymd': return yy + mm + dd;
|
||||
case 'Ymd': return yyyy + mm + dd;
|
||||
case 'Ym': return yyyy + mm;
|
||||
case 'ym': return yy + mm;
|
||||
case 'Y': return yyyy;
|
||||
case 'y': return yy;
|
||||
default: return yy + mm + dd;
|
||||
}
|
||||
};
|
||||
|
||||
return segments.map(function(seg) {
|
||||
switch (seg.type) {
|
||||
case 'static': return seg.value || '?';
|
||||
case 'separator': return seg.value || '-';
|
||||
case 'date': return formatDate(seg.format || 'ymd');
|
||||
case 'param': return seg.default || ('{' + (seg.key || '?') + '}');
|
||||
case 'mapping': return seg.default || ('{' + (seg.key || '?') + '}');
|
||||
case 'sequence': return String(seqNum).padStart(sequencePadding, '0');
|
||||
default: return '';
|
||||
}
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function updatePreview() {
|
||||
var previewEl = document.getElementById('previewArea');
|
||||
if (segments.length === 0) {
|
||||
previewEl.innerHTML = '<p class="text-gray-400">세그먼트를 추가하면 미리보기가 표시됩니다.</p>';
|
||||
return;
|
||||
}
|
||||
previewEl.innerHTML =
|
||||
'<div class="text-lg font-mono">' +
|
||||
'<span class="text-gray-500">1번:</span> ' +
|
||||
'<span class="font-bold text-blue-700">' + escapeHtml(generatePreviewStr(1)) + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="text-lg font-mono mt-1">' +
|
||||
'<span class="text-gray-500">2번:</span> ' +
|
||||
'<span class="font-bold text-blue-700">' + escapeHtml(generatePreviewStr(2)) + '</span>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 폼 제출 (fetch + JSON)
|
||||
// ========================================
|
||||
function prepareSubmitData() {
|
||||
return segments.map(function(seg) {
|
||||
var clean = { type: seg.type };
|
||||
switch (seg.type) {
|
||||
case 'static':
|
||||
case 'separator':
|
||||
clean.value = seg.value;
|
||||
break;
|
||||
case 'date':
|
||||
clean.format = seg.format;
|
||||
break;
|
||||
case 'param':
|
||||
clean.key = seg.key;
|
||||
if (seg.default) clean.default = seg.default;
|
||||
break;
|
||||
case 'mapping':
|
||||
clean.key = seg.key;
|
||||
if (seg.default) clean.default = seg.default;
|
||||
clean.map = {};
|
||||
(seg._mapEntries || []).forEach(function(entry) {
|
||||
if (entry.key) clean.map[entry.key] = entry.value;
|
||||
});
|
||||
break;
|
||||
case 'sequence':
|
||||
break;
|
||||
}
|
||||
return clean;
|
||||
});
|
||||
}
|
||||
|
||||
async function submitForm(url, method) {
|
||||
method = method || 'POST';
|
||||
|
||||
var docType = document.querySelector('[name="document_type"]').value;
|
||||
if (!docType) {
|
||||
showToast('문서유형을 선택해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
if (segments.length === 0) {
|
||||
showToast('최소 1개 이상의 세그먼트를 추가해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
var formData = {
|
||||
document_type: docType,
|
||||
rule_name: document.querySelector('[name="rule_name"]').value,
|
||||
reset_period: document.querySelector('[name="reset_period"]').value,
|
||||
sequence_padding: parseInt(document.querySelector('[name="sequence_padding"]').value) || 2,
|
||||
is_active: document.querySelector('[name="is_active"]').checked ? 1 : 0,
|
||||
pattern: prepareSubmitData(),
|
||||
};
|
||||
|
||||
try {
|
||||
var response = await fetch(url, {
|
||||
method: method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
|
||||
},
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
|
||||
var result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
showToast(result.message, 'success');
|
||||
if (result.redirect) window.location.href = result.redirect;
|
||||
} else if (response.status === 422) {
|
||||
var errors = result.errors || {};
|
||||
var errorMsg = '입력 오류: ';
|
||||
for (var field in errors) {
|
||||
errorMsg += errors[field].join(', ') + ' ';
|
||||
}
|
||||
showToast(errorMsg.trim(), 'error');
|
||||
} else {
|
||||
showToast(result.message || '저장에 실패했습니다.', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Submit error:', error);
|
||||
showToast('요청 처리 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
66
resources/views/numbering/partials/table.blade.php
Normal file
66
resources/views/numbering/partials/table.blade.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
|
||||
<x-table-swipe>
|
||||
<table class="min-w-full">
|
||||
<thead class="bg-gray-50 border-b">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-700 uppercase">#</th>
|
||||
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-700 uppercase">규칙명</th>
|
||||
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-700 uppercase">문서유형</th>
|
||||
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-700 uppercase">패턴 미리보기</th>
|
||||
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-700 uppercase">리셋주기</th>
|
||||
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-700 uppercase">상태</th>
|
||||
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-700 uppercase">작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
@forelse($rules as $rule)
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-6 py-4 text-sm text-gray-500">{{ $rule->id }}</td>
|
||||
<td class="px-6 py-4 text-sm font-medium text-gray-900">
|
||||
{{ $rule->rule_name ?? '-' }}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-700">
|
||||
{{ $rule->document_type_label }}
|
||||
<span class="text-xs text-gray-400">({{ $rule->document_type }})</span>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<code class="text-sm bg-gray-100 px-2 py-1 rounded font-mono">{{ $rule->preview }}</code>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-500">
|
||||
{{ $rule->reset_period_label }}
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
@if($rule->is_active)
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">
|
||||
활성
|
||||
</span>
|
||||
@else
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-gray-100 text-gray-800">
|
||||
비활성
|
||||
</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm font-medium">
|
||||
<a href="{{ route('numbering-rules.edit', $rule->id) }}"
|
||||
class="text-blue-600 hover:text-blue-900 mr-3">수정</a>
|
||||
<button onclick="confirmDelete({{ $rule->id }}, '{{ $rule->rule_name }}')"
|
||||
class="text-red-600 hover:text-red-900">삭제</button>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="7" class="px-6 py-12 text-center text-gray-500">
|
||||
등록된 채번 규칙이 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</x-table-swipe>
|
||||
</div>
|
||||
|
||||
@if($rules->hasPages())
|
||||
<div class="mt-4">
|
||||
{{ $rules->links() }}
|
||||
</div>
|
||||
@endif
|
||||
@@ -17,6 +17,7 @@
|
||||
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\NumberingRuleController;
|
||||
use App\Http\Controllers\Api\Admin\Quote\QuoteFormulaCategoryController;
|
||||
use App\Http\Controllers\Api\Admin\Quote\QuoteFormulaController;
|
||||
use App\Http\Controllers\Api\Admin\RoleController;
|
||||
@@ -655,6 +656,15 @@
|
||||
Route::get('/database-tables/{table}/columns', [ItemFieldController::class, 'tableColumns'])->name('tableColumns');
|
||||
});
|
||||
|
||||
// 채번 규칙 관리 API
|
||||
Route::prefix('numbering-rules')->name('numbering-rules.')->group(function () {
|
||||
Route::get('/', [NumberingRuleController::class, 'index'])->name('index');
|
||||
Route::post('/', [NumberingRuleController::class, 'store'])->name('store');
|
||||
Route::put('/{id}', [NumberingRuleController::class, 'update'])->name('update');
|
||||
Route::delete('/{id}', [NumberingRuleController::class, 'destroy'])->name('destroy');
|
||||
Route::post('/preview', [NumberingRuleController::class, 'preview'])->name('preview');
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 견적수식 관리 API
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
use App\Http\Controllers\PostController;
|
||||
use App\Http\Controllers\ProfileController;
|
||||
use App\Http\Controllers\ProjectManagementController;
|
||||
use App\Http\Controllers\NumberingRuleController;
|
||||
use App\Http\Controllers\QuoteFormulaController;
|
||||
use App\Http\Controllers\RoleController;
|
||||
use App\Http\Controllers\RolePermissionController;
|
||||
@@ -132,6 +133,13 @@
|
||||
Route::get('/{id}/edit', [DepartmentController::class, 'edit'])->name('edit');
|
||||
});
|
||||
|
||||
// 채번 규칙 관리 (Blade 화면만)
|
||||
Route::prefix('numbering-rules')->name('numbering-rules.')->group(function () {
|
||||
Route::get('/', [NumberingRuleController::class, 'index'])->name('index');
|
||||
Route::get('/create', [NumberingRuleController::class, 'create'])->name('create');
|
||||
Route::get('/{id}/edit', [NumberingRuleController::class, 'edit'])->name('edit');
|
||||
});
|
||||
|
||||
// 사용자 관리 (Blade 화면만)
|
||||
Route::prefix('users')->name('users.')->group(function () {
|
||||
Route::get('/', [UserController::class, 'index'])->name('index');
|
||||
|
||||
Reference in New Issue
Block a user