feat: [daily-logs] 일일 스크럼 기능 구현
주요 기능:
- 일일 로그 CRUD (생성, 조회, 수정, 삭제, 복원, 영구삭제)
- 로그 항목(Entry) 관리 (추가, 상태변경, 삭제, 순서변경)
- 주간 타임라인 (최근 7일 진행률 표시)
- 테이블 리스트 아코디언 상세보기
- 담당자 자동완성 (일반 사용자는 슈퍼관리자 목록 제외)
- HTMX 기반 동적 테이블 로딩 및 필터링
- Soft Delete 지원
파일 추가:
- Models: AdminPmDailyLog, AdminPmDailyLogEntry
- Controllers: DailyLogController (Web, API)
- Service: DailyLogService
- Requests: StoreDailyLogRequest, UpdateDailyLogRequest
- Views: index, show, table partial, modal-form partial
라우트 추가:
- Web: /daily-logs, /daily-logs/today, /daily-logs/{id}
- API: /api/admin/daily-logs/* (CRUD + 항목관리)
This commit is contained in:
261
app/Http/Controllers/Api/Admin/DailyLogController.php
Normal file
261
app/Http/Controllers/Api/Admin/DailyLogController.php
Normal file
@@ -0,0 +1,261 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\DailyLog\StoreDailyLogRequest;
|
||||
use App\Http\Requests\DailyLog\UpdateDailyLogRequest;
|
||||
use App\Services\ProjectManagement\DailyLogService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class DailyLogController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DailyLogService $dailyLogService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 일일 로그 목록 (HTMX용)
|
||||
*/
|
||||
public function index(Request $request): View|JsonResponse
|
||||
{
|
||||
$tenantId = session('current_tenant_id', 1);
|
||||
|
||||
$filters = $request->only([
|
||||
'search',
|
||||
'project_id',
|
||||
'start_date',
|
||||
'end_date',
|
||||
'trashed',
|
||||
'sort_by',
|
||||
'sort_direction',
|
||||
]);
|
||||
|
||||
$logs = $this->dailyLogService->getDailyLogs($tenantId, $filters, 15);
|
||||
|
||||
// HTMX 요청이면 HTML 파셜 반환
|
||||
if ($request->header('HX-Request')) {
|
||||
return view('daily-logs.partials.table', compact('logs'));
|
||||
}
|
||||
|
||||
// 일반 요청이면 JSON
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $logs,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 일일 로그 통계
|
||||
*/
|
||||
public function stats(Request $request): JsonResponse
|
||||
{
|
||||
$tenantId = session('current_tenant_id', 1);
|
||||
$projectId = $request->input('project_id');
|
||||
|
||||
$stats = $this->dailyLogService->getStats($tenantId, $projectId);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $stats,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 일일 로그 상세 조회
|
||||
*/
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
$log = $this->dailyLogService->getDailyLogById($id, true);
|
||||
|
||||
if (! $log) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '일일 로그를 찾을 수 없습니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $log,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 일일 로그 생성
|
||||
*/
|
||||
public function store(StoreDailyLogRequest $request): JsonResponse
|
||||
{
|
||||
$tenantId = session('current_tenant_id', 1);
|
||||
|
||||
$log = $this->dailyLogService->createDailyLog($tenantId, $request->validated());
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '일일 로그가 생성되었습니다.',
|
||||
'data' => $log,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 일일 로그 수정
|
||||
*/
|
||||
public function update(UpdateDailyLogRequest $request, int $id): JsonResponse
|
||||
{
|
||||
$log = $this->dailyLogService->updateDailyLog($id, $request->validated());
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '일일 로그가 수정되었습니다.',
|
||||
'data' => $log,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 일일 로그 삭제 (Soft Delete)
|
||||
*/
|
||||
public function destroy(int $id): JsonResponse
|
||||
{
|
||||
$this->dailyLogService->deleteDailyLog($id);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '일일 로그가 삭제되었습니다.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 일일 로그 복원
|
||||
*/
|
||||
public function restore(int $id): JsonResponse
|
||||
{
|
||||
$this->dailyLogService->restoreDailyLog($id);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '일일 로그가 복원되었습니다.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 일일 로그 영구 삭제
|
||||
*/
|
||||
public function forceDestroy(int $id): JsonResponse
|
||||
{
|
||||
$this->dailyLogService->forceDeleteDailyLog($id);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '일일 로그가 영구 삭제되었습니다.',
|
||||
]);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 항목(Entry) 관리 API
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 항목 추가
|
||||
*/
|
||||
public function addEntry(Request $request, int $logId): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'assignee_type' => 'required|in:team,user',
|
||||
'assignee_id' => 'nullable|integer',
|
||||
'assignee_name' => 'required|string|max:100',
|
||||
'content' => 'required|string|max:2000',
|
||||
'status' => 'nullable|in:todo,in_progress,done',
|
||||
]);
|
||||
|
||||
$entry = $this->dailyLogService->addEntry($logId, $validated);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '항목이 추가되었습니다.',
|
||||
'data' => $entry,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 항목 상태 변경
|
||||
*/
|
||||
public function updateEntryStatus(Request $request, int $entryId): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'status' => 'required|in:todo,in_progress,done',
|
||||
]);
|
||||
|
||||
$entry = $this->dailyLogService->updateEntryStatus($entryId, $validated['status']);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '상태가 변경되었습니다.',
|
||||
'data' => $entry,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 항목 삭제
|
||||
*/
|
||||
public function deleteEntry(int $entryId): JsonResponse
|
||||
{
|
||||
$this->dailyLogService->deleteEntry($entryId);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '항목이 삭제되었습니다.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 항목 순서 변경
|
||||
*/
|
||||
public function reorderEntries(Request $request, int $logId): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'entry_ids' => 'required|array',
|
||||
'entry_ids.*' => 'integer',
|
||||
]);
|
||||
|
||||
$this->dailyLogService->reorderEntries($logId, $validated['entry_ids']);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '순서가 변경되었습니다.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 담당자 목록 조회 (자동완성용)
|
||||
*/
|
||||
public function assignees(): JsonResponse
|
||||
{
|
||||
$tenantId = session('current_tenant_id', 1);
|
||||
|
||||
$assignees = $this->dailyLogService->getAssigneeList($tenantId);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $assignees,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 오늘 날짜 로그 조회 또는 생성
|
||||
*/
|
||||
public function today(Request $request): JsonResponse
|
||||
{
|
||||
$tenantId = session('current_tenant_id', 1);
|
||||
$projectId = $request->input('project_id');
|
||||
$today = now()->format('Y-m-d');
|
||||
|
||||
$log = $this->dailyLogService->getOrCreateByDate($tenantId, $today, $projectId);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $log,
|
||||
]);
|
||||
}
|
||||
}
|
||||
91
app/Http/Controllers/DailyLogController.php
Normal file
91
app/Http/Controllers/DailyLogController.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Admin\AdminPmDailyLogEntry;
|
||||
use App\Services\ProjectManagement\DailyLogService;
|
||||
use App\Services\ProjectManagement\ProjectService;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class DailyLogController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DailyLogService $dailyLogService,
|
||||
private readonly ProjectService $projectService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 일일 로그 목록 화면
|
||||
*/
|
||||
public function index(): View
|
||||
{
|
||||
$tenantId = session('current_tenant_id', 1);
|
||||
|
||||
$projects = $this->projectService->getActiveProjects();
|
||||
$stats = $this->dailyLogService->getStats($tenantId);
|
||||
$weeklyTimeline = $this->dailyLogService->getWeeklyTimeline($tenantId);
|
||||
$assigneeTypes = AdminPmDailyLogEntry::getAssigneeTypes();
|
||||
$entryStatuses = AdminPmDailyLogEntry::getStatuses();
|
||||
$assignees = $this->dailyLogService->getAssigneeList($tenantId);
|
||||
|
||||
return view('daily-logs.index', compact(
|
||||
'projects',
|
||||
'stats',
|
||||
'weeklyTimeline',
|
||||
'assigneeTypes',
|
||||
'entryStatuses',
|
||||
'assignees'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 일일 로그 상세 화면
|
||||
*/
|
||||
public function show(int $id): View
|
||||
{
|
||||
$tenantId = session('current_tenant_id', 1);
|
||||
|
||||
$log = $this->dailyLogService->getDailyLogById($id, true);
|
||||
|
||||
if (! $log) {
|
||||
abort(404, '일일 로그를 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
$projects = $this->projectService->getActiveProjects();
|
||||
$assigneeTypes = AdminPmDailyLogEntry::getAssigneeTypes();
|
||||
$entryStatuses = AdminPmDailyLogEntry::getStatuses();
|
||||
$assignees = $this->dailyLogService->getAssigneeList($tenantId);
|
||||
|
||||
return view('daily-logs.show', compact(
|
||||
'log',
|
||||
'projects',
|
||||
'assigneeTypes',
|
||||
'entryStatuses',
|
||||
'assignees'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 오늘 날짜 로그 화면 (자동 생성)
|
||||
*/
|
||||
public function today(): View
|
||||
{
|
||||
$tenantId = session('current_tenant_id', 1);
|
||||
$today = now()->format('Y-m-d');
|
||||
|
||||
$log = $this->dailyLogService->getOrCreateByDate($tenantId, $today);
|
||||
|
||||
$projects = $this->projectService->getActiveProjects();
|
||||
$assigneeTypes = AdminPmDailyLogEntry::getAssigneeTypes();
|
||||
$entryStatuses = AdminPmDailyLogEntry::getStatuses();
|
||||
$assignees = $this->dailyLogService->getAssigneeList($tenantId);
|
||||
|
||||
return view('daily-logs.show', compact(
|
||||
'log',
|
||||
'projects',
|
||||
'assigneeTypes',
|
||||
'entryStatuses',
|
||||
'assignees'
|
||||
));
|
||||
}
|
||||
}
|
||||
53
app/Http/Requests/DailyLog/StoreDailyLogRequest.php
Normal file
53
app/Http/Requests/DailyLog/StoreDailyLogRequest.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\DailyLog;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreDailyLogRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'project_id' => ['nullable', 'integer', 'exists:admin_pm_projects,id'],
|
||||
'log_date' => ['required', 'date', 'date_format:Y-m-d'],
|
||||
'summary' => ['nullable', 'string', 'max:5000'],
|
||||
'entries' => ['nullable', 'array'],
|
||||
'entries.*.assignee_type' => ['required_with:entries', 'in:team,user'],
|
||||
'entries.*.assignee_id' => ['nullable', 'integer'],
|
||||
'entries.*.assignee_name' => ['required_with:entries', 'string', 'max:100'],
|
||||
'entries.*.content' => ['required_with:entries', 'string', 'max:2000'],
|
||||
'entries.*.status' => ['nullable', 'in:todo,in_progress,done'],
|
||||
];
|
||||
}
|
||||
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'project_id' => '프로젝트',
|
||||
'log_date' => '로그 날짜',
|
||||
'summary' => '요약',
|
||||
'entries' => '항목',
|
||||
'entries.*.assignee_type' => '담당자 유형',
|
||||
'entries.*.assignee_id' => '담당자 ID',
|
||||
'entries.*.assignee_name' => '담당자 이름',
|
||||
'entries.*.content' => '업무 내용',
|
||||
'entries.*.status' => '상태',
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'log_date.required' => '로그 날짜는 필수입니다.',
|
||||
'log_date.date_format' => '로그 날짜 형식이 올바르지 않습니다. (YYYY-MM-DD)',
|
||||
'entries.*.assignee_name.required_with' => '담당자 이름은 필수입니다.',
|
||||
'entries.*.content.required_with' => '업무 내용은 필수입니다.',
|
||||
];
|
||||
}
|
||||
}
|
||||
51
app/Http/Requests/DailyLog/UpdateDailyLogRequest.php
Normal file
51
app/Http/Requests/DailyLog/UpdateDailyLogRequest.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\DailyLog;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateDailyLogRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'project_id' => ['nullable', 'integer', 'exists:admin_pm_projects,id'],
|
||||
'summary' => ['nullable', 'string', 'max:5000'],
|
||||
'entries' => ['nullable', 'array'],
|
||||
'entries.*.id' => ['nullable', 'integer', 'exists:admin_pm_daily_log_entries,id'],
|
||||
'entries.*.assignee_type' => ['required_with:entries', 'in:team,user'],
|
||||
'entries.*.assignee_id' => ['nullable', 'integer'],
|
||||
'entries.*.assignee_name' => ['required_with:entries', 'string', 'max:100'],
|
||||
'entries.*.content' => ['required_with:entries', 'string', 'max:2000'],
|
||||
'entries.*.status' => ['nullable', 'in:todo,in_progress,done'],
|
||||
];
|
||||
}
|
||||
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'project_id' => '프로젝트',
|
||||
'summary' => '요약',
|
||||
'entries' => '항목',
|
||||
'entries.*.id' => '항목 ID',
|
||||
'entries.*.assignee_type' => '담당자 유형',
|
||||
'entries.*.assignee_id' => '담당자 ID',
|
||||
'entries.*.assignee_name' => '담당자 이름',
|
||||
'entries.*.content' => '업무 내용',
|
||||
'entries.*.status' => '상태',
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'entries.*.assignee_name.required_with' => '담당자 이름은 필수입니다.',
|
||||
'entries.*.content.required_with' => '업무 내용은 필수입니다.',
|
||||
];
|
||||
}
|
||||
}
|
||||
146
app/Models/Admin/AdminPmDailyLog.php
Normal file
146
app/Models/Admin/AdminPmDailyLog.php
Normal file
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Admin;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
/**
|
||||
* 프로젝트 관리 - 일일 로그 모델
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $tenant_id
|
||||
* @property int|null $project_id
|
||||
* @property \Carbon\Carbon $log_date
|
||||
* @property string|null $summary
|
||||
* @property int $created_by
|
||||
* @property int|null $updated_by
|
||||
* @property int|null $deleted_by
|
||||
* @property \Carbon\Carbon $created_at
|
||||
* @property \Carbon\Carbon $updated_at
|
||||
* @property \Carbon\Carbon|null $deleted_at
|
||||
*/
|
||||
class AdminPmDailyLog extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $table = 'admin_pm_daily_logs';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'project_id',
|
||||
'log_date',
|
||||
'summary',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
'deleted_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'log_date' => 'date',
|
||||
'tenant_id' => 'integer',
|
||||
'project_id' => 'integer',
|
||||
'created_by' => 'integer',
|
||||
'updated_by' => 'integer',
|
||||
'deleted_by' => 'integer',
|
||||
];
|
||||
|
||||
/**
|
||||
* 관계: 프로젝트
|
||||
*/
|
||||
public function project(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AdminPmProject::class, 'project_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 관계: 항목들
|
||||
*/
|
||||
public function entries(): HasMany
|
||||
{
|
||||
return $this->hasMany(AdminPmDailyLogEntry::class, 'daily_log_id')->orderBy('sort_order');
|
||||
}
|
||||
|
||||
/**
|
||||
* 관계: 생성자
|
||||
*/
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
/**
|
||||
* 관계: 수정자
|
||||
*/
|
||||
public function updater(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'updated_by');
|
||||
}
|
||||
|
||||
/**
|
||||
* 스코프: 테넌트 필터
|
||||
*/
|
||||
public function scopeTenant($query, int $tenantId)
|
||||
{
|
||||
return $query->where('tenant_id', $tenantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 스코프: 날짜 범위 필터
|
||||
*/
|
||||
public function scopeDateRange($query, ?string $startDate, ?string $endDate)
|
||||
{
|
||||
if ($startDate) {
|
||||
$query->where('log_date', '>=', $startDate);
|
||||
}
|
||||
if ($endDate) {
|
||||
$query->where('log_date', '<=', $endDate);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* 스코프: 프로젝트 필터
|
||||
*/
|
||||
public function scopeProject($query, ?int $projectId)
|
||||
{
|
||||
if ($projectId) {
|
||||
return $query->where('project_id', $projectId);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* 항목 통계
|
||||
*/
|
||||
public function getEntryStatsAttribute(): array
|
||||
{
|
||||
return [
|
||||
'total' => $this->entries()->count(),
|
||||
'todo' => $this->entries()->where('status', AdminPmDailyLogEntry::STATUS_TODO)->count(),
|
||||
'in_progress' => $this->entries()->where('status', AdminPmDailyLogEntry::STATUS_IN_PROGRESS)->count(),
|
||||
'done' => $this->entries()->where('status', AdminPmDailyLogEntry::STATUS_DONE)->count(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 프로젝트명 (없으면 '전체')
|
||||
*/
|
||||
public function getProjectNameAttribute(): string
|
||||
{
|
||||
return $this->project?->name ?? '전체';
|
||||
}
|
||||
|
||||
/**
|
||||
* 포맷된 날짜
|
||||
*/
|
||||
public function getFormattedDateAttribute(): string
|
||||
{
|
||||
return $this->log_date->format('Y-m-d (D)');
|
||||
}
|
||||
}
|
||||
154
app/Models/Admin/AdminPmDailyLogEntry.php
Normal file
154
app/Models/Admin/AdminPmDailyLogEntry.php
Normal file
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Admin;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* 프로젝트 관리 - 일일 로그 항목 모델
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $daily_log_id
|
||||
* @property string $assignee_type
|
||||
* @property int|null $assignee_id
|
||||
* @property string $assignee_name
|
||||
* @property string $content
|
||||
* @property string $status
|
||||
* @property int $sort_order
|
||||
* @property \Carbon\Carbon $created_at
|
||||
* @property \Carbon\Carbon $updated_at
|
||||
*/
|
||||
class AdminPmDailyLogEntry extends Model
|
||||
{
|
||||
protected $table = 'admin_pm_daily_log_entries';
|
||||
|
||||
protected $fillable = [
|
||||
'daily_log_id',
|
||||
'assignee_type',
|
||||
'assignee_id',
|
||||
'assignee_name',
|
||||
'content',
|
||||
'status',
|
||||
'sort_order',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'daily_log_id' => 'integer',
|
||||
'assignee_id' => 'integer',
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
|
||||
/**
|
||||
* 담당자 유형 상수
|
||||
*/
|
||||
public const TYPE_TEAM = 'team';
|
||||
|
||||
public const TYPE_USER = 'user';
|
||||
|
||||
/**
|
||||
* 상태 상수
|
||||
*/
|
||||
public const STATUS_TODO = 'todo';
|
||||
|
||||
public const STATUS_IN_PROGRESS = 'in_progress';
|
||||
|
||||
public const STATUS_DONE = 'done';
|
||||
|
||||
/**
|
||||
* 담당자 유형 목록
|
||||
*/
|
||||
public static function getAssigneeTypes(): array
|
||||
{
|
||||
return [
|
||||
self::TYPE_TEAM => '팀',
|
||||
self::TYPE_USER => '개인',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 상태 목록
|
||||
*/
|
||||
public static function getStatuses(): array
|
||||
{
|
||||
return [
|
||||
self::STATUS_TODO => '예정',
|
||||
self::STATUS_IN_PROGRESS => '진행중',
|
||||
self::STATUS_DONE => '완료',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 관계: 일일 로그
|
||||
*/
|
||||
public function dailyLog(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AdminPmDailyLog::class, 'daily_log_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 관계: 사용자 담당자 (assignee_type이 user인 경우)
|
||||
*/
|
||||
public function assigneeUser(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'assignee_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 스코프: 상태별 필터
|
||||
*/
|
||||
public function scopeStatus($query, string $status)
|
||||
{
|
||||
return $query->where('status', $status);
|
||||
}
|
||||
|
||||
/**
|
||||
* 스코프: 담당자 유형별 필터
|
||||
*/
|
||||
public function scopeAssigneeType($query, string $type)
|
||||
{
|
||||
return $query->where('assignee_type', $type);
|
||||
}
|
||||
|
||||
/**
|
||||
* 상태 라벨 (한글)
|
||||
*/
|
||||
public function getStatusLabelAttribute(): string
|
||||
{
|
||||
return self::getStatuses()[$this->status] ?? $this->status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 담당자 유형 라벨 (한글)
|
||||
*/
|
||||
public function getAssigneeTypeLabelAttribute(): string
|
||||
{
|
||||
return self::getAssigneeTypes()[$this->assignee_type] ?? $this->assignee_type;
|
||||
}
|
||||
|
||||
/**
|
||||
* 상태별 색상 클래스
|
||||
*/
|
||||
public function getStatusColorAttribute(): string
|
||||
{
|
||||
return match ($this->status) {
|
||||
self::STATUS_TODO => 'bg-gray-100 text-gray-800',
|
||||
self::STATUS_IN_PROGRESS => 'bg-yellow-100 text-yellow-800',
|
||||
self::STATUS_DONE => 'bg-green-100 text-green-800',
|
||||
default => 'bg-gray-100 text-gray-800',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 담당자 유형별 색상 클래스
|
||||
*/
|
||||
public function getAssigneeTypeColorAttribute(): string
|
||||
{
|
||||
return match ($this->assignee_type) {
|
||||
self::TYPE_TEAM => 'bg-purple-100 text-purple-800',
|
||||
self::TYPE_USER => 'bg-blue-100 text-blue-800',
|
||||
default => 'bg-gray-100 text-gray-800',
|
||||
};
|
||||
}
|
||||
}
|
||||
432
app/Services/ProjectManagement/DailyLogService.php
Normal file
432
app/Services/ProjectManagement/DailyLogService.php
Normal file
@@ -0,0 +1,432 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\ProjectManagement;
|
||||
|
||||
use App\Models\Admin\AdminPmDailyLog;
|
||||
use App\Models\Admin\AdminPmDailyLogEntry;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class DailyLogService
|
||||
{
|
||||
/**
|
||||
* 일일 로그 목록 조회 (페이지네이션)
|
||||
*/
|
||||
public function getDailyLogs(int $tenantId, array $filters = [], int $perPage = 15): LengthAwarePaginator
|
||||
{
|
||||
$query = AdminPmDailyLog::query()
|
||||
->tenant($tenantId)
|
||||
->with(['project:id,name', 'creator:id,name', 'entries'])
|
||||
->withCount('entries')
|
||||
->withTrashed();
|
||||
|
||||
// 날짜 범위 필터
|
||||
if (! empty($filters['start_date']) || ! empty($filters['end_date'])) {
|
||||
$query->dateRange($filters['start_date'] ?? null, $filters['end_date'] ?? null);
|
||||
}
|
||||
|
||||
// 프로젝트 필터
|
||||
if (! empty($filters['project_id'])) {
|
||||
$query->project($filters['project_id']);
|
||||
}
|
||||
|
||||
// 검색 필터 (요약 내용)
|
||||
if (! empty($filters['search'])) {
|
||||
$search = $filters['search'];
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('summary', 'like', "%{$search}%")
|
||||
->orWhereHas('entries', function ($eq) use ($search) {
|
||||
$eq->where('content', 'like', "%{$search}%")
|
||||
->orWhere('assignee_name', 'like', "%{$search}%");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Soft Delete 필터
|
||||
if (isset($filters['trashed'])) {
|
||||
if ($filters['trashed'] === 'only') {
|
||||
$query->onlyTrashed();
|
||||
} elseif ($filters['trashed'] === 'with') {
|
||||
$query->withTrashed();
|
||||
}
|
||||
}
|
||||
|
||||
// 정렬 (기본: 날짜 내림차순)
|
||||
$sortBy = $filters['sort_by'] ?? 'log_date';
|
||||
$sortDirection = $filters['sort_direction'] ?? 'desc';
|
||||
$query->orderBy($sortBy, $sortDirection);
|
||||
|
||||
return $query->paginate($perPage);
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 일일 로그 조회
|
||||
*/
|
||||
public function getDailyLogById(int $id, bool $withTrashed = false): ?AdminPmDailyLog
|
||||
{
|
||||
$query = AdminPmDailyLog::query()
|
||||
->with(['project:id,name', 'creator:id,name', 'updater:id,name', 'entries']);
|
||||
|
||||
if ($withTrashed) {
|
||||
$query->withTrashed();
|
||||
}
|
||||
|
||||
return $query->find($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 날짜의 로그 조회 (또는 생성)
|
||||
*/
|
||||
public function getOrCreateByDate(int $tenantId, string $date, ?int $projectId = null): AdminPmDailyLog
|
||||
{
|
||||
$log = AdminPmDailyLog::query()
|
||||
->tenant($tenantId)
|
||||
->where('log_date', $date)
|
||||
->where('project_id', $projectId)
|
||||
->first();
|
||||
|
||||
if (! $log) {
|
||||
$log = AdminPmDailyLog::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'project_id' => $projectId,
|
||||
'log_date' => $date,
|
||||
'created_by' => auth()->id(),
|
||||
]);
|
||||
}
|
||||
|
||||
return $log->load('entries');
|
||||
}
|
||||
|
||||
/**
|
||||
* 일일 로그 생성
|
||||
*/
|
||||
public function createDailyLog(int $tenantId, array $data): AdminPmDailyLog
|
||||
{
|
||||
return DB::transaction(function () use ($tenantId, $data) {
|
||||
$log = AdminPmDailyLog::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'project_id' => $data['project_id'] ?? null,
|
||||
'log_date' => $data['log_date'],
|
||||
'summary' => $data['summary'] ?? null,
|
||||
'created_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
// 항목이 있으면 함께 생성
|
||||
if (! empty($data['entries'])) {
|
||||
$this->syncEntries($log, $data['entries']);
|
||||
}
|
||||
|
||||
return $log->load('entries');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 일일 로그 수정
|
||||
*/
|
||||
public function updateDailyLog(int $id, array $data): AdminPmDailyLog
|
||||
{
|
||||
return DB::transaction(function () use ($id, $data) {
|
||||
$log = AdminPmDailyLog::findOrFail($id);
|
||||
|
||||
$log->update([
|
||||
'project_id' => $data['project_id'] ?? $log->project_id,
|
||||
'summary' => $data['summary'] ?? $log->summary,
|
||||
'updated_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
// 항목 동기화
|
||||
if (isset($data['entries'])) {
|
||||
$this->syncEntries($log, $data['entries']);
|
||||
}
|
||||
|
||||
return $log->fresh('entries');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 항목 동기화 (bulk upsert/delete)
|
||||
*/
|
||||
protected function syncEntries(AdminPmDailyLog $log, array $entries): void
|
||||
{
|
||||
$existingIds = $log->entries()->pluck('id')->toArray();
|
||||
$incomingIds = [];
|
||||
|
||||
foreach ($entries as $index => $entryData) {
|
||||
if (! empty($entryData['id'])) {
|
||||
// 기존 항목 업데이트
|
||||
$entry = AdminPmDailyLogEntry::find($entryData['id']);
|
||||
if ($entry && $entry->daily_log_id === $log->id) {
|
||||
$entry->update([
|
||||
'assignee_type' => $entryData['assignee_type'] ?? $entry->assignee_type,
|
||||
'assignee_id' => $entryData['assignee_id'] ?? null,
|
||||
'assignee_name' => $entryData['assignee_name'],
|
||||
'content' => $entryData['content'],
|
||||
'status' => $entryData['status'] ?? $entry->status,
|
||||
'sort_order' => $index,
|
||||
]);
|
||||
$incomingIds[] = $entry->id;
|
||||
}
|
||||
} else {
|
||||
// 새 항목 생성
|
||||
$entry = $log->entries()->create([
|
||||
'assignee_type' => $entryData['assignee_type'] ?? 'user',
|
||||
'assignee_id' => $entryData['assignee_id'] ?? null,
|
||||
'assignee_name' => $entryData['assignee_name'],
|
||||
'content' => $entryData['content'],
|
||||
'status' => $entryData['status'] ?? 'todo',
|
||||
'sort_order' => $index,
|
||||
]);
|
||||
$incomingIds[] = $entry->id;
|
||||
}
|
||||
}
|
||||
|
||||
// 제거된 항목 삭제
|
||||
$toDelete = array_diff($existingIds, $incomingIds);
|
||||
if (! empty($toDelete)) {
|
||||
AdminPmDailyLogEntry::whereIn('id', $toDelete)->delete();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 일일 로그 삭제 (Soft Delete)
|
||||
*/
|
||||
public function deleteDailyLog(int $id): bool
|
||||
{
|
||||
$log = AdminPmDailyLog::findOrFail($id);
|
||||
|
||||
$log->deleted_by = auth()->id();
|
||||
$log->save();
|
||||
|
||||
return $log->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* 일일 로그 복원
|
||||
*/
|
||||
public function restoreDailyLog(int $id): bool
|
||||
{
|
||||
$log = AdminPmDailyLog::onlyTrashed()->findOrFail($id);
|
||||
|
||||
$log->deleted_by = null;
|
||||
|
||||
return $log->restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* 일일 로그 영구 삭제
|
||||
*/
|
||||
public function forceDeleteDailyLog(int $id): bool
|
||||
{
|
||||
$log = AdminPmDailyLog::withTrashed()->findOrFail($id);
|
||||
|
||||
return $log->forceDelete();
|
||||
}
|
||||
|
||||
/**
|
||||
* 항목 상태 변경
|
||||
*/
|
||||
public function updateEntryStatus(int $entryId, string $status): AdminPmDailyLogEntry
|
||||
{
|
||||
$entry = AdminPmDailyLogEntry::findOrFail($entryId);
|
||||
$entry->update(['status' => $status]);
|
||||
|
||||
return $entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* 항목 추가
|
||||
*/
|
||||
public function addEntry(int $logId, array $data): AdminPmDailyLogEntry
|
||||
{
|
||||
$log = AdminPmDailyLog::findOrFail($logId);
|
||||
|
||||
// 마지막 정렬 순서 가져오기
|
||||
$maxOrder = $log->entries()->max('sort_order') ?? -1;
|
||||
|
||||
return $log->entries()->create([
|
||||
'assignee_type' => $data['assignee_type'] ?? 'user',
|
||||
'assignee_id' => $data['assignee_id'] ?? null,
|
||||
'assignee_name' => $data['assignee_name'],
|
||||
'content' => $data['content'],
|
||||
'status' => $data['status'] ?? 'todo',
|
||||
'sort_order' => $maxOrder + 1,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 항목 삭제
|
||||
*/
|
||||
public function deleteEntry(int $entryId): bool
|
||||
{
|
||||
return AdminPmDailyLogEntry::findOrFail($entryId)->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* 항목 순서 변경
|
||||
*/
|
||||
public function reorderEntries(int $logId, array $entryIds): void
|
||||
{
|
||||
foreach ($entryIds as $index => $entryId) {
|
||||
AdminPmDailyLogEntry::where('id', $entryId)
|
||||
->where('daily_log_id', $logId)
|
||||
->update(['sort_order' => $index]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 통계 조회
|
||||
*/
|
||||
public function getStats(int $tenantId, ?int $projectId = null): array
|
||||
{
|
||||
$query = AdminPmDailyLog::query()->tenant($tenantId);
|
||||
|
||||
if ($projectId) {
|
||||
$query->project($projectId);
|
||||
}
|
||||
|
||||
$totalLogs = $query->count();
|
||||
|
||||
// 최근 7일 로그 수
|
||||
$recentLogs = (clone $query)
|
||||
->where('log_date', '>=', now()->subDays(7)->format('Y-m-d'))
|
||||
->count();
|
||||
|
||||
// 전체 항목 통계
|
||||
$entryStats = AdminPmDailyLogEntry::query()
|
||||
->whereHas('dailyLog', function ($q) use ($tenantId, $projectId) {
|
||||
$q->tenant($tenantId);
|
||||
if ($projectId) {
|
||||
$q->project($projectId);
|
||||
}
|
||||
})
|
||||
->selectRaw("
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN status = 'todo' THEN 1 ELSE 0 END) as todo,
|
||||
SUM(CASE WHEN status = 'in_progress' THEN 1 ELSE 0 END) as in_progress,
|
||||
SUM(CASE WHEN status = 'done' THEN 1 ELSE 0 END) as done
|
||||
")
|
||||
->first();
|
||||
|
||||
return [
|
||||
'total_logs' => $totalLogs,
|
||||
'recent_logs' => $recentLogs,
|
||||
'entries' => [
|
||||
'total' => (int) ($entryStats->total ?? 0),
|
||||
'todo' => (int) ($entryStats->todo ?? 0),
|
||||
'in_progress' => (int) ($entryStats->in_progress ?? 0),
|
||||
'done' => (int) ($entryStats->done ?? 0),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 주간 타임라인 데이터 조회 (최근 7일)
|
||||
*/
|
||||
public function getWeeklyTimeline(int $tenantId, ?int $projectId = null): array
|
||||
{
|
||||
$days = [];
|
||||
$today = now();
|
||||
|
||||
// 최근 7일 날짜 목록 생성
|
||||
for ($i = 6; $i >= 0; $i--) {
|
||||
$date = $today->copy()->subDays($i);
|
||||
$days[$date->format('Y-m-d')] = [
|
||||
'date' => $date->format('Y-m-d'),
|
||||
'day_name' => $date->locale('ko')->isoFormat('ddd'),
|
||||
'day_number' => $date->format('d'),
|
||||
'is_today' => $i === 0,
|
||||
'is_weekend' => $date->isWeekend(),
|
||||
'log' => null,
|
||||
];
|
||||
}
|
||||
|
||||
// 해당 기간의 로그 조회
|
||||
$startDate = $today->copy()->subDays(6)->format('Y-m-d');
|
||||
$endDate = $today->format('Y-m-d');
|
||||
|
||||
$query = AdminPmDailyLog::query()
|
||||
->tenant($tenantId)
|
||||
->whereBetween('log_date', [$startDate, $endDate])
|
||||
->with(['entries' => function ($q) {
|
||||
$q->select('id', 'daily_log_id', 'status');
|
||||
}]);
|
||||
|
||||
if ($projectId) {
|
||||
$query->project($projectId);
|
||||
}
|
||||
|
||||
$logs = $query->get();
|
||||
|
||||
// 로그 데이터 매핑
|
||||
foreach ($logs as $log) {
|
||||
$dateKey = $log->log_date->format('Y-m-d');
|
||||
if (isset($days[$dateKey])) {
|
||||
$entryStats = [
|
||||
'total' => $log->entries->count(),
|
||||
'done' => $log->entries->where('status', 'done')->count(),
|
||||
'in_progress' => $log->entries->where('status', 'in_progress')->count(),
|
||||
'todo' => $log->entries->where('status', 'todo')->count(),
|
||||
];
|
||||
|
||||
$days[$dateKey]['log'] = [
|
||||
'id' => $log->id,
|
||||
'summary' => $log->summary,
|
||||
'entry_stats' => $entryStats,
|
||||
'completion_rate' => $entryStats['total'] > 0
|
||||
? round(($entryStats['done'] / $entryStats['total']) * 100)
|
||||
: 0,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return array_values($days);
|
||||
}
|
||||
|
||||
/**
|
||||
* 담당자 목록 조회 (자동완성용)
|
||||
* - 일반 사용자는 슈퍼관리자 목록을 볼 수 없음
|
||||
*/
|
||||
public function getAssigneeList(int $tenantId): array
|
||||
{
|
||||
// 사용자 목록
|
||||
$query = User::query()
|
||||
->whereHas('tenants', function ($q) use ($tenantId) {
|
||||
$q->where('tenant_id', $tenantId);
|
||||
})
|
||||
->where('is_active', true);
|
||||
|
||||
// 슈퍼관리자가 아니면 슈퍼관리자 목록 제외
|
||||
if (! auth()->user()?->is_super_admin) {
|
||||
$query->where('is_super_admin', false);
|
||||
}
|
||||
|
||||
$users = $query
|
||||
->orderBy('name')
|
||||
->get(['id', 'name'])
|
||||
->map(fn ($u) => [
|
||||
'type' => 'user',
|
||||
'id' => $u->id,
|
||||
'name' => $u->name,
|
||||
])
|
||||
->toArray();
|
||||
|
||||
// 팀 목록 (부서 활용)
|
||||
$teams = DB::table('departments')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('is_active', true)
|
||||
->orderBy('name')
|
||||
->get(['id', 'name'])
|
||||
->map(fn ($d) => [
|
||||
'type' => 'team',
|
||||
'id' => $d->id,
|
||||
'name' => $d->name,
|
||||
])
|
||||
->toArray();
|
||||
|
||||
return [
|
||||
'users' => $users,
|
||||
'teams' => $teams,
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user