diff --git a/app/Http/Controllers/Api/Admin/DailyLogController.php b/app/Http/Controllers/Api/Admin/DailyLogController.php new file mode 100644 index 00000000..b2937048 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/DailyLogController.php @@ -0,0 +1,261 @@ +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, + ]); + } +} diff --git a/app/Http/Controllers/DailyLogController.php b/app/Http/Controllers/DailyLogController.php new file mode 100644 index 00000000..6fa56587 --- /dev/null +++ b/app/Http/Controllers/DailyLogController.php @@ -0,0 +1,91 @@ +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' + )); + } +} diff --git a/app/Http/Requests/DailyLog/StoreDailyLogRequest.php b/app/Http/Requests/DailyLog/StoreDailyLogRequest.php new file mode 100644 index 00000000..597f132a --- /dev/null +++ b/app/Http/Requests/DailyLog/StoreDailyLogRequest.php @@ -0,0 +1,53 @@ + ['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' => '업무 내용은 필수입니다.', + ]; + } +} diff --git a/app/Http/Requests/DailyLog/UpdateDailyLogRequest.php b/app/Http/Requests/DailyLog/UpdateDailyLogRequest.php new file mode 100644 index 00000000..097f48f6 --- /dev/null +++ b/app/Http/Requests/DailyLog/UpdateDailyLogRequest.php @@ -0,0 +1,51 @@ + ['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' => '업무 내용은 필수입니다.', + ]; + } +} diff --git a/app/Models/Admin/AdminPmDailyLog.php b/app/Models/Admin/AdminPmDailyLog.php new file mode 100644 index 00000000..dc8f2369 --- /dev/null +++ b/app/Models/Admin/AdminPmDailyLog.php @@ -0,0 +1,146 @@ + '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)'); + } +} diff --git a/app/Models/Admin/AdminPmDailyLogEntry.php b/app/Models/Admin/AdminPmDailyLogEntry.php new file mode 100644 index 00000000..a6fa29c6 --- /dev/null +++ b/app/Models/Admin/AdminPmDailyLogEntry.php @@ -0,0 +1,154 @@ + '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', + }; + } +} diff --git a/app/Services/ProjectManagement/DailyLogService.php b/app/Services/ProjectManagement/DailyLogService.php new file mode 100644 index 00000000..ae84abd2 --- /dev/null +++ b/app/Services/ProjectManagement/DailyLogService.php @@ -0,0 +1,432 @@ +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, + ]; + } +} diff --git a/resources/views/daily-logs/index.blade.php b/resources/views/daily-logs/index.blade.php new file mode 100644 index 00000000..4940cbb5 --- /dev/null +++ b/resources/views/daily-logs/index.blade.php @@ -0,0 +1,695 @@ +@extends('layouts.app') + +@section('title', '일일 스크럼') + +@section('content') + +
| 날짜 | +프로젝트 | +요약 | +항목 | +작성자 | +상태 | +액션 | +
|---|---|---|---|---|---|---|
|
+
+
+
+
+
+
+ {{ $log->log_date->format('Y-m-d') }}
+
+
+ {{ $log->log_date->format('l') }}
+
+ |
+ + @if($log->project) + + {{ $log->project->name }} + + @else + 전체 + @endif + | +
+ @if($log->summary)
+
+ {{ Str::limit($log->summary, 50) }}
+
+ @else
+ -
+ @endif
+ |
+
+
+ {{ $log->entries_count }}개
+ @if($log->entries->count() > 0)
+
+
+ @php
+ $stats = $log->entry_stats;
+ @endphp
+ @if($stats['todo'] > 0)
+
+ @endif
+ @if($stats['in_progress'] > 0)
+
+ @endif
+ @if($stats['done'] > 0)
+
+ @endif
+
+ @endif
+ |
+ + {{ $log->creator?->name ?? '-' }} + | ++ @if($log->trashed()) + + 삭제됨 + + @else + + 활성 + + @endif + | ++ @if($log->trashed()) + + + @if(auth()->user()?->is_super_admin) + + @endif + @else + + + + @endif + | +
| + 일일 로그가 없습니다. + | +||||||
{{ $log->summary }}
+ @else +요약이 작성되지 않았습니다.
+ @endif + +{{ $entry->content }}
+