feat: [approval] 결재관리 Phase 1 MVP 구현
- 모델 4개: Approval, ApprovalStep, ApprovalForm, ApprovalLine - ApprovalService: 목록/CRUD/워크플로우(상신/승인/반려/회수) 비즈니스 로직 - ApprovalApiController: JSON API 엔드포인트 (기안함/결재함/완료함/참조함) - ApprovalController: Blade 뷰 컨트롤러 (HX-Redirect 처리) - 뷰 8개: drafts, pending, completed, references, create, edit, show - partials: _status-badge, _step-progress, _approval-line-editor - api.php/web.php 라우트 등록
This commit is contained in:
288
app/Http/Controllers/Api/Admin/ApprovalApiController.php
Normal file
288
app/Http/Controllers/Api/Admin/ApprovalApiController.php
Normal file
@@ -0,0 +1,288 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\ApprovalService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ApprovalApiController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ApprovalService $service
|
||||
) {}
|
||||
|
||||
// =========================================================================
|
||||
// 목록
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 기안함
|
||||
*/
|
||||
public function drafts(Request $request): JsonResponse
|
||||
{
|
||||
$result = $this->service->getMyDrafts(
|
||||
$request->only(['search', 'status', 'is_urgent', 'date_from', 'date_to']),
|
||||
(int) $request->get('per_page', 15)
|
||||
);
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 대기함
|
||||
*/
|
||||
public function pending(Request $request): JsonResponse
|
||||
{
|
||||
$result = $this->service->getPendingForMe(
|
||||
auth()->id(),
|
||||
$request->only(['search', 'is_urgent', 'date_from', 'date_to']),
|
||||
(int) $request->get('per_page', 15)
|
||||
);
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 처리 완료함
|
||||
*/
|
||||
public function completed(Request $request): JsonResponse
|
||||
{
|
||||
$result = $this->service->getCompletedByMe(
|
||||
auth()->id(),
|
||||
$request->only(['search', 'status', 'date_from', 'date_to']),
|
||||
(int) $request->get('per_page', 15)
|
||||
);
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 참조함
|
||||
*/
|
||||
public function references(Request $request): JsonResponse
|
||||
{
|
||||
$result = $this->service->getReferencesForMe(
|
||||
auth()->id(),
|
||||
$request->only(['search', 'date_from', 'date_to']),
|
||||
(int) $request->get('per_page', 15)
|
||||
);
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// CRUD
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 상세 조회
|
||||
*/
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
$approval = $this->service->getApproval($id);
|
||||
|
||||
return response()->json(['success' => true, 'data' => $approval]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 생성 (임시저장)
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'form_id' => 'required|exists:approval_forms,id',
|
||||
'title' => 'required|string|max:200',
|
||||
'body' => 'nullable|string',
|
||||
'is_urgent' => 'boolean',
|
||||
'steps' => 'nullable|array',
|
||||
'steps.*.user_id' => 'required_with:steps|exists:users,id',
|
||||
'steps.*.step_type' => 'required_with:steps|in:approval,agreement,reference',
|
||||
]);
|
||||
|
||||
$approval = $this->service->createApproval($request->all());
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '결재 문서가 저장되었습니다.',
|
||||
'data' => $approval,
|
||||
], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* 수정
|
||||
*/
|
||||
public function update(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'title' => 'sometimes|string|max:200',
|
||||
'body' => 'nullable|string',
|
||||
'is_urgent' => 'boolean',
|
||||
'steps' => 'nullable|array',
|
||||
'steps.*.user_id' => 'required_with:steps|exists:users,id',
|
||||
'steps.*.step_type' => 'required_with:steps|in:approval,agreement,reference',
|
||||
]);
|
||||
|
||||
try {
|
||||
$approval = $this->service->updateApproval($id, $request->all());
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '결재 문서가 수정되었습니다.',
|
||||
'data' => $approval,
|
||||
]);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 삭제
|
||||
*/
|
||||
public function destroy(int $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
$this->service->deleteApproval($id);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '결재 문서가 삭제되었습니다.',
|
||||
]);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 워크플로우
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 상신
|
||||
*/
|
||||
public function submit(int $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
$approval = $this->service->submit($id);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '결재가 상신되었습니다.',
|
||||
'data' => $approval,
|
||||
]);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 승인
|
||||
*/
|
||||
public function approve(Request $request, int $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
$approval = $this->service->approve($id, $request->get('comment'));
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '승인되었습니다.',
|
||||
'data' => $approval,
|
||||
]);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 반려
|
||||
*/
|
||||
public function reject(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'comment' => 'required|string|max:1000',
|
||||
]);
|
||||
|
||||
try {
|
||||
$approval = $this->service->reject($id, $request->get('comment'));
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '반려되었습니다.',
|
||||
'data' => $approval,
|
||||
]);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 회수
|
||||
*/
|
||||
public function cancel(int $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
$approval = $this->service->cancel($id);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '결재가 회수되었습니다.',
|
||||
'data' => $approval,
|
||||
]);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 유틸
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 결재선 템플릿 목록
|
||||
*/
|
||||
public function lines(): JsonResponse
|
||||
{
|
||||
$lines = $this->service->getApprovalLines();
|
||||
|
||||
return response()->json(['success' => true, 'data' => $lines]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 양식 목록
|
||||
*/
|
||||
public function forms(): JsonResponse
|
||||
{
|
||||
$forms = $this->service->getApprovalForms();
|
||||
|
||||
return response()->json(['success' => true, 'data' => $forms]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 미처리 건수
|
||||
*/
|
||||
public function badgeCounts(): JsonResponse
|
||||
{
|
||||
$counts = $this->service->getBadgeCounts(auth()->id());
|
||||
|
||||
return response()->json(['success' => true, 'data' => $counts]);
|
||||
}
|
||||
}
|
||||
113
app/Http/Controllers/ApprovalController.php
Normal file
113
app/Http/Controllers/ApprovalController.php
Normal file
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\ApprovalService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ApprovalController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ApprovalService $service
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 기안함
|
||||
*/
|
||||
public function drafts(Request $request): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('approvals.drafts'));
|
||||
}
|
||||
|
||||
return view('approvals.drafts');
|
||||
}
|
||||
|
||||
/**
|
||||
* 기안 작성
|
||||
*/
|
||||
public function create(Request $request): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('approvals.create'));
|
||||
}
|
||||
|
||||
$forms = $this->service->getApprovalForms();
|
||||
$lines = $this->service->getApprovalLines();
|
||||
|
||||
return view('approvals.create', compact('forms', 'lines'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 기안 수정
|
||||
*/
|
||||
public function edit(Request $request, int $id): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('approvals.edit', $id));
|
||||
}
|
||||
|
||||
$approval = $this->service->getApproval($id);
|
||||
|
||||
if (! $approval->isEditable() || $approval->drafter_id !== auth()->id()) {
|
||||
abort(403, '수정할 수 없습니다.');
|
||||
}
|
||||
|
||||
$forms = $this->service->getApprovalForms();
|
||||
$lines = $this->service->getApprovalLines();
|
||||
|
||||
return view('approvals.edit', compact('approval', 'forms', 'lines'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 상세
|
||||
*/
|
||||
public function show(Request $request, int $id): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('approvals.show', $id));
|
||||
}
|
||||
|
||||
$approval = $this->service->getApproval($id);
|
||||
|
||||
return view('approvals.show', compact('approval'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 대기함
|
||||
*/
|
||||
public function pending(Request $request): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('approvals.pending'));
|
||||
}
|
||||
|
||||
return view('approvals.pending');
|
||||
}
|
||||
|
||||
/**
|
||||
* 참조함
|
||||
*/
|
||||
public function references(Request $request): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('approvals.references'));
|
||||
}
|
||||
|
||||
return view('approvals.references');
|
||||
}
|
||||
|
||||
/**
|
||||
* 완료함
|
||||
*/
|
||||
public function completed(Request $request): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('approvals.completed'));
|
||||
}
|
||||
|
||||
return view('approvals.completed');
|
||||
}
|
||||
}
|
||||
242
app/Models/Approvals/Approval.php
Normal file
242
app/Models/Approvals/Approval.php
Normal file
@@ -0,0 +1,242 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Approvals;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Approval extends Model
|
||||
{
|
||||
use BelongsToTenant, SoftDeletes;
|
||||
|
||||
protected $table = 'approvals';
|
||||
|
||||
protected $casts = [
|
||||
'content' => 'array',
|
||||
'attachments' => 'array',
|
||||
'drafted_at' => 'datetime',
|
||||
'completed_at' => 'datetime',
|
||||
'current_step' => 'integer',
|
||||
'is_urgent' => 'boolean',
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'document_number',
|
||||
'form_id',
|
||||
'line_id',
|
||||
'title',
|
||||
'content',
|
||||
'body',
|
||||
'status',
|
||||
'is_urgent',
|
||||
'drafter_id',
|
||||
'department_id',
|
||||
'drafted_at',
|
||||
'completed_at',
|
||||
'current_step',
|
||||
'attachments',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
'deleted_by',
|
||||
];
|
||||
|
||||
protected $attributes = [
|
||||
'status' => 'draft',
|
||||
'current_step' => 0,
|
||||
'is_urgent' => false,
|
||||
];
|
||||
|
||||
// =========================================================================
|
||||
// 상태 상수
|
||||
// =========================================================================
|
||||
|
||||
public const STATUS_DRAFT = 'draft';
|
||||
|
||||
public const STATUS_PENDING = 'pending';
|
||||
|
||||
public const STATUS_APPROVED = 'approved';
|
||||
|
||||
public const STATUS_REJECTED = 'rejected';
|
||||
|
||||
public const STATUS_CANCELLED = 'cancelled';
|
||||
|
||||
public const STATUSES = [
|
||||
self::STATUS_DRAFT,
|
||||
self::STATUS_PENDING,
|
||||
self::STATUS_APPROVED,
|
||||
self::STATUS_REJECTED,
|
||||
self::STATUS_CANCELLED,
|
||||
];
|
||||
|
||||
// =========================================================================
|
||||
// 관계 정의
|
||||
// =========================================================================
|
||||
|
||||
public function form(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ApprovalForm::class, 'form_id');
|
||||
}
|
||||
|
||||
public function line(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ApprovalLine::class, 'line_id');
|
||||
}
|
||||
|
||||
public function drafter(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'drafter_id');
|
||||
}
|
||||
|
||||
public function steps(): HasMany
|
||||
{
|
||||
return $this->hasMany(ApprovalStep::class, 'approval_id')->orderBy('step_order');
|
||||
}
|
||||
|
||||
public function approverSteps(): HasMany
|
||||
{
|
||||
return $this->hasMany(ApprovalStep::class, 'approval_id')
|
||||
->whereIn('step_type', [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT])
|
||||
->orderBy('step_order');
|
||||
}
|
||||
|
||||
public function referenceSteps(): HasMany
|
||||
{
|
||||
return $this->hasMany(ApprovalStep::class, 'approval_id')
|
||||
->where('step_type', ApprovalLine::STEP_TYPE_REFERENCE)
|
||||
->orderBy('step_order');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 스코프
|
||||
// =========================================================================
|
||||
|
||||
public function scopeWithStatus($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);
|
||||
}
|
||||
|
||||
public function scopeApproved($query)
|
||||
{
|
||||
return $query->where('status', self::STATUS_APPROVED);
|
||||
}
|
||||
|
||||
public function scopeRejected($query)
|
||||
{
|
||||
return $query->where('status', self::STATUS_REJECTED);
|
||||
}
|
||||
|
||||
public function scopeCompleted($query)
|
||||
{
|
||||
return $query->whereIn('status', [self::STATUS_APPROVED, self::STATUS_REJECTED, self::STATUS_CANCELLED]);
|
||||
}
|
||||
|
||||
public function scopeByDrafter($query, int $userId)
|
||||
{
|
||||
return $query->where('drafter_id', $userId);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 헬퍼 메서드
|
||||
// =========================================================================
|
||||
|
||||
public function isEditable(): bool
|
||||
{
|
||||
return in_array($this->status, [self::STATUS_DRAFT, self::STATUS_REJECTED]);
|
||||
}
|
||||
|
||||
public function isSubmittable(): bool
|
||||
{
|
||||
return in_array($this->status, [self::STATUS_DRAFT, self::STATUS_REJECTED]);
|
||||
}
|
||||
|
||||
public function isActionable(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_PENDING;
|
||||
}
|
||||
|
||||
public function isCancellable(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_PENDING;
|
||||
}
|
||||
|
||||
public function isDeletable(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_DRAFT;
|
||||
}
|
||||
|
||||
public function getStatusLabelAttribute(): string
|
||||
{
|
||||
return match ($this->status) {
|
||||
self::STATUS_DRAFT => '임시저장',
|
||||
self::STATUS_PENDING => '진행',
|
||||
self::STATUS_APPROVED => '완료',
|
||||
self::STATUS_REJECTED => '반려',
|
||||
self::STATUS_CANCELLED => '회수',
|
||||
default => $this->status,
|
||||
};
|
||||
}
|
||||
|
||||
public function getStatusColorAttribute(): string
|
||||
{
|
||||
return match ($this->status) {
|
||||
self::STATUS_DRAFT => 'gray',
|
||||
self::STATUS_PENDING => 'blue',
|
||||
self::STATUS_APPROVED => 'green',
|
||||
self::STATUS_REJECTED => 'red',
|
||||
self::STATUS_CANCELLED => 'yellow',
|
||||
default => 'gray',
|
||||
};
|
||||
}
|
||||
|
||||
public function getCurrentApproverStep(): ?ApprovalStep
|
||||
{
|
||||
return $this->steps()
|
||||
->whereIn('step_type', [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT])
|
||||
->where('status', ApprovalStep::STATUS_PENDING)
|
||||
->orderBy('step_order')
|
||||
->first();
|
||||
}
|
||||
|
||||
public function isCurrentApprover(int $userId): bool
|
||||
{
|
||||
$currentStep = $this->getCurrentApproverStep();
|
||||
|
||||
return $currentStep && $currentStep->approver_id === $userId;
|
||||
}
|
||||
|
||||
public function isReferee(int $userId): bool
|
||||
{
|
||||
return $this->referenceSteps()
|
||||
->where('approver_id', $userId)
|
||||
->exists();
|
||||
}
|
||||
|
||||
public function getProgressAttribute(): array
|
||||
{
|
||||
$totalSteps = $this->approverSteps()->count();
|
||||
$completedSteps = $this->approverSteps()
|
||||
->whereIn('status', [ApprovalStep::STATUS_APPROVED, ApprovalStep::STATUS_REJECTED])
|
||||
->count();
|
||||
|
||||
return [
|
||||
'total' => $totalSteps,
|
||||
'completed' => $completedSteps,
|
||||
'percentage' => $totalSteps > 0 ? round(($completedSteps / $totalSteps) * 100) : 0,
|
||||
];
|
||||
}
|
||||
}
|
||||
92
app/Models/Approvals/ApprovalForm.php
Normal file
92
app/Models/Approvals/ApprovalForm.php
Normal file
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Approvals;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class ApprovalForm extends Model
|
||||
{
|
||||
use BelongsToTenant, SoftDeletes;
|
||||
|
||||
protected $table = 'approval_forms';
|
||||
|
||||
protected $casts = [
|
||||
'template' => 'array',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'name',
|
||||
'code',
|
||||
'category',
|
||||
'template',
|
||||
'is_active',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
'deleted_by',
|
||||
];
|
||||
|
||||
// =========================================================================
|
||||
// 카테고리 상수
|
||||
// =========================================================================
|
||||
|
||||
public const CATEGORY_REQUEST = 'request';
|
||||
|
||||
public const CATEGORY_EXPENSE = 'expense';
|
||||
|
||||
public const CATEGORY_EXPENSE_ESTIMATE = 'expense_estimate';
|
||||
|
||||
public const CATEGORIES = [
|
||||
self::CATEGORY_REQUEST,
|
||||
self::CATEGORY_EXPENSE,
|
||||
self::CATEGORY_EXPENSE_ESTIMATE,
|
||||
];
|
||||
|
||||
// =========================================================================
|
||||
// 관계 정의
|
||||
// =========================================================================
|
||||
|
||||
public function approvals(): HasMany
|
||||
{
|
||||
return $this->hasMany(Approval::class, 'form_id');
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 스코프
|
||||
// =========================================================================
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
public function scopeCategory($query, string $category)
|
||||
{
|
||||
return $query->where('category', $category);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 헬퍼 메서드
|
||||
// =========================================================================
|
||||
|
||||
public function getCategoryLabelAttribute(): string
|
||||
{
|
||||
return match ($this->category) {
|
||||
self::CATEGORY_REQUEST => '품의서',
|
||||
self::CATEGORY_EXPENSE => '지출결의서',
|
||||
self::CATEGORY_EXPENSE_ESTIMATE => '지출 예상 내역서',
|
||||
default => $this->category ?? '',
|
||||
};
|
||||
}
|
||||
}
|
||||
83
app/Models/Approvals/ApprovalLine.php
Normal file
83
app/Models/Approvals/ApprovalLine.php
Normal file
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Approvals;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class ApprovalLine extends Model
|
||||
{
|
||||
use BelongsToTenant, SoftDeletes;
|
||||
|
||||
protected $table = 'approval_lines';
|
||||
|
||||
protected $casts = [
|
||||
'steps' => 'array',
|
||||
'is_default' => 'boolean',
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'name',
|
||||
'steps',
|
||||
'is_default',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
'deleted_by',
|
||||
];
|
||||
|
||||
// =========================================================================
|
||||
// 단계 유형 상수
|
||||
// =========================================================================
|
||||
|
||||
public const STEP_TYPE_APPROVAL = 'approval';
|
||||
|
||||
public const STEP_TYPE_AGREEMENT = 'agreement';
|
||||
|
||||
public const STEP_TYPE_REFERENCE = 'reference';
|
||||
|
||||
public const STEP_TYPES = [
|
||||
self::STEP_TYPE_APPROVAL,
|
||||
self::STEP_TYPE_AGREEMENT,
|
||||
self::STEP_TYPE_REFERENCE,
|
||||
];
|
||||
|
||||
// =========================================================================
|
||||
// 관계 정의
|
||||
// =========================================================================
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 스코프
|
||||
// =========================================================================
|
||||
|
||||
public function scopeDefault($query)
|
||||
{
|
||||
return $query->where('is_default', true);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 헬퍼 메서드
|
||||
// =========================================================================
|
||||
|
||||
public function getStepCountAttribute(): int
|
||||
{
|
||||
return count($this->steps ?? []);
|
||||
}
|
||||
|
||||
public function getApproverIdsAttribute(): array
|
||||
{
|
||||
return collect($this->steps ?? [])
|
||||
->pluck('user_id')
|
||||
->filter()
|
||||
->values()
|
||||
->toArray();
|
||||
}
|
||||
}
|
||||
137
app/Models/Approvals/ApprovalStep.php
Normal file
137
app/Models/Approvals/ApprovalStep.php
Normal file
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Approvals;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ApprovalStep extends Model
|
||||
{
|
||||
protected $table = 'approval_steps';
|
||||
|
||||
protected $casts = [
|
||||
'step_order' => 'integer',
|
||||
'acted_at' => 'datetime',
|
||||
'is_read' => 'boolean',
|
||||
'read_at' => 'datetime',
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'approval_id',
|
||||
'step_order',
|
||||
'step_type',
|
||||
'approver_id',
|
||||
'approver_name',
|
||||
'approver_department',
|
||||
'approver_position',
|
||||
'status',
|
||||
'comment',
|
||||
'acted_at',
|
||||
'is_read',
|
||||
'read_at',
|
||||
];
|
||||
|
||||
protected $attributes = [
|
||||
'status' => 'pending',
|
||||
'is_read' => false,
|
||||
];
|
||||
|
||||
// =========================================================================
|
||||
// 상태 상수
|
||||
// =========================================================================
|
||||
|
||||
public const STATUS_PENDING = 'pending';
|
||||
|
||||
public const STATUS_APPROVED = 'approved';
|
||||
|
||||
public const STATUS_REJECTED = 'rejected';
|
||||
|
||||
public const STATUS_SKIPPED = 'skipped';
|
||||
|
||||
public const STATUSES = [
|
||||
self::STATUS_PENDING,
|
||||
self::STATUS_APPROVED,
|
||||
self::STATUS_REJECTED,
|
||||
self::STATUS_SKIPPED,
|
||||
];
|
||||
|
||||
// =========================================================================
|
||||
// 관계 정의
|
||||
// =========================================================================
|
||||
|
||||
public function approval(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Approval::class, 'approval_id');
|
||||
}
|
||||
|
||||
public function approver(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'approver_id');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 스코프
|
||||
// =========================================================================
|
||||
|
||||
public function scopePending($query)
|
||||
{
|
||||
return $query->where('status', self::STATUS_PENDING);
|
||||
}
|
||||
|
||||
public function scopeApproved($query)
|
||||
{
|
||||
return $query->where('status', self::STATUS_APPROVED);
|
||||
}
|
||||
|
||||
public function scopeByApprover($query, int $userId)
|
||||
{
|
||||
return $query->where('approver_id', $userId);
|
||||
}
|
||||
|
||||
public function scopeApprovalOnly($query)
|
||||
{
|
||||
return $query->whereIn('step_type', [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT]);
|
||||
}
|
||||
|
||||
public function scopeReferenceOnly($query)
|
||||
{
|
||||
return $query->where('step_type', ApprovalLine::STEP_TYPE_REFERENCE);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 헬퍼 메서드
|
||||
// =========================================================================
|
||||
|
||||
public function isActionable(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_PENDING
|
||||
&& in_array($this->step_type, [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT]);
|
||||
}
|
||||
|
||||
public function isReference(): bool
|
||||
{
|
||||
return $this->step_type === ApprovalLine::STEP_TYPE_REFERENCE;
|
||||
}
|
||||
|
||||
public function getStatusLabelAttribute(): string
|
||||
{
|
||||
return match ($this->status) {
|
||||
self::STATUS_PENDING => '대기',
|
||||
self::STATUS_APPROVED => '승인',
|
||||
self::STATUS_REJECTED => '반려',
|
||||
self::STATUS_SKIPPED => '건너뜀',
|
||||
default => $this->status,
|
||||
};
|
||||
}
|
||||
|
||||
public function getStepTypeLabelAttribute(): string
|
||||
{
|
||||
return match ($this->step_type) {
|
||||
ApprovalLine::STEP_TYPE_APPROVAL => '결재',
|
||||
ApprovalLine::STEP_TYPE_AGREEMENT => '합의',
|
||||
ApprovalLine::STEP_TYPE_REFERENCE => '참조',
|
||||
default => $this->step_type,
|
||||
};
|
||||
}
|
||||
}
|
||||
500
app/Services/ApprovalService.php
Normal file
500
app/Services/ApprovalService.php
Normal file
@@ -0,0 +1,500 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Approvals\Approval;
|
||||
use App\Models\Approvals\ApprovalForm;
|
||||
use App\Models\Approvals\ApprovalLine;
|
||||
use App\Models\Approvals\ApprovalStep;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ApprovalService
|
||||
{
|
||||
// =========================================================================
|
||||
// 목록 조회
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 기안함 (내가 기안한 문서)
|
||||
*/
|
||||
public function getMyDrafts(array $filters = [], int $perPage = 15): LengthAwarePaginator
|
||||
{
|
||||
$userId = auth()->id();
|
||||
|
||||
$query = Approval::with(['form', 'steps.approver'])
|
||||
->byDrafter($userId);
|
||||
|
||||
$this->applyFilters($query, $filters);
|
||||
|
||||
return $query->orderByDesc('created_at')->paginate($perPage);
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 대기함 (내가 현재 결재자인 문서)
|
||||
*/
|
||||
public function getPendingForMe(int $userId, array $filters = [], int $perPage = 15): LengthAwarePaginator
|
||||
{
|
||||
$query = Approval::with(['form', 'drafter', 'steps.approver'])
|
||||
->pending()
|
||||
->whereHas('steps', function ($q) use ($userId) {
|
||||
$q->where('approver_id', $userId)
|
||||
->where('status', ApprovalStep::STATUS_PENDING)
|
||||
->approvalOnly()
|
||||
->whereColumn('step_order', function ($sub) {
|
||||
$sub->selectRaw('MIN(step_order)')
|
||||
->from('approval_steps as inner_steps')
|
||||
->whereColumn('inner_steps.approval_id', 'approval_steps.approval_id')
|
||||
->where('inner_steps.status', ApprovalStep::STATUS_PENDING)
|
||||
->whereIn('inner_steps.step_type', [
|
||||
ApprovalLine::STEP_TYPE_APPROVAL,
|
||||
ApprovalLine::STEP_TYPE_AGREEMENT,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
$this->applyFilters($query, $filters);
|
||||
|
||||
return $query->orderByDesc('drafted_at')->paginate($perPage);
|
||||
}
|
||||
|
||||
/**
|
||||
* 처리 완료함 (내가 결재한 문서)
|
||||
*/
|
||||
public function getCompletedByMe(int $userId, array $filters = [], int $perPage = 15): LengthAwarePaginator
|
||||
{
|
||||
$query = Approval::with(['form', 'drafter', 'steps.approver'])
|
||||
->whereHas('steps', function ($q) use ($userId) {
|
||||
$q->where('approver_id', $userId)
|
||||
->whereIn('status', [ApprovalStep::STATUS_APPROVED, ApprovalStep::STATUS_REJECTED]);
|
||||
});
|
||||
|
||||
$this->applyFilters($query, $filters);
|
||||
|
||||
return $query->orderByDesc('updated_at')->paginate($perPage);
|
||||
}
|
||||
|
||||
/**
|
||||
* 참조함 (내가 참조자인 문서)
|
||||
*/
|
||||
public function getReferencesForMe(int $userId, array $filters = [], int $perPage = 15): LengthAwarePaginator
|
||||
{
|
||||
$query = Approval::with(['form', 'drafter', 'steps.approver'])
|
||||
->whereHas('steps', function ($q) use ($userId) {
|
||||
$q->where('approver_id', $userId)
|
||||
->where('step_type', ApprovalLine::STEP_TYPE_REFERENCE);
|
||||
});
|
||||
|
||||
$this->applyFilters($query, $filters);
|
||||
|
||||
return $query->orderByDesc('created_at')->paginate($perPage);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// CRUD
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 상세 조회
|
||||
*/
|
||||
public function getApproval(int $id): Approval
|
||||
{
|
||||
return Approval::with(['form', 'drafter', 'line', 'steps.approver'])
|
||||
->findOrFail($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 생성 (임시저장)
|
||||
*/
|
||||
public function createApproval(array $data): Approval
|
||||
{
|
||||
return DB::transaction(function () use ($data) {
|
||||
$tenantId = session('selected_tenant_id');
|
||||
$userId = auth()->id();
|
||||
|
||||
$approval = Approval::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'document_number' => $this->generateDocumentNumber($tenantId),
|
||||
'form_id' => $data['form_id'],
|
||||
'line_id' => $data['line_id'] ?? null,
|
||||
'title' => $data['title'],
|
||||
'content' => $data['content'] ?? [],
|
||||
'body' => $data['body'] ?? null,
|
||||
'status' => Approval::STATUS_DRAFT,
|
||||
'is_urgent' => $data['is_urgent'] ?? false,
|
||||
'drafter_id' => $userId,
|
||||
'department_id' => $data['department_id'] ?? null,
|
||||
'current_step' => 0,
|
||||
'created_by' => $userId,
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
|
||||
if (! empty($data['steps'])) {
|
||||
$this->saveApprovalSteps($approval, $data['steps']);
|
||||
}
|
||||
|
||||
return $approval->load(['form', 'drafter', 'steps.approver']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 수정 (draft/rejected만)
|
||||
*/
|
||||
public function updateApproval(int $id, array $data): Approval
|
||||
{
|
||||
return DB::transaction(function () use ($id, $data) {
|
||||
$approval = Approval::findOrFail($id);
|
||||
|
||||
if (! $approval->isEditable()) {
|
||||
throw new \InvalidArgumentException('수정할 수 없는 상태입니다.');
|
||||
}
|
||||
|
||||
$approval->update([
|
||||
'form_id' => $data['form_id'] ?? $approval->form_id,
|
||||
'line_id' => $data['line_id'] ?? $approval->line_id,
|
||||
'title' => $data['title'] ?? $approval->title,
|
||||
'content' => $data['content'] ?? $approval->content,
|
||||
'body' => $data['body'] ?? $approval->body,
|
||||
'is_urgent' => $data['is_urgent'] ?? $approval->is_urgent,
|
||||
'department_id' => $data['department_id'] ?? $approval->department_id,
|
||||
'updated_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
if (isset($data['steps'])) {
|
||||
$approval->steps()->delete();
|
||||
$this->saveApprovalSteps($approval, $data['steps']);
|
||||
}
|
||||
|
||||
return $approval->load(['form', 'drafter', 'steps.approver']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 삭제 (draft만)
|
||||
*/
|
||||
public function deleteApproval(int $id): bool
|
||||
{
|
||||
$approval = Approval::findOrFail($id);
|
||||
|
||||
if (! $approval->isDeletable()) {
|
||||
throw new \InvalidArgumentException('삭제할 수 없는 상태입니다.');
|
||||
}
|
||||
|
||||
$approval->steps()->delete();
|
||||
$approval->update(['deleted_by' => auth()->id()]);
|
||||
|
||||
return $approval->delete();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 워크플로우
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 상신 (draft/rejected → pending)
|
||||
*/
|
||||
public function submit(int $id): Approval
|
||||
{
|
||||
return DB::transaction(function () use ($id) {
|
||||
$approval = Approval::with('steps')->findOrFail($id);
|
||||
|
||||
if (! $approval->isSubmittable()) {
|
||||
throw new \InvalidArgumentException('상신할 수 없는 상태입니다.');
|
||||
}
|
||||
|
||||
$approverSteps = $approval->steps
|
||||
->whereIn('step_type', [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT]);
|
||||
|
||||
if ($approverSteps->isEmpty()) {
|
||||
throw new \InvalidArgumentException('결재선을 설정해주세요.');
|
||||
}
|
||||
|
||||
// 반려 후 재상신이면 모든 step 초기화
|
||||
if ($approval->status === Approval::STATUS_REJECTED) {
|
||||
$approval->steps()->update([
|
||||
'status' => ApprovalStep::STATUS_PENDING,
|
||||
'comment' => null,
|
||||
'acted_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
$approval->update([
|
||||
'status' => Approval::STATUS_PENDING,
|
||||
'drafted_at' => now(),
|
||||
'current_step' => 1,
|
||||
'updated_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
return $approval->fresh(['form', 'drafter', 'steps.approver']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 승인
|
||||
*/
|
||||
public function approve(int $id, ?string $comment = null): Approval
|
||||
{
|
||||
return DB::transaction(function () use ($id, $comment) {
|
||||
$approval = Approval::with('steps')->findOrFail($id);
|
||||
|
||||
if (! $approval->isActionable()) {
|
||||
throw new \InvalidArgumentException('승인할 수 없는 상태입니다.');
|
||||
}
|
||||
|
||||
$currentStep = $approval->getCurrentApproverStep();
|
||||
if (! $currentStep || $currentStep->approver_id !== auth()->id()) {
|
||||
throw new \InvalidArgumentException('현재 결재자가 아닙니다.');
|
||||
}
|
||||
|
||||
$currentStep->update([
|
||||
'status' => ApprovalStep::STATUS_APPROVED,
|
||||
'comment' => $comment,
|
||||
'acted_at' => now(),
|
||||
]);
|
||||
|
||||
// 다음 결재자 확인
|
||||
$nextStep = $approval->steps()
|
||||
->whereIn('step_type', [ApprovalLine::STEP_TYPE_APPROVAL, ApprovalLine::STEP_TYPE_AGREEMENT])
|
||||
->where('status', ApprovalStep::STATUS_PENDING)
|
||||
->orderBy('step_order')
|
||||
->first();
|
||||
|
||||
if ($nextStep) {
|
||||
$approval->update([
|
||||
'current_step' => $nextStep->step_order,
|
||||
'updated_by' => auth()->id(),
|
||||
]);
|
||||
} else {
|
||||
// 마지막 결재자 → 문서 승인 완료
|
||||
$approval->update([
|
||||
'status' => Approval::STATUS_APPROVED,
|
||||
'completed_at' => now(),
|
||||
'updated_by' => auth()->id(),
|
||||
]);
|
||||
}
|
||||
|
||||
return $approval->fresh(['form', 'drafter', 'steps.approver']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 반려
|
||||
*/
|
||||
public function reject(int $id, string $comment): Approval
|
||||
{
|
||||
return DB::transaction(function () use ($id, $comment) {
|
||||
$approval = Approval::with('steps')->findOrFail($id);
|
||||
|
||||
if (! $approval->isActionable()) {
|
||||
throw new \InvalidArgumentException('반려할 수 없는 상태입니다.');
|
||||
}
|
||||
|
||||
$currentStep = $approval->getCurrentApproverStep();
|
||||
if (! $currentStep || $currentStep->approver_id !== auth()->id()) {
|
||||
throw new \InvalidArgumentException('현재 결재자가 아닙니다.');
|
||||
}
|
||||
|
||||
if (empty($comment)) {
|
||||
throw new \InvalidArgumentException('반려 사유를 입력해주세요.');
|
||||
}
|
||||
|
||||
$currentStep->update([
|
||||
'status' => ApprovalStep::STATUS_REJECTED,
|
||||
'comment' => $comment,
|
||||
'acted_at' => now(),
|
||||
]);
|
||||
|
||||
$approval->update([
|
||||
'status' => Approval::STATUS_REJECTED,
|
||||
'completed_at' => now(),
|
||||
'updated_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
return $approval->fresh(['form', 'drafter', 'steps.approver']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 회수 (기안자만, pending → cancelled)
|
||||
*/
|
||||
public function cancel(int $id): Approval
|
||||
{
|
||||
return DB::transaction(function () use ($id) {
|
||||
$approval = Approval::with('steps')->findOrFail($id);
|
||||
|
||||
if (! $approval->isCancellable()) {
|
||||
throw new \InvalidArgumentException('회수할 수 없는 상태입니다.');
|
||||
}
|
||||
|
||||
if ($approval->drafter_id !== auth()->id()) {
|
||||
throw new \InvalidArgumentException('기안자만 회수할 수 있습니다.');
|
||||
}
|
||||
|
||||
// 모든 pending steps → skipped
|
||||
$approval->steps()
|
||||
->where('status', ApprovalStep::STATUS_PENDING)
|
||||
->update(['status' => ApprovalStep::STATUS_SKIPPED]);
|
||||
|
||||
$approval->update([
|
||||
'status' => Approval::STATUS_CANCELLED,
|
||||
'completed_at' => now(),
|
||||
'updated_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
return $approval->fresh(['form', 'drafter', 'steps.approver']);
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 결재선
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 결재선 템플릿 목록
|
||||
*/
|
||||
public function getApprovalLines(): Collection
|
||||
{
|
||||
return ApprovalLine::orderBy('name')->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* 양식 목록
|
||||
*/
|
||||
public function getApprovalForms(): Collection
|
||||
{
|
||||
return ApprovalForm::active()->orderBy('name')->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 단계 저장 + 스냅샷
|
||||
*/
|
||||
public function saveApprovalSteps(Approval $approval, array $steps): void
|
||||
{
|
||||
foreach ($steps as $index => $step) {
|
||||
$user = User::find($step['user_id']);
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
$departmentName = null;
|
||||
$positionName = null;
|
||||
|
||||
if ($user) {
|
||||
$employee = $user->tenantProfiles()
|
||||
->where('tenant_id', $tenantId)
|
||||
->first();
|
||||
|
||||
if ($employee) {
|
||||
$departmentName = $employee->department?->name;
|
||||
$positionName = $employee->position_label;
|
||||
}
|
||||
}
|
||||
|
||||
ApprovalStep::create([
|
||||
'approval_id' => $approval->id,
|
||||
'step_order' => $index + 1,
|
||||
'step_type' => $step['step_type'] ?? ApprovalLine::STEP_TYPE_APPROVAL,
|
||||
'approver_id' => $step['user_id'],
|
||||
'approver_name' => $user?->name ?? '',
|
||||
'approver_department' => $departmentName,
|
||||
'approver_position' => $positionName,
|
||||
'status' => ApprovalStep::STATUS_PENDING,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 미처리 건수 (뱃지용)
|
||||
*/
|
||||
public function getBadgeCounts(int $userId): array
|
||||
{
|
||||
$pendingCount = Approval::pending()
|
||||
->whereHas('steps', function ($q) use ($userId) {
|
||||
$q->where('approver_id', $userId)
|
||||
->where('status', ApprovalStep::STATUS_PENDING)
|
||||
->approvalOnly()
|
||||
->whereColumn('step_order', function ($sub) {
|
||||
$sub->selectRaw('MIN(step_order)')
|
||||
->from('approval_steps as inner_steps')
|
||||
->whereColumn('inner_steps.approval_id', 'approval_steps.approval_id')
|
||||
->where('inner_steps.status', ApprovalStep::STATUS_PENDING)
|
||||
->whereIn('inner_steps.step_type', [
|
||||
ApprovalLine::STEP_TYPE_APPROVAL,
|
||||
ApprovalLine::STEP_TYPE_AGREEMENT,
|
||||
]);
|
||||
});
|
||||
})
|
||||
->count();
|
||||
|
||||
$draftCount = Approval::draft()->byDrafter($userId)->count();
|
||||
|
||||
return [
|
||||
'pending' => $pendingCount,
|
||||
'draft' => $draftCount,
|
||||
];
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Private 헬퍼
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 문서번호 채번 (APR-YYMMDD-001)
|
||||
*/
|
||||
private function generateDocumentNumber(int $tenantId): string
|
||||
{
|
||||
$prefix = 'APR';
|
||||
$dateKey = now()->format('ymd');
|
||||
$documentType = 'approval';
|
||||
$periodKey = $dateKey;
|
||||
|
||||
DB::statement(
|
||||
'INSERT INTO numbering_sequences
|
||||
(tenant_id, document_type, scope_key, period_key, last_sequence, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 1, NOW(), NOW())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
last_sequence = last_sequence + 1,
|
||||
updated_at = NOW()',
|
||||
[$tenantId, $documentType, '', $periodKey]
|
||||
);
|
||||
|
||||
$sequence = (int) DB::table('numbering_sequences')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('document_type', $documentType)
|
||||
->where('scope_key', '')
|
||||
->where('period_key', $periodKey)
|
||||
->value('last_sequence');
|
||||
|
||||
return $prefix.'-'.$dateKey.'-'.str_pad((string) $sequence, 3, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
/**
|
||||
* 공통 필터 적용
|
||||
*/
|
||||
private function applyFilters($query, array $filters): void
|
||||
{
|
||||
if (! empty($filters['search'])) {
|
||||
$search = $filters['search'];
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('title', 'like', "%{$search}%")
|
||||
->orWhere('document_number', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
if (! empty($filters['status'])) {
|
||||
$query->where('status', $filters['status']);
|
||||
}
|
||||
|
||||
if (! empty($filters['is_urgent'])) {
|
||||
$query->where('is_urgent', true);
|
||||
}
|
||||
|
||||
if (! empty($filters['date_from'])) {
|
||||
$query->where('created_at', '>=', $filters['date_from']);
|
||||
}
|
||||
|
||||
if (! empty($filters['date_to'])) {
|
||||
$query->where('created_at', '<=', $filters['date_to'].' 23:59:59');
|
||||
}
|
||||
}
|
||||
}
|
||||
122
resources/views/approvals/completed.blade.php
Normal file
122
resources/views/approvals/completed.blade.php
Normal file
@@ -0,0 +1,122 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', '완료함')
|
||||
|
||||
@section('content')
|
||||
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-800">완료함</h1>
|
||||
</div>
|
||||
|
||||
<x-filter-collapsible id="filterForm">
|
||||
<form id="filterForm" class="flex flex-wrap gap-2 sm:gap-4">
|
||||
<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-36">
|
||||
<select name="status" 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="approved">승인</option>
|
||||
<option value="rejected">반려</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>
|
||||
|
||||
<div id="approval-table" class="bg-white rounded-lg shadow-sm overflow-hidden">
|
||||
<div class="flex justify-center items-center p-12">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="pagination-area" class="mt-4"></div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadCompleted();
|
||||
document.getElementById('filterForm').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
loadCompleted();
|
||||
});
|
||||
});
|
||||
|
||||
function loadCompleted(page = 1) {
|
||||
const form = document.getElementById('filterForm');
|
||||
const params = new URLSearchParams(new FormData(form));
|
||||
params.set('page', page);
|
||||
params.set('per_page', 15);
|
||||
|
||||
fetch(`/api/admin/approvals/completed?${params}`, {
|
||||
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' }
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => renderTable(data.data || [], data))
|
||||
.catch(() => {
|
||||
document.getElementById('approval-table').innerHTML = '<div class="p-8 text-center text-gray-500">데이터를 불러올 수 없습니다.</div>';
|
||||
});
|
||||
}
|
||||
|
||||
function renderTable(items, pagination) {
|
||||
const container = document.getElementById('approval-table');
|
||||
|
||||
if (!items.length) {
|
||||
container.innerHTML = '<div class="p-8 text-center text-gray-500">처리한 결재 문서가 없습니다.</div>';
|
||||
document.getElementById('pagination-area').innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const statusBadge = (status) => {
|
||||
const map = {
|
||||
approved: '<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700">완료</span>',
|
||||
rejected: '<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-700">반려</span>',
|
||||
pending: '<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-700">진행</span>',
|
||||
cancelled: '<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-700">회수</span>',
|
||||
};
|
||||
return map[status] || status;
|
||||
};
|
||||
|
||||
let html = `<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">문서번호</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">제목</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">기안자</th>
|
||||
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase">상태</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">완료일</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">`;
|
||||
|
||||
items.forEach(item => {
|
||||
const completedAt = item.completed_at ? new Date(item.completed_at).toLocaleDateString('ko-KR') : '-';
|
||||
|
||||
html += `<tr class="hover:bg-gray-50 cursor-pointer" onclick="location.href='/approval-mgmt/${item.id}'">
|
||||
<td class="px-4 py-3 text-sm text-gray-600 whitespace-nowrap">${item.document_number || '-'}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-800 font-medium">${item.title || '-'}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-600">${item.drafter?.name || '-'}</td>
|
||||
<td class="px-4 py-3 text-center">${statusBadge(item.status)}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-500 whitespace-nowrap">${completedAt}</td>
|
||||
</tr>`;
|
||||
});
|
||||
|
||||
html += '</tbody></table></div>';
|
||||
container.innerHTML = html;
|
||||
|
||||
const area = document.getElementById('pagination-area');
|
||||
if (!pagination.last_page || pagination.last_page <= 1) { area.innerHTML = ''; return; }
|
||||
let pHtml = '<div class="flex justify-center gap-1">';
|
||||
for (let i = 1; i <= pagination.last_page; i++) {
|
||||
const active = i === pagination.current_page ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-100';
|
||||
pHtml += `<button onclick="loadCompleted(${i})" class="px-3 py-1 rounded border text-sm ${active}">${i}</button>`;
|
||||
}
|
||||
pHtml += '</div>';
|
||||
area.innerHTML = pHtml;
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
147
resources/views/approvals/create.blade.php
Normal file
147
resources/views/approvals/create.blade.php
Normal file
@@ -0,0 +1,147 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', '기안 작성')
|
||||
|
||||
@section('content')
|
||||
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-800">기안 작성</h1>
|
||||
<a href="{{ route('approvals.drafts') }}" class="text-gray-600 hover:text-gray-800 text-sm">
|
||||
← 기안함으로 돌아가기
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col lg:flex-row gap-6">
|
||||
{{-- 좌측: 양식 --}}
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-800 mb-4">문서 내용</h2>
|
||||
|
||||
{{-- 양식 선택 --}}
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">양식 <span class="text-red-500">*</span></label>
|
||||
<select id="form_id" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
@foreach($forms as $form)
|
||||
<option value="{{ $form->id }}">{{ $form->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{{-- 제목 --}}
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">제목 <span class="text-red-500">*</span></label>
|
||||
<input type="text" id="title" maxlength="200" placeholder="결재 제목을 입력하세요"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
|
||||
{{-- 긴급 여부 --}}
|
||||
<div class="mb-4">
|
||||
<label class="inline-flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" id="is_urgent" class="rounded border-gray-300 text-red-600 focus:ring-red-500">
|
||||
<span class="text-sm text-gray-700">긴급</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{{-- 본문 --}}
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">본문</label>
|
||||
<textarea id="body" rows="12" placeholder="기안 내용을 입력하세요..."
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
style="min-height: 300px;"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 우측: 결재선 --}}
|
||||
<div class="shrink-0" style="width: 100%; max-width: 380px;">
|
||||
@include('approvals.partials._approval-line-editor', [
|
||||
'lines' => $lines,
|
||||
'initialSteps' => [],
|
||||
'selectedLineId' => '',
|
||||
])
|
||||
|
||||
{{-- 액션 버튼 --}}
|
||||
<div class="mt-4 space-y-2">
|
||||
<button onclick="saveApproval('draft')"
|
||||
class="w-full bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-lg transition text-sm font-medium">
|
||||
임시저장
|
||||
</button>
|
||||
<button onclick="saveApproval('submit')"
|
||||
class="w-full bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition text-sm font-medium">
|
||||
상신
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
async function saveApproval(action) {
|
||||
const title = document.getElementById('title').value.trim();
|
||||
if (!title) {
|
||||
showToast('제목을 입력해주세요.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const editor = document.querySelector('[x-data]').__x.$data;
|
||||
const steps = editor.getStepsData();
|
||||
|
||||
if (action === 'submit' && steps.filter(s => s.step_type !== 'reference').length === 0) {
|
||||
showToast('결재자를 1명 이상 추가해주세요.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
form_id: document.getElementById('form_id').value,
|
||||
title: title,
|
||||
body: document.getElementById('body').value,
|
||||
is_urgent: document.getElementById('is_urgent').checked,
|
||||
steps: steps,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/approvals', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.success && !data.data) {
|
||||
showToast(data.message || '저장에 실패했습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'submit') {
|
||||
const submitResponse = await fetch(`/api/admin/approvals/${data.data.id}/submit`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const submitData = await submitResponse.json();
|
||||
|
||||
if (submitData.success) {
|
||||
showToast('결재가 상신되었습니다.', 'success');
|
||||
setTimeout(() => location.href = '/approval-mgmt/drafts', 500);
|
||||
} else {
|
||||
showToast(submitData.message || '상신에 실패했습니다.', 'error');
|
||||
}
|
||||
} else {
|
||||
showToast('임시저장되었습니다.', 'success');
|
||||
setTimeout(() => location.href = `/approval-mgmt/${data.data.id}/edit`, 500);
|
||||
}
|
||||
} catch (e) {
|
||||
showToast('서버 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
169
resources/views/approvals/drafts.blade.php
Normal file
169
resources/views/approvals/drafts.blade.php
Normal file
@@ -0,0 +1,169 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', '기안함')
|
||||
|
||||
@section('content')
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-800">기안함</h1>
|
||||
<a href="{{ route('approvals.create') }}" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition text-center w-full sm:w-auto">
|
||||
+ 새 기안
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 필터 영역 -->
|
||||
<x-filter-collapsible id="filterForm">
|
||||
<form id="filterForm" class="flex flex-wrap gap-2 sm:gap-4">
|
||||
<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-36">
|
||||
<select name="status" 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="draft">임시저장</option>
|
||||
<option value="pending">진행</option>
|
||||
<option value="approved">완료</option>
|
||||
<option value="rejected">반려</option>
|
||||
<option value="cancelled">회수</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>
|
||||
|
||||
<!-- 테이블 영역 -->
|
||||
<div id="approval-table" class="bg-white rounded-lg shadow-sm overflow-hidden">
|
||||
<div class="flex justify-center items-center p-12">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 페이지네이션 -->
|
||||
<div id="pagination-area" class="mt-4"></div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadDrafts();
|
||||
|
||||
document.getElementById('filterForm').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
loadDrafts();
|
||||
});
|
||||
});
|
||||
|
||||
function loadDrafts(page = 1) {
|
||||
const form = document.getElementById('filterForm');
|
||||
const params = new URLSearchParams(new FormData(form));
|
||||
params.set('page', page);
|
||||
params.set('per_page', 15);
|
||||
|
||||
fetch(`/api/admin/approvals/drafts?${params}`, {
|
||||
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' }
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
renderTable(data.data || [], data);
|
||||
})
|
||||
.catch(() => {
|
||||
document.getElementById('approval-table').innerHTML = '<div class="p-8 text-center text-gray-500">데이터를 불러올 수 없습니다.</div>';
|
||||
});
|
||||
}
|
||||
|
||||
function renderTable(items, pagination) {
|
||||
const container = document.getElementById('approval-table');
|
||||
|
||||
if (!items.length) {
|
||||
container.innerHTML = '<div class="p-8 text-center text-gray-500">기안 문서가 없습니다.</div>';
|
||||
document.getElementById('pagination-area').innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const statusBadge = (status) => {
|
||||
const map = {
|
||||
draft: '<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-700">임시저장</span>',
|
||||
pending: '<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-700">진행</span>',
|
||||
approved: '<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700">완료</span>',
|
||||
rejected: '<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-700">반려</span>',
|
||||
cancelled: '<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-700">회수</span>',
|
||||
};
|
||||
return map[status] || status;
|
||||
};
|
||||
|
||||
let html = `<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">문서번호</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">제목</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">양식</th>
|
||||
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase">상태</th>
|
||||
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase">긴급</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">작성일</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">`;
|
||||
|
||||
items.forEach(item => {
|
||||
const createdAt = item.created_at ? new Date(item.created_at).toLocaleDateString('ko-KR') : '-';
|
||||
const urgent = item.is_urgent ? '<span class="text-red-500 font-bold text-xs">긴급</span>' : '';
|
||||
const url = item.status === 'draft' || item.status === 'rejected'
|
||||
? `/approval-mgmt/${item.id}/edit`
|
||||
: `/approval-mgmt/${item.id}`;
|
||||
|
||||
html += `<tr class="hover:bg-gray-50 cursor-pointer" onclick="location.href='${url}'">
|
||||
<td class="px-4 py-3 text-sm text-gray-600 whitespace-nowrap">${item.document_number || '-'}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-800 font-medium">${item.title || '-'}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-600">${item.form?.name || '-'}</td>
|
||||
<td class="px-4 py-3 text-center">${statusBadge(item.status)}</td>
|
||||
<td class="px-4 py-3 text-center">${urgent}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-500 whitespace-nowrap">${createdAt}</td>
|
||||
</tr>`;
|
||||
});
|
||||
|
||||
html += '</tbody></table></div>';
|
||||
container.innerHTML = html;
|
||||
|
||||
// 페이지네이션
|
||||
renderPagination(pagination);
|
||||
}
|
||||
|
||||
function renderPagination(data) {
|
||||
const area = document.getElementById('pagination-area');
|
||||
if (!data.last_page || data.last_page <= 1) {
|
||||
area.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<div class="flex justify-center gap-1">';
|
||||
for (let i = 1; i <= data.last_page; i++) {
|
||||
const active = i === data.current_page ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-100';
|
||||
html += `<button onclick="loadDrafts(${i})" class="px-3 py-1 rounded border text-sm ${active}">${i}</button>`;
|
||||
}
|
||||
html += '</div>';
|
||||
area.innerHTML = html;
|
||||
}
|
||||
|
||||
function confirmDelete(id, title) {
|
||||
if (!confirm(`"${title}" 문서를 삭제하시겠습니까?`)) return;
|
||||
|
||||
fetch(`/api/admin/approvals/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRF-TOKEN': '{{ csrf_token() }}', 'Accept': 'application/json' }
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
showToast(data.message, 'success');
|
||||
loadDrafts();
|
||||
} else {
|
||||
showToast(data.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
207
resources/views/approvals/edit.blade.php
Normal file
207
resources/views/approvals/edit.blade.php
Normal file
@@ -0,0 +1,207 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', '기안 수정')
|
||||
|
||||
@section('content')
|
||||
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-800">기안 수정</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">{{ $approval->document_number }}</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ route('approvals.show', $approval->id) }}" class="text-gray-600 hover:text-gray-800 text-sm px-3 py-2 border rounded-lg">
|
||||
상세보기
|
||||
</a>
|
||||
<a href="{{ route('approvals.drafts') }}" class="text-gray-600 hover:text-gray-800 text-sm px-3 py-2 border rounded-lg">
|
||||
기안함
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if($approval->status === 'rejected')
|
||||
<div class="bg-red-50 border-l-4 border-red-400 p-4 mb-6 rounded">
|
||||
<div class="flex items-center">
|
||||
<span class="text-red-700 font-medium">반려됨</span>
|
||||
</div>
|
||||
@php
|
||||
$rejectedStep = $approval->steps->firstWhere('status', 'rejected');
|
||||
@endphp
|
||||
@if($rejectedStep)
|
||||
<p class="text-sm text-red-600 mt-1">
|
||||
{{ $rejectedStep->approver_name ?? '' }} ({{ $rejectedStep->acted_at?->format('Y-m-d H:i') }}):
|
||||
{{ $rejectedStep->comment }}
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="flex flex-col lg:flex-row gap-6">
|
||||
{{-- 좌측: 양식 --}}
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-800 mb-4">문서 내용</h2>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">양식 <span class="text-red-500">*</span></label>
|
||||
<select id="form_id" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
@foreach($forms as $form)
|
||||
<option value="{{ $form->id }}" {{ $approval->form_id == $form->id ? 'selected' : '' }}>{{ $form->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">제목 <span class="text-red-500">*</span></label>
|
||||
<input type="text" id="title" maxlength="200" value="{{ $approval->title }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="inline-flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" id="is_urgent" {{ $approval->is_urgent ? 'checked' : '' }}
|
||||
class="rounded border-gray-300 text-red-600 focus:ring-red-500">
|
||||
<span class="text-sm text-gray-700">긴급</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">본문</label>
|
||||
<textarea id="body" rows="12"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
style="min-height: 300px;">{{ $approval->body }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 우측: 결재선 --}}
|
||||
<div class="shrink-0" style="width: 100%; max-width: 380px;">
|
||||
@php
|
||||
$initialSteps = $approval->steps->map(fn($s) => [
|
||||
'user_id' => $s->approver_id,
|
||||
'user_name' => $s->approver_name ?? ($s->approver?->name ?? ''),
|
||||
'department' => $s->approver_department ?? '',
|
||||
'position' => $s->approver_position ?? '',
|
||||
'step_type' => $s->step_type,
|
||||
])->toArray();
|
||||
@endphp
|
||||
@include('approvals.partials._approval-line-editor', [
|
||||
'lines' => $lines,
|
||||
'initialSteps' => $initialSteps,
|
||||
'selectedLineId' => $approval->line_id ?? '',
|
||||
])
|
||||
|
||||
<div class="mt-4 space-y-2">
|
||||
<button onclick="updateApproval('save')"
|
||||
class="w-full bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-lg transition text-sm font-medium">
|
||||
저장
|
||||
</button>
|
||||
<button onclick="updateApproval('submit')"
|
||||
class="w-full bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition text-sm font-medium">
|
||||
{{ $approval->status === 'rejected' ? '재상신' : '상신' }}
|
||||
</button>
|
||||
@if($approval->isDeletable())
|
||||
<button onclick="deleteApproval()"
|
||||
class="w-full bg-red-100 hover:bg-red-200 text-red-700 px-4 py-2 rounded-lg transition text-sm font-medium">
|
||||
삭제
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
async function updateApproval(action) {
|
||||
const title = document.getElementById('title').value.trim();
|
||||
if (!title) {
|
||||
showToast('제목을 입력해주세요.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const editor = document.querySelector('[x-data]').__x.$data;
|
||||
const steps = editor.getStepsData();
|
||||
|
||||
if (action === 'submit' && steps.filter(s => s.step_type !== 'reference').length === 0) {
|
||||
showToast('결재자를 1명 이상 추가해주세요.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
form_id: document.getElementById('form_id').value,
|
||||
title: title,
|
||||
body: document.getElementById('body').value,
|
||||
is_urgent: document.getElementById('is_urgent').checked,
|
||||
steps: steps,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/approvals/{{ $approval->id }}', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.success) {
|
||||
showToast(data.message || '저장에 실패했습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'submit') {
|
||||
const submitResponse = await fetch('/api/admin/approvals/{{ $approval->id }}/submit', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const submitData = await submitResponse.json();
|
||||
|
||||
if (submitData.success) {
|
||||
showToast('결재가 상신되었습니다.', 'success');
|
||||
setTimeout(() => location.href = '/approval-mgmt/drafts', 500);
|
||||
} else {
|
||||
showToast(submitData.message || '상신에 실패했습니다.', 'error');
|
||||
}
|
||||
} else {
|
||||
showToast('저장되었습니다.', 'success');
|
||||
}
|
||||
} catch (e) {
|
||||
showToast('서버 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteApproval() {
|
||||
if (!confirm('이 문서를 삭제하시겠습니까?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/approvals/{{ $approval->id }}', {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
showToast('삭제되었습니다.', 'success');
|
||||
setTimeout(() => location.href = '/approval-mgmt/drafts', 500);
|
||||
} else {
|
||||
showToast(data.message || '삭제에 실패했습니다.', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showToast('서버 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
@@ -0,0 +1,172 @@
|
||||
{{-- 결재선 편집 컴포넌트 (Alpine.js) --}}
|
||||
<div x-data="approvalLineEditor()" class="bg-white rounded-lg shadow-sm p-4">
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-4">결재선</h3>
|
||||
|
||||
{{-- 결재선 템플릿 선택 --}}
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">결재선 템플릿</label>
|
||||
<select x-model="selectedLineId" @change="loadLine()" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="">직접 설정</option>
|
||||
<template x-for="line in lines" :key="line.id">
|
||||
<option :value="line.id" x-text="line.name + ' (' + (line.steps?.length || 0) + '단계)'"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{{-- 결재자 추가 --}}
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">결재자 검색</label>
|
||||
<div class="relative">
|
||||
<input type="text"
|
||||
x-model="searchQuery"
|
||||
@input.debounce.300ms="searchUsers()"
|
||||
placeholder="이름 또는 부서로 검색..."
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
|
||||
{{-- 검색 결과 드롭다운 --}}
|
||||
<div x-show="searchResults.length > 0" x-cloak
|
||||
@click.away="searchResults = []"
|
||||
class="absolute z-10 w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg max-h-48 overflow-y-auto">
|
||||
<template x-for="user in searchResults" :key="user.id">
|
||||
<button type="button"
|
||||
@click="addStep(user)"
|
||||
class="w-full text-left px-3 py-2 hover:bg-blue-50 text-sm flex justify-between items-center">
|
||||
<span>
|
||||
<span x-text="user.name" class="font-medium"></span>
|
||||
<span x-text="user.department || ''" class="text-gray-500 ml-1"></span>
|
||||
</span>
|
||||
<span x-text="user.position || ''" class="text-xs text-gray-400"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 결재 단계 목록 --}}
|
||||
<div class="space-y-2">
|
||||
<template x-for="(step, index) in steps" :key="index">
|
||||
<div class="flex items-center gap-2 p-3 bg-gray-50 rounded-lg">
|
||||
<span class="shrink-0 flex items-center justify-center bg-blue-100 text-blue-700 rounded-full font-medium text-xs" style="width: 24px; height: 24px;"
|
||||
x-text="index + 1"></span>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="font-medium text-sm" x-text="step.user_name"></span>
|
||||
<span class="text-xs text-gray-500" x-text="step.department || ''"></span>
|
||||
</div>
|
||||
<span class="text-xs text-gray-400" x-text="step.position || ''"></span>
|
||||
</div>
|
||||
|
||||
<select x-model="step.step_type" class="shrink-0 px-2 py-1 border border-gray-300 rounded text-xs focus:outline-none">
|
||||
<option value="approval">결재</option>
|
||||
<option value="agreement">합의</option>
|
||||
<option value="reference">참조</option>
|
||||
</select>
|
||||
|
||||
{{-- 순서 이동 --}}
|
||||
<button type="button" @click="moveStep(index, -1)" :disabled="index === 0"
|
||||
class="shrink-0 p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30">
|
||||
<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 15l7-7 7 7"/></svg>
|
||||
</button>
|
||||
<button type="button" @click="moveStep(index, 1)" :disabled="index === steps.length - 1"
|
||||
class="shrink-0 p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
|
||||
</button>
|
||||
|
||||
{{-- 삭제 --}}
|
||||
<button type="button" @click="removeStep(index)"
|
||||
class="shrink-0 p-1 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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div x-show="steps.length === 0" class="text-center py-8 text-gray-400 text-sm">
|
||||
결재선이 비어 있습니다. 결재자를 추가해주세요.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- hidden inputs --}}
|
||||
<template x-for="(step, index) in steps" :key="'hidden-' + index">
|
||||
<div>
|
||||
<input type="hidden" :name="'steps[' + index + '][user_id]'" :value="step.user_id">
|
||||
<input type="hidden" :name="'steps[' + index + '][step_type]'" :value="step.step_type">
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function approvalLineEditor() {
|
||||
return {
|
||||
lines: @json($lines ?? []),
|
||||
steps: @json($initialSteps ?? []),
|
||||
selectedLineId: '{{ $selectedLineId ?? '' }}',
|
||||
searchQuery: '',
|
||||
searchResults: [],
|
||||
|
||||
loadLine() {
|
||||
if (!this.selectedLineId) return;
|
||||
const line = this.lines.find(l => l.id == this.selectedLineId);
|
||||
if (line && line.steps) {
|
||||
this.steps = line.steps.map(s => ({
|
||||
user_id: s.user_id,
|
||||
user_name: s.user_name || '사용자 ' + s.user_id,
|
||||
department: s.department || '',
|
||||
position: s.position || '',
|
||||
step_type: s.step_type || 'approval',
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
async searchUsers() {
|
||||
if (this.searchQuery.length < 1) {
|
||||
this.searchResults = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`/api/admin/tenant-users/search?q=${encodeURIComponent(this.searchQuery)}`);
|
||||
const data = await response.json();
|
||||
this.searchResults = (data.data || data).slice(0, 10);
|
||||
} catch (e) {
|
||||
this.searchResults = [];
|
||||
}
|
||||
},
|
||||
|
||||
addStep(user) {
|
||||
if (this.steps.find(s => s.user_id === user.id)) {
|
||||
showToast('이미 추가된 결재자입니다.', 'warning');
|
||||
return;
|
||||
}
|
||||
this.steps.push({
|
||||
user_id: user.id,
|
||||
user_name: user.name,
|
||||
department: user.department || '',
|
||||
position: user.position || '',
|
||||
step_type: 'approval',
|
||||
});
|
||||
this.searchQuery = '';
|
||||
this.searchResults = [];
|
||||
},
|
||||
|
||||
removeStep(index) {
|
||||
this.steps.splice(index, 1);
|
||||
},
|
||||
|
||||
moveStep(index, direction) {
|
||||
const newIndex = index + direction;
|
||||
if (newIndex < 0 || newIndex >= this.steps.length) return;
|
||||
const temp = this.steps[index];
|
||||
this.steps[index] = this.steps[newIndex];
|
||||
this.steps[newIndex] = temp;
|
||||
this.steps = [...this.steps];
|
||||
},
|
||||
|
||||
getStepsData() {
|
||||
return this.steps.map((s, i) => ({
|
||||
user_id: s.user_id,
|
||||
step_type: s.step_type,
|
||||
}));
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
16
resources/views/approvals/partials/_status-badge.blade.php
Normal file
16
resources/views/approvals/partials/_status-badge.blade.php
Normal file
@@ -0,0 +1,16 @@
|
||||
@props(['status' => 'draft'])
|
||||
|
||||
@php
|
||||
$config = match($status) {
|
||||
'draft' => ['label' => '임시저장', 'class' => 'bg-gray-100 text-gray-700'],
|
||||
'pending' => ['label' => '진행', 'class' => 'bg-blue-100 text-blue-700'],
|
||||
'approved' => ['label' => '완료', 'class' => 'bg-green-100 text-green-700'],
|
||||
'rejected' => ['label' => '반려', 'class' => 'bg-red-100 text-red-700'],
|
||||
'cancelled' => ['label' => '회수', 'class' => 'bg-yellow-100 text-yellow-700'],
|
||||
default => ['label' => $status, 'class' => 'bg-gray-100 text-gray-700'],
|
||||
};
|
||||
@endphp
|
||||
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {{ $config['class'] }}">
|
||||
{{ $config['label'] }}
|
||||
</span>
|
||||
55
resources/views/approvals/partials/_step-progress.blade.php
Normal file
55
resources/views/approvals/partials/_step-progress.blade.php
Normal file
@@ -0,0 +1,55 @@
|
||||
{{-- 결재 진행 단계 시각화 --}}
|
||||
@props(['steps' => [], 'currentStep' => 0])
|
||||
|
||||
<div class="flex items-center gap-1 overflow-x-auto py-4">
|
||||
@foreach($steps as $index => $step)
|
||||
@php
|
||||
$isApprover = in_array($step['step_type'] ?? 'approval', ['approval', 'agreement']);
|
||||
$statusConfig = match($step['status'] ?? 'pending') {
|
||||
'approved' => ['icon' => '✓', 'bg' => 'bg-green-500', 'border' => 'border-green-500', 'text' => 'text-green-700'],
|
||||
'rejected' => ['icon' => '✗', 'bg' => 'bg-red-500', 'border' => 'border-red-500', 'text' => 'text-red-700'],
|
||||
'skipped' => ['icon' => '—', 'bg' => 'bg-gray-400', 'border' => 'border-gray-400', 'text' => 'text-gray-500'],
|
||||
default => ['icon' => ($step['step_order'] ?? $index + 1), 'bg' => 'bg-white', 'border' => 'border-gray-300', 'text' => 'text-gray-500'],
|
||||
};
|
||||
$isCurrent = $isApprover && ($step['status'] ?? 'pending') === 'pending' && ($step['step_order'] ?? 0) == $currentStep;
|
||||
if ($isCurrent) {
|
||||
$statusConfig['bg'] = 'bg-blue-500';
|
||||
$statusConfig['border'] = 'border-blue-500';
|
||||
$statusConfig['text'] = 'text-blue-700';
|
||||
$statusConfig['icon'] = ($step['step_order'] ?? $index + 1);
|
||||
}
|
||||
$typeLabel = match($step['step_type'] ?? 'approval') {
|
||||
'approval' => '결재',
|
||||
'agreement' => '합의',
|
||||
'reference' => '참조',
|
||||
default => '',
|
||||
};
|
||||
@endphp
|
||||
|
||||
@if($index > 0)
|
||||
<div class="shrink-0" style="width: 24px; height: 2px; background-color: {{ in_array($step['status'] ?? 'pending', ['approved']) ? '#22c55e' : '#d1d5db' }};"></div>
|
||||
@endif
|
||||
|
||||
<div class="flex flex-col items-center shrink-0" style="min-width: 70px;">
|
||||
{{-- 원형 아이콘 --}}
|
||||
<div class="flex items-center justify-center rounded-full border-2 {{ $statusConfig['border'] }} {{ in_array($step['status'] ?? 'pending', ['approved', 'rejected', 'skipped']) || $isCurrent ? $statusConfig['bg'] . ' text-white' : $statusConfig['bg'] }}"
|
||||
style="width: 36px; height: 36px; font-size: 14px; font-weight: 600;">
|
||||
{!! $statusConfig['icon'] !!}
|
||||
</div>
|
||||
{{-- 결재자명 --}}
|
||||
<span class="text-xs mt-1 {{ $statusConfig['text'] }} font-medium whitespace-nowrap">
|
||||
{{ $step['approver_name'] ?? ($step['approver']['name'] ?? '미지정') }}
|
||||
</span>
|
||||
{{-- 유형 + 직급 --}}
|
||||
<span class="text-xs text-gray-400 whitespace-nowrap">
|
||||
{{ $typeLabel }}{{ !empty($step['approver_position']) ? ' · ' . $step['approver_position'] : '' }}
|
||||
</span>
|
||||
{{-- 처리일시 --}}
|
||||
@if(!empty($step['acted_at']))
|
||||
<span class="text-xs text-gray-400 whitespace-nowrap">
|
||||
{{ \Carbon\Carbon::parse($step['acted_at'])->format('m/d H:i') }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
109
resources/views/approvals/pending.blade.php
Normal file
109
resources/views/approvals/pending.blade.php
Normal file
@@ -0,0 +1,109 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', '결재함')
|
||||
|
||||
@section('content')
|
||||
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-800">결재함</h1>
|
||||
</div>
|
||||
|
||||
<x-filter-collapsible id="filterForm">
|
||||
<form id="filterForm" class="flex flex-wrap gap-2 sm:gap-4">
|
||||
<div class="flex-1 min-w-0 w-full sm:w-auto">
|
||||
<input type="text" name="search" placeholder="제목, 문서번호 검색..."
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
<button type="submit" class="bg-gray-600 hover:bg-gray-700 text-white px-6 py-2 rounded-lg transition w-full sm:w-auto">
|
||||
검색
|
||||
</button>
|
||||
</form>
|
||||
</x-filter-collapsible>
|
||||
|
||||
<div id="approval-table" class="bg-white rounded-lg shadow-sm overflow-hidden">
|
||||
<div class="flex justify-center items-center p-12">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="pagination-area" class="mt-4"></div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadPending();
|
||||
document.getElementById('filterForm').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
loadPending();
|
||||
});
|
||||
});
|
||||
|
||||
function loadPending(page = 1) {
|
||||
const form = document.getElementById('filterForm');
|
||||
const params = new URLSearchParams(new FormData(form));
|
||||
params.set('page', page);
|
||||
params.set('per_page', 15);
|
||||
|
||||
fetch(`/api/admin/approvals/pending?${params}`, {
|
||||
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' }
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => renderTable(data.data || [], data))
|
||||
.catch(() => {
|
||||
document.getElementById('approval-table').innerHTML = '<div class="p-8 text-center text-gray-500">데이터를 불러올 수 없습니다.</div>';
|
||||
});
|
||||
}
|
||||
|
||||
function renderTable(items, pagination) {
|
||||
const container = document.getElementById('approval-table');
|
||||
|
||||
if (!items.length) {
|
||||
container.innerHTML = '<div class="p-8 text-center text-gray-500">결재 대기 문서가 없습니다.</div>';
|
||||
document.getElementById('pagination-area').innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = `<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">문서번호</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">제목</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">기안자</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">양식</th>
|
||||
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase">긴급</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">기안일</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">`;
|
||||
|
||||
items.forEach(item => {
|
||||
const draftedAt = item.drafted_at ? new Date(item.drafted_at).toLocaleDateString('ko-KR') : '-';
|
||||
const urgent = item.is_urgent ? '<span class="text-red-500 font-bold text-xs">긴급</span>' : '';
|
||||
|
||||
html += `<tr class="hover:bg-gray-50 cursor-pointer" onclick="location.href='/approval-mgmt/${item.id}'">
|
||||
<td class="px-4 py-3 text-sm text-gray-600 whitespace-nowrap">${item.document_number || '-'}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-800 font-medium">${item.title || '-'}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-600">${item.drafter?.name || '-'}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-600">${item.form?.name || '-'}</td>
|
||||
<td class="px-4 py-3 text-center">${urgent}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-500 whitespace-nowrap">${draftedAt}</td>
|
||||
</tr>`;
|
||||
});
|
||||
|
||||
html += '</tbody></table></div>';
|
||||
container.innerHTML = html;
|
||||
|
||||
// 페이지네이션
|
||||
const area = document.getElementById('pagination-area');
|
||||
if (!pagination.last_page || pagination.last_page <= 1) { area.innerHTML = ''; return; }
|
||||
let pHtml = '<div class="flex justify-center gap-1">';
|
||||
for (let i = 1; i <= pagination.last_page; i++) {
|
||||
const active = i === pagination.current_page ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-100';
|
||||
pHtml += `<button onclick="loadPending(${i})" class="px-3 py-1 rounded border text-sm ${active}">${i}</button>`;
|
||||
}
|
||||
pHtml += '</div>';
|
||||
area.innerHTML = pHtml;
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
116
resources/views/approvals/references.blade.php
Normal file
116
resources/views/approvals/references.blade.php
Normal file
@@ -0,0 +1,116 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', '참조함')
|
||||
|
||||
@section('content')
|
||||
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-800">참조함</h1>
|
||||
</div>
|
||||
|
||||
<x-filter-collapsible id="filterForm">
|
||||
<form id="filterForm" class="flex flex-wrap gap-2 sm:gap-4">
|
||||
<div class="flex-1 min-w-0 w-full sm:w-auto">
|
||||
<input type="text" name="search" placeholder="제목, 문서번호 검색..."
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
<button type="submit" class="bg-gray-600 hover:bg-gray-700 text-white px-6 py-2 rounded-lg transition w-full sm:w-auto">
|
||||
검색
|
||||
</button>
|
||||
</form>
|
||||
</x-filter-collapsible>
|
||||
|
||||
<div id="approval-table" class="bg-white rounded-lg shadow-sm overflow-hidden">
|
||||
<div class="flex justify-center items-center p-12">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="pagination-area" class="mt-4"></div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadReferences();
|
||||
document.getElementById('filterForm').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
loadReferences();
|
||||
});
|
||||
});
|
||||
|
||||
function loadReferences(page = 1) {
|
||||
const form = document.getElementById('filterForm');
|
||||
const params = new URLSearchParams(new FormData(form));
|
||||
params.set('page', page);
|
||||
params.set('per_page', 15);
|
||||
|
||||
fetch(`/api/admin/approvals/references?${params}`, {
|
||||
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' }
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => renderTable(data.data || [], data))
|
||||
.catch(() => {
|
||||
document.getElementById('approval-table').innerHTML = '<div class="p-8 text-center text-gray-500">데이터를 불러올 수 없습니다.</div>';
|
||||
});
|
||||
}
|
||||
|
||||
function renderTable(items, pagination) {
|
||||
const container = document.getElementById('approval-table');
|
||||
|
||||
if (!items.length) {
|
||||
container.innerHTML = '<div class="p-8 text-center text-gray-500">참조된 결재 문서가 없습니다.</div>';
|
||||
document.getElementById('pagination-area').innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const statusBadge = (status) => {
|
||||
const map = {
|
||||
draft: '<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-700">임시저장</span>',
|
||||
pending: '<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-700">진행</span>',
|
||||
approved: '<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700">완료</span>',
|
||||
rejected: '<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-700">반려</span>',
|
||||
cancelled: '<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-700">회수</span>',
|
||||
};
|
||||
return map[status] || status;
|
||||
};
|
||||
|
||||
let html = `<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">문서번호</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">제목</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">기안자</th>
|
||||
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase">상태</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">기안일</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">`;
|
||||
|
||||
items.forEach(item => {
|
||||
const draftedAt = item.drafted_at ? new Date(item.drafted_at).toLocaleDateString('ko-KR') : (item.created_at ? new Date(item.created_at).toLocaleDateString('ko-KR') : '-');
|
||||
|
||||
html += `<tr class="hover:bg-gray-50 cursor-pointer" onclick="location.href='/approval-mgmt/${item.id}'">
|
||||
<td class="px-4 py-3 text-sm text-gray-600 whitespace-nowrap">${item.document_number || '-'}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-800 font-medium">${item.title || '-'}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-600">${item.drafter?.name || '-'}</td>
|
||||
<td class="px-4 py-3 text-center">${statusBadge(item.status)}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-500 whitespace-nowrap">${draftedAt}</td>
|
||||
</tr>`;
|
||||
});
|
||||
|
||||
html += '</tbody></table></div>';
|
||||
container.innerHTML = html;
|
||||
|
||||
const area = document.getElementById('pagination-area');
|
||||
if (!pagination.last_page || pagination.last_page <= 1) { area.innerHTML = ''; return; }
|
||||
let pHtml = '<div class="flex justify-center gap-1">';
|
||||
for (let i = 1; i <= pagination.last_page; i++) {
|
||||
const active = i === pagination.current_page ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-100';
|
||||
pHtml += `<button onclick="loadReferences(${i})" class="px-3 py-1 rounded border text-sm ${active}">${i}</button>`;
|
||||
}
|
||||
pHtml += '</div>';
|
||||
area.innerHTML = pHtml;
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
199
resources/views/approvals/show.blade.php
Normal file
199
resources/views/approvals/show.blade.php
Normal file
@@ -0,0 +1,199 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', '결재 상세')
|
||||
|
||||
@section('content')
|
||||
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-800">결재 상세</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">{{ $approval->document_number }}</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
@if($approval->isEditable() && $approval->drafter_id === auth()->id())
|
||||
<a href="{{ route('approvals.edit', $approval->id) }}"
|
||||
class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-lg transition text-sm">
|
||||
수정
|
||||
</a>
|
||||
@endif
|
||||
<a href="{{ route('approvals.drafts') }}" class="text-gray-600 hover:text-gray-800 text-sm px-3 py-2 border rounded-lg">
|
||||
목록
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 문서 정보 --}}
|
||||
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
|
||||
<div class="flex flex-wrap gap-x-8 gap-y-3 mb-4">
|
||||
<div>
|
||||
<span class="text-xs text-gray-500">상태</span>
|
||||
<div class="mt-1">
|
||||
@include('approvals.partials._status-badge', ['status' => $approval->status])
|
||||
@if($approval->is_urgent)
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-700 ml-1">긴급</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-xs text-gray-500">양식</span>
|
||||
<div class="mt-1 text-sm font-medium">{{ $approval->form?->name ?? '-' }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-xs text-gray-500">기안자</span>
|
||||
<div class="mt-1 text-sm font-medium">{{ $approval->drafter?->name ?? '-' }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-xs text-gray-500">기안일</span>
|
||||
<div class="mt-1 text-sm">{{ $approval->drafted_at?->format('Y-m-d H:i') ?? '-' }}</div>
|
||||
</div>
|
||||
@if($approval->completed_at)
|
||||
<div>
|
||||
<span class="text-xs text-gray-500">완료일</span>
|
||||
<div class="mt-1 text-sm">{{ $approval->completed_at->format('Y-m-d H:i') }}</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="border-t pt-4">
|
||||
<h2 class="text-lg font-semibold text-gray-800 mb-2">{{ $approval->title }}</h2>
|
||||
<div class="prose prose-sm max-w-none text-gray-700 whitespace-pre-wrap">{{ $approval->body ?? '(내용 없음)' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 결재 진행 단계 --}}
|
||||
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-2">결재 진행</h3>
|
||||
@include('approvals.partials._step-progress', [
|
||||
'steps' => $approval->steps->toArray(),
|
||||
'currentStep' => $approval->current_step,
|
||||
])
|
||||
|
||||
{{-- 결재 의견 목록 --}}
|
||||
@php
|
||||
$stepsWithComments = $approval->steps->filter(fn($s) => $s->comment);
|
||||
@endphp
|
||||
@if($stepsWithComments->isNotEmpty())
|
||||
<div class="mt-4 border-t pt-4">
|
||||
<h4 class="text-sm font-medium text-gray-700 mb-2">결재 의견</h4>
|
||||
<div class="space-y-2">
|
||||
@foreach($stepsWithComments as $step)
|
||||
<div class="flex gap-3 p-3 bg-gray-50 rounded-lg">
|
||||
<div class="shrink-0">
|
||||
@if($step->status === 'approved')
|
||||
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-green-100 text-green-600 text-xs">✓</span>
|
||||
@else
|
||||
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-red-100 text-red-600 text-xs">✗</span>
|
||||
@endif
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-medium">
|
||||
{{ $step->approver_name ?? ($step->approver?->name ?? '') }}
|
||||
<span class="text-gray-400 font-normal text-xs">{{ $step->acted_at?->format('Y-m-d H:i') }}</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 mt-1">{{ $step->comment }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- 액션 버튼 --}}
|
||||
@if($approval->isActionable() && $approval->isCurrentApprover(auth()->id()))
|
||||
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-4">결재 처리</h3>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">결재 의견</label>
|
||||
<textarea id="approval-comment" rows="3" placeholder="의견을 입력하세요 (반려 시 필수)"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button onclick="processApproval('approve')"
|
||||
class="bg-green-600 hover:bg-green-700 text-white px-6 py-2 rounded-lg transition text-sm font-medium">
|
||||
승인
|
||||
</button>
|
||||
<button onclick="processApproval('reject')"
|
||||
class="bg-red-600 hover:bg-red-700 text-white px-6 py-2 rounded-lg transition text-sm font-medium">
|
||||
반려
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- 회수 버튼 (기안자 + pending) --}}
|
||||
@if($approval->isCancellable() && $approval->drafter_id === auth()->id())
|
||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
||||
<button onclick="cancelApproval()"
|
||||
class="bg-yellow-500 hover:bg-yellow-600 text-white px-6 py-2 rounded-lg transition text-sm font-medium">
|
||||
결재 회수
|
||||
</button>
|
||||
<span class="text-xs text-gray-500 ml-2">진행 중인 결재를 취소합니다.</span>
|
||||
</div>
|
||||
@endif
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
async function processApproval(action) {
|
||||
const comment = document.getElementById('approval-comment')?.value || '';
|
||||
|
||||
if (action === 'reject' && !comment.trim()) {
|
||||
showToast('반려 시 사유를 입력해주세요.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'approve' && !confirm('승인하시겠습니까?')) return;
|
||||
if (action === 'reject' && !confirm('반려하시겠습니까?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/admin/approvals/{{ $approval->id }}/${action}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ comment: comment }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
showToast(data.message, 'success');
|
||||
setTimeout(() => location.reload(), 500);
|
||||
} else {
|
||||
showToast(data.message || '처리에 실패했습니다.', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showToast('서버 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelApproval() {
|
||||
if (!confirm('결재를 회수하시겠습니까? 이 작업은 되돌릴 수 없습니다.')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/approvals/{{ $approval->id }}/cancel', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
showToast(data.message, 'success');
|
||||
setTimeout(() => location.reload(), 500);
|
||||
} else {
|
||||
showToast(data.message || '회수에 실패했습니다.', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showToast('서버 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
@@ -893,6 +893,34 @@
|
||||
Route::get('/search', [\App\Http\Controllers\Api\Admin\TenantUserApiController::class, 'search'])->name('search');
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 결재관리 API
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
Route::middleware(['web', 'auth', 'hq.member'])->prefix('admin/approvals')->name('api.admin.approvals.')->group(function () {
|
||||
// 고정 경로 (먼저 선언)
|
||||
Route::get('/drafts', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'drafts'])->name('drafts');
|
||||
Route::get('/pending', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'pending'])->name('pending');
|
||||
Route::get('/completed', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'completed'])->name('completed');
|
||||
Route::get('/references', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'references'])->name('references');
|
||||
Route::get('/lines', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'lines'])->name('lines');
|
||||
Route::get('/forms', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'forms'])->name('forms');
|
||||
Route::get('/badge-counts', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'badgeCounts'])->name('badge-counts');
|
||||
|
||||
// CRUD
|
||||
Route::post('/', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'store'])->name('store');
|
||||
Route::get('/{id}', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'show'])->name('show');
|
||||
Route::put('/{id}', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'update'])->name('update');
|
||||
Route::delete('/{id}', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'destroy'])->name('destroy');
|
||||
|
||||
// 워크플로우
|
||||
Route::post('/{id}/submit', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'submit'])->name('submit');
|
||||
Route::post('/{id}/approve', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'approve'])->name('approve');
|
||||
Route::post('/{id}/reject', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'reject'])->name('reject');
|
||||
Route::post('/{id}/cancel', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'cancel'])->name('cancel');
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 문서 관리 API
|
||||
|
||||
@@ -150,6 +150,17 @@
|
||||
Route::get('/{id}/edit', [NumberingRuleController::class, 'edit'])->name('edit');
|
||||
});
|
||||
|
||||
// 결재관리 (Blade 화면)
|
||||
Route::prefix('approval-mgmt')->name('approvals.')->group(function () {
|
||||
Route::get('/drafts', [\App\Http\Controllers\ApprovalController::class, 'drafts'])->name('drafts');
|
||||
Route::get('/create', [\App\Http\Controllers\ApprovalController::class, 'create'])->name('create');
|
||||
Route::get('/pending', [\App\Http\Controllers\ApprovalController::class, 'pending'])->name('pending');
|
||||
Route::get('/references', [\App\Http\Controllers\ApprovalController::class, 'references'])->name('references');
|
||||
Route::get('/completed', [\App\Http\Controllers\ApprovalController::class, 'completed'])->name('completed');
|
||||
Route::get('/{id}', [\App\Http\Controllers\ApprovalController::class, 'show'])->name('show');
|
||||
Route::get('/{id}/edit', [\App\Http\Controllers\ApprovalController::class, 'edit'])->name('edit');
|
||||
});
|
||||
|
||||
// 사용자 관리 (Blade 화면만)
|
||||
Route::prefix('users')->name('users.')->group(function () {
|
||||
Route::get('/', [UserController::class, 'index'])->name('index');
|
||||
|
||||
Reference in New Issue
Block a user