diff --git a/app/Http/Controllers/Business/SystemizationTodos.php b/app/Http/Controllers/Business/SystemizationTodos.php index 631dfd1f..68856828 100644 --- a/app/Http/Controllers/Business/SystemizationTodos.php +++ b/app/Http/Controllers/Business/SystemizationTodos.php @@ -170,6 +170,7 @@ class SystemizationTodos extends Controller ]), 'import' => route('business.systemization-todos.import'), 'history' => route('business.systemization-todos.history'), + 'overdue' => route('business.systemization-todos.overdue'), 'historyExport' => route('business.systemization-todos.history.export'), 'columnWidths' => route('business.systemization-todos.column-widths'), 'gridCells' => route('business.systemization-todos.grid-cells'), @@ -1525,6 +1526,45 @@ class SystemizationTodos extends Controller ]); } + public function overdue(): JsonResponse + { + $sequenceMap = $this->hierarchySequenceMap(); + $items = SystemizationTodo::query() + ->where('status', '<>', SystemizationTodo::STATUS_COMPLETED) + ->whereNotNull('reminder_date') + ->whereDate('reminder_date', '<', today()) + ->orderBy('reminder_date') + ->orderBy('sort_order') + ->orderBy('id') + ->get() + ->map(function (SystemizationTodo $todo) use ($sequenceMap): array { + $sequence = $sequenceMap[$todo->id] ?? []; + $reminderDate = $todo->reminder_date?->copy()->startOfDay(); + + return [ + 'id' => (int) $todo->id, + 'type' => (string) ($sequence['category_display'] ?? $todo->category ?? ''), + 'content' => (string) ($sequence['section_display'] ?? $todo->section ?? ''), + 'title' => (string) ($todo->title ?? ''), + 'owner' => implode('、', $this->ownerNames($todo->owner)), + 'assignee' => (string) ($todo->assignee ?? ''), + 'start_date' => $todo->start_date?->format('Y-m-d') ?? '', + 'reminder_date' => $reminderDate?->format('Y-m-d') ?? '', + 'due_date' => $todo->due_date?->format('Y-m-d') ?? (string) ($todo->due_note ?? ''), + 'overdue_days' => $reminderDate === null + ? 0 + : (int) $reminderDate->diffInDays(today()->startOfDay()), + ]; + }) + ->values(); + + return response()->json([ + 'ok' => true, + 'count' => $items->count(), + 'items' => $items, + ]); + } + public function historyExport(Request $request): StreamedResponse { $action = trim((string) $request->query('action', '')); diff --git a/resources/views/business/pages/systemization-todos.blade.php b/resources/views/business/pages/systemization-todos.blade.php index 189bb053..e76eb571 100644 --- a/resources/views/business/pages/systemization-todos.blade.php +++ b/resources/views/business/pages/systemization-todos.blade.php @@ -23,6 +23,9 @@ .sys-todo-summary__value { color:#171717; font-size:1rem; font-weight:700; font-variant-numeric:tabular-nums; } .sys-todo-summary__item.is-warning .sys-todo-summary__value { color:#935f07; } .sys-todo-summary__item.is-success .sys-todo-summary__value { color:#53793e; } + .sys-todo-summary__item.is-action { width:100%; color:inherit; font:inherit; text-align:left; cursor:pointer; } + .sys-todo-summary__item.is-action:hover { border-color:#cbb98e; background:#fff8e8; } + .sys-todo-summary__item.is-action:focus-visible { outline:2px solid var(--sys-gold); outline-offset:2px; } .sys-todo-filter { display:grid; grid-template-columns:repeat(6,minmax(8.5rem,1fr)); gap:.65rem .55rem; align-items:end; margin:0 0 .85rem; padding:.7rem; border:1px solid #e6dfd2; border-radius:.65rem; background:#fcfaf5; } .sys-todo-filter > label { display:grid; grid-column:span 1; gap:.25rem; min-width:0; color:#595959; font-size:.72rem; } .sys-todo-filter > label.sys-todo-filter__keyword { grid-column:span 3; } @@ -349,6 +352,25 @@ .sys-todo-image-dialog__foot a { color:#f0e4bf; font-size:.75rem; } .sys-todo-image-dialog__foot button { min-height:1.9rem; padding:0 .65rem; border:1px solid #8b4c4c; border-radius:.4rem; background:#2a1d1d; color:#ffb4b4; font:inherit; font-size:.72rem; cursor:pointer; } .sys-todo-image-dialog__foot button[hidden] { display:none; } + .sys-todo-overdue-dialog { width:min(76rem,calc(100vw - 2rem)); max-height:90vh; padding:0; border:1px solid var(--sys-border); border-radius:.8rem; background:#fff; color:#171717; overflow:hidden; } + .sys-todo-overdue-dialog::backdrop { background:rgba(23,23,23,.58); } + .sys-todo-overdue-dialog__head { display:flex; align-items:center; gap:.55rem; padding:.8rem 1rem; border-bottom:1px solid #e8e0d2; background:#fff8e8; } + .sys-todo-overdue-dialog__head h2 { margin:0; font-size:.95rem; } + .sys-todo-overdue-dialog__count { color:#935f07; font-size:.78rem; font-weight:600; } + .sys-todo-overdue-dialog__head button { margin-left:auto; width:2rem; height:2rem; border:1px solid var(--sys-border); border-radius:999px; background:#fff; color:#595959; cursor:pointer; } + .sys-todo-overdue-dialog__body { max-height:calc(90vh - 6.5rem); padding:.8rem 1rem; overflow:auto; } + .sys-todo-overdue-dialog__status { padding:1.5rem; color:#666; font-size:.8rem; text-align:center; } + .sys-todo-overdue-dialog__status[hidden], .sys-todo-overdue-dialog__table[hidden], .sys-todo-overdue-dialog__empty[hidden] { display:none; } + .sys-todo-overdue-table { width:100%; min-width:70rem; border-collapse:collapse; font-size:.75rem; } + .sys-todo-overdue-table th { position:sticky; top:0; z-index:1; padding:.55rem .6rem; border-bottom:1px solid #e2d9ca; background:#f6f0e5; color:#51483c; text-align:left; white-space:nowrap; } + .sys-todo-overdue-table td { padding:.6rem; border-bottom:1px solid #eee7da; line-height:1.55; vertical-align:top; } + .sys-todo-overdue-table tbody tr:hover td { background:#fffdf8; } + .sys-todo-overdue-table .is-title { min-width:18rem; font-weight:600; overflow-wrap:anywhere; } + .sys-todo-overdue-table .is-date { white-space:nowrap; font-variant-numeric:tabular-nums; } + .sys-todo-overdue-table .is-danger { color:#b80000; font-weight:600; white-space:nowrap; } + .sys-todo-overdue-dialog__empty { padding:2rem 1rem; color:#666; font-size:.8rem; text-align:center; } + .sys-todo-overdue-dialog__foot { display:flex; justify-content:flex-end; padding:.65rem 1rem; border-top:1px solid #e8e0d2; background:#fcfaf5; } + .sys-todo-overdue-dialog__foot button { min-height:2rem; padding:0 .85rem; border:1px solid var(--sys-border); border-radius:.4rem; background:#fff; color:#51483c; font:inherit; font-size:.76rem; cursor:pointer; } @media (max-width:1100px) { .sys-todo-summary { grid-template-columns:repeat(2,minmax(0,1fr)); } .sys-todo-filter { grid-template-columns:repeat(3,minmax(8.5rem,1fr)); } @@ -396,7 +418,10 @@
全部
{{ number_format($todoSummary['total']) }}
未完成
{{ number_format($todoSummary['incomplete']) }}
已完成
{{ number_format($todoSummary['completed']) }}
-
已逾期
{{ number_format($todoSummary['overdue']) }}
+
@@ -1093,6 +1118,40 @@ + +
+ +

已逾期待办

+ + +
+
+
正在加载…
+ + +
+
+ +
+
+ @csrf @@ -1367,6 +1426,14 @@ var historyMeta = historyDialog.querySelector('[data-sys-todo-history-meta]'); var historyBody = historyDialog.querySelector('[data-sys-todo-history-body]'); var historyEmpty = historyDialog.querySelector('[data-sys-todo-history-empty]'); + var overdueTrigger = pageRoot.querySelector('[data-sys-todo-overdue-open]'); + var overdueDialog = document.getElementById('sysTodoOverdueDialog'); + var overdueCount = overdueDialog.querySelector('[data-sys-todo-overdue-count]'); + var overdueStatus = overdueDialog.querySelector('[data-sys-todo-overdue-status]'); + var overdueTable = overdueDialog.querySelector('[data-sys-todo-overdue-table]'); + var overdueBody = overdueDialog.querySelector('[data-sys-todo-overdue-body]'); + var overdueEmpty = overdueDialog.querySelector('[data-sys-todo-overdue-empty]'); + var overdueRequestSerial = 0; var routes = @json($todoRoutes, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); var positionGroups = @json($todoPositionGroups, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); var contentOptions = @json($todoContentOptions, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); @@ -2186,6 +2253,88 @@ else deleteConfirm.removeAttribute('open'); } + function closeOverdueDialog() { + overdueRequestSerial += 1; + if (typeof overdueDialog.close === 'function') overdueDialog.close(); + else overdueDialog.removeAttribute('open'); + if (overdueTrigger) overdueTrigger.focus({ preventScroll: true }); + } + + function renderOverdueItems(items) { + var rows = Array.isArray(items) ? items : []; + overdueCount.textContent = '共 ' + rows.length + ' 条'; + overdueStatus.hidden = true; + overdueTable.hidden = rows.length === 0; + overdueEmpty.hidden = rows.length !== 0; + overdueBody.replaceChildren(); + + rows.forEach(function (item, index) { + var row = document.createElement('tr'); + var values = [ + { text: String(index + 1), className: 'is-date' }, + { text: item.type || '—' }, + { text: item.content || '—' }, + { text: item.title || '—', className: 'is-title' }, + { text: item.owner || '—' }, + { text: item.assignee || '—' }, + { text: item.reminder_date || '—', className: 'is-date' }, + { text: item.due_date || '—', className: 'is-date' }, + { + text: Number(item.overdue_days || 0) > 0 + ? '逾期 ' + Number(item.overdue_days) + ' 天' + : '已逾期', + className: 'is-danger', + }, + ]; + values.forEach(function (value) { + var cell = document.createElement('td'); + cell.textContent = value.text; + if (value.className) cell.className = value.className; + row.appendChild(cell); + }); + overdueBody.appendChild(row); + }); + } + + function openOverdueDialog() { + if (!overdueTrigger || !overdueDialog || !routes.overdue) return; + var requestSerial = ++overdueRequestSerial; + overdueCount.textContent = ''; + overdueBody.replaceChildren(); + overdueTable.hidden = true; + overdueEmpty.hidden = true; + overdueStatus.hidden = false; + overdueStatus.textContent = '正在加载…'; + if (typeof overdueDialog.showModal === 'function') overdueDialog.showModal(); + else overdueDialog.setAttribute('open', ''); + + fetch(routes.overdue, { + method: 'GET', + credentials: 'same-origin', + headers: { Accept: 'application/json' }, + }) + .then(function (response) { + if (!response.ok) throw new Error('逾期待办加载失败,请稍后重试。'); + return response.json(); + }) + .then(function (data) { + if (requestSerial !== overdueRequestSerial) return; + renderOverdueItems(data.items); + }) + .catch(function (error) { + if (requestSerial !== overdueRequestSerial) return; + overdueStatus.hidden = false; + overdueStatus.textContent = error.message || '逾期待办加载失败,请稍后重试。'; + }); + } + + if (overdueTrigger) overdueTrigger.addEventListener('click', openOverdueDialog); + overdueDialog.addEventListener('click', function (event) { + if (event.target.closest('[data-sys-todo-overdue-close]') || event.target === overdueDialog) { + closeOverdueDialog(); + } + }); + function historyFilteredEvents() { var action = historyAction.value; var keyword = historyKeyword.value.trim().toLowerCase(); diff --git a/routes/admin.php b/routes/admin.php index c8f1d776..d7cd4af1 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -944,6 +944,7 @@ Route::prefix('workspace/other/systemization-todos') ->name('column-widths'); Route::post('/', [SystemizationTodos::class, 'store'])->name('store'); Route::get('history', [SystemizationTodos::class, 'history'])->name('history'); + Route::get('overdue', [SystemizationTodos::class, 'overdue'])->name('overdue'); Route::patch('grid/cells', [SystemizationTodos::class, 'batchUpdateCells']) ->name('grid-cells'); Route::post('grid/create', [SystemizationTodos::class, 'createGridRow']) diff --git a/tests/Feature/Business/SystemizationTodosTest.php b/tests/Feature/Business/SystemizationTodosTest.php index 95241abc..8d197e05 100644 --- a/tests/Feature/Business/SystemizationTodosTest.php +++ b/tests/Feature/Business/SystemizationTodosTest.php @@ -31,6 +31,8 @@ class SystemizationTodosTest extends FeatureTestCase ->assertSeeText('责任人') ->assertSeeText('待办事项序号') ->assertSeeText('已逾期') + ->assertSee('data-sys-todo-overdue-open', false) + ->assertSee('id="sysTodoOverdueDialog"', false) ->assertSeeText('开始时间') ->assertSeeText('完成时间') ->assertSeeText('待定') @@ -1557,6 +1559,44 @@ class SystemizationTodosTest extends FeatureTestCase } } + public function test_overdue_endpoint_lists_the_todos_counted_as_overdue(): void + { + Carbon::setTestNow('2026-10-26 12:00:00'); + try { + $this->loginAs(); + $overdue = $this->createTodo( + '收入', + '逾期核对', + '逾期需要处理', + '2026-10-20', + 1, + '人参、晨曦', + '苏木、秋香', + ); + $this->createTodo('收入', '逾期核对', '今天到期不逾期', '2026-10-26', 2); + $this->createTodo('收入', '逾期核对', '未来到期不逾期', '2026-10-30', 3); + $completed = $this->createTodo('收入', '逾期核对', '已完成不逾期', '2026-10-20', 4); + $completed->update([ + 'status' => SystemizationTodo::STATUS_COMPLETED, + 'completed_at' => '2026-10-21', + ]); + $this->createTodo('收入', '逾期核对', '没有完成时间', null, 5); + + $this->getJson(route('business.systemization-todos.overdue')) + ->assertOk() + ->assertJsonPath('ok', true) + ->assertJsonPath('count', 1) + ->assertJsonPath('items.0.id', $overdue->id) + ->assertJsonPath('items.0.title', '逾期需要处理') + ->assertJsonPath('items.0.owner', '人参、晨曦') + ->assertJsonPath('items.0.assignee', '苏木、秋香') + ->assertJsonPath('items.0.reminder_date', '2026-10-20') + ->assertJsonPath('items.0.overdue_days', 6); + } finally { + Carbon::setTestNow(); + } + } + public function test_reminder_keeps_the_first_saved_completion_date(): void { Carbon::setTestNow('2026-10-26 12:00:00');