feat: 增加系统化待办逾期待办弹窗 (#1057)
This commit is contained in:
parent
78f3bbb2a1
commit
80aa79ddc4
@ -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', ''));
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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'])
|
||||
|
||||
@ -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');
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user