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:
김보곤
2026-02-27 23:17:17 +09:00
parent bcb45c9362
commit 12c9ad620a
19 changed files with 2806 additions and 0 deletions

View 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]);
}
}

View 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');
}
}

View 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,
];
}
}

View 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 ?? '',
};
}
}

View 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();
}
}

View 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,
};
}
}

View 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');
}
}
}

View 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

View 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">
&larr; 기안함으로 돌아가기
</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

View 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

View 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

View File

@@ -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>

View 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>

View 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' => '&#10003;', 'bg' => 'bg-green-500', 'border' => 'border-green-500', 'text' => 'text-green-700'],
'rejected' => ['icon' => '&#10007;', 'bg' => 'bg-red-500', 'border' => 'border-red-500', 'text' => 'text-red-700'],
'skipped' => ['icon' => '&#8212;', '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>

View 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

View 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

View 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">&#10003;</span>
@else
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-red-100 text-red-600 text-xs">&#10007;</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

View File

@@ -893,6 +893,34 @@
Route::get('/search', [\App\Http\Controllers\Api\Admin\TenantUserApiController::class, 'search'])->name('search'); 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 | 문서 관리 API

View File

@@ -150,6 +150,17 @@
Route::get('/{id}/edit', [NumberingRuleController::class, 'edit'])->name('edit'); 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 화면만) // 사용자 관리 (Blade 화면만)
Route::prefix('users')->name('users.')->group(function () { Route::prefix('users')->name('users.')->group(function () {
Route::get('/', [UserController::class, 'index'])->name('index'); Route::get('/', [UserController::class, 'index'])->name('index');