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,
|
||||
];
|
||||
}
|
||||
}
|
||||
695
resources/views/daily-logs/index.blade.php
Normal file
695
resources/views/daily-logs/index.blade.php
Normal file
@@ -0,0 +1,695 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', '일일 스크럼')
|
||||
|
||||
@section('content')
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-800">📅 일일 스크럼</h1>
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ route('daily-logs.today') }}" class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg transition">
|
||||
오늘 작성
|
||||
</a>
|
||||
<button type="button"
|
||||
onclick="openCreateModal()"
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition">
|
||||
+ 새 로그
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 주간 타임라인 -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-4 mb-6">
|
||||
<h2 class="text-sm font-medium text-gray-500 mb-3">최근 7일</h2>
|
||||
<div class="grid grid-cols-7 gap-2">
|
||||
@foreach($weeklyTimeline as $index => $day)
|
||||
<div class="relative group">
|
||||
<button type="button"
|
||||
onclick="{{ $day['log'] ? 'scrollToTableRow(' . $day['log']['id'] . ')' : 'openCreateModalWithDate(\'' . $day['date'] . '\')' }}"
|
||||
data-log-id="{{ $day['log']['id'] ?? '' }}"
|
||||
data-date="{{ $day['date'] }}"
|
||||
class="day-card w-full text-left p-3 rounded-lg border-2 transition-all hover:shadow-md
|
||||
{{ $day['is_today'] ? 'border-blue-500 bg-blue-50' : 'border-gray-200' }}
|
||||
{{ $day['is_weekend'] && !$day['is_today'] ? 'bg-gray-50' : '' }}
|
||||
{{ $day['log'] ? 'cursor-pointer' : 'cursor-pointer hover:border-blue-300' }}">
|
||||
<!-- 요일 -->
|
||||
<div class="text-xs font-medium {{ $day['is_weekend'] ? 'text-red-500' : 'text-gray-500' }} {{ $day['is_today'] ? 'text-blue-600' : '' }}">
|
||||
{{ $day['day_name'] }}
|
||||
</div>
|
||||
<!-- 날짜 -->
|
||||
<div class="text-lg font-bold {{ $day['is_today'] ? 'text-blue-600' : 'text-gray-800' }}">
|
||||
{{ $day['day_number'] }}
|
||||
</div>
|
||||
|
||||
@if($day['log'])
|
||||
<!-- 로그 있음: 진행률 표시 -->
|
||||
<div class="mt-2">
|
||||
<div class="w-full bg-gray-200 rounded-full h-1.5">
|
||||
<div class="bg-green-500 h-1.5 rounded-full transition-all" style="width: {{ $day['log']['completion_rate'] }}%"></div>
|
||||
</div>
|
||||
<div class="flex justify-between items-center mt-1">
|
||||
<span class="text-xs text-gray-500">{{ $day['log']['entry_stats']['total'] }}건</span>
|
||||
<span class="text-xs font-medium {{ $day['log']['completion_rate'] >= 100 ? 'text-green-600' : 'text-blue-600' }}">
|
||||
{{ $day['log']['completion_rate'] }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<!-- 로그 없음 -->
|
||||
<div class="mt-2 text-center">
|
||||
<span class="text-xs text-gray-400">미작성</span>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- 오늘 표시 -->
|
||||
@if($day['is_today'])
|
||||
<div class="absolute -top-1 -right-1 w-3 h-3 bg-blue-500 rounded-full animate-pulse"></div>
|
||||
@endif
|
||||
</button>
|
||||
|
||||
<!-- 툴팁 (호버 시 표시) -->
|
||||
@if($day['log'] && $day['log']['entry_stats']['total'] > 0)
|
||||
<div class="hidden group-hover:block absolute z-10 left-1/2 -translate-x-1/2 top-full mt-2 w-36 bg-gray-800 text-white text-xs rounded-lg p-2 shadow-lg pointer-events-none">
|
||||
<div class="flex justify-between mb-1">
|
||||
<span>예정</span>
|
||||
<span>{{ $day['log']['entry_stats']['todo'] }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between mb-1">
|
||||
<span>진행중</span>
|
||||
<span>{{ $day['log']['entry_stats']['in_progress'] }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>완료</span>
|
||||
<span>{{ $day['log']['entry_stats']['done'] }}</span>
|
||||
</div>
|
||||
<div class="absolute -top-1 left-1/2 -translate-x-1/2 w-2 h-2 bg-gray-800 rotate-45"></div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 통계 카드 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div class="bg-white rounded-lg shadow-sm p-4">
|
||||
<div class="text-sm text-gray-500">전체 로그</div>
|
||||
<div class="text-2xl font-bold text-gray-800">{{ $stats['total_logs'] ?? 0 }}</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow-sm p-4">
|
||||
<div class="text-sm text-gray-500">최근 7일</div>
|
||||
<div class="text-2xl font-bold text-blue-600">{{ $stats['recent_logs'] ?? 0 }}</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow-sm p-4">
|
||||
<div class="text-sm text-gray-500">진행중 항목</div>
|
||||
<div class="text-2xl font-bold text-yellow-600">{{ $stats['entries']['in_progress'] ?? 0 }}</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow-sm p-4">
|
||||
<div class="text-sm text-gray-500">완료 항목</div>
|
||||
<div class="text-2xl font-bold text-green-600">{{ $stats['entries']['done'] ?? 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 필터 영역 -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-4 mb-6">
|
||||
<form id="filterForm" class="flex gap-4 flex-wrap">
|
||||
<!-- 검색 -->
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<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-48">
|
||||
<select name="project_id" 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>
|
||||
@foreach($projects as $project)
|
||||
<option value="{{ $project->id }}">{{ $project->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 날짜 범위 -->
|
||||
<div class="w-40">
|
||||
<input type="date"
|
||||
name="start_date"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
<span class="self-center text-gray-500">~</span>
|
||||
<div class="w-40">
|
||||
<input type="date"
|
||||
name="end_date"
|
||||
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-36">
|
||||
<select name="trashed" 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="with">삭제 포함</option>
|
||||
<option value="only">삭제만</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 검색 버튼 -->
|
||||
<button type="submit" class="bg-gray-600 hover:bg-gray-700 text-white px-6 py-2 rounded-lg transition">
|
||||
검색
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 테이블 영역 (HTMX로 로드) -->
|
||||
<div id="log-table"
|
||||
hx-get="/api/admin/daily-logs"
|
||||
hx-trigger="load, filterSubmit from:body"
|
||||
hx-include="#filterForm"
|
||||
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'
|
||||
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>
|
||||
|
||||
<!-- 생성/수정 모달 -->
|
||||
@include('daily-logs.partials.modal-form', [
|
||||
'projects' => $projects,
|
||||
'assigneeTypes' => $assigneeTypes,
|
||||
'entryStatuses' => $entryStatuses,
|
||||
'assignees' => $assignees
|
||||
])
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
<script>
|
||||
// 담당자 데이터
|
||||
const assignees = @json($assignees);
|
||||
|
||||
// 폼 제출 시 HTMX 이벤트 트리거
|
||||
document.getElementById('filterForm').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
htmx.trigger('#log-table', 'filterSubmit');
|
||||
});
|
||||
|
||||
// 모달 열기
|
||||
function openCreateModal() {
|
||||
document.getElementById('modalTitle').textContent = '새 일일 로그';
|
||||
document.getElementById('logForm').reset();
|
||||
document.getElementById('logId').value = '';
|
||||
document.getElementById('logDate').value = new Date().toISOString().split('T')[0];
|
||||
clearEntries();
|
||||
document.getElementById('logModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
// 특정 날짜로 모달 열기 (주간 타임라인에서 사용)
|
||||
function openCreateModalWithDate(date) {
|
||||
document.getElementById('modalTitle').textContent = '새 일일 로그';
|
||||
document.getElementById('logForm').reset();
|
||||
document.getElementById('logId').value = '';
|
||||
document.getElementById('logDate').value = date;
|
||||
clearEntries();
|
||||
document.getElementById('logModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
// 모달 닫기
|
||||
function closeModal() {
|
||||
document.getElementById('logModal').classList.add('hidden');
|
||||
}
|
||||
|
||||
// 로그 수정 모달 열기
|
||||
function editLog(id) {
|
||||
fetch(`/api/admin/daily-logs/${id}`, {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
}
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
document.getElementById('modalTitle').textContent = '일일 로그 수정';
|
||||
document.getElementById('logId').value = data.data.id;
|
||||
document.getElementById('logDate').value = data.data.log_date;
|
||||
document.getElementById('projectId').value = data.data.project_id || '';
|
||||
document.getElementById('summary').value = data.data.summary || '';
|
||||
|
||||
// 항목들 로드
|
||||
clearEntries();
|
||||
if (data.data.entries && data.data.entries.length > 0) {
|
||||
data.data.entries.forEach(entry => addEntry(entry));
|
||||
}
|
||||
|
||||
document.getElementById('logModal').classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 항목 비우기
|
||||
function clearEntries() {
|
||||
document.getElementById('entriesContainer').innerHTML = '';
|
||||
}
|
||||
|
||||
// 항목 추가
|
||||
function addEntry(entry = null) {
|
||||
const container = document.getElementById('entriesContainer');
|
||||
const index = container.children.length;
|
||||
|
||||
const html = `
|
||||
<div class="entry-item border rounded-lg p-4 bg-gray-50" data-index="${index}">
|
||||
<div class="flex gap-4 mb-2">
|
||||
<input type="hidden" name="entries[${index}][id]" value="${entry?.id || ''}">
|
||||
<div class="w-24">
|
||||
<select name="entries[${index}][assignee_type]" class="w-full px-2 py-1 border rounded text-sm" onchange="updateAssigneeOptions(this, ${index})">
|
||||
<option value="user" ${(!entry || entry.assignee_type === 'user') ? 'selected' : ''}>개인</option>
|
||||
<option value="team" ${entry?.assignee_type === 'team' ? 'selected' : ''}>팀</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<input type="text"
|
||||
name="entries[${index}][assignee_name]"
|
||||
value="${entry?.assignee_name || ''}"
|
||||
placeholder="담당자 (직접입력 또는 선택)"
|
||||
list="assigneeList${index}"
|
||||
class="w-full px-2 py-1 border rounded text-sm"
|
||||
required>
|
||||
<datalist id="assigneeList${index}">
|
||||
${getAssigneeOptions(entry?.assignee_type || 'user')}
|
||||
</datalist>
|
||||
<input type="hidden" name="entries[${index}][assignee_id]" value="${entry?.assignee_id || ''}">
|
||||
</div>
|
||||
<div class="w-24">
|
||||
<select name="entries[${index}][status]" class="w-full px-2 py-1 border rounded text-sm">
|
||||
<option value="todo" ${(!entry || entry.status === 'todo') ? 'selected' : ''}>예정</option>
|
||||
<option value="in_progress" ${entry?.status === 'in_progress' ? 'selected' : ''}>진행중</option>
|
||||
<option value="done" ${entry?.status === 'done' ? 'selected' : ''}>완료</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="button" onclick="removeEntry(this)" class="text-red-500 hover:text-red-700">
|
||||
<svg class="w-5 h-5" 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>
|
||||
<textarea name="entries[${index}][content]"
|
||||
placeholder="업무 내용"
|
||||
rows="2"
|
||||
class="w-full px-2 py-1 border rounded text-sm"
|
||||
required>${entry?.content || ''}</textarea>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.insertAdjacentHTML('beforeend', html);
|
||||
}
|
||||
|
||||
// 담당자 옵션 생성
|
||||
function getAssigneeOptions(type) {
|
||||
const list = type === 'team' ? assignees.teams : assignees.users;
|
||||
return list.map(item => `<option value="${item.name}" data-id="${item.id}">`).join('');
|
||||
}
|
||||
|
||||
// 담당자 타입 변경 시 옵션 업데이트
|
||||
function updateAssigneeOptions(select, index) {
|
||||
const datalist = document.getElementById(`assigneeList${index}`);
|
||||
datalist.innerHTML = getAssigneeOptions(select.value);
|
||||
}
|
||||
|
||||
// 항목 삭제
|
||||
function removeEntry(btn) {
|
||||
btn.closest('.entry-item').remove();
|
||||
reindexEntries();
|
||||
}
|
||||
|
||||
// 항목 인덱스 재정렬
|
||||
function reindexEntries() {
|
||||
const entries = document.querySelectorAll('.entry-item');
|
||||
entries.forEach((entry, index) => {
|
||||
entry.dataset.index = index;
|
||||
entry.querySelectorAll('[name^="entries["]').forEach(input => {
|
||||
input.name = input.name.replace(/entries\[\d+\]/, `entries[${index}]`);
|
||||
});
|
||||
const datalist = entry.querySelector('datalist');
|
||||
if (datalist) {
|
||||
datalist.id = `assigneeList${index}`;
|
||||
}
|
||||
const nameInput = entry.querySelector('input[list]');
|
||||
if (nameInput) {
|
||||
nameInput.setAttribute('list', `assigneeList${index}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 폼 제출
|
||||
document.getElementById('logForm').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const logId = document.getElementById('logId').value;
|
||||
const url = logId ? `/api/admin/daily-logs/${logId}` : '/api/admin/daily-logs';
|
||||
const method = logId ? 'PUT' : 'POST';
|
||||
|
||||
const formData = new FormData(this);
|
||||
const data = {};
|
||||
|
||||
// FormData를 객체로 변환
|
||||
for (let [key, value] of formData.entries()) {
|
||||
const match = key.match(/^entries\[(\d+)\]\[(.+)\]$/);
|
||||
if (match) {
|
||||
const idx = match[1];
|
||||
const field = match[2];
|
||||
if (!data.entries) data.entries = [];
|
||||
if (!data.entries[idx]) data.entries[idx] = {};
|
||||
data.entries[idx][field] = value;
|
||||
} else {
|
||||
data[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// 빈 항목 제거
|
||||
if (data.entries) {
|
||||
data.entries = data.entries.filter(e => e && e.assignee_name && e.content);
|
||||
}
|
||||
|
||||
fetch(url, {
|
||||
method: method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
closeModal();
|
||||
htmx.trigger('#log-table', 'filterSubmit');
|
||||
} else {
|
||||
alert(result.message || '오류가 발생했습니다.');
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
alert('오류가 발생했습니다.');
|
||||
});
|
||||
});
|
||||
|
||||
// 삭제 확인
|
||||
function confirmDelete(id, date) {
|
||||
if (confirm(`"${date}" 일일 로그를 삭제하시겠습니까?`)) {
|
||||
fetch(`/api/admin/daily-logs/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
}
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
htmx.trigger('#log-table', 'filterSubmit');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 복원 확인
|
||||
function confirmRestore(id, date) {
|
||||
if (confirm(`"${date}" 일일 로그를 복원하시겠습니까?`)) {
|
||||
fetch(`/api/admin/daily-logs/${id}/restore`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
}
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
htmx.trigger('#log-table', 'filterSubmit');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 영구삭제 확인
|
||||
function confirmForceDelete(id, date) {
|
||||
if (confirm(`"${date}" 일일 로그를 영구 삭제하시겠습니까?\n\n⚠️ 이 작업은 되돌릴 수 없습니다!`)) {
|
||||
fetch(`/api/admin/daily-logs/${id}/force`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
}
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
htmx.trigger('#log-table', 'filterSubmit');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 주간 타임라인 → 테이블 행 스크롤 및 아코디언 열기
|
||||
// ========================================
|
||||
function scrollToTableRow(logId) {
|
||||
// 테이블 행 찾기
|
||||
const row = document.querySelector(`tr.log-row[data-log-id="${logId}"]`);
|
||||
|
||||
if (row) {
|
||||
// 선택된 카드 하이라이트
|
||||
document.querySelectorAll('.day-card').forEach(card => {
|
||||
card.classList.remove('ring-2', 'ring-indigo-500');
|
||||
});
|
||||
const selectedCard = document.querySelector(`.day-card[data-log-id="${logId}"]`);
|
||||
if (selectedCard) {
|
||||
selectedCard.classList.add('ring-2', 'ring-indigo-500');
|
||||
}
|
||||
|
||||
// 테이블 행으로 스크롤
|
||||
row.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
|
||||
// 스크롤 완료 후 아코디언 열기 (약간의 딜레이)
|
||||
setTimeout(() => {
|
||||
// 가상 이벤트 객체 생성
|
||||
const fakeEvent = { target: row };
|
||||
toggleTableAccordion(logId, fakeEvent);
|
||||
}, 300);
|
||||
} else {
|
||||
// 테이블에 해당 로그가 없는 경우 (필터링 등으로 인해)
|
||||
alert('해당 로그가 현재 목록에 표시되지 않습니다.\n필터를 확인해주세요.');
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 테이블 리스트 아코디언 기능
|
||||
// ========================================
|
||||
let tableOpenAccordionId = null;
|
||||
|
||||
// 테이블 아코디언 토글
|
||||
function toggleTableAccordion(logId, event) {
|
||||
// 클릭한 요소가 버튼이면 무시 (이벤트 버블링 방지)
|
||||
if (event.target.closest('button') || event.target.closest('a')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const row = document.querySelector(`tr.log-row[data-log-id="${logId}"]`);
|
||||
const accordionRow = document.querySelector(`tr.accordion-row[data-accordion-for="${logId}"]`);
|
||||
const chevron = row.querySelector('.accordion-chevron');
|
||||
|
||||
// 같은 행을 다시 클릭하면 닫기
|
||||
if (tableOpenAccordionId === logId) {
|
||||
accordionRow.classList.add('hidden');
|
||||
chevron.classList.remove('rotate-90');
|
||||
row.classList.remove('bg-blue-50');
|
||||
tableOpenAccordionId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// 다른 열린 아코디언 닫기
|
||||
if (tableOpenAccordionId !== null) {
|
||||
const prevRow = document.querySelector(`tr.log-row[data-log-id="${tableOpenAccordionId}"]`);
|
||||
const prevAccordion = document.querySelector(`tr.accordion-row[data-accordion-for="${tableOpenAccordionId}"]`);
|
||||
if (prevRow && prevAccordion) {
|
||||
prevAccordion.classList.add('hidden');
|
||||
prevRow.querySelector('.accordion-chevron')?.classList.remove('rotate-90');
|
||||
prevRow.classList.remove('bg-blue-50');
|
||||
}
|
||||
}
|
||||
|
||||
// 현재 아코디언 열기
|
||||
accordionRow.classList.remove('hidden');
|
||||
chevron.classList.add('rotate-90');
|
||||
row.classList.add('bg-blue-50');
|
||||
tableOpenAccordionId = logId;
|
||||
|
||||
// 데이터 로드
|
||||
loadTableAccordionContent(logId);
|
||||
}
|
||||
|
||||
// 테이블 아코디언 콘텐츠 로드
|
||||
function loadTableAccordionContent(logId) {
|
||||
const contentDiv = document.getElementById(`accordion-content-${logId}`);
|
||||
contentDiv.innerHTML = '<div class="text-center py-4 text-gray-500">로딩 중...</div>';
|
||||
|
||||
fetch(`/api/admin/daily-logs/${logId}`, {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
}
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
renderTableAccordionContent(logId, data.data);
|
||||
} else {
|
||||
contentDiv.innerHTML = '<div class="text-center py-4 text-red-500">데이터 로드 실패</div>';
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
contentDiv.innerHTML = '<div class="text-center py-4 text-red-500">데이터 로드 실패</div>';
|
||||
});
|
||||
}
|
||||
|
||||
// 테이블 아코디언 콘텐츠 렌더링
|
||||
function renderTableAccordionContent(logId, log) {
|
||||
const contentDiv = document.getElementById(`accordion-content-${logId}`);
|
||||
|
||||
const statusColors = {
|
||||
'todo': 'bg-gray-100 text-gray-700',
|
||||
'in_progress': 'bg-yellow-100 text-yellow-700',
|
||||
'done': 'bg-green-100 text-green-700'
|
||||
};
|
||||
const statusLabels = {
|
||||
'todo': '예정',
|
||||
'in_progress': '진행중',
|
||||
'done': '완료'
|
||||
};
|
||||
|
||||
let entriesHtml = '';
|
||||
if (log.entries && log.entries.length > 0) {
|
||||
entriesHtml = log.entries.map(entry => `
|
||||
<div class="flex items-start gap-3 p-3 bg-white rounded-lg border" data-entry-id="${entry.id}">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="font-medium text-gray-900">${entry.assignee_name}</span>
|
||||
<span class="px-2 py-0.5 text-xs rounded-full ${statusColors[entry.status]}">${statusLabels[entry.status]}</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600">${entry.content}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
${entry.status !== 'todo' ? `
|
||||
<button onclick="updateTableEntryStatus(${logId}, ${entry.id}, 'todo')" class="p-1 text-gray-400 hover:text-gray-600" title="예정">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" stroke-width="2"/></svg>
|
||||
</button>` : ''}
|
||||
${entry.status !== 'in_progress' ? `
|
||||
<button onclick="updateTableEntryStatus(${logId}, ${entry.id}, 'in_progress')" class="p-1 text-yellow-500 hover:text-yellow-700" title="진행중">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/></svg>
|
||||
</button>` : ''}
|
||||
${entry.status !== 'done' ? `
|
||||
<button onclick="updateTableEntryStatus(${logId}, ${entry.id}, 'done')" class="p-1 text-green-500 hover:text-green-700" title="완료">
|
||||
<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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
</button>` : ''}
|
||||
<button onclick="deleteTableEntry(${logId}, ${entry.id})" class="p-1 text-red-400 hover:text-red-600" title="삭제">
|
||||
<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 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
} else {
|
||||
entriesHtml = '<div class="text-center py-4 text-gray-400">등록된 항목이 없습니다.</div>';
|
||||
}
|
||||
|
||||
contentDiv.innerHTML = `
|
||||
<div class="space-y-3">
|
||||
${log.summary ? `<div class="text-sm text-gray-700 mb-3 pb-3 border-b">${log.summary}</div>` : ''}
|
||||
<div class="space-y-2">
|
||||
${entriesHtml}
|
||||
</div>
|
||||
<div class="pt-3 border-t flex justify-between items-center">
|
||||
<button onclick="openQuickAddTableEntry(${logId})" class="text-sm text-blue-600 hover:text-blue-800">
|
||||
+ 항목 추가
|
||||
</button>
|
||||
<button onclick="editLog(${logId})" class="text-sm text-indigo-600 hover:text-indigo-800">
|
||||
전체 수정
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 테이블 항목 상태 업데이트
|
||||
function updateTableEntryStatus(logId, entryId, status) {
|
||||
fetch(`/api/admin/daily-logs/entries/${entryId}/status`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
},
|
||||
body: JSON.stringify({ status })
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
loadTableAccordionContent(logId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 테이블 항목 삭제
|
||||
function deleteTableEntry(logId, entryId) {
|
||||
if (confirm('이 항목을 삭제하시겠습니까?')) {
|
||||
fetch(`/api/admin/daily-logs/entries/${entryId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
}
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
loadTableAccordionContent(logId);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 테이블 빠른 항목 추가
|
||||
function openQuickAddTableEntry(logId) {
|
||||
const content = prompt('업무 내용을 입력하세요:');
|
||||
if (content && content.trim()) {
|
||||
const assigneeName = prompt('담당자 이름을 입력하세요:');
|
||||
if (assigneeName && assigneeName.trim()) {
|
||||
fetch(`/api/admin/daily-logs/${logId}/entries`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
assignee_type: 'user',
|
||||
assignee_name: assigneeName.trim(),
|
||||
content: content.trim(),
|
||||
status: 'todo'
|
||||
})
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
loadTableAccordionContent(logId);
|
||||
} else {
|
||||
alert(result.message || '오류가 발생했습니다.');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HTMX 로드 완료 후 테이블 아코디언 상태 리셋
|
||||
document.body.addEventListener('htmx:afterSwap', function(event) {
|
||||
if (event.detail.target.id === 'log-table') {
|
||||
tableOpenAccordionId = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
94
resources/views/daily-logs/partials/modal-form.blade.php
Normal file
94
resources/views/daily-logs/partials/modal-form.blade.php
Normal file
@@ -0,0 +1,94 @@
|
||||
<!-- 일일 로그 생성/수정 모달 -->
|
||||
<div id="logModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
|
||||
<!-- 배경 -->
|
||||
<div class="fixed inset-0 bg-black bg-opacity-50 transition-opacity" onclick="closeModal()"></div>
|
||||
|
||||
<!-- 모달 컨텐츠 -->
|
||||
<div class="flex min-h-full items-center justify-center p-4">
|
||||
<div class="relative transform overflow-hidden rounded-lg bg-white shadow-xl transition-all w-full max-w-2xl">
|
||||
<!-- 헤더 -->
|
||||
<div class="bg-gray-50 px-6 py-4 border-b">
|
||||
<h3 id="modalTitle" class="text-lg font-semibold text-gray-900">새 일일 로그</h3>
|
||||
<button type="button" onclick="closeModal()" class="absolute top-4 right-4 text-gray-400 hover:text-gray-600">
|
||||
<svg class="w-6 h-6" 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>
|
||||
|
||||
<!-- 폼 -->
|
||||
<form id="logForm" class="p-6">
|
||||
<input type="hidden" id="logId" name="id">
|
||||
|
||||
<!-- 기본 정보 -->
|
||||
<div class="grid grid-cols-2 gap-4 mb-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">날짜 <span class="text-red-500">*</span></label>
|
||||
<input type="date"
|
||||
id="logDate"
|
||||
name="log_date"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">프로젝트</label>
|
||||
<select id="projectId"
|
||||
name="project_id"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="">전체 (프로젝트 미지정)</option>
|
||||
@foreach($projects as $project)
|
||||
<option value="{{ $project->id }}">{{ $project->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">일일 요약</label>
|
||||
<textarea id="summary"
|
||||
name="summary"
|
||||
rows="2"
|
||||
placeholder="오늘의 주요 활동 요약..."
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- 항목 목록 -->
|
||||
<div class="mb-6">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<label class="block text-sm font-medium text-gray-700">업무 항목</label>
|
||||
<button type="button"
|
||||
onclick="addEntry()"
|
||||
class="text-sm text-blue-600 hover:text-blue-800">
|
||||
+ 항목 추가
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="entriesContainer" class="space-y-3">
|
||||
<!-- 항목들이 여기에 동적으로 추가됨 -->
|
||||
</div>
|
||||
|
||||
<div class="mt-3 text-center">
|
||||
<button type="button"
|
||||
onclick="addEntry()"
|
||||
class="text-sm text-gray-500 hover:text-gray-700 py-2">
|
||||
+ 새 항목 추가
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 버튼 -->
|
||||
<div class="flex justify-end space-x-3 pt-4 border-t">
|
||||
<button type="button"
|
||||
onclick="closeModal()"
|
||||
class="px-4 py-2 text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 transition">
|
||||
취소
|
||||
</button>
|
||||
<button type="submit"
|
||||
class="px-4 py-2 text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition">
|
||||
저장
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
129
resources/views/daily-logs/partials/table.blade.php
Normal file
129
resources/views/daily-logs/partials/table.blade.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<!-- 일일 로그 테이블 -->
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">날짜</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">프로젝트</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">요약</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">항목</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">작성자</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">상태</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">액션</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
@forelse($logs as $log)
|
||||
<!-- 메인 행 (클릭 가능) -->
|
||||
<tr class="log-row cursor-pointer hover:bg-gray-50 {{ $log->trashed() ? 'bg-red-50' : '' }}"
|
||||
data-log-id="{{ $log->id }}"
|
||||
onclick="toggleTableAccordion({{ $log->id }}, event)">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex items-center">
|
||||
<svg class="accordion-chevron w-4 h-4 mr-2 text-gray-400 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-900">
|
||||
{{ $log->log_date->format('Y-m-d') }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{{ $log->log_date->format('l') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
@if($log->project)
|
||||
<span class="px-2 py-1 text-xs font-medium rounded-full bg-blue-100 text-blue-800">
|
||||
{{ $log->project->name }}
|
||||
</span>
|
||||
@else
|
||||
<span class="text-gray-400">전체</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
@if($log->summary)
|
||||
<div class="text-sm text-gray-900 truncate max-w-xs" title="{{ $log->summary }}">
|
||||
{{ Str::limit($log->summary, 50) }}
|
||||
</div>
|
||||
@else
|
||||
<span class="text-gray-400">-</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-sm text-gray-900">{{ $log->entries_count }}개</span>
|
||||
@if($log->entries->count() > 0)
|
||||
<div class="flex space-x-1">
|
||||
@php
|
||||
$stats = $log->entry_stats;
|
||||
@endphp
|
||||
@if($stats['todo'] > 0)
|
||||
<span class="w-2 h-2 rounded-full bg-gray-400" title="예정: {{ $stats['todo'] }}"></span>
|
||||
@endif
|
||||
@if($stats['in_progress'] > 0)
|
||||
<span class="w-2 h-2 rounded-full bg-yellow-400" title="진행중: {{ $stats['in_progress'] }}"></span>
|
||||
@endif
|
||||
@if($stats['done'] > 0)
|
||||
<span class="w-2 h-2 rounded-full bg-green-400" title="완료: {{ $stats['done'] }}"></span>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{{ $log->creator?->name ?? '-' }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
@if($log->trashed())
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 text-red-800">
|
||||
삭제됨
|
||||
</span>
|
||||
@else
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">
|
||||
활성
|
||||
</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2" onclick="event.stopPropagation()">
|
||||
@if($log->trashed())
|
||||
<!-- 삭제된 항목 -->
|
||||
<button onclick="confirmRestore({{ $log->id }}, '{{ $log->log_date->format('Y-m-d') }}')"
|
||||
class="text-green-600 hover:text-green-900">복원</button>
|
||||
@if(auth()->user()?->is_super_admin)
|
||||
<button onclick="confirmForceDelete({{ $log->id }}, '{{ $log->log_date->format('Y-m-d') }}')"
|
||||
class="text-red-600 hover:text-red-900">영구삭제</button>
|
||||
@endif
|
||||
@else
|
||||
<!-- 일반 항목 액션 -->
|
||||
<button onclick="editLog({{ $log->id }})"
|
||||
class="text-indigo-600 hover:text-indigo-900">수정</button>
|
||||
<button onclick="confirmDelete({{ $log->id }}, '{{ $log->log_date->format('Y-m-d') }}')"
|
||||
class="text-red-600 hover:text-red-900">삭제</button>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
<!-- 아코디언 상세 행 (숨겨진 상태) -->
|
||||
<tr class="accordion-row hidden" data-accordion-for="{{ $log->id }}">
|
||||
<td colspan="7" class="px-6 py-4 bg-gray-50">
|
||||
<div class="accordion-content" id="accordion-content-{{ $log->id }}">
|
||||
<div class="text-center py-4 text-gray-500">로딩 중...</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="7" class="px-6 py-12 text-center text-gray-500">
|
||||
일일 로그가 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- 페이지네이션 -->
|
||||
@if($logs->hasPages())
|
||||
<div class="px-6 py-4 border-t border-gray-200">
|
||||
{{ $logs->withQueryString()->links() }}
|
||||
</div>
|
||||
@endif
|
||||
312
resources/views/daily-logs/show.blade.php
Normal file
312
resources/views/daily-logs/show.blade.php
Normal file
@@ -0,0 +1,312 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', '일일 스크럼 - ' . $log->log_date->format('Y-m-d'))
|
||||
|
||||
@section('content')
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<a href="{{ route('daily-logs.index') }}" class="text-blue-600 hover:text-blue-800 text-sm mb-2 inline-block">
|
||||
← 목록으로
|
||||
</a>
|
||||
<h1 class="text-2xl font-bold text-gray-800">📅 {{ $log->log_date->format('Y-m-d (l)') }}</h1>
|
||||
@if($log->project)
|
||||
<span class="px-2 py-1 text-xs font-medium rounded-full bg-blue-100 text-blue-800">
|
||||
{{ $log->project->name }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button type="button"
|
||||
onclick="openEditModal()"
|
||||
class="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-lg transition">
|
||||
수정
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 요약 카드 -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
|
||||
<h2 class="text-lg font-semibold text-gray-800 mb-3">일일 요약</h2>
|
||||
@if($log->summary)
|
||||
<p class="text-gray-700 whitespace-pre-wrap">{{ $log->summary }}</p>
|
||||
@else
|
||||
<p class="text-gray-400">요약이 작성되지 않았습니다.</p>
|
||||
@endif
|
||||
|
||||
<div class="mt-4 pt-4 border-t flex items-center text-sm text-gray-500">
|
||||
<span>작성자: {{ $log->creator?->name ?? '-' }}</span>
|
||||
<span class="mx-2">•</span>
|
||||
<span>작성일: {{ $log->created_at->format('Y-m-d H:i') }}</span>
|
||||
@if($log->updater)
|
||||
<span class="mx-2">•</span>
|
||||
<span>수정자: {{ $log->updater->name }} ({{ $log->updated_at->format('Y-m-d H:i') }})</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 항목 통계 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
@php $stats = $log->entry_stats; @endphp
|
||||
<div class="bg-white rounded-lg shadow-sm p-4">
|
||||
<div class="text-sm text-gray-500">전체</div>
|
||||
<div class="text-2xl font-bold text-gray-800">{{ $stats['total'] }}</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow-sm p-4">
|
||||
<div class="text-sm text-gray-500">예정</div>
|
||||
<div class="text-2xl font-bold text-gray-600">{{ $stats['todo'] }}</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow-sm p-4">
|
||||
<div class="text-sm text-gray-500">진행중</div>
|
||||
<div class="text-2xl font-bold text-yellow-600">{{ $stats['in_progress'] }}</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow-sm p-4">
|
||||
<div class="text-sm text-gray-500">완료</div>
|
||||
<div class="text-2xl font-bold text-green-600">{{ $stats['done'] }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 항목 목록 -->
|
||||
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
|
||||
<div class="px-6 py-4 border-b flex justify-between items-center">
|
||||
<h2 class="text-lg font-semibold text-gray-800">업무 항목</h2>
|
||||
<button type="button"
|
||||
onclick="openAddEntryModal()"
|
||||
class="text-sm text-blue-600 hover:text-blue-800">
|
||||
+ 항목 추가
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="entriesList" class="divide-y divide-gray-200">
|
||||
@forelse($log->entries as $entry)
|
||||
<div class="p-4 hover:bg-gray-50 entry-row" data-entry-id="{{ $entry->id }}">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<!-- 담당자 유형 배지 -->
|
||||
<span class="px-2 py-0.5 text-xs font-medium rounded-full {{ $entry->assignee_type_color }}">
|
||||
{{ $entry->assignee_type_label }}
|
||||
</span>
|
||||
<!-- 담당자 이름 -->
|
||||
<span class="font-medium text-gray-900">{{ $entry->assignee_name }}</span>
|
||||
<!-- 상태 배지 -->
|
||||
<span class="px-2 py-0.5 text-xs font-medium rounded-full {{ $entry->status_color }}">
|
||||
{{ $entry->status_label }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-gray-700 whitespace-pre-wrap">{{ $entry->content }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 ml-4">
|
||||
<!-- 상태 변경 버튼들 -->
|
||||
<div class="flex gap-1">
|
||||
@if($entry->status !== 'todo')
|
||||
<button onclick="updateStatus({{ $entry->id }}, 'todo')"
|
||||
class="p-1 text-gray-400 hover:text-gray-600" title="예정으로 변경">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="10" stroke-width="2"/>
|
||||
</svg>
|
||||
</button>
|
||||
@endif
|
||||
@if($entry->status !== 'in_progress')
|
||||
<button onclick="updateStatus({{ $entry->id }}, 'in_progress')"
|
||||
class="p-1 text-yellow-400 hover:text-yellow-600" title="진행중으로 변경">
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
</svg>
|
||||
</button>
|
||||
@endif
|
||||
@if($entry->status !== 'done')
|
||||
<button onclick="updateStatus({{ $entry->id }}, 'done')"
|
||||
class="p-1 text-green-400 hover:text-green-600" title="완료로 변경">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
<!-- 삭제 버튼 -->
|
||||
<button onclick="confirmDeleteEntry({{ $entry->id }})"
|
||||
class="p-1 text-red-400 hover:text-red-600" title="삭제">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<div class="p-12 text-center text-gray-500">
|
||||
등록된 항목이 없습니다.
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 항목 추가 모달 -->
|
||||
<div id="addEntryModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
|
||||
<div class="fixed inset-0 bg-black bg-opacity-50" onclick="closeAddEntryModal()"></div>
|
||||
<div class="flex min-h-full items-center justify-center p-4">
|
||||
<div class="relative bg-white rounded-lg shadow-xl w-full max-w-lg">
|
||||
<div class="bg-gray-50 px-6 py-4 border-b">
|
||||
<h3 class="text-lg font-semibold text-gray-900">항목 추가</h3>
|
||||
<button type="button" onclick="closeAddEntryModal()" class="absolute top-4 right-4 text-gray-400 hover:text-gray-600">
|
||||
<svg class="w-6 h-6" 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>
|
||||
<form id="addEntryForm" class="p-6">
|
||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">담당자 유형</label>
|
||||
<select name="assignee_type" id="newAssigneeType" onchange="updateNewAssigneeOptions()"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg">
|
||||
@foreach($assigneeTypes as $value => $label)
|
||||
<option value="{{ $value }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">상태</label>
|
||||
<select name="status"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg">
|
||||
@foreach($entryStatuses as $value => $label)
|
||||
<option value="{{ $value }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
</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"
|
||||
name="assignee_name"
|
||||
id="newAssigneeName"
|
||||
list="newAssigneeList"
|
||||
placeholder="담당자 (직접입력 또는 선택)"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg">
|
||||
<datalist id="newAssigneeList">
|
||||
@foreach($assignees['users'] as $user)
|
||||
<option value="{{ $user['name'] }}" data-id="{{ $user['id'] }}">
|
||||
@endforeach
|
||||
</datalist>
|
||||
<input type="hidden" name="assignee_id" id="newAssigneeId">
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">업무 내용 <span class="text-red-500">*</span></label>
|
||||
<textarea name="content"
|
||||
rows="3"
|
||||
required
|
||||
placeholder="업무 내용을 입력하세요..."
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg"></textarea>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button type="button" onclick="closeAddEntryModal()"
|
||||
class="px-4 py-2 text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300">
|
||||
취소
|
||||
</button>
|
||||
<button type="submit"
|
||||
class="px-4 py-2 text-white bg-blue-600 rounded-lg hover:bg-blue-700">
|
||||
추가
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
const logId = {{ $log->id }};
|
||||
const assignees = @json($assignees);
|
||||
|
||||
// 상태 업데이트
|
||||
function updateStatus(entryId, status) {
|
||||
fetch(`/api/admin/daily-logs/entries/${entryId}/status`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
},
|
||||
body: JSON.stringify({ status })
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
location.reload();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 항목 삭제
|
||||
function confirmDeleteEntry(entryId) {
|
||||
if (confirm('이 항목을 삭제하시겠습니까?')) {
|
||||
fetch(`/api/admin/daily-logs/entries/${entryId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
}
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
location.reload();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 항목 추가 모달
|
||||
function openAddEntryModal() {
|
||||
document.getElementById('addEntryForm').reset();
|
||||
document.getElementById('addEntryModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeAddEntryModal() {
|
||||
document.getElementById('addEntryModal').classList.add('hidden');
|
||||
}
|
||||
|
||||
// 담당자 옵션 업데이트
|
||||
function updateNewAssigneeOptions() {
|
||||
const type = document.getElementById('newAssigneeType').value;
|
||||
const datalist = document.getElementById('newAssigneeList');
|
||||
const list = type === 'team' ? assignees.teams : assignees.users;
|
||||
|
||||
datalist.innerHTML = list.map(item =>
|
||||
`<option value="${item.name}" data-id="${item.id}">`
|
||||
).join('');
|
||||
}
|
||||
|
||||
// 항목 추가 폼 제출
|
||||
document.getElementById('addEntryForm').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(this);
|
||||
const data = Object.fromEntries(formData.entries());
|
||||
|
||||
fetch(`/api/admin/daily-logs/${logId}/entries`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert(result.message || '오류가 발생했습니다.');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 로그 수정 모달 열기 (index.blade.php의 모달 재사용)
|
||||
function openEditModal() {
|
||||
// index 페이지로 리다이렉트하면서 수정 모달 열기
|
||||
window.location.href = '{{ route('daily-logs.index') }}?edit={{ $log->id }}';
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
@@ -300,6 +300,16 @@ class="flex items-center gap-2 pr-3 py-2 rounded-lg text-sm text-gray-700 hover:
|
||||
<span class="font-medium">JSON Import</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ route('daily-logs.index') }}"
|
||||
class="flex items-center gap-2 pr-3 py-2 rounded-lg text-sm text-gray-700 hover:bg-gray-100 {{ request()->routeIs('daily-logs.*') ? 'bg-primary text-white hover:bg-primary' : '' }}"
|
||||
style="padding-left: 2rem;">
|
||||
<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="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span class="font-medium">일일 스크럼</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
use App\Http\Controllers\Api\Admin\DepartmentController;
|
||||
use App\Http\Controllers\Api\Admin\MenuController;
|
||||
use App\Http\Controllers\Api\Admin\PermissionController;
|
||||
use App\Http\Controllers\Api\Admin\DailyLogController;
|
||||
use App\Http\Controllers\Api\Admin\ProjectManagement\ImportController as PmImportController;
|
||||
use App\Http\Controllers\Api\Admin\ProjectManagement\IssueController as PmIssueController;
|
||||
use App\Http\Controllers\Api\Admin\ProjectManagement\ProjectController as PmProjectController;
|
||||
@@ -317,4 +318,41 @@
|
||||
Route::post('/project/{projectId}/tasks', [PmImportController::class, 'importTasks'])->name('importTasks');
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 일일 스크럼 API
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
Route::prefix('daily-logs')->name('daily-logs.')->group(function () {
|
||||
// 고정 경로
|
||||
Route::get('/stats', [DailyLogController::class, 'stats'])->name('stats');
|
||||
Route::get('/today', [DailyLogController::class, 'today'])->name('today');
|
||||
Route::get('/assignees', [DailyLogController::class, 'assignees'])->name('assignees');
|
||||
|
||||
// 기본 CRUD
|
||||
Route::get('/', [DailyLogController::class, 'index'])->name('index');
|
||||
Route::post('/', [DailyLogController::class, 'store'])->name('store');
|
||||
Route::get('/{id}', [DailyLogController::class, 'show'])->name('show');
|
||||
Route::put('/{id}', [DailyLogController::class, 'update'])->name('update');
|
||||
Route::delete('/{id}', [DailyLogController::class, 'destroy'])->name('destroy');
|
||||
|
||||
// 복원 (일반관리자 가능)
|
||||
Route::post('/{id}/restore', [DailyLogController::class, 'restore'])->name('restore');
|
||||
|
||||
// 슈퍼관리자 전용 액션 (영구삭제)
|
||||
Route::middleware('super.admin')->group(function () {
|
||||
Route::delete('/{id}/force', [DailyLogController::class, 'forceDestroy'])->name('forceDestroy');
|
||||
});
|
||||
|
||||
// 항목(Entry) 관리
|
||||
Route::post('/{logId}/entries', [DailyLogController::class, 'addEntry'])->name('addEntry');
|
||||
Route::post('/{logId}/entries/reorder', [DailyLogController::class, 'reorderEntries'])->name('reorderEntries');
|
||||
});
|
||||
|
||||
// 항목 개별 API (로그 ID 없이 직접 접근)
|
||||
Route::prefix('daily-logs/entries')->name('daily-logs.entries.')->group(function () {
|
||||
Route::put('/{entryId}/status', [DailyLogController::class, 'updateEntryStatus'])->name('updateStatus');
|
||||
Route::delete('/{entryId}', [DailyLogController::class, 'deleteEntry'])->name('delete');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
use App\Http\Controllers\DevTools\FlowTesterController;
|
||||
use App\Http\Controllers\MenuController;
|
||||
use App\Http\Controllers\PermissionController;
|
||||
use App\Http\Controllers\DailyLogController;
|
||||
use App\Http\Controllers\ProjectManagementController;
|
||||
use App\Http\Controllers\RoleController;
|
||||
use App\Http\Controllers\RolePermissionController;
|
||||
@@ -123,6 +124,13 @@
|
||||
Route::get('/import', [ProjectManagementController::class, 'import'])->name('import');
|
||||
});
|
||||
|
||||
// 일일 스크럼 (Blade 화면만)
|
||||
Route::prefix('daily-logs')->name('daily-logs.')->group(function () {
|
||||
Route::get('/', [DailyLogController::class, 'index'])->name('index');
|
||||
Route::get('/today', [DailyLogController::class, 'today'])->name('today');
|
||||
Route::get('/{id}', [DailyLogController::class, 'show'])->name('show');
|
||||
});
|
||||
|
||||
// 대시보드
|
||||
Route::get('/dashboard', function () {
|
||||
return view('dashboard.index');
|
||||
|
||||
Reference in New Issue
Block a user