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:
2025-12-01 14:07:55 +09:00
parent 189376ffad
commit a2477837d0
14 changed files with 2474 additions and 0 deletions

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

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

View 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' => '업무 내용은 필수입니다.',
];
}
}

View 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' => '업무 내용은 필수입니다.',
];
}
}

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

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

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