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') +
+ @if($document) + {{ $document->document_no }} - {{ $document->title }} + @else + 템플릿을 선택하여 문서를 작성합니다. + @endif +
+{{ $tpl->category }}
+ + @empty +사용 가능한 템플릿이 없습니다.
+ @endforelse +작성된 문서를 관리합니다.
+| 문서번호 | +제목 | +템플릿 | +상태 | +작성자 | +작성일 | +관리 | +
|---|
{{ $document->document_no }} - {{ $document->title }}
+테이블 형태의 데이터 표시는 추후 구현 예정입니다.
+{{ $attachment->file->original_name ?? '파일명 없음' }}
++ {{ $attachment->type_label }} · + {{ $attachment->file ? number_format($attachment->file->size / 1024, 1) . ' KB' : '-' }} +
+{{ $approval->step_name }}
+ @if($approval->approved_at) +{{ $approval->approved_at->format('Y-m-d H:i') }}
+ @endif + @if($approval->comment) +{{ $approval->comment }}
+ @endif +결재선이 설정되지 않았습니다.
+ @endif +문서 이력 기능은 추후 구현 예정입니다.
+