@extends('layouts.app') @push('head') @endpush @section('content') @include('projects._nav') @php // 預設值,主甘特圖區塊會覆蓋 $ganttRangeStart = null; $ganttTotalDays = 0; $ganttTodayPct = 0; $ganttTasksJson = '[]'; $ganttMsJson = '[]'; $ganttPredefined = '{}'; $ganttPalette = '[]'; $ganttTodayColW = 0; $ganttGridJson = '[]'; $ganttColLabels = '[]'; @endphp
{{-- Header --}}
專案時程 / 甘特圖
{{ $project->name }}
@if($activeContract && (count($flatTasks) || $milestones->isNotEmpty()))
{{-- 匯出 --}} {{-- 週/日切換 --}} @if($canManage) @if($canImport) @endif @if($activeContract) @endif @endif
@endif
@if(session('success'))
{{ session('success') }}
@endif {{-- ── 未選合約 ─────────────────────────────────────────── --}} @if(!$activeContract)
請先從上方導覽列選擇合約,再查看該合約的甘特圖
{{-- ── 初始化精靈 ───────────────────────────────────────── --}} @elseif(empty($flatTasks) && $milestones->isEmpty())
初始化專案時程 {{ $activeContract->name }}
@csrf
{{-- 起迄 --}}
開始日期
結束日期
{{-- 階段選擇 --}}
@foreach($phases as $key => $info)
@endforeach
{{-- 自訂階段 --}}
{{-- ── 主甘特圖 ─────────────────────────────────────────── --}} @else @php $today = \Carbon\CarbonImmutable::today(); // 計算時間軸範圍:只用任務/查核點日期 + 今天,不用合約日期 // 避免合約跨度(如全年)讓時間軸出現大量空欄 $dates = collect(); foreach ($flatTasks as $row) { $t = $row['task']; if ($t->start_date) $dates->push($t->start_date); if ($t->end_date) $dates->push($t->end_date); } foreach ($milestones as $ms) { $dates->push($ms->due_date); } $dates->push($today); // 強制用可變 Carbon,避免 CarbonImmutable 的 addDay/addWeek/addMonth 不修改自身造成無窮迴圈 $rangeStart = \Carbon\Carbon::parse($dates->min())->startOfWeek(\Carbon\Carbon::MONDAY); $rangeEnd = \Carbon\Carbon::parse($dates->max())->endOfWeek(\Carbon\Carbon::SUNDAY); $totalDays = $rangeStart->diffInDays($rangeEnd) + 1; // 建立欄位 $columns = []; $monthGroups = []; // 週/日視圖雙層表頭用 if ($viewMode === 'month') { // 月視圖:以完整月份為範圍,避免邊界月份過窄導致標籤溢出 $rangeStart = \Carbon\Carbon::parse($dates->min())->startOfMonth(); $rangeEnd = \Carbon\Carbon::parse($dates->max())->endOfMonth(); $totalDays = $rangeStart->diffInDays($rangeEnd) + 1; $cur = $rangeStart->copy(); while ($cur->lte($rangeEnd)) { $mStart = $cur->copy()->startOfMonth(); $mEndi = $cur->copy()->endOfMonth(); $wDays = $mEndi->diffInDays($mStart) + 1; $columns[] = [ 'label' => $cur->format('Y/n月'), 'leftDays' => $rangeStart->diffInDays($mStart), 'widDays' => $wDays, 'isToday' => $today->between($mStart, $mEndi), 'month' => $cur->format('Y-m'), ]; $cur->addMonth(); } } elseif ($viewMode === 'week') { $cur = $rangeStart->copy(); while ($cur->lte($rangeEnd)) { $wStart = $cur->copy()->startOfWeek(\Carbon\Carbon::MONDAY); $wEnd = $cur->copy()->endOfWeek(\Carbon\Carbon::SUNDAY); $left = $rangeStart->diffInDays($wStart); $width = min($wEnd, $rangeEnd)->diffInDays(max($wStart, $rangeStart)) + 1; $columns[] = [ 'label' => 'W'.$cur->isoWeek(), 'leftDays' => $left, 'widDays' => $width, 'isToday' => $today->between($wStart, $wEnd), 'month' => $wStart->format('Y-m'), ]; $cur->addWeek(); } } else { // 日視圖 $cur = $rangeStart->copy(); while ($cur->lte($rangeEnd)) { $columns[] = [ 'label' => $cur->format('j'), 'leftDays' => $rangeStart->diffInDays($cur), 'widDays' => 1, 'isToday' => $cur->isSameDay($today), 'month' => $cur->format('Y-m'), ]; $cur->addDay(); } } $numCols = count($columns); // 月份分組(週/日視圖雙層表頭用) if ($viewMode !== 'month') { $prevMKey = null; $mIdx = -1; $mIdxByCi = []; // column index → monthGroups index foreach ($columns as $ci => $col) { $mKey = $col['month']; $mDate = \Carbon\Carbon::createFromFormat('Y-m-d', $mKey.'-01'); $label = $mDate->format('Y/m'); $isTm = $today->format('Y-m') === $mKey; if ($mKey !== $prevMKey) { $mIdx++; $monthGroups[] = ['label' => $label, 'span' => 1, 'isToday' => $isTm, 'idx' => $mIdx]; $prevMKey = $mKey; } else { $monthGroups[count($monthGroups) - 1]['span']++; } $mIdxByCi[$ci] = $mIdx; } // 把 monthIdx 寫回 columns foreach ($columns as $ci => &$col) { $col['monthIdx'] = $mIdxByCi[$ci] ?? 0; } unset($col); } $todayLeft = max(0, min(100, $rangeStart->diffInDays($today) / $totalDays * 100)); // 計算 bar 位置 $barPos = function($start, $end) use ($rangeStart, $totalDays) { $s = $start ?? $end; $e = $end ?? $start; if (!$s) return null; $left = max(0, $rangeStart->diffInDays($s) / $totalDays * 100); $right = min(100, ($rangeStart->diffInDays($e) + 1) / $totalDays * 100); return ['left' => $left, 'width' => max(0.3, $right - $left)]; }; $diaPos = fn($date) => max(0, min(100, $rangeStart->diffInDays($date) / $totalDays * 100)); // 預計算 JS 用的 JSON(避免 @json 解析複雜運算式失敗) $ganttRangeStart = $rangeStart; $ganttTotalDays = $totalDays; $ganttTodayPct = round($todayLeft, 4); // Canvas 用:today 欄寬度 + 各欄左邊界(百分比) $ganttTodayColW = 0; $ganttGridPcts = []; foreach ($columns as $col) { $ganttGridPcts[] = round($col['leftDays'] / $totalDays * 100, 4); if ($col['isToday']) { $ganttTodayColW = round($col['widDays'] / $totalDays * 100, 4); } } $ganttGridJson = json_encode($ganttGridPcts); $ganttColLabels = $viewMode === 'day' ? json_encode(array_column($columns, 'label')) : '[]'; $ganttTasksJson = json_encode( collect($flatTasks)->map(fn($r) => [ 'id' => $r['task']->id, 'parent_id' => $r['task']->parent_id, 'start' => $r['task']->start_date?->format('Y-m-d'), 'end' => $r['task']->end_date?->format('Y-m-d'), 'rootIndex' => $r['rootIndex'], 'depth' => $r['depth'], 'phase' => $r['task']->phase, ])->values() ); $ganttMsJson = json_encode( $milestones->map(fn($ms) => [ 'id' => $ms->id, 'due' => $ms->due_date->format('Y-m-d'), 'done' => $ms->isCompleted(), 'late' => $ms->isOverdue(), ])->values() ); $ganttPredefined = json_encode( collect(\App\Models\ProjectGanttTask::$predefinedPhases)->map(fn($v) => $v['color']) ); $ganttPalette = json_encode(\App\Models\ProjectGanttTask::$paletteColors); @endphp {{-- 合約資訊列 --}}
{{ $activeContract->name }} @if($activeContract->start_date || $activeContract->end_date) {{ $activeContract->start_date?->format('Y/m/d') ?? '?' }} → {{ $activeContract->end_date?->format('Y/m/d') ?? '?' }} @endif 今天 {{ $today->format('Y/m/d') }}
{{-- ── Gantt 圖 ──────────────────────────────────────── --}}
@if($viewMode === 'day') @else @foreach($columns as $col) @endforeach @endif {{-- Header --}} @if($viewMode !== 'month') {{-- 月份列(第一列) --}} @foreach($monthGroups as $mg) @endforeach {{-- 週/日列(第二列) --}} @if($viewMode === 'day') {{-- 日視圖:單一佔位格,標籤由 canvas 繪製,避免 365 個 @else @foreach($columns as $col) @endforeach @endif @else {{-- 月視圖:單列 --}} @foreach($columns as $col) @endforeach @endif {{-- ── 預計算:查核點依所屬區塊分組 --}} @php $msByBlock = $milestones->groupBy('gantt_task_id'); $noBlockMs = $msByBlock->get(null, collect()); // rootIndex → top-level task id $rootIdByIdx = []; foreach ($flatTasks as $r) { if ($r['depth'] === 0) $rootIdByIdx[$r['rootIndex']] = $r['task']->id; } @endphp {{-- ── WBS 任務(每個頂層區塊結尾附查核點)────── --}} @if(!empty($flatTasks)) @php // 預先計算 phase / color,避免迴圈內 O(n²) 查找 $_predef = \App\Models\ProjectGanttTask::$predefinedPhases; $_palette = \App\Models\ProjectGanttTask::$paletteColors; $_phaseByRoot = []; $_colorByRoot = []; foreach ($flatTasks as $_r) { if ($_r['depth'] === 0) { $ph = $_r['task']->phase; $_phaseByRoot[$_r['rootIndex']] = $ph; $_colorByRoot[$_r['rootIndex']] = isset($_predef[$ph]) ? $_predef[$ph]['color'] : ($_palette[$_r['rootIndex'] % count($_palette)] ?? '#0d6efd'); } } @endphp @foreach($flatTasks as $fIdx => $row) @php $t = $row['task']; $depth = $row['depth']; $ri = $row['rootIndex']; $color = $_colorByRoot[$ri] ?? '#0d6efd'; $isGroup = ($depth === 0); $isFirstInBlock = ($depth === 0); $nextRow = $flatTasks[$fIdx + 1] ?? null; $isLastInBlock = !$nextRow || $nextRow['depth'] === 0; @endphp {{-- 每個頂層區塊加一個 section header --}} @if($isFirstInBlock) @endif {{-- 此區塊結束 → 插入屬於此區塊的查核點 --}} @if($isLastInBlock) @php $rootId = $rootIdByIdx[$ri] ?? null; $blockMs = $msByBlock->get($rootId, collect()); @endphp @foreach($blockMs as $ms) @php $msDone = $ms->isCompleted(); $msLate = $ms->isOverdue(); $msColor = $msDone ? '#198754' : ($msLate ? '#dc3545' : '#fd7e14'); @endphp @endforeach @endif @endforeach @endif {{-- ── 查核點(未指定區塊)──────────────────── --}} @if($noBlockMs->isNotEmpty()) @foreach($noBlockMs as $ms) @php $msDone = $ms->isCompleted(); $msLate = $ms->isOverdue(); $msColor = $msDone ? '#198754' : ($msLate ? '#dc3545' : '#fd7e14'); @endphp @endforeach @endif
工作項目 / 查核點{{ $mg['label'] }}
拖慢渲染 --}}
{{ $col['label'] }}
工作項目 / 查核點{{ $col['label'] }}
{{ $t->name }}
@if($canManage) @endif @if($isGroup) @else @endif @if($t->wbs_code) {{ $t->wbs_code }} @endif {{ $t->name }} @if($t->assignee) {{ $t->assignee->name }} @endif @if($canManage)
@csrf @method('DELETE')
@endif
@if($canManage) @endif {{ $ms->name }} {{ $ms->due_date->format('m/d') }} @if($canManage)
@csrf
@csrf @method('DELETE')
@endif
查核點(未指定區塊)
@if($canManage) @endif {{ $ms->name }} {{ $ms->due_date->format('m/d') }} @if($canManage)
@csrf
@csrf @method('DELETE')
@endif
{{-- SVG overlay for bars & diamonds ──────────────────── --}} {{-- 改用 absolute overlay div on scroll container --}}
{{-- /gantt-wrap --}} {{-- ── Gantt 視覺層(Canvas-like overlay)─────────────── --}} {{-- 使用獨立 div + JS 繪製 bar/diamond 覆蓋在 table 上 --}}
{{-- ── 查核點詳情(依區塊分組,表格式)──────────────────── --}} @if($milestones->isNotEmpty()) @php $msDetailByBlock = $milestones->groupBy('gantt_task_id'); $msNoBlock = $msDetailByBlock->get(null, collect()); $topTasksDetail = collect($flatTasks)->where('depth', 0)->pluck('task'); $predefD = \App\Models\ProjectGanttTask::$predefinedPhases; $paletteD = \App\Models\ProjectGanttTask::$paletteColors; @endphp
{{-- 標題列 --}}
排序:
{{-- /msDetailBody --}}
@endif @endif{{-- end main gantt --}}
{{-- /gantt-page --}} {{-- ── Modals ──────────────────────────────────────────────── --}} @if($canManage && $activeContract) {{-- ⚙ 時程設定 --}} {{-- 新增/編輯工作項目 --}} {{-- 新增/編輯查核點 --}} @if($canImport) {{-- 匯入工時評估 --}} @endif {{-- ── 刪除甘特圖確認 modal ──────────────────────────────────── --}} @if($activeContract) @endif @endif{{-- /canManage --}} @endsection @push('scripts') @endpush