@@ -81,24 +82,6 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc
-
-
@@ -188,9 +171,6 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc
name: '',
category: '',
title: '',
- company_name: '경동기업',
- company_address: '',
- company_contact: '',
footer_remark_label: '비고',
footer_judgement_label: '종합판정',
footer_judgement_options: ['합격', '불합격', '조건부합격'],
@@ -214,9 +194,6 @@ function generateId() {
templateState.name = loadedData.name || '';
templateState.category = loadedData.category || '';
templateState.title = loadedData.title || '';
- templateState.company_name = loadedData.company_name || '';
- templateState.company_address = loadedData.company_address || '';
- templateState.company_contact = loadedData.company_contact || '';
templateState.footer_remark_label = loadedData.footer_remark_label || '';
templateState.footer_judgement_label = loadedData.footer_judgement_label || '';
templateState.footer_judgement_options = loadedData.footer_judgement_options || [];
@@ -238,13 +215,10 @@ function initBasicFields() {
document.getElementById('name').value = templateState.name || '';
document.getElementById('category').value = templateState.category || '';
document.getElementById('title').value = templateState.title || '';
- document.getElementById('company_name').value = templateState.company_name || '';
- document.getElementById('company_address').value = templateState.company_address || '';
- document.getElementById('company_contact').value = templateState.company_contact || '';
document.getElementById('is_active').checked = templateState.is_active;
// 변경 이벤트 바인딩
- ['name', 'category', 'title', 'company_name', 'company_address', 'company_contact'].forEach(field => {
+ ['name', 'category', 'title'].forEach(field => {
document.getElementById(field).addEventListener('input', function() {
templateState[field] = this.value;
});
@@ -564,9 +538,6 @@ function saveTemplate() {
name: name,
category: document.getElementById('category').value,
title: document.getElementById('title').value,
- company_name: document.getElementById('company_name').value,
- company_address: document.getElementById('company_address').value,
- company_contact: document.getElementById('company_contact').value,
is_active: document.getElementById('is_active').checked,
approval_lines: templateState.approval_lines,
sections: templateState.sections,
@@ -618,7 +589,7 @@ function closePreviewModal() {
function generatePreviewHtml() {
const title = document.getElementById('title').value || '검사 성적서';
- const companyName = document.getElementById('company_name').value || '회사명';
+ const companyName = '{{ $tenant?->company_name ?? "회사명" }}';
return `
diff --git a/resources/views/document-templates/index.blade.php b/resources/views/document-templates/index.blade.php
index deb85370..a287fd99 100644
--- a/resources/views/document-templates/index.blade.php
+++ b/resources/views/document-templates/index.blade.php
@@ -40,8 +40,8 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc
From c65d3f49dcad9d9c79ba7b1f19a27537218bf74a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?=
Date: Wed, 28 Jan 2026 21:51:23 +0900
Subject: [PATCH 04/12] =?UTF-8?q?feat:=20=EB=AC=B8=EC=84=9C=20=EA=B4=80?=
=?UTF-8?q?=EB=A6=AC=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20MNG=20=EA=B4=80?=
=?UTF-8?q?=EB=A6=AC=EC=9E=90=20=ED=8C=A8=EB=84=90=20=EA=B5=AC=ED=98=84=20?=
=?UTF-8?q?(Phase=202)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Document 관련 모델 4개 생성 (Document, DocumentApproval, DocumentData, DocumentAttachment)
- DocumentController 생성 (목록/생성/상세/수정 페이지)
- DocumentApiController 생성 (AJAX CRUD 처리)
- 문서 관리 뷰 3개 생성 (index, edit, show)
- 웹/API 라우트 등록
Co-Authored-By: Claude Opus 4.5
---
.../Api/Admin/DocumentApiController.php | 226 +++++++++++++++++
app/Http/Controllers/DocumentController.php | 121 +++++++++
app/Models/Documents/Document.php | 178 +++++++++++++
app/Models/Documents/DocumentApproval.php | 94 +++++++
app/Models/Documents/DocumentAttachment.php | 69 +++++
app/Models/Documents/DocumentData.php | 47 ++++
resources/views/documents/edit.blade.php | 211 +++++++++++++++
resources/views/documents/index.blade.php | 240 ++++++++++++++++++
resources/views/documents/show.blade.php | 200 +++++++++++++++
routes/api.php | 16 +-
routes/web.php | 40 ++-
11 files changed, 1435 insertions(+), 7 deletions(-)
create mode 100644 app/Http/Controllers/Api/Admin/DocumentApiController.php
create mode 100644 app/Http/Controllers/DocumentController.php
create mode 100644 app/Models/Documents/Document.php
create mode 100644 app/Models/Documents/DocumentApproval.php
create mode 100644 app/Models/Documents/DocumentAttachment.php
create mode 100644 app/Models/Documents/DocumentData.php
create mode 100644 resources/views/documents/edit.blade.php
create mode 100644 resources/views/documents/index.blade.php
create mode 100644 resources/views/documents/show.blade.php
diff --git a/app/Http/Controllers/Api/Admin/DocumentApiController.php b/app/Http/Controllers/Api/Admin/DocumentApiController.php
new file mode 100644
index 00000000..149c393b
--- /dev/null
+++ b/app/Http/Controllers/Api/Admin/DocumentApiController.php
@@ -0,0 +1,226 @@
+where('tenant_id', $tenantId)
+ ->orderBy('created_at', 'desc');
+
+ // 상태 필터
+ if ($request->filled('status')) {
+ $query->where('status', $request->status);
+ }
+
+ // 템플릿 필터
+ if ($request->filled('template_id')) {
+ $query->where('template_id', $request->template_id);
+ }
+
+ // 검색
+ if ($request->filled('search')) {
+ $search = $request->search;
+ $query->where(function ($q) use ($search) {
+ $q->where('document_no', 'like', "%{$search}%")
+ ->orWhere('title', 'like', "%{$search}%");
+ });
+ }
+
+ $documents = $query->paginate($request->input('per_page', 15));
+
+ return response()->json($documents);
+ }
+
+ /**
+ * 문서 상세 조회
+ */
+ public function show(int $id): JsonResponse
+ {
+ $tenantId = session('selected_tenant_id');
+
+ $document = Document::with([
+ 'template.approvalLines',
+ 'template.basicFields',
+ 'template.sections.items',
+ 'template.columns',
+ 'approvals.user',
+ 'data',
+ 'attachments.file',
+ 'creator',
+ 'updater',
+ ])->where('tenant_id', $tenantId)->findOrFail($id);
+
+ return response()->json([
+ 'success' => true,
+ 'data' => $document,
+ ]);
+ }
+
+ /**
+ * 문서 생성
+ */
+ public function store(Request $request): JsonResponse
+ {
+ $tenantId = session('selected_tenant_id');
+ $userId = auth()->id();
+
+ $request->validate([
+ 'template_id' => 'required|exists:document_templates,id',
+ 'title' => 'required|string|max:255',
+ 'data' => 'nullable|array',
+ 'data.*.field_key' => 'required|string',
+ 'data.*.field_value' => 'nullable|string',
+ ]);
+
+ // 문서 번호 생성
+ $documentNo = $this->generateDocumentNo($tenantId, $request->template_id);
+
+ $document = Document::create([
+ 'tenant_id' => $tenantId,
+ 'template_id' => $request->template_id,
+ 'document_no' => $documentNo,
+ 'title' => $request->title,
+ 'status' => Document::STATUS_DRAFT,
+ 'created_by' => $userId,
+ 'updated_by' => $userId,
+ ]);
+
+ // 문서 데이터 저장
+ if ($request->filled('data')) {
+ foreach ($request->data as $item) {
+ if (! empty($item['field_value'])) {
+ DocumentData::create([
+ 'document_id' => $document->id,
+ 'field_key' => $item['field_key'],
+ 'field_value' => $item['field_value'],
+ ]);
+ }
+ }
+ }
+
+ return response()->json([
+ 'success' => true,
+ 'message' => '문서가 저장되었습니다.',
+ 'data' => $document->fresh(['template', 'data']),
+ ], 201);
+ }
+
+ /**
+ * 문서 수정
+ */
+ public function update(int $id, Request $request): JsonResponse
+ {
+ $tenantId = session('selected_tenant_id');
+ $userId = auth()->id();
+
+ $document = Document::where('tenant_id', $tenantId)->findOrFail($id);
+
+ // 작성중 또는 반려 상태에서만 수정 가능
+ if (! in_array($document->status, [Document::STATUS_DRAFT, Document::STATUS_REJECTED])) {
+ return response()->json([
+ 'success' => false,
+ 'message' => '현재 상태에서는 수정할 수 없습니다.',
+ ], 422);
+ }
+
+ $request->validate([
+ 'title' => 'sometimes|required|string|max:255',
+ 'data' => 'nullable|array',
+ 'data.*.field_key' => 'required|string',
+ 'data.*.field_value' => 'nullable|string',
+ ]);
+
+ $document->update([
+ 'title' => $request->input('title', $document->title),
+ 'updated_by' => $userId,
+ ]);
+
+ // 문서 데이터 업데이트
+ if ($request->has('data')) {
+ // 기존 데이터 삭제
+ $document->data()->delete();
+
+ // 새 데이터 저장
+ foreach ($request->data as $item) {
+ if (! empty($item['field_value'])) {
+ DocumentData::create([
+ 'document_id' => $document->id,
+ 'field_key' => $item['field_key'],
+ 'field_value' => $item['field_value'],
+ ]);
+ }
+ }
+ }
+
+ return response()->json([
+ 'success' => true,
+ 'message' => '문서가 수정되었습니다.',
+ 'data' => $document->fresh(['template', 'data']),
+ ]);
+ }
+
+ /**
+ * 문서 삭제 (소프트 삭제)
+ */
+ public function destroy(int $id): JsonResponse
+ {
+ $tenantId = session('selected_tenant_id');
+
+ $document = Document::where('tenant_id', $tenantId)->findOrFail($id);
+
+ // 작성중 상태에서만 삭제 가능
+ if ($document->status !== Document::STATUS_DRAFT) {
+ return response()->json([
+ 'success' => false,
+ 'message' => '작성중 상태의 문서만 삭제할 수 있습니다.',
+ ], 422);
+ }
+
+ $document->delete();
+
+ return response()->json([
+ 'success' => true,
+ 'message' => '문서가 삭제되었습니다.',
+ ]);
+ }
+
+ /**
+ * 문서 번호 생성
+ */
+ private function generateDocumentNo(int $tenantId, int $templateId): string
+ {
+ $prefix = 'DOC';
+ $date = now()->format('Ymd');
+
+ $lastDocument = Document::where('tenant_id', $tenantId)
+ ->where('template_id', $templateId)
+ ->whereDate('created_at', now()->toDateString())
+ ->orderBy('id', 'desc')
+ ->first();
+
+ $sequence = 1;
+ if ($lastDocument) {
+ // 마지막 문서 번호에서 시퀀스 추출
+ $parts = explode('-', $lastDocument->document_no);
+ if (count($parts) >= 3) {
+ $sequence = (int) end($parts) + 1;
+ }
+ }
+
+ return sprintf('%s-%s-%04d', $prefix, $date, $sequence);
+ }
+}
diff --git a/app/Http/Controllers/DocumentController.php b/app/Http/Controllers/DocumentController.php
new file mode 100644
index 00000000..7c3db747
--- /dev/null
+++ b/app/Http/Controllers/DocumentController.php
@@ -0,0 +1,121 @@
+header('HX-Request')) {
+ return response('', 200)->header('HX-Redirect', route('documents.index'));
+ }
+
+ $tenantId = session('selected_tenant_id');
+
+ // 템플릿 목록 (필터용)
+ $templates = $tenantId
+ ? DocumentTemplate::where(function ($q) use ($tenantId) {
+ $q->whereNull('tenant_id')->orWhere('tenant_id', $tenantId);
+ })->where('is_active', true)->orderBy('name')->get()
+ : collect();
+
+ return view('documents.index', [
+ 'templates' => $templates,
+ 'statuses' => Document::STATUS_LABELS,
+ ]);
+ }
+
+ /**
+ * 문서 생성 페이지
+ */
+ public function create(Request $request): View
+ {
+ $tenantId = session('selected_tenant_id');
+ $templateId = $request->query('template_id');
+
+ // 템플릿 목록
+ $templates = $tenantId
+ ? DocumentTemplate::where(function ($q) use ($tenantId) {
+ $q->whereNull('tenant_id')->orWhere('tenant_id', $tenantId);
+ })->where('is_active', true)->orderBy('name')->get()
+ : collect();
+
+ // 선택된 템플릿
+ $template = $templateId
+ ? DocumentTemplate::with(['approvalLines', 'basicFields', 'sections.items', 'columns'])->find($templateId)
+ : null;
+
+ return view('documents.edit', [
+ 'document' => null,
+ 'template' => $template,
+ 'templates' => $templates,
+ 'isCreate' => true,
+ ]);
+ }
+
+ /**
+ * 문서 수정 페이지
+ */
+ public function edit(int $id): View
+ {
+ $tenantId = session('selected_tenant_id');
+
+ $document = Document::with([
+ 'template.approvalLines',
+ 'template.basicFields',
+ 'template.sections.items',
+ 'template.columns',
+ 'approvals.user',
+ 'data',
+ 'attachments.file',
+ 'creator',
+ ])->where('tenant_id', $tenantId)->findOrFail($id);
+
+ // 템플릿 목록 (변경용)
+ $templates = $tenantId
+ ? DocumentTemplate::where(function ($q) use ($tenantId) {
+ $q->whereNull('tenant_id')->orWhere('tenant_id', $tenantId);
+ })->where('is_active', true)->orderBy('name')->get()
+ : collect();
+
+ return view('documents.edit', [
+ 'document' => $document,
+ 'template' => $document->template,
+ 'templates' => $templates,
+ 'isCreate' => false,
+ ]);
+ }
+
+ /**
+ * 문서 상세 페이지 (읽기 전용)
+ */
+ public function show(int $id): View
+ {
+ $tenantId = session('selected_tenant_id');
+
+ $document = Document::with([
+ 'template.approvalLines',
+ 'template.basicFields',
+ 'template.sections.items',
+ 'template.columns',
+ 'approvals.user',
+ 'data',
+ 'attachments.file',
+ 'creator',
+ 'updater',
+ ])->where('tenant_id', $tenantId)->findOrFail($id);
+
+ return view('documents.show', [
+ 'document' => $document,
+ ]);
+ }
+}
diff --git a/app/Models/Documents/Document.php b/app/Models/Documents/Document.php
new file mode 100644
index 00000000..d6a2bb3e
--- /dev/null
+++ b/app/Models/Documents/Document.php
@@ -0,0 +1,178 @@
+ '작성중',
+ self::STATUS_PENDING => '결재중',
+ self::STATUS_APPROVED => '승인',
+ self::STATUS_REJECTED => '반려',
+ self::STATUS_CANCELLED => '취소',
+ ];
+
+ protected $fillable = [
+ 'tenant_id',
+ 'template_id',
+ 'document_no',
+ 'title',
+ 'status',
+ 'linkable_type',
+ 'linkable_id',
+ 'submitted_at',
+ 'completed_at',
+ 'created_by',
+ 'updated_by',
+ 'deleted_by',
+ ];
+
+ protected $casts = [
+ 'submitted_at' => 'datetime',
+ 'completed_at' => 'datetime',
+ ];
+
+ protected $attributes = [
+ 'status' => self::STATUS_DRAFT,
+ ];
+
+ // =========================================================================
+ // Relationships
+ // =========================================================================
+
+ public function template(): BelongsTo
+ {
+ return $this->belongsTo(DocumentTemplate::class, 'template_id');
+ }
+
+ public function approvals(): HasMany
+ {
+ return $this->hasMany(DocumentApproval::class)->orderBy('step');
+ }
+
+ public function data(): HasMany
+ {
+ return $this->hasMany(DocumentData::class);
+ }
+
+ public function attachments(): HasMany
+ {
+ return $this->hasMany(DocumentAttachment::class);
+ }
+
+ public function linkable(): MorphTo
+ {
+ return $this->morphTo();
+ }
+
+ public function creator(): BelongsTo
+ {
+ return $this->belongsTo(User::class, 'created_by');
+ }
+
+ public function updater(): BelongsTo
+ {
+ return $this->belongsTo(User::class, 'updated_by');
+ }
+
+ // =========================================================================
+ // Accessors
+ // =========================================================================
+
+ public function getStatusLabelAttribute(): string
+ {
+ return self::STATUS_LABELS[$this->status] ?? $this->status;
+ }
+
+ public function getStatusColorAttribute(): string
+ {
+ return match ($this->status) {
+ self::STATUS_DRAFT => 'gray',
+ self::STATUS_PENDING => 'yellow',
+ self::STATUS_APPROVED => 'green',
+ self::STATUS_REJECTED => 'red',
+ self::STATUS_CANCELLED => 'gray',
+ default => 'gray',
+ };
+ }
+
+ // =========================================================================
+ // Scopes
+ // =========================================================================
+
+ public function scopeStatus($query, string $status)
+ {
+ return $query->where('status', $status);
+ }
+
+ public function scopeDraft($query)
+ {
+ return $query->where('status', self::STATUS_DRAFT);
+ }
+
+ public function scopePending($query)
+ {
+ return $query->where('status', self::STATUS_PENDING);
+ }
+
+ // =========================================================================
+ // Helper Methods
+ // =========================================================================
+
+ public function isDraft(): bool
+ {
+ return $this->status === self::STATUS_DRAFT;
+ }
+
+ public function isPending(): bool
+ {
+ return $this->status === self::STATUS_PENDING;
+ }
+
+ public function isApproved(): bool
+ {
+ return $this->status === self::STATUS_APPROVED;
+ }
+
+ public function isRejected(): bool
+ {
+ return $this->status === self::STATUS_REJECTED;
+ }
+
+ public function canEdit(): bool
+ {
+ return in_array($this->status, [self::STATUS_DRAFT, self::STATUS_REJECTED]);
+ }
+}
diff --git a/app/Models/Documents/DocumentApproval.php b/app/Models/Documents/DocumentApproval.php
new file mode 100644
index 00000000..a9899c00
--- /dev/null
+++ b/app/Models/Documents/DocumentApproval.php
@@ -0,0 +1,94 @@
+ '대기',
+ self::STATUS_APPROVED => '승인',
+ self::STATUS_REJECTED => '반려',
+ ];
+
+ protected $fillable = [
+ 'document_id',
+ 'user_id',
+ 'step',
+ 'role',
+ 'status',
+ 'comment',
+ 'acted_at',
+ 'created_by',
+ 'updated_by',
+ ];
+
+ protected $casts = [
+ 'step' => 'integer',
+ 'acted_at' => 'datetime',
+ ];
+
+ // =========================================================================
+ // Relationships
+ // =========================================================================
+
+ public function document(): BelongsTo
+ {
+ return $this->belongsTo(Document::class);
+ }
+
+ public function user(): BelongsTo
+ {
+ return $this->belongsTo(User::class);
+ }
+
+ // =========================================================================
+ // Accessors
+ // =========================================================================
+
+ public function getStatusLabelAttribute(): string
+ {
+ return self::STATUS_LABELS[$this->status] ?? $this->status;
+ }
+
+ public function getStatusColorAttribute(): string
+ {
+ return match ($this->status) {
+ self::STATUS_PENDING => 'yellow',
+ self::STATUS_APPROVED => 'green',
+ self::STATUS_REJECTED => 'red',
+ default => 'gray',
+ };
+ }
+
+ // =========================================================================
+ // Helper Methods
+ // =========================================================================
+
+ public function isPending(): bool
+ {
+ return $this->status === self::STATUS_PENDING;
+ }
+
+ public function isApproved(): bool
+ {
+ return $this->status === self::STATUS_APPROVED;
+ }
+
+ public function isRejected(): bool
+ {
+ return $this->status === self::STATUS_REJECTED;
+ }
+}
diff --git a/app/Models/Documents/DocumentAttachment.php b/app/Models/Documents/DocumentAttachment.php
new file mode 100644
index 00000000..0509185a
--- /dev/null
+++ b/app/Models/Documents/DocumentAttachment.php
@@ -0,0 +1,69 @@
+ '일반',
+ self::TYPE_SIGNATURE => '서명',
+ self::TYPE_IMAGE => '이미지',
+ self::TYPE_REFERENCE => '참고자료',
+ ];
+
+ protected $fillable = [
+ 'document_id',
+ 'file_id',
+ 'attachment_type',
+ 'description',
+ 'created_by',
+ ];
+
+ protected $attributes = [
+ 'attachment_type' => self::TYPE_GENERAL,
+ ];
+
+ // =========================================================================
+ // Relationships
+ // =========================================================================
+
+ public function document(): BelongsTo
+ {
+ return $this->belongsTo(Document::class);
+ }
+
+ public function file(): BelongsTo
+ {
+ return $this->belongsTo(File::class);
+ }
+
+ public function creator(): BelongsTo
+ {
+ return $this->belongsTo(User::class, 'created_by');
+ }
+
+ // =========================================================================
+ // Accessors
+ // =========================================================================
+
+ public function getTypeLabelAttribute(): string
+ {
+ return self::TYPE_LABELS[$this->attachment_type] ?? $this->attachment_type;
+ }
+}
diff --git a/app/Models/Documents/DocumentData.php b/app/Models/Documents/DocumentData.php
new file mode 100644
index 00000000..6421ac97
--- /dev/null
+++ b/app/Models/Documents/DocumentData.php
@@ -0,0 +1,47 @@
+ 'integer',
+ ];
+
+ // =========================================================================
+ // Relationships
+ // =========================================================================
+
+ public function document(): BelongsTo
+ {
+ return $this->belongsTo(Document::class);
+ }
+
+ // =========================================================================
+ // Scopes
+ // =========================================================================
+
+ public function scopeForSection($query, int $sectionId)
+ {
+ return $query->where('section_id', $sectionId);
+ }
+
+ public function scopeForField($query, string $fieldKey)
+ {
+ return $query->where('field_key', $fieldKey);
+ }
+}
diff --git a/resources/views/documents/edit.blade.php b/resources/views/documents/edit.blade.php
new file mode 100644
index 00000000..7f6c4671
--- /dev/null
+++ b/resources/views/documents/edit.blade.php
@@ -0,0 +1,211 @@
+@extends('layouts.app')
+
+@section('title', $isCreate ? '새 문서 작성' : '문서 수정')
+
+@section('content')
+
+ {{-- 헤더 --}}
+
+
+
{{ $isCreate ? '새 문서 작성' : '문서 수정' }}
+
+ @if($document)
+ {{ $document->document_no }} - {{ $document->title }}
+ @else
+ 템플릿을 선택하여 문서를 작성합니다.
+ @endif
+
+
+
+
+ 목록으로
+
+
+
+ {{-- 템플릿 선택 (생성 시) --}}
+ @if($isCreate && !$template)
+
+ @endif
+
+ {{-- 문서 폼 --}}
+ @if($template)
+
+ @endif
+
+@endsection
+
+@push('scripts')
+
+@endpush
\ No newline at end of file
diff --git a/resources/views/documents/index.blade.php b/resources/views/documents/index.blade.php
new file mode 100644
index 00000000..41798958
--- /dev/null
+++ b/resources/views/documents/index.blade.php
@@ -0,0 +1,240 @@
+@extends('layouts.app')
+
+@section('title', '문서 관리')
+
+@section('content')
+
+ {{-- 헤더 --}}
+
+
+ {{-- 필터 --}}
+
+
+
+
+ {{-- 문서 목록 --}}
+
+
+
+
+
+ | 문서번호 |
+ 제목 |
+ 템플릿 |
+ 상태 |
+ 작성자 |
+ 작성일 |
+ 관리 |
+
+
+
+ {{-- HTMX로 로드 --}}
+
+
+
+
+ {{-- 페이지네이션 --}}
+
+
+
+@endsection
+
+@push('scripts')
+
+@endpush
\ No newline at end of file
diff --git a/resources/views/documents/show.blade.php b/resources/views/documents/show.blade.php
new file mode 100644
index 00000000..4dcf9e2a
--- /dev/null
+++ b/resources/views/documents/show.blade.php
@@ -0,0 +1,200 @@
+@extends('layouts.app')
+
+@section('title', '문서 상세')
+
+@section('content')
+
+ {{-- 헤더 --}}
+
+
+
문서 상세
+
{{ $document->document_no }} - {{ $document->title }}
+
+
+
+
+ {{-- 문서 정보 --}}
+
+ {{-- 메인 컨텐츠 --}}
+
+ {{-- 기본 정보 --}}
+
+
기본 정보
+
+
+
+
- 문서번호
+ - {{ $document->document_no }}
+
+
+
- 템플릿
+ - {{ $document->template->name ?? '-' }}
+
+
+
- 제목
+ - {{ $document->title }}
+
+
+
- 상태
+ -
+
+ {{ $document->status_label }}
+
+
+
+
+
- 작성자
+ - {{ $document->creator->name ?? '-' }}
+
+
+
- 작성일
+ - {{ $document->created_at?->format('Y-m-d H:i') ?? '-' }}
+
+ @if($document->updated_at && $document->updated_at->ne($document->created_at))
+
+
- 수정자
+ - {{ $document->updater->name ?? '-' }}
+
+
+
- 수정일
+ - {{ $document->updated_at?->format('Y-m-d H:i') ?? '-' }}
+
+ @endif
+
+
+
+ {{-- 기본 필드 데이터 --}}
+ @if($document->template?->basicFields && $document->template->basicFields->count() > 0)
+
+
{{ $document->template->title ?? '문서 정보' }}
+
+
+ @foreach($document->template->basicFields as $field)
+ @php
+ $fieldData = $document->data->where('field_key', $field->field_key)->first();
+ $value = $fieldData?->field_value ?? '-';
+ @endphp
+
+
- {{ $field->label }}
+ - {{ $value }}
+
+ @endforeach
+
+
+ @endif
+
+ {{-- 섹션 데이터 (테이블) --}}
+ @if($document->template?->sections && $document->template->sections->count() > 0)
+ @foreach($document->template->sections as $section)
+
+
{{ $section->name }}
+
테이블 형태의 데이터 표시는 추후 구현 예정입니다.
+
+ @endforeach
+ @endif
+
+ {{-- 첨부파일 --}}
+ @if($document->attachments && $document->attachments->count() > 0)
+
+
첨부파일
+
+
+ @foreach($document->attachments as $attachment)
+ -
+
+
+
+
{{ $attachment->file->original_name ?? '파일명 없음' }}
+
+ {{ $attachment->type_label }} ·
+ {{ $attachment->file ? number_format($attachment->file->size / 1024, 1) . ' KB' : '-' }}
+
+
+
+ @if($attachment->file)
+
+ 다운로드
+
+ @endif
+
+ @endforeach
+
+
+ @endif
+
+
+ {{-- 사이드바 --}}
+
+ {{-- 결재 현황 --}}
+
+
결재 현황
+
+ @if($document->approvals && $document->approvals->count() > 0)
+
+ @foreach($document->approvals->sortBy('step_order') as $approval)
+ -
+
+ @if($approval->status === 'APPROVED')
+
+ @elseif($approval->status === 'REJECTED')
+
+ @else
+ {{ $approval->step_order }}
+ @endif
+
+
+
{{ $approval->user->name ?? '미지정' }}
+
{{ $approval->step_name }}
+ @if($approval->approved_at)
+
{{ $approval->approved_at->format('Y-m-d H:i') }}
+ @endif
+ @if($approval->comment)
+
{{ $approval->comment }}
+ @endif
+
+
+ @endforeach
+
+ @else
+
결재선이 설정되지 않았습니다.
+ @endif
+
+
+ {{-- 문서 이력 --}}
+
+
문서 이력
+
문서 이력 기능은 추후 구현 예정입니다.
+
+
+
+
+@endsection
\ No newline at end of file
diff --git a/routes/api.php b/routes/api.php
index ce1b9b8c..8d060e20 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -1,11 +1,12 @@
name('upload-image');
});
+/*
+|--------------------------------------------------------------------------
+| 문서 관리 API
+|--------------------------------------------------------------------------
+*/
+Route::middleware(['web', 'auth', 'hq.member'])->prefix('admin/documents')->name('api.admin.documents.')->group(function () {
+ Route::get('/', [DocumentApiController::class, 'index'])->name('index');
+ Route::post('/', [DocumentApiController::class, 'store'])->name('store');
+ Route::get('/{id}', [DocumentApiController::class, 'show'])->name('show');
+ Route::patch('/{id}', [DocumentApiController::class, 'update'])->name('update');
+ Route::delete('/{id}', [DocumentApiController::class, 'destroy'])->name('destroy');
+});
+
/*
|--------------------------------------------------------------------------
| 카테고리 관리 API
diff --git a/routes/web.php b/routes/web.php
index 11bc1aeb..418eb3d9 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -6,17 +6,21 @@
use App\Http\Controllers\AuditLogController;
use App\Http\Controllers\Auth\LoginController;
use App\Http\Controllers\BoardController;
+use App\Http\Controllers\CategoryController;
+use App\Http\Controllers\CommonCodeController;
use App\Http\Controllers\CustomerCenterController;
use App\Http\Controllers\DailyLogController;
use App\Http\Controllers\DepartmentController;
use App\Http\Controllers\DevTools\ApiExplorerController;
use App\Http\Controllers\DevTools\FlowTesterController;
+use App\Http\Controllers\DocumentController;
+use App\Http\Controllers\DocumentTemplateController;
use App\Http\Controllers\FcmController;
use App\Http\Controllers\ItemFieldController;
use App\Http\Controllers\Lab\AIController;
-use App\Http\Controllers\Lab\ManagementController;
use App\Http\Controllers\Lab\StrategyController;
use App\Http\Controllers\MenuController;
+use App\Http\Controllers\MenuSyncController;
use App\Http\Controllers\PermissionController;
use App\Http\Controllers\PostController;
use App\Http\Controllers\ProfileController;
@@ -27,10 +31,6 @@
use App\Http\Controllers\System\AiConfigController;
use App\Http\Controllers\TenantController;
use App\Http\Controllers\TenantSettingController;
-use App\Http\Controllers\CommonCodeController;
-use App\Http\Controllers\DocumentTemplateController;
-use App\Http\Controllers\CategoryController;
-use App\Http\Controllers\MenuSyncController;
use App\Http\Controllers\UserController;
use Illuminate\Support\Facades\Route;
@@ -319,6 +319,14 @@
Route::get('/{id}/edit', [DocumentTemplateController::class, 'edit'])->name('edit');
});
+ // 문서 관리
+ Route::prefix('documents')->name('documents.')->group(function () {
+ Route::get('/', [DocumentController::class, 'index'])->name('index');
+ Route::get('/create', [DocumentController::class, 'create'])->name('create');
+ Route::get('/{id}', [DocumentController::class, 'show'])->whereNumber('id')->name('show');
+ Route::get('/{id}/edit', [DocumentController::class, 'edit'])->whereNumber('id')->name('edit');
+ });
+
// AI 설정 관리
Route::prefix('system/ai-config')->name('system.ai-config.')->group(function () {
Route::get('/', [AiConfigController::class, 'index'])->name('index');
@@ -629,6 +637,7 @@
if (request()->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('finance.account-transactions'));
}
+
return view('finance.account-transactions');
})->name('account-transactions');
@@ -641,12 +650,13 @@
});
// 기존 fund-schedule URL 리다이렉트 (호환성)
- Route::get('/fund-schedule', fn() => redirect()->route('finance.fund-schedules.index'))->name('fund-schedule');
+ Route::get('/fund-schedule', fn () => redirect()->route('finance.fund-schedules.index'))->name('fund-schedule');
Route::get('/daily-fund', function () {
// HTMX 요청이면 전체 페이지로 리다이렉트 (React 스크립트 로딩을 위해)
if (request()->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('finance.daily-fund'));
}
+
return view('finance.daily-fund');
})->name('daily-fund');
@@ -655,12 +665,14 @@
if (request()->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('finance.corporate-cards'));
}
+
return view('finance.corporate-cards');
})->name('corporate-cards');
Route::get('/card-transactions', function () {
if (request()->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('finance.card-transactions'));
}
+
return view('finance.card-transactions');
})->name('card-transactions');
@@ -669,12 +681,14 @@
if (request()->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('finance.income'));
}
+
return view('finance.income');
})->name('income');
Route::get('/expense', function () {
if (request()->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('finance.expense'));
}
+
return view('finance.expense');
})->name('expense');
@@ -683,12 +697,14 @@
if (request()->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('finance.sales'));
}
+
return view('finance.sales');
})->name('sales');
Route::get('/purchase', function () {
if (request()->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('finance.purchase'));
}
+
return view('finance.purchase');
})->name('purchase');
@@ -697,24 +713,28 @@
if (request()->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('finance.sales-commission'));
}
+
return view('finance.sales-commission');
})->name('sales-commission');
Route::get('/consulting-fee', function () {
if (request()->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('finance.consulting-fee'));
}
+
return view('finance.consulting-fee');
})->name('consulting-fee');
Route::get('/customer-settlement', function () {
if (request()->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('finance.customer-settlement'));
}
+
return view('finance.customer-settlement');
})->name('customer-settlement');
Route::get('/subscription', function () {
if (request()->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('finance.subscription'));
}
+
return view('finance.subscription');
})->name('subscription');
@@ -723,12 +743,14 @@
if (request()->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('finance.corporate-vehicles'));
}
+
return view('finance.corporate-vehicles');
})->name('corporate-vehicles');
Route::get('/vehicle-maintenance', function () {
if (request()->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('finance.vehicle-maintenance'));
}
+
return view('finance.vehicle-maintenance');
})->name('vehicle-maintenance');
@@ -737,12 +759,14 @@
if (request()->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('finance.customers'));
}
+
return view('finance.customers');
})->name('customers');
Route::get('/partners', function () {
if (request()->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('finance.partners'));
}
+
return view('finance.partners');
})->name('partners');
@@ -751,12 +775,14 @@
if (request()->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('finance.receivables'));
}
+
return view('finance.receivables');
})->name('receivables');
Route::get('/payables', function () {
if (request()->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('finance.payables'));
}
+
return view('finance.payables');
})->name('payables');
@@ -765,12 +791,14 @@
if (request()->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('finance.refunds'));
}
+
return view('finance.refunds');
})->name('refunds');
Route::get('/vat', function () {
if (request()->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('finance.vat'));
}
+
return view('finance.vat');
})->name('vat');
});
From 1d9473d7d0e12f1c2e25be2861e22cbb4d110af7 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?=
Date: Wed, 28 Jan 2026 23:07:15 +0900
Subject: [PATCH 05/12] =?UTF-8?q?refactor:=EC=B9=B4=ED=85=8C=EA=B3=A0?=
=?UTF-8?q?=EB=A6=AC/=EA=B3=B5=ED=86=B5=EC=BD=94=EB=93=9C=20=ED=83=AD=20?=
=?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20=EC=84=B8=EB=A1=9C?=
=?UTF-8?q?=ED=98=95=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 가로 탭 → 좌측 세로 탭(사이드바 스타일)으로 변경
- 코드 그룹이 많아져도 세로 스크롤로 확인 가능
- 우측 글로벌/테넌트 2컬럼 그리드 유지
Co-Authored-By: Claude Opus 4.5
---
resources/views/categories/index.blade.php | 28 +++++++++++---------
resources/views/common-codes/index.blade.php | 27 +++++++++++--------
2 files changed, 32 insertions(+), 23 deletions(-)
diff --git a/resources/views/categories/index.blade.php b/resources/views/categories/index.blade.php
index 5b511975..4b4fd318 100644
--- a/resources/views/categories/index.blade.php
+++ b/resources/views/categories/index.blade.php
@@ -43,26 +43,29 @@ class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg
헤더에서 테넌트를 선택해주세요.
@else
-
-
-
-
diff --git a/resources/views/common-codes/index.blade.php b/resources/views/common-codes/index.blade.php
index 434fac6c..43ae126b 100644
--- a/resources/views/common-codes/index.blade.php
+++ b/resources/views/common-codes/index.blade.php
@@ -61,25 +61,29 @@ class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg
@endif
-
-
-
-
From 8b7f0b9f7ffa90afe4ebc4a284dec37a2a63a5d7 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?=
Date: Thu, 29 Jan 2026 00:31:51 +0900
Subject: [PATCH 06/12] =?UTF-8?q?feat:=EA=B3=B5=ED=86=B5=EC=BD=94=EB=93=9C?=
=?UTF-8?q?/=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EB=8F=99=EA=B8=B0?=
=?UTF-8?q?=ED=99=94=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- CommonCodeSyncController, CategorySyncController 생성
- 환경설정은 메뉴 동기화와 공유 (TenantSetting)
- Export/Import API 추가 (/common-code-sync, /category-sync)
- Push(로컬→원격), Pull(원격→로컬) 양방향 동기화
- 동일 코드 존재 시 체크박스 비활성화 (충돌 방지)
- 글로벌 + 테넌트 코드 모두 동기화 가능
- 공통코드/카테고리 관리 페이지에 동기화 버튼 추가
Co-Authored-By: Claude Opus 4.5
---
.../Controllers/CategorySyncController.php | 490 ++++++++++++++++++
.../Controllers/CommonCodeSyncController.php | 388 ++++++++++++++
resources/views/categories/index.blade.php | 25 +-
resources/views/categories/sync.blade.php | 369 +++++++++++++
resources/views/common-codes/index.blade.php | 25 +-
resources/views/common-codes/sync.blade.php | 370 +++++++++++++
routes/web.php | 30 +-
7 files changed, 1680 insertions(+), 17 deletions(-)
create mode 100644 app/Http/Controllers/CategorySyncController.php
create mode 100644 app/Http/Controllers/CommonCodeSyncController.php
create mode 100644 resources/views/categories/sync.blade.php
create mode 100644 resources/views/common-codes/sync.blade.php
diff --git a/app/Http/Controllers/CategorySyncController.php b/app/Http/Controllers/CategorySyncController.php
new file mode 100644
index 00000000..fc05cd42
--- /dev/null
+++ b/app/Http/Controllers/CategorySyncController.php
@@ -0,0 +1,490 @@
+where('tenant_id', $this->getTenantId())
+ ->where('setting_group', 'menu_sync')
+ ->where('setting_key', 'environments')
+ ->first();
+
+ return $setting?->setting_value ?? [
+ 'dev' => ['name' => '개발', 'url' => '', 'api_key' => ''],
+ 'prod' => ['name' => '운영', 'url' => '', 'api_key' => ''],
+ ];
+ }
+
+ /**
+ * 카테고리 동기화 페이지
+ */
+ public function index(Request $request): View|Response
+ {
+ if ($request->header('HX-Request')) {
+ return response('', 200)->header('HX-Redirect', route('categories.sync.index'));
+ }
+
+ $environments = $this->getEnvironments();
+ $selectedEnv = $request->get('env', 'dev');
+
+ // 로컬 카테고리 조회
+ $localCategories = $this->getCategoryList();
+
+ // 원격 카테고리 조회
+ $remoteCategories = [];
+ $remoteError = null;
+
+ if (! empty($environments[$selectedEnv]['url'])) {
+ try {
+ $remoteCategories = $this->fetchRemoteCategories($environments[$selectedEnv]);
+ } catch (\Exception $e) {
+ $remoteError = $e->getMessage();
+ }
+ }
+
+ // 차이점 계산
+ $diff = $this->calculateDiff($localCategories, $remoteCategories);
+
+ return view('categories.sync', [
+ 'environments' => $environments,
+ 'selectedEnv' => $selectedEnv,
+ 'localCategories' => $localCategories,
+ 'remoteCategories' => $remoteCategories,
+ 'remoteError' => $remoteError,
+ 'diff' => $diff,
+ ]);
+ }
+
+ /**
+ * 카테고리 Export API (다른 환경에서 호출)
+ */
+ public function export(Request $request): JsonResponse
+ {
+ // API Key 검증
+ $apiKey = $request->header('X-Menu-Sync-Key');
+ $validKey = config('app.menu_sync_api_key', env('MENU_SYNC_API_KEY'));
+
+ if (empty($validKey) || $apiKey !== $validKey) {
+ return response()->json(['error' => 'Unauthorized'], 401);
+ }
+
+ $categories = $this->getCategoryList();
+
+ return response()->json([
+ 'success' => true,
+ 'environment' => config('app.env'),
+ 'exported_at' => now()->toIso8601String(),
+ 'categories' => $categories,
+ ]);
+ }
+
+ /**
+ * 카테고리 Import API (다른 환경에서 호출)
+ */
+ public function import(Request $request): JsonResponse
+ {
+ // API Key 검증
+ $apiKey = $request->header('X-Menu-Sync-Key');
+ $validKey = config('app.menu_sync_api_key', env('MENU_SYNC_API_KEY'));
+
+ if (empty($validKey) || $apiKey !== $validKey) {
+ return response()->json(['error' => 'Unauthorized'], 401);
+ }
+
+ $validated = $request->validate([
+ 'categories' => 'required|array',
+ 'categories.*.is_global' => 'required|boolean',
+ 'categories.*.tenant_id' => 'nullable|integer',
+ 'categories.*.code_group' => 'required|string|max:50',
+ 'categories.*.code' => 'required|string|max:50',
+ 'categories.*.name' => 'required|string|max:100',
+ 'categories.*.parent_code' => 'nullable|string|max:50',
+ 'categories.*.sort_order' => 'nullable|integer',
+ 'categories.*.description' => 'nullable|string',
+ 'categories.*.is_active' => 'nullable|boolean',
+ ]);
+
+ $imported = 0;
+ $skipped = 0;
+
+ foreach ($validated['categories'] as $catData) {
+ if ($catData['is_global']) {
+ // 글로벌 카테고리
+ $exists = GlobalCategory::where('code_group', $catData['code_group'])
+ ->where('code', $catData['code'])
+ ->exists();
+
+ if ($exists) {
+ $skipped++;
+ continue;
+ }
+
+ // 부모 찾기
+ $parentId = null;
+ if (! empty($catData['parent_code'])) {
+ $parent = GlobalCategory::where('code_group', $catData['code_group'])
+ ->where('code', $catData['parent_code'])
+ ->first();
+ $parentId = $parent?->id;
+ }
+
+ GlobalCategory::create([
+ 'parent_id' => $parentId,
+ 'code_group' => $catData['code_group'],
+ 'code' => $catData['code'],
+ 'name' => $catData['name'],
+ 'sort_order' => $catData['sort_order'] ?? 0,
+ 'description' => $catData['description'] ?? null,
+ 'is_active' => $catData['is_active'] ?? true,
+ ]);
+ } else {
+ // 테넌트 카테고리
+ $tenantId = $catData['tenant_id'] ?? $this->getTenantId();
+
+ $exists = Category::withoutGlobalScopes()
+ ->where('tenant_id', $tenantId)
+ ->where('code_group', $catData['code_group'])
+ ->where('code', $catData['code'])
+ ->exists();
+
+ if ($exists) {
+ $skipped++;
+ continue;
+ }
+
+ // 부모 찾기
+ $parentId = null;
+ if (! empty($catData['parent_code'])) {
+ $parent = Category::withoutGlobalScopes()
+ ->where('tenant_id', $tenantId)
+ ->where('code_group', $catData['code_group'])
+ ->where('code', $catData['parent_code'])
+ ->first();
+ $parentId = $parent?->id;
+ }
+
+ Category::create([
+ 'tenant_id' => $tenantId,
+ 'parent_id' => $parentId,
+ 'code_group' => $catData['code_group'],
+ 'code' => $catData['code'],
+ 'name' => $catData['name'],
+ 'sort_order' => $catData['sort_order'] ?? 0,
+ 'description' => $catData['description'] ?? null,
+ 'is_active' => $catData['is_active'] ?? true,
+ ]);
+ }
+ $imported++;
+ }
+
+ return response()->json([
+ 'success' => true,
+ 'message' => "{$imported}개 카테고리가 동기화되었습니다." . ($skipped > 0 ? " ({$skipped}개 스킵)" : ''),
+ 'imported' => $imported,
+ 'skipped' => $skipped,
+ ]);
+ }
+
+ /**
+ * Push (로컬 → 원격)
+ */
+ public function push(Request $request): JsonResponse
+ {
+ $validated = $request->validate([
+ 'env' => 'required|string|in:dev,prod',
+ 'category_keys' => 'required|array|min:1',
+ 'category_keys.*' => 'string',
+ ]);
+
+ $environments = $this->getEnvironments();
+ $env = $environments[$validated['env']] ?? null;
+
+ if (! $env || empty($env['url'])) {
+ return response()->json(['error' => '환경 설정이 없습니다.'], 400);
+ }
+
+ // 선택된 카테고리 조회
+ $localCategories = $this->getCategoryList();
+ $selectedCategories = array_filter($localCategories, function ($cat) use ($validated) {
+ $key = $this->makeCategoryKey($cat);
+ return in_array($key, $validated['category_keys']);
+ });
+
+ if (empty($selectedCategories)) {
+ return response()->json(['error' => '선택된 카테고리가 없습니다.'], 400);
+ }
+
+ // 원격 서버로 전송
+ try {
+ $response = Http::withHeaders([
+ 'X-Menu-Sync-Key' => $env['api_key'],
+ 'Accept' => 'application/json',
+ ])->post(rtrim($env['url'], '/') . '/category-sync/import', [
+ 'categories' => array_values($selectedCategories),
+ ]);
+
+ if ($response->successful()) {
+ return response()->json([
+ 'success' => true,
+ 'message' => $response->json('message', '동기화 완료'),
+ 'imported' => $response->json('imported', 0),
+ 'skipped' => $response->json('skipped', 0),
+ ]);
+ }
+
+ return response()->json([
+ 'error' => $response->json('error', '원격 서버 오류'),
+ ], $response->status());
+ } catch (\Exception $e) {
+ return response()->json(['error' => '연결 실패: ' . $e->getMessage()], 500);
+ }
+ }
+
+ /**
+ * Pull (원격 → 로컬)
+ */
+ public function pull(Request $request): JsonResponse
+ {
+ $validated = $request->validate([
+ 'env' => 'required|string|in:dev,prod',
+ 'category_keys' => 'required|array|min:1',
+ 'category_keys.*' => 'string',
+ ]);
+
+ $environments = $this->getEnvironments();
+ $env = $environments[$validated['env']] ?? null;
+
+ if (! $env || empty($env['url'])) {
+ return response()->json(['error' => '환경 설정이 없습니다.'], 400);
+ }
+
+ // 원격 카테고리 조회
+ try {
+ $remoteCategories = $this->fetchRemoteCategories($env);
+ } catch (\Exception $e) {
+ return response()->json(['error' => $e->getMessage()], 500);
+ }
+
+ // 선택된 카테고리만 필터링
+ $selectedCategories = array_filter($remoteCategories, function ($cat) use ($validated) {
+ $key = $this->makeCategoryKey($cat);
+ return in_array($key, $validated['category_keys']);
+ });
+
+ if (empty($selectedCategories)) {
+ return response()->json(['error' => '선택된 카테고리를 찾을 수 없습니다.'], 400);
+ }
+
+ // 로컬에 Import
+ $imported = 0;
+ $skipped = 0;
+
+ foreach ($selectedCategories as $catData) {
+ if ($catData['is_global']) {
+ $exists = GlobalCategory::where('code_group', $catData['code_group'])
+ ->where('code', $catData['code'])
+ ->exists();
+
+ if ($exists) {
+ $skipped++;
+ continue;
+ }
+
+ $parentId = null;
+ if (! empty($catData['parent_code'])) {
+ $parent = GlobalCategory::where('code_group', $catData['code_group'])
+ ->where('code', $catData['parent_code'])
+ ->first();
+ $parentId = $parent?->id;
+ }
+
+ GlobalCategory::create([
+ 'parent_id' => $parentId,
+ 'code_group' => $catData['code_group'],
+ 'code' => $catData['code'],
+ 'name' => $catData['name'],
+ 'sort_order' => $catData['sort_order'] ?? 0,
+ 'description' => $catData['description'] ?? null,
+ 'is_active' => $catData['is_active'] ?? true,
+ ]);
+ } else {
+ $tenantId = $this->getTenantId();
+
+ $exists = Category::withoutGlobalScopes()
+ ->where('tenant_id', $tenantId)
+ ->where('code_group', $catData['code_group'])
+ ->where('code', $catData['code'])
+ ->exists();
+
+ if ($exists) {
+ $skipped++;
+ continue;
+ }
+
+ $parentId = null;
+ if (! empty($catData['parent_code'])) {
+ $parent = Category::withoutGlobalScopes()
+ ->where('tenant_id', $tenantId)
+ ->where('code_group', $catData['code_group'])
+ ->where('code', $catData['parent_code'])
+ ->first();
+ $parentId = $parent?->id;
+ }
+
+ Category::create([
+ 'tenant_id' => $tenantId,
+ 'parent_id' => $parentId,
+ 'code_group' => $catData['code_group'],
+ 'code' => $catData['code'],
+ 'name' => $catData['name'],
+ 'sort_order' => $catData['sort_order'] ?? 0,
+ 'description' => $catData['description'] ?? null,
+ 'is_active' => $catData['is_active'] ?? true,
+ ]);
+ }
+ $imported++;
+ }
+
+ return response()->json([
+ 'success' => true,
+ 'message' => "{$imported}개 카테고리가 동기화되었습니다." . ($skipped > 0 ? " ({$skipped}개 스킵)" : ''),
+ 'imported' => $imported,
+ 'skipped' => $skipped,
+ ]);
+ }
+
+ /**
+ * 카테고리 목록 조회 (글로벌 + 테넌트)
+ */
+ private function getCategoryList(): array
+ {
+ $tenantId = $this->getTenantId();
+ $categories = [];
+
+ // 글로벌 카테고리
+ $globalCategories = GlobalCategory::whereNull('deleted_at')
+ ->orderBy('code_group')
+ ->orderBy('sort_order')
+ ->get();
+
+ foreach ($globalCategories as $cat) {
+ $parentCode = null;
+ if ($cat->parent_id) {
+ $parent = GlobalCategory::find($cat->parent_id);
+ $parentCode = $parent?->code;
+ }
+
+ $categories[] = [
+ 'is_global' => true,
+ 'tenant_id' => null,
+ 'code_group' => $cat->code_group,
+ 'code' => $cat->code,
+ 'name' => $cat->name,
+ 'parent_code' => $parentCode,
+ 'sort_order' => $cat->sort_order,
+ 'description' => $cat->description,
+ 'is_active' => $cat->is_active,
+ ];
+ }
+
+ // 테넌트 카테고리
+ $tenantCategories = Category::withoutGlobalScopes()
+ ->where('tenant_id', $tenantId)
+ ->whereNull('deleted_at')
+ ->orderBy('code_group')
+ ->orderBy('sort_order')
+ ->get();
+
+ foreach ($tenantCategories as $cat) {
+ $parentCode = null;
+ if ($cat->parent_id) {
+ $parent = Category::withoutGlobalScopes()->find($cat->parent_id);
+ $parentCode = $parent?->code;
+ }
+
+ $categories[] = [
+ 'is_global' => false,
+ 'tenant_id' => $cat->tenant_id,
+ 'code_group' => $cat->code_group,
+ 'code' => $cat->code,
+ 'name' => $cat->name,
+ 'parent_code' => $parentCode,
+ 'sort_order' => $cat->sort_order,
+ 'description' => $cat->description,
+ 'is_active' => $cat->is_active,
+ ];
+ }
+
+ return $categories;
+ }
+
+ /**
+ * 원격 카테고리 조회
+ */
+ private function fetchRemoteCategories(array $env): array
+ {
+ $response = Http::withHeaders([
+ 'X-Menu-Sync-Key' => $env['api_key'],
+ 'Accept' => 'application/json',
+ ])->timeout(10)->get(rtrim($env['url'], '/') . '/category-sync/export');
+
+ if (! $response->successful()) {
+ throw new \Exception('API 오류: HTTP ' . $response->status());
+ }
+
+ $data = $response->json();
+ if (! isset($data['categories'])) {
+ throw new \Exception('잘못된 응답 형식');
+ }
+
+ return $data['categories'];
+ }
+
+ /**
+ * 카테고리 키 생성 (유니크 식별자)
+ */
+ private function makeCategoryKey(array $cat): string
+ {
+ $typePart = $cat['is_global'] ? 'global' : "tenant:{$cat['tenant_id']}";
+ return "{$typePart}:{$cat['code_group']}:{$cat['code']}";
+ }
+
+ /**
+ * 차이점 계산
+ */
+ private function calculateDiff(array $localCategories, array $remoteCategories): array
+ {
+ $localKeys = array_map(fn($c) => $this->makeCategoryKey($c), $localCategories);
+ $remoteKeys = array_map(fn($c) => $this->makeCategoryKey($c), $remoteCategories);
+
+ return [
+ 'local_only' => array_values(array_diff($localKeys, $remoteKeys)),
+ 'remote_only' => array_values(array_diff($remoteKeys, $localKeys)),
+ 'both' => array_values(array_intersect($localKeys, $remoteKeys)),
+ ];
+ }
+}
\ No newline at end of file
diff --git a/app/Http/Controllers/CommonCodeSyncController.php b/app/Http/Controllers/CommonCodeSyncController.php
new file mode 100644
index 00000000..ba1b8f92
--- /dev/null
+++ b/app/Http/Controllers/CommonCodeSyncController.php
@@ -0,0 +1,388 @@
+where('tenant_id', $this->getTenantId())
+ ->where('setting_group', 'menu_sync')
+ ->where('setting_key', 'environments')
+ ->first();
+
+ return $setting?->setting_value ?? [
+ 'dev' => ['name' => '개발', 'url' => '', 'api_key' => ''],
+ 'prod' => ['name' => '운영', 'url' => '', 'api_key' => ''],
+ ];
+ }
+
+ /**
+ * 공통코드 동기화 페이지
+ */
+ public function index(Request $request): View|Response
+ {
+ if ($request->header('HX-Request')) {
+ return response('', 200)->header('HX-Redirect', route('common-codes.sync.index'));
+ }
+
+ $environments = $this->getEnvironments();
+ $selectedEnv = $request->get('env', 'dev');
+
+ // 로컬 코드 조회
+ $localCodes = $this->getCodeList();
+
+ // 원격 코드 조회
+ $remoteCodes = [];
+ $remoteError = null;
+
+ if (! empty($environments[$selectedEnv]['url'])) {
+ try {
+ $remoteCodes = $this->fetchRemoteCodes($environments[$selectedEnv]);
+ } catch (\Exception $e) {
+ $remoteError = $e->getMessage();
+ }
+ }
+
+ // 차이점 계산
+ $diff = $this->calculateDiff($localCodes, $remoteCodes);
+
+ return view('common-codes.sync', [
+ 'environments' => $environments,
+ 'selectedEnv' => $selectedEnv,
+ 'localCodes' => $localCodes,
+ 'remoteCodes' => $remoteCodes,
+ 'remoteError' => $remoteError,
+ 'diff' => $diff,
+ ]);
+ }
+
+ /**
+ * 공통코드 Export API (다른 환경에서 호출)
+ */
+ public function export(Request $request): JsonResponse
+ {
+ // API Key 검증
+ $apiKey = $request->header('X-Menu-Sync-Key');
+ $validKey = config('app.menu_sync_api_key', env('MENU_SYNC_API_KEY'));
+
+ if (empty($validKey) || $apiKey !== $validKey) {
+ return response()->json(['error' => 'Unauthorized'], 401);
+ }
+
+ $codes = $this->getCodeList();
+
+ return response()->json([
+ 'success' => true,
+ 'environment' => config('app.env'),
+ 'exported_at' => now()->toIso8601String(),
+ 'codes' => $codes,
+ ]);
+ }
+
+ /**
+ * 공통코드 Import API (다른 환경에서 호출)
+ */
+ public function import(Request $request): JsonResponse
+ {
+ // API Key 검증
+ $apiKey = $request->header('X-Menu-Sync-Key');
+ $validKey = config('app.menu_sync_api_key', env('MENU_SYNC_API_KEY'));
+
+ if (empty($validKey) || $apiKey !== $validKey) {
+ return response()->json(['error' => 'Unauthorized'], 401);
+ }
+
+ $validated = $request->validate([
+ 'codes' => 'required|array',
+ 'codes.*.tenant_id' => 'nullable|integer',
+ 'codes.*.code_group' => 'required|string|max:50',
+ 'codes.*.code' => 'required|string|max:50',
+ 'codes.*.name' => 'required|string|max:100',
+ 'codes.*.sort_order' => 'nullable|integer',
+ 'codes.*.attributes' => 'nullable|array',
+ 'codes.*.is_active' => 'nullable|boolean',
+ ]);
+
+ $imported = 0;
+ $skipped = 0;
+
+ foreach ($validated['codes'] as $codeData) {
+ // 동일 코드 존재 확인
+ $exists = CommonCode::query()
+ ->where('tenant_id', $codeData['tenant_id'] ?? null)
+ ->where('code_group', $codeData['code_group'])
+ ->where('code', $codeData['code'])
+ ->exists();
+
+ if ($exists) {
+ $skipped++;
+ continue;
+ }
+
+ CommonCode::create([
+ 'tenant_id' => $codeData['tenant_id'] ?? null,
+ 'code_group' => $codeData['code_group'],
+ 'code' => $codeData['code'],
+ 'name' => $codeData['name'],
+ 'sort_order' => $codeData['sort_order'] ?? 0,
+ 'attributes' => $codeData['attributes'] ?? null,
+ 'is_active' => $codeData['is_active'] ?? true,
+ ]);
+ $imported++;
+ }
+
+ return response()->json([
+ 'success' => true,
+ 'message' => "{$imported}개 코드가 동기화되었습니다." . ($skipped > 0 ? " ({$skipped}개 스킵)" : ''),
+ 'imported' => $imported,
+ 'skipped' => $skipped,
+ ]);
+ }
+
+ /**
+ * Push (로컬 → 원격)
+ */
+ public function push(Request $request): JsonResponse
+ {
+ $validated = $request->validate([
+ 'env' => 'required|string|in:dev,prod',
+ 'code_keys' => 'required|array|min:1',
+ 'code_keys.*' => 'string',
+ ]);
+
+ $environments = $this->getEnvironments();
+ $env = $environments[$validated['env']] ?? null;
+
+ if (! $env || empty($env['url'])) {
+ return response()->json(['error' => '환경 설정이 없습니다.'], 400);
+ }
+
+ // 선택된 코드 조회
+ $localCodes = $this->getCodeList();
+ $selectedCodes = array_filter($localCodes, function ($code) use ($validated) {
+ $key = $this->makeCodeKey($code);
+ return in_array($key, $validated['code_keys']);
+ });
+
+ if (empty($selectedCodes)) {
+ return response()->json(['error' => '선택된 코드가 없습니다.'], 400);
+ }
+
+ // 원격 서버로 전송
+ try {
+ $response = Http::withHeaders([
+ 'X-Menu-Sync-Key' => $env['api_key'],
+ 'Accept' => 'application/json',
+ ])->post(rtrim($env['url'], '/') . '/common-code-sync/import', [
+ 'codes' => array_values($selectedCodes),
+ ]);
+
+ if ($response->successful()) {
+ return response()->json([
+ 'success' => true,
+ 'message' => $response->json('message', '동기화 완료'),
+ 'imported' => $response->json('imported', 0),
+ 'skipped' => $response->json('skipped', 0),
+ ]);
+ }
+
+ return response()->json([
+ 'error' => $response->json('error', '원격 서버 오류'),
+ ], $response->status());
+ } catch (\Exception $e) {
+ return response()->json(['error' => '연결 실패: ' . $e->getMessage()], 500);
+ }
+ }
+
+ /**
+ * Pull (원격 → 로컬)
+ */
+ public function pull(Request $request): JsonResponse
+ {
+ $validated = $request->validate([
+ 'env' => 'required|string|in:dev,prod',
+ 'code_keys' => 'required|array|min:1',
+ 'code_keys.*' => 'string',
+ ]);
+
+ $environments = $this->getEnvironments();
+ $env = $environments[$validated['env']] ?? null;
+
+ if (! $env || empty($env['url'])) {
+ return response()->json(['error' => '환경 설정이 없습니다.'], 400);
+ }
+
+ // 원격 코드 조회
+ try {
+ $remoteCodes = $this->fetchRemoteCodes($env);
+ } catch (\Exception $e) {
+ return response()->json(['error' => $e->getMessage()], 500);
+ }
+
+ // 선택된 코드만 필터링
+ $selectedCodes = array_filter($remoteCodes, function ($code) use ($validated) {
+ $key = $this->makeCodeKey($code);
+ return in_array($key, $validated['code_keys']);
+ });
+
+ if (empty($selectedCodes)) {
+ return response()->json(['error' => '선택된 코드를 찾을 수 없습니다.'], 400);
+ }
+
+ // 로컬에 Import
+ $imported = 0;
+ $skipped = 0;
+
+ foreach ($selectedCodes as $codeData) {
+ // 동일 코드 존재 확인
+ $exists = CommonCode::query()
+ ->where('tenant_id', $codeData['tenant_id'] ?? null)
+ ->where('code_group', $codeData['code_group'])
+ ->where('code', $codeData['code'])
+ ->exists();
+
+ if ($exists) {
+ $skipped++;
+ continue;
+ }
+
+ CommonCode::create([
+ 'tenant_id' => $codeData['tenant_id'] ?? null,
+ 'code_group' => $codeData['code_group'],
+ 'code' => $codeData['code'],
+ 'name' => $codeData['name'],
+ 'sort_order' => $codeData['sort_order'] ?? 0,
+ 'attributes' => $codeData['attributes'] ?? null,
+ 'is_active' => $codeData['is_active'] ?? true,
+ ]);
+ $imported++;
+ }
+
+ return response()->json([
+ 'success' => true,
+ 'message' => "{$imported}개 코드가 동기화되었습니다." . ($skipped > 0 ? " ({$skipped}개 스킵)" : ''),
+ 'imported' => $imported,
+ 'skipped' => $skipped,
+ ]);
+ }
+
+ /**
+ * 코드 목록 조회 (글로벌 + 테넌트)
+ */
+ private function getCodeList(): array
+ {
+ $tenantId = $this->getTenantId();
+
+ // 글로벌 코드 (tenant_id IS NULL)
+ $globalCodes = CommonCode::query()
+ ->whereNull('tenant_id')
+ ->orderBy('code_group')
+ ->orderBy('sort_order')
+ ->get();
+
+ // 테넌트 코드
+ $tenantCodes = CommonCode::query()
+ ->where('tenant_id', $tenantId)
+ ->orderBy('code_group')
+ ->orderBy('sort_order')
+ ->get();
+
+ $codes = [];
+
+ foreach ($globalCodes as $code) {
+ $codes[] = [
+ 'tenant_id' => null,
+ 'code_group' => $code->code_group,
+ 'code' => $code->code,
+ 'name' => $code->name,
+ 'sort_order' => $code->sort_order,
+ 'attributes' => $code->attributes,
+ 'is_active' => $code->is_active,
+ 'is_global' => true,
+ ];
+ }
+
+ foreach ($tenantCodes as $code) {
+ $codes[] = [
+ 'tenant_id' => $code->tenant_id,
+ 'code_group' => $code->code_group,
+ 'code' => $code->code,
+ 'name' => $code->name,
+ 'sort_order' => $code->sort_order,
+ 'attributes' => $code->attributes,
+ 'is_active' => $code->is_active,
+ 'is_global' => false,
+ ];
+ }
+
+ return $codes;
+ }
+
+ /**
+ * 원격 코드 조회
+ */
+ private function fetchRemoteCodes(array $env): array
+ {
+ $response = Http::withHeaders([
+ 'X-Menu-Sync-Key' => $env['api_key'],
+ 'Accept' => 'application/json',
+ ])->timeout(10)->get(rtrim($env['url'], '/') . '/common-code-sync/export');
+
+ if (! $response->successful()) {
+ throw new \Exception('API 오류: HTTP ' . $response->status());
+ }
+
+ $data = $response->json();
+ if (! isset($data['codes'])) {
+ throw new \Exception('잘못된 응답 형식');
+ }
+
+ return $data['codes'];
+ }
+
+ /**
+ * 코드 키 생성 (유니크 식별자)
+ */
+ private function makeCodeKey(array $code): string
+ {
+ $tenantPart = $code['tenant_id'] ?? 'global';
+ return "{$tenantPart}:{$code['code_group']}:{$code['code']}";
+ }
+
+ /**
+ * 차이점 계산
+ */
+ private function calculateDiff(array $localCodes, array $remoteCodes): array
+ {
+ $localKeys = array_map(fn($c) => $this->makeCodeKey($c), $localCodes);
+ $remoteKeys = array_map(fn($c) => $this->makeCodeKey($c), $remoteCodes);
+
+ return [
+ 'local_only' => array_values(array_diff($localKeys, $remoteKeys)),
+ 'remote_only' => array_values(array_diff($remoteKeys, $localKeys)),
+ 'both' => array_values(array_intersect($localKeys, $remoteKeys)),
+ ];
+ }
+}
\ No newline at end of file
diff --git a/resources/views/categories/index.blade.php b/resources/views/categories/index.blade.php
index 4b4fd318..cdc37599 100644
--- a/resources/views/categories/index.blade.php
+++ b/resources/views/categories/index.blade.php
@@ -22,16 +22,25 @@
@endif
- @if($tenant)
-
diff --git a/resources/views/categories/sync.blade.php b/resources/views/categories/sync.blade.php
new file mode 100644
index 00000000..a9d9ea69
--- /dev/null
+++ b/resources/views/categories/sync.blade.php
@@ -0,0 +1,369 @@
+@extends('layouts.app')
+
+@section('title', '카테고리 동기화')
+
+@section('content')
+
+
+
+
+
+
+
+ @if($remoteError)
+
+
+ 원격 서버 연결 실패: {{ $remoteError }}
+
+ @endif
+
+ @if(empty($environments[$selectedEnv]['url']))
+
+
+
메뉴 동기화 환경 설정에서 {{ $environments[$selectedEnv]['name'] ?? strtoupper($selectedEnv) }} 서버 URL을 설정해주세요.
+
+ @endif
+
+
+ @if(!empty($environments[$selectedEnv]['url']) && !$remoteError)
+
+
+
+
+
+
+
+
로컬에만 있음
+
{{ count($diff['local_only']) }}
+
+
+
+
+
+
+
+
+
+
양쪽 모두
+
{{ count($diff['both']) }}
+
+
+
+
+
+
+
+
+
+
원격에만 있음
+
{{ count($diff['remote_only']) }}
+
+
+
+
+ @endif
+
+
+
+
+
+
+
+
+
+
+
+
로컬 (현재)
+
({{ count($localCategories) }}개)
+
+ @if(!empty($environments[$selectedEnv]['url']) && !$remoteError)
+
+ 0개 선택
+
+
+ @endif
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ $environments[$selectedEnv]['name'] ?? strtoupper($selectedEnv) }}
+
({{ count($remoteCategories) }}개)
+
+ @if(!empty($environments[$selectedEnv]['url']) && !$remoteError)
+
+ 0개 선택
+
+
+ @endif
+
+
+ @if(empty($environments[$selectedEnv]['url']))
+
+ @elseif($remoteError)
+
+ @elseif(empty($remoteCategories))
+
+ @else
+
+ @endif
+
+
+
+
+@endsection
+
+@push('scripts')
+
+@endpush
\ No newline at end of file
diff --git a/resources/views/common-codes/index.blade.php b/resources/views/common-codes/index.blade.php
index 43ae126b..7daa3483 100644
--- a/resources/views/common-codes/index.blade.php
+++ b/resources/views/common-codes/index.blade.php
@@ -22,16 +22,25 @@
@endif