feat: 增加系统化待办逾期待办弹窗 (#1057)

This commit is contained in:
chenxi 2026-11-11 09:30:37 +08:00
parent 78f3bbb2a1
commit 80aa79ddc4
4 changed files with 231 additions and 1 deletions

View File

@ -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', ''));

View File

@ -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 @@
<div class="sys-todo-summary__item"><div class="sys-todo-summary__label">全部</div><div class="sys-todo-summary__value" data-sys-todo-summary="total">{{ number_format($todoSummary['total']) }}</div></div>
<div class="sys-todo-summary__item is-warning"><div class="sys-todo-summary__label">未完成</div><div class="sys-todo-summary__value" data-sys-todo-summary="incomplete">{{ number_format($todoSummary['incomplete']) }}</div></div>
<div class="sys-todo-summary__item is-success"><div class="sys-todo-summary__label">已完成</div><div class="sys-todo-summary__value" data-sys-todo-summary="completed">{{ number_format($todoSummary['completed']) }}</div></div>
<div class="sys-todo-summary__item is-warning"><div class="sys-todo-summary__label">已逾期</div><div class="sys-todo-summary__value" data-sys-todo-summary="overdue">{{ number_format($todoSummary['overdue']) }}</div></div>
<button type="button" class="sys-todo-summary__item is-warning is-action" data-sys-todo-overdue-open aria-haspopup="dialog" aria-controls="sysTodoOverdueDialog">
<div class="sys-todo-summary__label">已逾期</div>
<div class="sys-todo-summary__value" data-sys-todo-summary="overdue">{{ number_format($todoSummary['overdue']) }}</div>
</button>
</div>
<form class="sys-todo-filter" method="GET" action="{{ request()->url() }}">
@ -1093,6 +1118,40 @@
</div>
</dialog>
<dialog class="sys-todo-overdue-dialog" id="sysTodoOverdueDialog" aria-labelledby="sysTodoOverdueDialogTitle">
<div class="sys-todo-overdue-dialog__head">
<span class="material-icons-outlined" aria-hidden="true">warning_amber</span>
<h2 id="sysTodoOverdueDialogTitle">已逾期待办</h2>
<span class="sys-todo-overdue-dialog__count" data-sys-todo-overdue-count></span>
<button type="button" data-sys-todo-overdue-close aria-label="关闭逾期待办弹窗">×</button>
</div>
<div class="sys-todo-overdue-dialog__body">
<div class="sys-todo-overdue-dialog__status" data-sys-todo-overdue-status>正在加载…</div>
<div class="sys-todo-overdue-dialog__table" data-sys-todo-overdue-table hidden>
<table class="sys-todo-overdue-table">
<thead>
<tr>
<th>序号</th>
<th>类型</th>
<th>内容</th>
<th>待办事项</th>
<th>总负责人</th>
<th>责任人</th>
<th>首次完成时间</th>
<th>当前完成时间</th>
<th>逾期</th>
</tr>
</thead>
<tbody data-sys-todo-overdue-body></tbody>
</table>
</div>
<div class="sys-todo-overdue-dialog__empty" data-sys-todo-overdue-empty hidden>当前没有逾期待办。</div>
</div>
<div class="sys-todo-overdue-dialog__foot">
<button type="button" data-sys-todo-overdue-close>关闭</button>
</div>
</dialog>
<dialog class="sys-todo-confirm" id="sysTodoDeleteConfirm" aria-labelledby="sysTodoDeleteConfirmTitle">
<form method="POST" data-sys-todo-delete-form>
@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();

View File

@ -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'])

View File

@ -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');