feat:문서양식관리 기능 추가 및 권한 토글 개선
- 문서양식관리 CRUD 기능 구현 (생산관리 > 문서양식관리) - 결재라인, 섹션, 컬럼 동적 관리 (Vanilla JS) - 섹션별 이미지 업로드 기능 - SortableJS 드래그앤드롭 순서 변경 - 문서 미리보기 모달 - document_type 글로벌 코드 추가 (품질, 생산, 영업, 구매, 일반, 기타) - 역할/부서 권한 토글 시 페이지 새로고침 방지 (hx-swap="none") Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
320
app/Http/Controllers/Api/Admin/DocumentTemplateApiController.php
Normal file
320
app/Http/Controllers/Api/Admin/DocumentTemplateApiController.php
Normal file
@@ -0,0 +1,320 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\DocumentTemplate;
|
||||
use App\Models\DocumentTemplateApprovalLine;
|
||||
use App\Models\DocumentTemplateBasicField;
|
||||
use App\Models\DocumentTemplateColumn;
|
||||
use App\Models\DocumentTemplateSection;
|
||||
use App\Models\DocumentTemplateSectionItem;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class DocumentTemplateApiController extends Controller
|
||||
{
|
||||
/**
|
||||
* 목록 조회 (HTMX 테이블)
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$query = DocumentTemplate::query()
|
||||
->withCount(['sections', 'columns']);
|
||||
|
||||
// 검색
|
||||
if ($search = $request->input('search')) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('title', 'like', "%{$search}%")
|
||||
->orWhere('category', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
// 카테고리 필터
|
||||
if ($category = $request->input('category')) {
|
||||
$query->where('category', $category);
|
||||
}
|
||||
|
||||
// 활성 상태 필터
|
||||
if ($request->has('is_active') && $request->input('is_active') !== '') {
|
||||
$query->where('is_active', $request->boolean('is_active'));
|
||||
}
|
||||
|
||||
$templates = $query->orderBy('updated_at', 'desc')
|
||||
->paginate($request->input('per_page', 10));
|
||||
|
||||
return view('document-templates.partials.table', compact('templates'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 조회
|
||||
*/
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
$template = DocumentTemplate::with([
|
||||
'approvalLines',
|
||||
'basicFields',
|
||||
'sections.items',
|
||||
'columns',
|
||||
])->findOrFail($id);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $template,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 생성
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:100',
|
||||
'category' => 'nullable|string|max:50',
|
||||
'title' => 'nullable|string|max:200',
|
||||
'company_name' => 'nullable|string|max:100',
|
||||
'company_address' => 'nullable|string|max:255',
|
||||
'company_contact' => 'nullable|string|max:100',
|
||||
'footer_remark_label' => 'nullable|string|max:50',
|
||||
'footer_judgement_label' => 'nullable|string|max:50',
|
||||
'footer_judgement_options' => 'nullable|array',
|
||||
'is_active' => 'boolean',
|
||||
// 관계 데이터
|
||||
'approval_lines' => 'nullable|array',
|
||||
'basic_fields' => 'nullable|array',
|
||||
'sections' => 'nullable|array',
|
||||
'columns' => 'nullable|array',
|
||||
]);
|
||||
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
$template = DocumentTemplate::create([
|
||||
'tenant_id' => session('selected_tenant_id'),
|
||||
'name' => $validated['name'],
|
||||
'category' => $validated['category'] ?? null,
|
||||
'title' => $validated['title'] ?? null,
|
||||
'company_name' => $validated['company_name'] ?? '경동기업',
|
||||
'company_address' => $validated['company_address'] ?? null,
|
||||
'company_contact' => $validated['company_contact'] ?? null,
|
||||
'footer_remark_label' => $validated['footer_remark_label'] ?? '부적합 내용',
|
||||
'footer_judgement_label' => $validated['footer_judgement_label'] ?? '종합판정',
|
||||
'footer_judgement_options' => $validated['footer_judgement_options'] ?? ['적합', '부적합'],
|
||||
'is_active' => $validated['is_active'] ?? true,
|
||||
]);
|
||||
|
||||
// 관계 데이터 저장
|
||||
$this->saveRelations($template, $validated);
|
||||
|
||||
DB::commit();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '문서양식이 생성되었습니다.',
|
||||
'data' => $template->load(['approvalLines', 'basicFields', 'sections.items', 'columns']),
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '생성 중 오류가 발생했습니다: '.$e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 수정
|
||||
*/
|
||||
public function update(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$template = DocumentTemplate::findOrFail($id);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:100',
|
||||
'category' => 'nullable|string|max:50',
|
||||
'title' => 'nullable|string|max:200',
|
||||
'company_name' => 'nullable|string|max:100',
|
||||
'company_address' => 'nullable|string|max:255',
|
||||
'company_contact' => 'nullable|string|max:100',
|
||||
'footer_remark_label' => 'nullable|string|max:50',
|
||||
'footer_judgement_label' => 'nullable|string|max:50',
|
||||
'footer_judgement_options' => 'nullable|array',
|
||||
'is_active' => 'boolean',
|
||||
// 관계 데이터
|
||||
'approval_lines' => 'nullable|array',
|
||||
'basic_fields' => 'nullable|array',
|
||||
'sections' => 'nullable|array',
|
||||
'columns' => 'nullable|array',
|
||||
]);
|
||||
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
$template->update([
|
||||
'name' => $validated['name'],
|
||||
'category' => $validated['category'] ?? null,
|
||||
'title' => $validated['title'] ?? null,
|
||||
'company_name' => $validated['company_name'] ?? null,
|
||||
'company_address' => $validated['company_address'] ?? null,
|
||||
'company_contact' => $validated['company_contact'] ?? null,
|
||||
'footer_remark_label' => $validated['footer_remark_label'] ?? '부적합 내용',
|
||||
'footer_judgement_label' => $validated['footer_judgement_label'] ?? '종합판정',
|
||||
'footer_judgement_options' => $validated['footer_judgement_options'] ?? ['적합', '부적합'],
|
||||
'is_active' => $validated['is_active'] ?? true,
|
||||
]);
|
||||
|
||||
// 관계 데이터 저장 (기존 데이터 삭제 후 재생성)
|
||||
$this->saveRelations($template, $validated, true);
|
||||
|
||||
DB::commit();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '문서양식이 수정되었습니다.',
|
||||
'data' => $template->fresh(['approvalLines', 'basicFields', 'sections.items', 'columns']),
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '수정 중 오류가 발생했습니다: '.$e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 삭제
|
||||
*/
|
||||
public function destroy(int $id): JsonResponse
|
||||
{
|
||||
$template = DocumentTemplate::findOrFail($id);
|
||||
$template->delete();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '문서양식이 삭제되었습니다.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성 상태 토글
|
||||
*/
|
||||
public function toggleActive(int $id): JsonResponse
|
||||
{
|
||||
$template = DocumentTemplate::findOrFail($id);
|
||||
$template->update(['is_active' => ! $template->is_active]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => $template->is_active ? '활성화되었습니다.' : '비활성화되었습니다.',
|
||||
'is_active' => $template->is_active,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지 업로드
|
||||
*/
|
||||
public function uploadImage(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'image' => 'required|image|max:5120', // 5MB
|
||||
]);
|
||||
|
||||
$path = $request->file('image')->store('document-templates', 'public');
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'path' => $path,
|
||||
'url' => asset('storage/'.$path),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 관계 데이터 저장
|
||||
*/
|
||||
private function saveRelations(DocumentTemplate $template, array $data, bool $deleteExisting = false): void
|
||||
{
|
||||
// 기존 데이터 삭제 (수정 시)
|
||||
if ($deleteExisting) {
|
||||
$template->approvalLines()->delete();
|
||||
$template->basicFields()->delete();
|
||||
// sections는 cascade로 items도 함께 삭제됨
|
||||
$template->sections()->delete();
|
||||
$template->columns()->delete();
|
||||
}
|
||||
|
||||
// 결재라인
|
||||
if (! empty($data['approval_lines'])) {
|
||||
foreach ($data['approval_lines'] as $index => $line) {
|
||||
DocumentTemplateApprovalLine::create([
|
||||
'template_id' => $template->id,
|
||||
'name' => $line['name'] ?? '',
|
||||
'dept' => $line['dept'] ?? '',
|
||||
'role' => $line['role'] ?? '',
|
||||
'sort_order' => $index,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// 기본 필드
|
||||
if (! empty($data['basic_fields'])) {
|
||||
foreach ($data['basic_fields'] as $index => $field) {
|
||||
DocumentTemplateBasicField::create([
|
||||
'template_id' => $template->id,
|
||||
'label' => $field['label'] ?? '',
|
||||
'field_type' => $field['field_type'] ?? 'text',
|
||||
'default_value' => $field['default_value'] ?? '',
|
||||
'sort_order' => $index,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// 섹션 및 항목
|
||||
if (! empty($data['sections'])) {
|
||||
foreach ($data['sections'] as $sIndex => $section) {
|
||||
$newSection = DocumentTemplateSection::create([
|
||||
'template_id' => $template->id,
|
||||
'title' => $section['title'] ?? '',
|
||||
'image_path' => $section['image_path'] ?? null,
|
||||
'sort_order' => $sIndex,
|
||||
]);
|
||||
|
||||
if (! empty($section['items'])) {
|
||||
foreach ($section['items'] as $iIndex => $item) {
|
||||
DocumentTemplateSectionItem::create([
|
||||
'section_id' => $newSection->id,
|
||||
'category' => $item['category'] ?? '',
|
||||
'item' => $item['item'] ?? '',
|
||||
'standard' => $item['standard'] ?? '',
|
||||
'method' => $item['method'] ?? '',
|
||||
'frequency' => $item['frequency'] ?? '',
|
||||
'regulation' => $item['regulation'] ?? '',
|
||||
'sort_order' => $iIndex,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 컬럼
|
||||
if (! empty($data['columns'])) {
|
||||
foreach ($data['columns'] as $index => $column) {
|
||||
DocumentTemplateColumn::create([
|
||||
'template_id' => $template->id,
|
||||
'label' => $column['label'] ?? '',
|
||||
'width' => $column['width'] ?? '100px',
|
||||
'column_type' => $column['column_type'] ?? 'text',
|
||||
'group_name' => $column['group_name'] ?? null,
|
||||
'sub_labels' => $column['sub_labels'] ?? null,
|
||||
'sort_order' => $index,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ class CommonCodeController extends Controller
|
||||
'bad_debt_progress' => '대손진행',
|
||||
'height_construction_cost' => '높이시공비',
|
||||
'width_construction_cost' => '폭시공비',
|
||||
'document_type' => '문서분류',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
133
app/Http/Controllers/DocumentTemplateController.php
Normal file
133
app/Http/Controllers/DocumentTemplateController.php
Normal file
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\DocumentTemplate;
|
||||
use App\Models\Products\CommonCode;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class DocumentTemplateController extends Controller
|
||||
{
|
||||
/**
|
||||
* 문서양식 목록 페이지
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
{
|
||||
return view('document-templates.index', [
|
||||
'documentTypes' => $this->getDocumentTypes(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 문서양식 생성 페이지
|
||||
*/
|
||||
public function create(): View
|
||||
{
|
||||
return view('document-templates.edit', [
|
||||
'template' => null,
|
||||
'templateData' => null,
|
||||
'isCreate' => true,
|
||||
'documentTypes' => $this->getDocumentTypes(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 문서양식 수정 페이지
|
||||
*/
|
||||
public function edit(int $id): View
|
||||
{
|
||||
$template = DocumentTemplate::with([
|
||||
'approvalLines',
|
||||
'basicFields',
|
||||
'sections.items',
|
||||
'columns',
|
||||
])->findOrFail($id);
|
||||
|
||||
// JavaScript용 데이터 변환
|
||||
$templateData = $this->prepareTemplateData($template);
|
||||
|
||||
return view('document-templates.edit', [
|
||||
'template' => $template,
|
||||
'templateData' => $templateData,
|
||||
'isCreate' => false,
|
||||
'documentTypes' => $this->getDocumentTypes(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 문서분류 목록 조회 (글로벌 + 테넌트)
|
||||
*/
|
||||
private function getDocumentTypes(): array
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
return CommonCode::query()
|
||||
->where(function ($query) use ($tenantId) {
|
||||
$query->whereNull('tenant_id');
|
||||
if ($tenantId) {
|
||||
$query->orWhere('tenant_id', $tenantId);
|
||||
}
|
||||
})
|
||||
->where('code_group', 'document_type')
|
||||
->where('is_active', true)
|
||||
->orderBy('sort_order')
|
||||
->pluck('name', 'code')
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* JavaScript용 템플릿 데이터 준비
|
||||
*/
|
||||
private function prepareTemplateData(DocumentTemplate $template): array
|
||||
{
|
||||
return [
|
||||
'name' => $template->name,
|
||||
'category' => $template->category,
|
||||
'title' => $template->title,
|
||||
'company_name' => $template->company_name,
|
||||
'company_address' => $template->company_address,
|
||||
'company_contact' => $template->company_contact,
|
||||
'footer_remark_label' => $template->footer_remark_label,
|
||||
'footer_judgement_label' => $template->footer_judgement_label,
|
||||
'footer_judgement_options' => $template->footer_judgement_options,
|
||||
'is_active' => $template->is_active,
|
||||
'approval_lines' => $template->approvalLines->map(function ($l) {
|
||||
return [
|
||||
'id' => $l->id,
|
||||
'name' => $l->name,
|
||||
'dept' => $l->dept,
|
||||
'role' => $l->role,
|
||||
];
|
||||
})->toArray(),
|
||||
'sections' => $template->sections->map(function ($s) {
|
||||
return [
|
||||
'id' => $s->id,
|
||||
'title' => $s->title,
|
||||
'image_path' => $s->image_path,
|
||||
'items' => $s->items->map(function ($i) {
|
||||
return [
|
||||
'id' => $i->id,
|
||||
'category' => $i->category,
|
||||
'item' => $i->item,
|
||||
'standard' => $i->standard,
|
||||
'method' => $i->method,
|
||||
'frequency' => $i->frequency,
|
||||
'regulation' => $i->regulation,
|
||||
];
|
||||
})->toArray(),
|
||||
];
|
||||
})->toArray(),
|
||||
'columns' => $template->columns->map(function ($c) {
|
||||
return [
|
||||
'id' => $c->id,
|
||||
'label' => $c->label,
|
||||
'width' => $c->width,
|
||||
'column_type' => $c->column_type,
|
||||
'group_name' => $c->group_name,
|
||||
'sub_labels' => $c->sub_labels,
|
||||
];
|
||||
})->toArray(),
|
||||
];
|
||||
}
|
||||
}
|
||||
69
app/Models/DocumentTemplate.php
Normal file
69
app/Models/DocumentTemplate.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class DocumentTemplate extends Model
|
||||
{
|
||||
use HasFactory, BelongsToTenant, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'name',
|
||||
'category',
|
||||
'title',
|
||||
'company_name',
|
||||
'company_address',
|
||||
'company_contact',
|
||||
'footer_remark_label',
|
||||
'footer_judgement_label',
|
||||
'footer_judgement_options',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'footer_judgement_options' => 'array',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
* 결재라인
|
||||
*/
|
||||
public function approvalLines(): HasMany
|
||||
{
|
||||
return $this->hasMany(DocumentTemplateApprovalLine::class, 'template_id')
|
||||
->orderBy('sort_order');
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 필드
|
||||
*/
|
||||
public function basicFields(): HasMany
|
||||
{
|
||||
return $this->hasMany(DocumentTemplateBasicField::class, 'template_id')
|
||||
->orderBy('sort_order');
|
||||
}
|
||||
|
||||
/**
|
||||
* 검사 기준서 섹션
|
||||
*/
|
||||
public function sections(): HasMany
|
||||
{
|
||||
return $this->hasMany(DocumentTemplateSection::class, 'template_id')
|
||||
->orderBy('sort_order');
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블 컬럼
|
||||
*/
|
||||
public function columns(): HasMany
|
||||
{
|
||||
return $this->hasMany(DocumentTemplateColumn::class, 'template_id')
|
||||
->orderBy('sort_order');
|
||||
}
|
||||
}
|
||||
29
app/Models/DocumentTemplateApprovalLine.php
Normal file
29
app/Models/DocumentTemplateApprovalLine.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class DocumentTemplateApprovalLine extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'template_id',
|
||||
'name',
|
||||
'dept',
|
||||
'role',
|
||||
'sort_order',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
|
||||
public function template(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(DocumentTemplate::class, 'template_id');
|
||||
}
|
||||
}
|
||||
29
app/Models/DocumentTemplateBasicField.php
Normal file
29
app/Models/DocumentTemplateBasicField.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class DocumentTemplateBasicField extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'template_id',
|
||||
'label',
|
||||
'field_type',
|
||||
'default_value',
|
||||
'sort_order',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
|
||||
public function template(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(DocumentTemplate::class, 'template_id');
|
||||
}
|
||||
}
|
||||
32
app/Models/DocumentTemplateColumn.php
Normal file
32
app/Models/DocumentTemplateColumn.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class DocumentTemplateColumn extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'template_id',
|
||||
'label',
|
||||
'width',
|
||||
'column_type',
|
||||
'group_name',
|
||||
'sub_labels',
|
||||
'sort_order',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'sub_labels' => 'array',
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
|
||||
public function template(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(DocumentTemplate::class, 'template_id');
|
||||
}
|
||||
}
|
||||
35
app/Models/DocumentTemplateSection.php
Normal file
35
app/Models/DocumentTemplateSection.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class DocumentTemplateSection extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'template_id',
|
||||
'title',
|
||||
'image_path',
|
||||
'sort_order',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
|
||||
public function template(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(DocumentTemplate::class, 'template_id');
|
||||
}
|
||||
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(DocumentTemplateSectionItem::class, 'section_id')
|
||||
->orderBy('sort_order');
|
||||
}
|
||||
}
|
||||
32
app/Models/DocumentTemplateSectionItem.php
Normal file
32
app/Models/DocumentTemplateSectionItem.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class DocumentTemplateSectionItem extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'section_id',
|
||||
'category',
|
||||
'item',
|
||||
'standard',
|
||||
'method',
|
||||
'frequency',
|
||||
'regulation',
|
||||
'sort_order',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
|
||||
public function section(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(DocumentTemplateSection::class, 'section_id');
|
||||
}
|
||||
}
|
||||
@@ -299,6 +299,14 @@ protected function seedMainMenus(): void
|
||||
'hidden' => true,
|
||||
'options' => ['route_name' => 'categories.index', 'section' => 'main', 'meta' => ['status' => 'preparing']],
|
||||
]);
|
||||
$this->createMenu([
|
||||
'parent_id' => $productionGroup->id,
|
||||
'name' => '문서양식관리',
|
||||
'url' => '/document-templates',
|
||||
'icon' => 'document-duplicate',
|
||||
'sort_order' => $prodSubOrder++,
|
||||
'options' => ['route_name' => 'document-templates.index', 'section' => 'main'],
|
||||
]);
|
||||
|
||||
// ========================================
|
||||
// 콘텐츠 관리 그룹
|
||||
|
||||
@@ -74,8 +74,7 @@ class="toggle-btn flex items-center text-blue-500 hover:text-blue-700 focus:outl
|
||||
{{ isset($permissions[$menu->id][$type]) && $permissions[$menu->id][$type] ? 'checked' : '' }}
|
||||
class="h-5 w-5 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer"
|
||||
hx-post="/api/admin/department-permissions/toggle"
|
||||
hx-trigger="click"
|
||||
hx-target="#permission-matrix"
|
||||
hx-swap="none"
|
||||
hx-include="[name='department_id'],[name='guard_name']"
|
||||
hx-vals='{"menu_id": {{ $menu->id }}, "permission_type": "{{ $type }}"}'
|
||||
>
|
||||
|
||||
845
resources/views/document-templates/edit.blade.php
Normal file
845
resources/views/document-templates/edit.blade.php
Normal file
@@ -0,0 +1,845 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', $isCreate ? '문서양식 등록' : '문서양식 편집')
|
||||
|
||||
@section('content')
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<!-- 헤더 -->
|
||||
<div class="flex flex-col lg:flex-row lg:justify-between lg:items-center gap-4 mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-800">
|
||||
{{ $isCreate ? '문서양식 등록' : '문서양식 편집' }}
|
||||
</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
검사 성적서, 작업지시서 등의 문서 양식을 설정합니다.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button" onclick="openPreviewModal()" class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-lg transition flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
미리보기
|
||||
</button>
|
||||
<a href="{{ route('document-templates.index') }}" class="bg-gray-200 hover:bg-gray-300 text-gray-700 px-4 py-2 rounded-lg transition">
|
||||
목록
|
||||
</a>
|
||||
<button type="button" onclick="saveTemplate()" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 탭 네비게이션 -->
|
||||
<div class="mb-6 border-b border-gray-200 bg-white rounded-t-lg">
|
||||
<nav class="-mb-px flex">
|
||||
<button onclick="switchTab('basic')" id="tab-basic"
|
||||
class="tab-btn px-6 py-4 text-sm font-medium border-b-2 border-blue-500 text-blue-600">
|
||||
기본정보
|
||||
</button>
|
||||
<button onclick="switchTab('approval')" id="tab-approval"
|
||||
class="tab-btn px-6 py-4 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700">
|
||||
결재라인
|
||||
</button>
|
||||
<button onclick="switchTab('sections')" id="tab-sections"
|
||||
class="tab-btn px-6 py-4 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700">
|
||||
검사 기준서
|
||||
</button>
|
||||
<button onclick="switchTab('columns')" id="tab-columns"
|
||||
class="tab-btn px-6 py-4 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700">
|
||||
테이블 컬럼
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- 기본정보 탭 -->
|
||||
<div id="content-basic" class="tab-content bg-white rounded-lg shadow-sm p-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- 양식명 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">양식명 <span class="text-red-500">*</span></label>
|
||||
<input type="text" id="name" placeholder="예: 최종검사 성적서"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
<!-- 분류 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">분류</label>
|
||||
<select id="category" 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 $code => $name)
|
||||
<option value="{{ $name }}">{{ $name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<!-- 문서 제목 -->
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">문서 제목</label>
|
||||
<input type="text" id="title" placeholder="예: 최종 검사 성적서"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
<!-- 회사명 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">회사명</label>
|
||||
<input type="text" id="company_name" placeholder="예: 경동기업"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
<!-- 회사 연락처 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">회사 연락처</label>
|
||||
<input type="text" id="company_contact" placeholder="예: TEL: 02-0000-0000"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
<!-- 회사 주소 -->
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">회사 주소</label>
|
||||
<input type="text" id="company_address" placeholder="예: 서울시 강남구..."
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
<!-- 활성 상태 -->
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="checkbox" id="is_active" checked class="w-4 h-4 text-blue-600 rounded focus:ring-blue-500">
|
||||
<label for="is_active" class="text-sm text-gray-700">활성화</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 결재라인 탭 -->
|
||||
<div id="content-approval" class="tab-content bg-white rounded-lg shadow-sm p-6 hidden">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-medium text-gray-800">결재라인 설정</h3>
|
||||
<button type="button" onclick="addApprovalLine()" class="bg-blue-600 hover:bg-blue-700 text-white px-3 py-1.5 rounded-lg text-sm flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
추가
|
||||
</button>
|
||||
</div>
|
||||
<div id="approval-lines" class="space-y-3">
|
||||
<!-- 동적으로 추가됨 -->
|
||||
</div>
|
||||
<p id="approval-empty" class="text-gray-400 text-center py-8 hidden">결재라인이 없습니다. 추가 버튼을 클릭하세요.</p>
|
||||
</div>
|
||||
|
||||
<!-- 검사 기준서 탭 -->
|
||||
<div id="content-sections" class="tab-content bg-white rounded-lg shadow-sm p-6 hidden">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-medium text-gray-800">검사 기준서 섹션</h3>
|
||||
<button type="button" onclick="addSection()" class="bg-blue-600 hover:bg-blue-700 text-white px-3 py-1.5 rounded-lg text-sm flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
섹션 추가
|
||||
</button>
|
||||
</div>
|
||||
<div id="sections-container" class="space-y-4">
|
||||
<!-- 동적으로 추가됨 -->
|
||||
</div>
|
||||
<p id="sections-empty" class="text-gray-400 text-center py-8 hidden">검사 기준서 섹션이 없습니다. 섹션 추가 버튼을 클릭하세요.</p>
|
||||
</div>
|
||||
|
||||
<!-- 테이블 컬럼 탭 -->
|
||||
<div id="content-columns" class="tab-content bg-white rounded-lg shadow-sm p-6 hidden">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-medium text-gray-800">검사 데이터 테이블 컬럼</h3>
|
||||
<button type="button" onclick="addColumn()" class="bg-blue-600 hover:bg-blue-700 text-white px-3 py-1.5 rounded-lg text-sm flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
컬럼 추가
|
||||
</button>
|
||||
</div>
|
||||
<div id="columns-container" class="space-y-3">
|
||||
<!-- 동적으로 추가됨 -->
|
||||
</div>
|
||||
<p id="columns-empty" class="text-gray-400 text-center py-8 hidden">테이블 컬럼이 없습니다. 컬럼 추가 버튼을 클릭하세요.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 미리보기 모달 -->
|
||||
<div id="preview-modal" class="fixed inset-0 z-50 hidden overflow-y-auto">
|
||||
<div class="flex items-center justify-center min-h-screen p-4">
|
||||
<div class="fixed inset-0 bg-black bg-opacity-50" onclick="closePreviewModal()"></div>
|
||||
<div class="relative bg-white rounded-lg shadow-xl max-w-5xl w-full max-h-[90vh] overflow-hidden">
|
||||
<div class="flex justify-between items-center p-4 border-b">
|
||||
<h3 class="text-lg font-bold text-gray-800">문서 미리보기</h3>
|
||||
<button onclick="closePreviewModal()" class="text-gray-400 hover:text-gray-600">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="preview-content" class="p-4 overflow-auto max-h-[calc(90vh-120px)]">
|
||||
<!-- 미리보기 내용 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
// ===== 상태 관리 =====
|
||||
const templateState = {
|
||||
id: {{ $template->id ?? 'null' }},
|
||||
name: '',
|
||||
category: '',
|
||||
title: '',
|
||||
company_name: '경동기업',
|
||||
company_address: '',
|
||||
company_contact: '',
|
||||
footer_remark_label: '비고',
|
||||
footer_judgement_label: '종합판정',
|
||||
footer_judgement_options: ['합격', '불합격', '조건부합격'],
|
||||
is_active: true,
|
||||
approval_lines: [],
|
||||
sections: [],
|
||||
columns: []
|
||||
};
|
||||
|
||||
// 고유 ID 생성
|
||||
let uniqueId = 0;
|
||||
function generateId() {
|
||||
return `temp_${++uniqueId}`;
|
||||
}
|
||||
|
||||
// ===== 초기화 =====
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
@if($template && isset($templateData))
|
||||
// 기존 데이터 로드 (컨트롤러에서 준비된 데이터 사용)
|
||||
const loadedData = @json($templateData);
|
||||
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 || [];
|
||||
templateState.is_active = loadedData.is_active || false;
|
||||
templateState.approval_lines = loadedData.approval_lines || [];
|
||||
templateState.sections = loadedData.sections || [];
|
||||
templateState.columns = loadedData.columns || [];
|
||||
@endif
|
||||
|
||||
// UI 초기화
|
||||
initBasicFields();
|
||||
renderApprovalLines();
|
||||
renderSections();
|
||||
renderColumns();
|
||||
});
|
||||
|
||||
// ===== 기본정보 =====
|
||||
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 => {
|
||||
document.getElementById(field).addEventListener('input', function() {
|
||||
templateState[field] = this.value;
|
||||
});
|
||||
});
|
||||
document.getElementById('is_active').addEventListener('change', function() {
|
||||
templateState.is_active = this.checked;
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 탭 전환 =====
|
||||
function switchTab(tabId) {
|
||||
// 모든 탭 버튼 비활성화
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||
btn.classList.remove('border-blue-500', 'text-blue-600');
|
||||
btn.classList.add('border-transparent', 'text-gray-500');
|
||||
});
|
||||
// 선택된 탭 버튼 활성화
|
||||
const activeBtn = document.getElementById(`tab-${tabId}`);
|
||||
activeBtn.classList.add('border-blue-500', 'text-blue-600');
|
||||
activeBtn.classList.remove('border-transparent', 'text-gray-500');
|
||||
|
||||
// 모든 컨텐츠 숨김
|
||||
document.querySelectorAll('.tab-content').forEach(content => {
|
||||
content.classList.add('hidden');
|
||||
});
|
||||
// 선택된 컨텐츠 표시
|
||||
document.getElementById(`content-${tabId}`).classList.remove('hidden');
|
||||
}
|
||||
|
||||
// ===== 결재라인 =====
|
||||
function addApprovalLine() {
|
||||
const line = { id: generateId(), name: '', dept: '', role: '' };
|
||||
templateState.approval_lines.push(line);
|
||||
renderApprovalLines();
|
||||
}
|
||||
|
||||
function removeApprovalLine(id) {
|
||||
templateState.approval_lines = templateState.approval_lines.filter(l => l.id !== id);
|
||||
renderApprovalLines();
|
||||
}
|
||||
|
||||
function updateApprovalLine(id, field, value) {
|
||||
const line = templateState.approval_lines.find(l => l.id === id);
|
||||
if (line) line[field] = value;
|
||||
}
|
||||
|
||||
function renderApprovalLines() {
|
||||
const container = document.getElementById('approval-lines');
|
||||
const emptyMsg = document.getElementById('approval-empty');
|
||||
|
||||
if (templateState.approval_lines.length === 0) {
|
||||
container.innerHTML = '';
|
||||
emptyMsg.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
emptyMsg.classList.add('hidden');
|
||||
container.innerHTML = templateState.approval_lines.map((line, idx) => `
|
||||
<div class="flex items-center gap-3 p-3 bg-gray-50 rounded-lg cursor-move" data-id="${line.id}">
|
||||
<span class="text-gray-400 font-bold" title="드래그하여 순서 변경">⋮⋮</span>
|
||||
<span class="text-gray-400 font-mono text-sm w-6">${idx + 1}</span>
|
||||
<input type="text" value="${escapeHtml(line.name)}" placeholder="단계명 (작성, 검토, 승인)"
|
||||
onchange="updateApprovalLine('${line.id}', 'name', this.value)"
|
||||
class="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm">
|
||||
<input type="text" value="${escapeHtml(line.dept)}" placeholder="부서"
|
||||
onchange="updateApprovalLine('${line.id}', 'dept', this.value)"
|
||||
class="w-32 px-3 py-2 border border-gray-300 rounded-lg text-sm">
|
||||
<input type="text" value="${escapeHtml(line.role)}" placeholder="직책/담당자"
|
||||
onchange="updateApprovalLine('${line.id}', 'role', this.value)"
|
||||
class="w-32 px-3 py-2 border border-gray-300 rounded-lg text-sm">
|
||||
<button onclick="removeApprovalLine('${line.id}')" class="text-red-500 hover:text-red-700 p-1">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// ===== 섹션 관리 =====
|
||||
function addSection() {
|
||||
const section = { id: generateId(), title: '', image_path: null, items: [] };
|
||||
templateState.sections.push(section);
|
||||
renderSections();
|
||||
}
|
||||
|
||||
function removeSection(id) {
|
||||
templateState.sections = templateState.sections.filter(s => s.id !== id);
|
||||
renderSections();
|
||||
}
|
||||
|
||||
function updateSection(id, field, value) {
|
||||
const section = templateState.sections.find(s => s.id === id);
|
||||
if (section) section[field] = value;
|
||||
}
|
||||
|
||||
function addSectionItem(sectionId) {
|
||||
const section = templateState.sections.find(s => s.id === sectionId);
|
||||
if (section) {
|
||||
section.items.push({
|
||||
id: generateId(),
|
||||
category: '',
|
||||
item: '',
|
||||
standard: '',
|
||||
method: '',
|
||||
frequency: '',
|
||||
regulation: ''
|
||||
});
|
||||
renderSections();
|
||||
}
|
||||
}
|
||||
|
||||
function removeSectionItem(sectionId, itemId) {
|
||||
const section = templateState.sections.find(s => s.id === sectionId);
|
||||
if (section) {
|
||||
section.items = section.items.filter(i => i.id !== itemId);
|
||||
renderSections();
|
||||
}
|
||||
}
|
||||
|
||||
function updateSectionItem(sectionId, itemId, field, value) {
|
||||
const section = templateState.sections.find(s => s.id === sectionId);
|
||||
if (section) {
|
||||
const item = section.items.find(i => i.id === itemId);
|
||||
if (item) item[field] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function renderSections() {
|
||||
const container = document.getElementById('sections-container');
|
||||
const emptyMsg = document.getElementById('sections-empty');
|
||||
|
||||
if (templateState.sections.length === 0) {
|
||||
container.innerHTML = '';
|
||||
emptyMsg.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
emptyMsg.classList.add('hidden');
|
||||
container.innerHTML = templateState.sections.map((section, idx) => `
|
||||
<div class="border border-gray-200 rounded-lg overflow-hidden" data-section-id="${section.id}">
|
||||
<div class="bg-gray-50 p-4 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3 flex-1">
|
||||
<span class="text-gray-400 font-bold cursor-move drag-handle" title="드래그하여 순서 변경">⋮⋮</span>
|
||||
<input type="text" value="${escapeHtml(section.title)}" placeholder="섹션 제목 (예: 가이드레일)"
|
||||
onchange="updateSection('${section.id}', 'title', this.value)"
|
||||
class="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm font-medium">
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-gray-600 hover:text-blue-600 text-sm flex items-center gap-1 cursor-pointer">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
이미지
|
||||
<input type="file" accept="image/*" class="hidden" onchange="uploadSectionImage('${section.id}', this)">
|
||||
</label>
|
||||
<button onclick="addSectionItem('${section.id}')" class="text-blue-600 hover:text-blue-800 text-sm flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
항목 추가
|
||||
</button>
|
||||
<button onclick="removeSection('${section.id}')" class="text-red-500 hover:text-red-700 p-1">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
${section.image_path ? `
|
||||
<div class="px-4 py-2 bg-blue-50 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<img src="/storage/${section.image_path}" alt="섹션 이미지" class="h-16 rounded border border-gray-200">
|
||||
<span class="text-xs text-gray-500">이미지 첨부됨</span>
|
||||
</div>
|
||||
<button onclick="removeSectionImage('${section.id}')" class="text-red-500 hover:text-red-700 text-xs">
|
||||
이미지 삭제
|
||||
</button>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="p-4">
|
||||
${section.items.length > 0 ? `
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-100">
|
||||
<tr>
|
||||
<th class="px-2 py-2 text-left text-xs font-medium text-gray-500 w-24">구분</th>
|
||||
<th class="px-2 py-2 text-left text-xs font-medium text-gray-500">검사항목</th>
|
||||
<th class="px-2 py-2 text-left text-xs font-medium text-gray-500">검사기준</th>
|
||||
<th class="px-2 py-2 text-left text-xs font-medium text-gray-500 w-24">검사방법</th>
|
||||
<th class="px-2 py-2 text-left text-xs font-medium text-gray-500 w-24">검사주기</th>
|
||||
<th class="px-2 py-2 text-left text-xs font-medium text-gray-500 w-28">관련규정</th>
|
||||
<th class="px-2 py-2 w-10"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${section.items.map(item => `
|
||||
<tr class="border-t" data-item-id="${item.id}">
|
||||
<td class="px-1 py-1">
|
||||
<input type="text" value="${escapeHtml(item.category)}" placeholder="구분"
|
||||
onchange="updateSectionItem('${section.id}', '${item.id}', 'category', this.value)"
|
||||
class="w-full px-2 py-1 border border-gray-200 rounded text-xs">
|
||||
</td>
|
||||
<td class="px-1 py-1">
|
||||
<input type="text" value="${escapeHtml(item.item)}" placeholder="검사항목"
|
||||
onchange="updateSectionItem('${section.id}', '${item.id}', 'item', this.value)"
|
||||
class="w-full px-2 py-1 border border-gray-200 rounded text-xs">
|
||||
</td>
|
||||
<td class="px-1 py-1">
|
||||
<input type="text" value="${escapeHtml(item.standard)}" placeholder="검사기준"
|
||||
onchange="updateSectionItem('${section.id}', '${item.id}', 'standard', this.value)"
|
||||
class="w-full px-2 py-1 border border-gray-200 rounded text-xs">
|
||||
</td>
|
||||
<td class="px-1 py-1">
|
||||
<input type="text" value="${escapeHtml(item.method)}" placeholder="방법"
|
||||
onchange="updateSectionItem('${section.id}', '${item.id}', 'method', this.value)"
|
||||
class="w-full px-2 py-1 border border-gray-200 rounded text-xs">
|
||||
</td>
|
||||
<td class="px-1 py-1">
|
||||
<input type="text" value="${escapeHtml(item.frequency)}" placeholder="주기"
|
||||
onchange="updateSectionItem('${section.id}', '${item.id}', 'frequency', this.value)"
|
||||
class="w-full px-2 py-1 border border-gray-200 rounded text-xs">
|
||||
</td>
|
||||
<td class="px-1 py-1">
|
||||
<input type="text" value="${escapeHtml(item.regulation)}" placeholder="규정"
|
||||
onchange="updateSectionItem('${section.id}', '${item.id}', 'regulation', this.value)"
|
||||
class="w-full px-2 py-1 border border-gray-200 rounded text-xs">
|
||||
</td>
|
||||
<td class="px-1 py-1">
|
||||
<button onclick="removeSectionItem('${section.id}', '${item.id}')" class="text-red-400 hover:text-red-600">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
` : `
|
||||
<p class="text-gray-400 text-center py-4 text-sm">항목이 없습니다. 항목 추가 버튼을 클릭하세요.</p>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// ===== 컬럼 관리 =====
|
||||
function addColumn() {
|
||||
const column = { id: generateId(), label: '', width: '100px', column_type: 'text', group_name: '', sub_labels: null };
|
||||
templateState.columns.push(column);
|
||||
renderColumns();
|
||||
}
|
||||
|
||||
function removeColumn(id) {
|
||||
templateState.columns = templateState.columns.filter(c => c.id !== id);
|
||||
renderColumns();
|
||||
}
|
||||
|
||||
function updateColumn(id, field, value) {
|
||||
const column = templateState.columns.find(c => c.id === id);
|
||||
if (column) column[field] = value;
|
||||
}
|
||||
|
||||
function renderColumns() {
|
||||
const container = document.getElementById('columns-container');
|
||||
const emptyMsg = document.getElementById('columns-empty');
|
||||
|
||||
if (templateState.columns.length === 0) {
|
||||
container.innerHTML = '';
|
||||
emptyMsg.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
emptyMsg.classList.add('hidden');
|
||||
container.innerHTML = templateState.columns.map((col, idx) => `
|
||||
<div class="flex items-center gap-3 p-3 bg-gray-50 rounded-lg cursor-move" data-column-id="${col.id}">
|
||||
<span class="text-gray-400 font-bold" title="드래그하여 순서 변경">⋮⋮</span>
|
||||
<span class="text-gray-400 font-mono text-sm w-6">${idx + 1}</span>
|
||||
<input type="text" value="${escapeHtml(col.label)}" placeholder="컬럼명"
|
||||
onchange="updateColumn('${col.id}', 'label', this.value)"
|
||||
class="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm">
|
||||
<input type="text" value="${escapeHtml(col.width)}" placeholder="너비"
|
||||
onchange="updateColumn('${col.id}', 'width', this.value)"
|
||||
class="w-24 px-3 py-2 border border-gray-300 rounded-lg text-sm">
|
||||
<select onchange="updateColumn('${col.id}', 'column_type', this.value)"
|
||||
class="w-32 px-3 py-2 border border-gray-300 rounded-lg text-sm">
|
||||
<option value="text" ${col.column_type === 'text' ? 'selected' : ''}>텍스트</option>
|
||||
<option value="check" ${col.column_type === 'check' ? 'selected' : ''}>체크</option>
|
||||
<option value="measurement" ${col.column_type === 'measurement' ? 'selected' : ''}>측정값</option>
|
||||
<option value="select" ${col.column_type === 'select' ? 'selected' : ''}>선택</option>
|
||||
</select>
|
||||
<input type="text" value="${escapeHtml(col.group_name || '')}" placeholder="그룹명"
|
||||
onchange="updateColumn('${col.id}', 'group_name', this.value)"
|
||||
class="w-28 px-3 py-2 border border-gray-300 rounded-lg text-sm">
|
||||
<button onclick="removeColumn('${col.id}')" class="text-red-500 hover:text-red-700 p-1">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// ===== 저장 =====
|
||||
function saveTemplate() {
|
||||
const name = document.getElementById('name').value.trim();
|
||||
if (!name) {
|
||||
showToast('양식명은 필수입니다.', 'warning');
|
||||
switchTab('basic');
|
||||
document.getElementById('name').focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const data = {
|
||||
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,
|
||||
columns: templateState.columns
|
||||
};
|
||||
|
||||
const url = templateState.id
|
||||
? `/api/admin/document-templates/${templateState.id}`
|
||||
: '/api/admin/document-templates';
|
||||
const method = templateState.id ? 'PUT' : 'POST';
|
||||
|
||||
fetch(url, {
|
||||
method: method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
showToast(result.message || '저장되었습니다.', 'success');
|
||||
if (!templateState.id && result.data?.id) {
|
||||
// 새로 생성된 경우 편집 페이지로 이동
|
||||
window.location.href = `/document-templates/${result.data.id}/edit`;
|
||||
}
|
||||
} else {
|
||||
showToast(result.message || '저장에 실패했습니다.', 'error');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Save error:', error);
|
||||
showToast('저장 중 오류가 발생했습니다.', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 미리보기 =====
|
||||
function openPreviewModal() {
|
||||
const content = document.getElementById('preview-content');
|
||||
content.innerHTML = generatePreviewHtml();
|
||||
document.getElementById('preview-modal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closePreviewModal() {
|
||||
document.getElementById('preview-modal').classList.add('hidden');
|
||||
}
|
||||
|
||||
function generatePreviewHtml() {
|
||||
const title = document.getElementById('title').value || '검사 성적서';
|
||||
const companyName = document.getElementById('company_name').value || '회사명';
|
||||
|
||||
return `
|
||||
<div class="bg-white p-8 border" style="font-family: 'Malgun Gothic', sans-serif;">
|
||||
<div class="text-center mb-6">
|
||||
<h1 class="text-2xl font-bold">${escapeHtml(title)}</h1>
|
||||
<p class="text-gray-600 mt-1">${escapeHtml(companyName)}</p>
|
||||
</div>
|
||||
|
||||
${templateState.approval_lines.length > 0 ? `
|
||||
<div class="flex justify-end mb-6">
|
||||
<table class="border-collapse border border-gray-400 text-sm">
|
||||
<tr>
|
||||
${templateState.approval_lines.map(line => `
|
||||
<td class="border border-gray-400 px-4 py-1 text-center font-medium bg-gray-100">${escapeHtml(line.name)}</td>
|
||||
`).join('')}
|
||||
</tr>
|
||||
<tr>
|
||||
${templateState.approval_lines.map(line => `
|
||||
<td class="border border-gray-400 px-4 py-6 text-center"></td>
|
||||
`).join('')}
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${templateState.sections.map(section => `
|
||||
<div class="mb-6">
|
||||
<h3 class="font-bold text-lg mb-2 border-b-2 border-gray-800 pb-1">${escapeHtml(section.title)}</h3> ${section.image_path ? `
|
||||
<div class="mb-3">
|
||||
<img src="/storage/${section.image_path}" alt="${escapeHtml(section.title)}" class="max-w-full h-auto border border-gray-300 rounded">
|
||||
</div>
|
||||
` : ''}
|
||||
${section.items.length > 0 ? `
|
||||
<table class="w-full border-collapse border border-gray-400 text-sm">
|
||||
<thead>
|
||||
<tr class="bg-gray-100">
|
||||
<th class="border border-gray-400 px-2 py-1">구분</th>
|
||||
<th class="border border-gray-400 px-2 py-1">검사항목</th>
|
||||
<th class="border border-gray-400 px-2 py-1">검사기준</th>
|
||||
<th class="border border-gray-400 px-2 py-1">검사방법</th>
|
||||
<th class="border border-gray-400 px-2 py-1">검사주기</th>
|
||||
<th class="border border-gray-400 px-2 py-1">관련규정</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${section.items.map(item => `
|
||||
<tr>
|
||||
<td class="border border-gray-400 px-2 py-1">${escapeHtml(item.category)}</td>
|
||||
<td class="border border-gray-400 px-2 py-1">${escapeHtml(item.item)}</td>
|
||||
<td class="border border-gray-400 px-2 py-1">${escapeHtml(item.standard)}</td>
|
||||
<td class="border border-gray-400 px-2 py-1">${escapeHtml(item.method)}</td>
|
||||
<td class="border border-gray-400 px-2 py-1">${escapeHtml(item.frequency)}</td>
|
||||
<td class="border border-gray-400 px-2 py-1">${escapeHtml(item.regulation)}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
` : '<p class="text-gray-400">항목 없음</p>'}
|
||||
</div>
|
||||
`).join('')}
|
||||
|
||||
${templateState.columns.length > 0 ? `
|
||||
<div class="mb-6">
|
||||
<h3 class="font-bold text-lg mb-2 border-b-2 border-gray-800 pb-1">검사 데이터</h3>
|
||||
<table class="w-full border-collapse border border-gray-400 text-sm">
|
||||
<thead>
|
||||
<tr class="bg-gray-100">
|
||||
${templateState.columns.map(col => `
|
||||
<th class="border border-gray-400 px-2 py-1" style="width: ${col.width}">${escapeHtml(col.label)}</th>
|
||||
`).join('')}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
${templateState.columns.map(() => `
|
||||
<td class="border border-gray-400 px-2 py-4"></td>
|
||||
`).join('')}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ===== 이미지 업로드 =====
|
||||
function uploadSectionImage(sectionId, input) {
|
||||
const file = input.files[0];
|
||||
if (!file) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
|
||||
showToast('이미지 업로드 중...', 'info');
|
||||
|
||||
fetch('/api/admin/document-templates/upload-image', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: formData
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
const section = templateState.sections.find(s => s.id === sectionId);
|
||||
if (section) {
|
||||
section.image_path = result.path;
|
||||
renderSections();
|
||||
showToast('이미지가 업로드되었습니다.', 'success');
|
||||
}
|
||||
} else {
|
||||
showToast(result.message || '이미지 업로드에 실패했습니다.', 'error');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Upload error:', error);
|
||||
showToast('이미지 업로드 중 오류가 발생했습니다.', 'error');
|
||||
});
|
||||
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
function removeSectionImage(sectionId) {
|
||||
const section = templateState.sections.find(s => s.id === sectionId);
|
||||
if (section) {
|
||||
section.image_path = null;
|
||||
renderSections();
|
||||
}
|
||||
}
|
||||
|
||||
// ===== SortableJS 초기화 =====
|
||||
function initSortable() {
|
||||
// 섹션 정렬
|
||||
const sectionsContainer = document.getElementById('sections-container');
|
||||
if (sectionsContainer && typeof Sortable !== 'undefined') {
|
||||
new Sortable(sectionsContainer, {
|
||||
animation: 150,
|
||||
handle: '.drag-handle',
|
||||
onEnd: function(evt) {
|
||||
const newOrder = [];
|
||||
sectionsContainer.querySelectorAll('[data-section-id]').forEach((el, idx) => {
|
||||
const sectionId = el.dataset.sectionId;
|
||||
const section = templateState.sections.find(s => s.id === sectionId);
|
||||
if (section) newOrder.push(section);
|
||||
});
|
||||
templateState.sections = newOrder;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 결재라인 정렬
|
||||
const approvalContainer = document.getElementById('approval-lines');
|
||||
if (approvalContainer && typeof Sortable !== 'undefined') {
|
||||
new Sortable(approvalContainer, {
|
||||
animation: 150,
|
||||
onEnd: function(evt) {
|
||||
const newOrder = [];
|
||||
approvalContainer.querySelectorAll('[data-id]').forEach((el) => {
|
||||
const id = el.dataset.id;
|
||||
const line = templateState.approval_lines.find(l => l.id === id);
|
||||
if (line) newOrder.push(line);
|
||||
});
|
||||
templateState.approval_lines = newOrder;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 컬럼 정렬
|
||||
const columnsContainer = document.getElementById('columns-container');
|
||||
if (columnsContainer && typeof Sortable !== 'undefined') {
|
||||
new Sortable(columnsContainer, {
|
||||
animation: 150,
|
||||
onEnd: function(evt) {
|
||||
const newOrder = [];
|
||||
columnsContainer.querySelectorAll('[data-column-id]').forEach((el) => {
|
||||
const id = el.dataset.columnId;
|
||||
const col = templateState.columns.find(c => c.id === id);
|
||||
if (col) newOrder.push(col);
|
||||
});
|
||||
templateState.columns = newOrder;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 렌더링 후 SortableJS 재초기화
|
||||
const originalRenderApprovalLines = renderApprovalLines;
|
||||
renderApprovalLines = function() {
|
||||
originalRenderApprovalLines();
|
||||
setTimeout(initSortable, 100);
|
||||
};
|
||||
|
||||
const originalRenderSections = renderSections;
|
||||
renderSections = function() {
|
||||
originalRenderSections();
|
||||
setTimeout(initSortable, 100);
|
||||
};
|
||||
|
||||
const originalRenderColumns = renderColumns;
|
||||
renderColumns = function() {
|
||||
originalRenderColumns();
|
||||
setTimeout(initSortable, 100);
|
||||
};
|
||||
|
||||
// 초기 SortableJS 로드
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
setTimeout(initSortable, 500);
|
||||
});
|
||||
|
||||
// ===== 유틸리티 =====
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- SortableJS CDN -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>
|
||||
@endpush
|
||||
165
resources/views/document-templates/index.blade.php
Normal file
165
resources/views/document-templates/index.blade.php
Normal file
@@ -0,0 +1,165 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', '문서양식 관리')
|
||||
|
||||
@section('content')
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="flex flex-col lg:flex-row lg:justify-between lg:items-center gap-4 mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-800">문서양식 관리</h1>
|
||||
<p class="text-sm text-gray-500 mt-1 hidden sm:block">
|
||||
검사 성적서, 작업지시서 등 문서 양식을 관리합니다.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2 sm:gap-3">
|
||||
<a href="{{ route('document-templates.create') }}"
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
새 양식
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 필터 영역 -->
|
||||
<x-filter-collapsible id="filterForm">
|
||||
<form id="filterForm" class="flex flex-wrap gap-2 sm:gap-4">
|
||||
<input type="hidden" name="per_page" id="perPageInput" value="10">
|
||||
<input type="hidden" name="page" id="pageInput" value="1">
|
||||
|
||||
<!-- 검색 -->
|
||||
<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>
|
||||
|
||||
<!-- 카테고리 필터 -->
|
||||
<div class="w-full sm:w-40">
|
||||
<select name="category" 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 $code => $name)
|
||||
<option value="{{ $name }}">{{ $name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 활성 상태 필터 -->
|
||||
<div class="w-full sm:w-32">
|
||||
<select name="is_active" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="">전체 상태</option>
|
||||
<option value="1">활성</option>
|
||||
<option value="0">비활성</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 검색 버튼 -->
|
||||
<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="template-table"
|
||||
hx-get="/api/admin/document-templates"
|
||||
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>
|
||||
// 폼 제출 시 HTMX 이벤트 트리거
|
||||
(function() {
|
||||
function initFilterForm() {
|
||||
const filterForm = document.getElementById('filterForm');
|
||||
if (filterForm && !filterForm._initialized) {
|
||||
filterForm.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
htmx.trigger('#template-table', 'filterSubmit');
|
||||
});
|
||||
filterForm._initialized = true;
|
||||
}
|
||||
}
|
||||
initFilterForm();
|
||||
document.addEventListener('DOMContentLoaded', initFilterForm);
|
||||
})();
|
||||
|
||||
// 삭제 확인
|
||||
window.confirmDelete = function(id, name) {
|
||||
showDeleteConfirm(name, () => {
|
||||
fetch(`/api/admin/document-templates/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
showToast(data.message || '삭제되었습니다.', 'success');
|
||||
htmx.trigger('#template-table', 'filterSubmit');
|
||||
} else {
|
||||
showToast(data.message || '삭제에 실패했습니다.', 'error');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
showToast('삭제 중 오류가 발생했습니다.', 'error');
|
||||
console.error('Delete error:', error);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// 활성 토글
|
||||
window.toggleActive = function(id, buttonEl) {
|
||||
const btn = buttonEl || document.querySelector(`tr[data-template-id="${id}"] button[onclick*="toggleActive"]`);
|
||||
if (!btn) return;
|
||||
|
||||
const isCurrentlyActive = btn.classList.contains('bg-blue-500');
|
||||
const thumb = btn.querySelector('span');
|
||||
|
||||
// 즉시 UI 토글
|
||||
btn.classList.toggle('bg-blue-500', !isCurrentlyActive);
|
||||
btn.classList.toggle('bg-gray-400', isCurrentlyActive);
|
||||
thumb.classList.toggle('translate-x-3.5', !isCurrentlyActive);
|
||||
thumb.classList.toggle('translate-x-0.5', isCurrentlyActive);
|
||||
|
||||
fetch(`/api/admin/document-templates/${id}/toggle-active`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (!data.success) {
|
||||
// 실패 시 롤백
|
||||
btn.classList.toggle('bg-blue-500', isCurrentlyActive);
|
||||
btn.classList.toggle('bg-gray-400', !isCurrentlyActive);
|
||||
thumb.classList.toggle('translate-x-3.5', isCurrentlyActive);
|
||||
thumb.classList.toggle('translate-x-0.5', !isCurrentlyActive);
|
||||
showToast(data.message || '상태 변경에 실패했습니다.', 'error');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
btn.classList.toggle('bg-blue-500', isCurrentlyActive);
|
||||
btn.classList.toggle('bg-gray-400', !isCurrentlyActive);
|
||||
thumb.classList.toggle('translate-x-3.5', isCurrentlyActive);
|
||||
thumb.classList.toggle('translate-x-0.5', !isCurrentlyActive);
|
||||
showToast('상태 변경 중 오류가 발생했습니다.', 'error');
|
||||
console.error('Toggle error:', error);
|
||||
});
|
||||
};
|
||||
</script>
|
||||
@endpush
|
||||
102
resources/views/document-templates/partials/table.blade.php
Normal file
102
resources/views/document-templates/partials/table.blade.php
Normal file
@@ -0,0 +1,102 @@
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">양식명</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">분류</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">문서 제목</th>
|
||||
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">섹션</th>
|
||||
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">컬럼</th>
|
||||
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">활성</th>
|
||||
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">수정일</th>
|
||||
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
@forelse($templates as $template)
|
||||
<tr class="hover:bg-gray-50" data-template-id="{{ $template->id }}">
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
<a href="{{ route('document-templates.edit', $template->id) }}"
|
||||
class="text-blue-600 hover:text-blue-800 font-medium">
|
||||
{{ $template->name }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
@if($template->category)
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
|
||||
@switch($template->category)
|
||||
@case('품질') bg-green-100 text-green-800 @break
|
||||
@case('생산') bg-blue-100 text-blue-800 @break
|
||||
@case('영업') bg-purple-100 text-purple-800 @break
|
||||
@case('구매') bg-orange-100 text-orange-800 @break
|
||||
@default bg-gray-100 text-gray-800
|
||||
@endswitch
|
||||
">
|
||||
{{ $template->category }}
|
||||
</span>
|
||||
@else
|
||||
<span class="text-gray-400">-</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 max-w-xs truncate" title="{{ $template->title }}">
|
||||
{{ $template->title ?: '-' }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center text-sm text-gray-600">
|
||||
{{ $template->sections_count ?? 0 }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center text-sm text-gray-600">
|
||||
{{ $template->columns_count ?? 0 }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<button onclick="toggleActive({{ $template->id }}, this)"
|
||||
class="relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none {{ $template->is_active ? 'bg-blue-500' : 'bg-gray-400' }}">
|
||||
<span class="pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out {{ $template->is_active ? 'translate-x-3.5' : 'translate-x-0.5' }}"></span>
|
||||
</button>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center text-sm text-gray-500">
|
||||
{{ $template->updated_at->format('Y-m-d') }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<a href="{{ route('document-templates.edit', $template->id) }}"
|
||||
class="text-gray-600 hover:text-blue-600 transition"
|
||||
title="편집">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</a>
|
||||
<button onclick="confirmDelete({{ $template->id }}, '{{ addslashes($template->name) }}')"
|
||||
class="text-gray-600 hover:text-red-600 transition"
|
||||
title="삭제">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="8" class="px-4 py-12 text-center text-gray-500">
|
||||
<div class="flex flex-col items-center">
|
||||
<svg class="w-12 h-12 text-gray-300 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<p>등록된 문서양식이 없습니다.</p>
|
||||
<a href="{{ route('document-templates.create') }}" class="mt-2 text-blue-600 hover:text-blue-800">
|
||||
+ 새 양식 만들기
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 페이지네이션 -->
|
||||
@if($templates->hasPages())
|
||||
<div class="px-4 py-3 border-t border-gray-200">
|
||||
@include('partials.pagination', ['paginator' => $templates, 'htmxTarget' => '#template-table', 'htmxTrigger' => 'filterSubmit'])
|
||||
</div>
|
||||
@endif
|
||||
@@ -74,7 +74,7 @@ class="toggle-btn flex items-center text-blue-500 hover:text-blue-700 focus:outl
|
||||
{{ isset($permissions[$menu->id][$type]) && $permissions[$menu->id][$type] ? 'checked' : '' }}
|
||||
class="h-5 w-5 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer"
|
||||
hx-post="/api/admin/role-permissions/toggle"
|
||||
hx-target="#permission-matrix"
|
||||
hx-swap="none"
|
||||
hx-include="[name='role_id'],[name='guard_name']"
|
||||
hx-vals='{"menu_id": {{ $menu->id }}, "permission_type": "{{ $type }}"}'
|
||||
>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use App\Http\Controllers\Api\Admin\BoardController;
|
||||
use App\Http\Controllers\Api\Admin\CustomerCenterController;
|
||||
use App\Http\Controllers\Api\Admin\DailyLogController;
|
||||
use App\Http\Controllers\Api\Admin\DepartmentController;
|
||||
use App\Http\Controllers\Api\Admin\DepartmentController;use App\Http\Controllers\Api\Admin\DocumentTemplateApiController;
|
||||
use App\Http\Controllers\Api\Admin\GlobalMenuController;
|
||||
use App\Http\Controllers\Api\Admin\ItemFieldController;
|
||||
use App\Http\Controllers\Api\Admin\MeetingLogController;
|
||||
@@ -733,6 +733,21 @@
|
||||
Route::delete('/{id}', [\App\Http\Controllers\Api\BizCertController::class, 'destroy'])->name('destroy');
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 문서양식 관리 API
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
Route::middleware(['web', 'auth', 'hq.member'])->prefix('admin/document-templates')->name('api.admin.document-templates.')->group(function () {
|
||||
Route::get('/', [DocumentTemplateApiController::class, 'index'])->name('index');
|
||||
Route::post('/', [DocumentTemplateApiController::class, 'store'])->name('store');
|
||||
Route::get('/{id}', [DocumentTemplateApiController::class, 'show'])->name('show');
|
||||
Route::put('/{id}', [DocumentTemplateApiController::class, 'update'])->name('update');
|
||||
Route::delete('/{id}', [DocumentTemplateApiController::class, 'destroy'])->name('destroy');
|
||||
Route::post('/{id}/toggle-active', [DocumentTemplateApiController::class, 'toggleActive'])->name('toggle-active');
|
||||
Route::post('/upload-image', [DocumentTemplateApiController::class, 'uploadImage'])->name('upload-image');
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 웹 녹음 AI 요약 API
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
use App\Http\Controllers\TenantController;
|
||||
use App\Http\Controllers\TenantSettingController;
|
||||
use App\Http\Controllers\CommonCodeController;
|
||||
use App\Http\Controllers\DocumentTemplateController;
|
||||
use App\Http\Controllers\MenuSyncController;
|
||||
use App\Http\Controllers\UserController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
@@ -308,6 +309,13 @@
|
||||
Route::delete('/{id}', [CommonCodeController::class, 'destroy'])->name('destroy');
|
||||
});
|
||||
|
||||
// 문서양식 관리
|
||||
Route::prefix('document-templates')->name('document-templates.')->group(function () {
|
||||
Route::get('/', [DocumentTemplateController::class, 'index'])->name('index');
|
||||
Route::get('/create', [DocumentTemplateController::class, 'create'])->name('create');
|
||||
Route::get('/{id}/edit', [DocumentTemplateController::class, 'edit'])->name('edit');
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 바로빌 Routes
|
||||
|
||||
Reference in New Issue
Block a user