diff --git a/app/Http/Controllers/Business/SystemizationTodos.php b/app/Http/Controllers/Business/SystemizationTodos.php index 023a43de..2b300249 100644 --- a/app/Http/Controllers/Business/SystemizationTodos.php +++ b/app/Http/Controllers/Business/SystemizationTodos.php @@ -17,6 +17,8 @@ use Throwable; class SystemizationTodos extends Controller { + private const OVERDUE_FILTER = '__overdue__'; + public function page(): array { $filters = $this->filters(); @@ -24,7 +26,11 @@ class SystemizationTodos extends Controller ->with('attachments') ->withCount('attachments'); - if ($filters['status'] !== '') { + if ($filters['status'] === self::OVERDUE_FILTER) { + $query->whereNotIn('status', [SystemizationTodo::STATUS_COMPLETED]) + ->whereNotNull('due_date') + ->whereDate('due_date', '<', today()); + } elseif ($filters['status'] !== '') { $query->where('status', $filters['status']); } if ($filters['category'] !== '') { @@ -33,8 +39,16 @@ class SystemizationTodos extends Controller if ($filters['owner'] !== '') { $query->where('owner', 'like', '%' . addcslashes($filters['owner'], '%_\\') . '%'); } - if ($filters['assignee'] !== '') { - $query->where('assignee', 'like', '%' . addcslashes($filters['assignee'], '%_\\') . '%'); + if ($filters['assignees'] !== []) { + $query->where(function ($builder) use ($filters): void { + foreach ($filters['assignees'] as $assignee) { + $builder->orWhere( + 'assignee', + 'like', + '%' . addcslashes($assignee, '%_\\') . '%', + ); + } + }); } if ($filters['start_date'] !== '') { $query->whereDate('start_date', $filters['start_date']); @@ -56,6 +70,20 @@ class SystemizationTodos extends Controller } $items = $query + ->select($query->qualifyColumn('*')) + ->selectRaw( + "ROW_NUMBER() OVER ( + PARTITION BY COALESCE(NULLIF(category, ''), '__uncategorized__') + ORDER BY + CASE WHEN status = '已完成' THEN 1 ELSE 0 END, + CASE WHEN section IS NULL OR section = '' THEN 1 ELSE 0 END, + section, + CASE WHEN due_date IS NULL THEN 1 ELSE 0 END, + due_date, + sort_order, + id + ) AS category_sequence" + ) ->orderByRaw("CASE WHEN status = '已完成' THEN 1 ELSE 0 END") ->orderByRaw("CASE WHEN category IS NULL OR category = '' THEN 1 ELSE 0 END") ->orderBy('category') @@ -85,6 +113,7 @@ class SystemizationTodos extends Controller 'todoOwners' => $this->peopleOptions('owner'), 'todoAssignees' => $this->peopleOptions('assignee'), 'todoStatuses' => SystemizationTodo::STATUSES, + 'todoOverdueFilter' => self::OVERDUE_FILTER, 'todoSummary' => [ 'total' => (clone $base)->count(), 'pending' => (clone $base)->where('status', SystemizationTodo::STATUS_PENDING)->count(), @@ -204,13 +233,24 @@ class SystemizationTodos extends Controller private function filters(): array { + $status = (string) request('status', ''); + $assignees = collect((array) request('assignee', [])) + ->filter(static fn (mixed $value): bool => is_string($value)) + ->map(static fn (string $value): string => trim($value)) + ->filter() + ->unique() + ->take(50) + ->values() + ->all(); + return [ - 'status' => in_array(request('status'), SystemizationTodo::STATUSES, true) - ? (string) request('status') + 'status' => $status === self::OVERDUE_FILTER + || in_array($status, SystemizationTodo::STATUSES, true) + ? $status : '', 'category' => trim((string) request('category', '')), 'owner' => trim((string) request('owner', '')), - 'assignee' => trim((string) request('assignee', '')), + 'assignees' => $assignees, 'start_date' => $this->normalizeDateFilter(request('start_date')), 'due_date' => $this->normalizeDateFilter(request('due_date')), 'keyword' => trim((string) request('keyword', '')), diff --git a/resources/views/business/pages/systemization-todos.blade.php b/resources/views/business/pages/systemization-todos.blade.php index 687c4fe7..ef6d4feb 100644 --- a/resources/views/business/pages/systemization-todos.blade.php +++ b/resources/views/business/pages/systemization-todos.blade.php @@ -21,6 +21,19 @@ .sys-todo-filter label { display:grid; flex:1 1 7.4rem; gap:.25rem; min-width:0; color:#595959; font-size:.72rem; } .sys-todo-filter label.sys-todo-filter__keyword { flex:2.2 1 16rem; } .sys-todo-filter input, .sys-todo-filter select { width:100%; height:2.125rem; padding:0 .6rem; border:1px solid var(--sys-border); border-radius:.45rem; background:#fff; color:#171717; font:inherit; font-size:.8125rem; } + .sys-todo-filter__multi { position:relative; flex:1 1 8.2rem; min-width:0; color:#595959; font-size:.72rem; } + .sys-todo-filter__multi > summary { position:relative; display:grid; align-content:center; gap:.18rem; min-height:2.95rem; padding:.28rem 1.8rem .28rem .6rem; border:1px solid var(--sys-border); border-radius:.45rem; background:#fff; color:#171717; cursor:pointer; list-style:none; } + .sys-todo-filter__multi > summary::-webkit-details-marker { display:none; } + .sys-todo-filter__multi > summary .material-icons-outlined { position:absolute; top:50%; right:.55rem; color:#777; font-size:1.05rem; transform:translateY(-50%); transition:transform 150ms ease; } + .sys-todo-filter__multi[open] > summary { border-color:var(--sys-gold); box-shadow:0 0 0 3px rgba(133,102,50,.14); } + .sys-todo-filter__multi[open] > summary .material-icons-outlined { transform:translateY(-50%) rotate(180deg); } + .sys-todo-filter__multi-label { color:#595959; font-size:.72rem; line-height:1; } + .sys-todo-filter__multi-value { overflow:hidden; font-size:.8125rem; line-height:1.2; text-overflow:ellipsis; white-space:nowrap; } + .sys-todo-filter__multi-menu { position:absolute; top:calc(100% + .3rem); left:0; z-index:20; display:grid; width:max(100%,13rem); max-height:17rem; padding:.35rem; border:1px solid var(--sys-border); border-radius:.55rem; background:#fff; box-shadow:0 8px 22px rgba(23,23,23,.16); overflow:auto; } + .sys-todo-filter__multi-menu label { display:flex; flex:none; align-items:center; gap:.45rem; min-height:2rem; padding:.3rem .45rem; border-radius:.35rem; color:#333; font-size:.78rem; cursor:pointer; } + .sys-todo-filter__multi-menu label:hover { background:var(--sys-gold-soft); } + .sys-todo-filter__multi-menu input[type="checkbox"] { flex:0 0 auto; width:1rem; height:1rem; padding:0; accent-color:var(--sys-gold); } + .sys-todo-filter__multi-empty { padding:.65rem; color:#777; font-size:.75rem; text-align:center; } .sys-todo-filter__actions { display:flex; flex:0 0 auto; gap:.45rem; } .sys-todo-table-wrap { overflow:auto; border:1px solid var(--sys-border); border-radius:.65rem; background:#fff; } .sys-todo-table-wrap { max-height:clamp(24rem,58vh,46rem); overscroll-behavior:contain; scrollbar-gutter:stable; } @@ -156,6 +169,7 @@
- + @php + $selectedAssignees = $todoAssignees->filter( + static fn (string $assignee): bool => in_array($assignee, $todoFilters['assignees'], true) + ); + $assigneeSummary = $selectedAssignees->isEmpty() + ? '全部责任人' + : '已选 ' . $selectedAssignees->count() . ' 人:' . $selectedAssignees->implode('、'); + @endphp +
+ + 责任人 + {{ $assigneeSummary }} + + +
+ @forelse ($todoAssignees as $assignee) + + @empty + 暂无可选责任人 + @endforelse +
+
@@ -188,7 +221,7 @@
@php - $cleanSectionLabel = static function (?string $value): string { + $cleanDisplayLabel = static function (?string $value): string { return trim((string) preg_replace( '/^\s*(?:\d+|[一二三四五六七八九十百]+)\s*[、..::)]\s*/u', '', @@ -196,7 +229,10 @@ )); }; $sectionLabels = $items - ->map(static fn ($groupItem): string => $cleanSectionLabel($groupItem->section)) + ->map(static fn ($groupItem): string => $cleanDisplayLabel($groupItem->section)) + ->all(); + $titleLabels = $items + ->map(static fn ($groupItem): string => $cleanDisplayLabel($groupItem->title)) ->all(); $groupValues = [ 'category' => $items->map(static fn ($groupItem): string => trim((string) ($groupItem->category ?? '')))->all(), @@ -264,7 +300,7 @@ 'id' => $item->id, 'category' => $item->category ?? '', 'section' => $sectionLabels[$loop->index] ?? '', - 'title' => $item->title, + 'title' => $titleLabels[$loop->index] ?? '', 'owner' => $item->owner ?? '', 'assignee' => $item->assignee ?? '', 'start_date' => $item->start_date?->format('Y-m-d') ?? '', @@ -295,8 +331,8 @@
{{ $sectionLabels[$loop->index] ?: '—' }}
@endif - {{ $items->firstItem() + $loop->index }} -
{{ $item->title }}
+ {{ (int) ($item->category_sequence ?? 0) }} +
{{ $titleLabels[$loop->index] }}
{{ $item->owner ?: '—' }} {{ $item->assignee ?: '—' }} {{ $item->start_date?->format('Y-m-d') ?: ($item->start_note ?: '—') }} @@ -443,6 +479,8 @@ var routes = @json($todoRoutes, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); var currentTodoId = null; var uploadPrompt = '点击选择、拖入此区域或直接粘贴,支持多张,每张不超过 10 MB。'; + var assigneeFilter = document.querySelector('[data-sys-todo-assignee-filter]'); + var assigneeSummary = assigneeFilter.querySelector('[data-sys-todo-assignee-summary]'); function decodePayload(value) { var binary = atob(value); @@ -462,6 +500,15 @@ .join('、'); } + function updateAssigneeSummary() { + var selected = Array.prototype.slice + .call(assigneeFilter.querySelectorAll('input[name="assignee[]"]:checked')) + .map(function (input) { return input.value; }); + assigneeSummary.textContent = selected.length + ? '已选 ' + selected.length + ' 人:' + selected.join('、') + : '全部责任人'; + } + function appendScreenshotFiles(files) { var images = Array.prototype.slice.call(files || []).filter(function (file) { return /^image\/(jpeg|png|webp)$/.test(file.type) @@ -612,6 +659,14 @@ imageViewer.addEventListener('click', function (event) { if (event.target === imageViewer) closeImageViewer(); }); + assigneeFilter.querySelectorAll('input[name="assignee[]"]').forEach(function (input) { + input.addEventListener('change', updateAssigneeSummary); + }); + document.addEventListener('click', function (event) { + if (!event.target.closest('[data-sys-todo-assignee-filter]')) { + assigneeFilter.removeAttribute('open'); + } + }); document.addEventListener('keydown', function (event) { if (event.target.matches('[data-sys-todo-image-close]') && (event.key === 'Enter' || event.key === ' ')) { event.preventDefault(); diff --git a/tests/Feature/Business/SystemizationTodosTest.php b/tests/Feature/Business/SystemizationTodosTest.php index 26cf6bbb..cd6c17d1 100644 --- a/tests/Feature/Business/SystemizationTodosTest.php +++ b/tests/Feature/Business/SystemizationTodosTest.php @@ -28,10 +28,13 @@ class SystemizationTodosTest extends FeatureTestCase ->assertSeeText('从 Excel 导入现有待办') ->assertSeeText('总负责人') ->assertSeeText('责任人') + ->assertSeeText('已逾期') ->assertSeeText('开始时间') ->assertSeeText('完成时间') ->assertSeeText('在列表底部新增一条') ->assertSee('data-sys-todo-create', false) + ->assertSee('data-sys-todo-assignee-filter', false) + ->assertSee('name="assignee[]"', false) ->assertSee('data-sys-todo-choose-files', false) ->assertSee('id="sysTodoImageViewer"', false); @@ -160,23 +163,44 @@ class SystemizationTodosTest extends FeatureTestCase { $this->loginAs(); - $this->createTodo('收入', '1、平台收入核对', '收入事项 A', '2026-10-26', 1, '晨曦', '栎阳、秋香'); - $this->createTodo('收入', '平台收入核对', '收入事项 B', '2026-10-27', 2); - $this->createTodo('成本', '3、退款核销', '成本事项', '2026-10-26', 3, '常仪', '山时'); + $this->createTodo('收入', '1、平台收入核对', '1、收入事项 A', '2026-10-26', 1, '晨曦', '栎阳、秋香'); + $this->createTodo('收入', '平台收入核对', '2、收入事项 B', '2026-10-27', 2); + $this->createTodo('成本', '3、退款核销', '3、成本事项', '2026-10-26', 3, '常仪', '山时'); + $this->createTodo('费用', '4、逾期核对', '4、逾期事项', '2026-10-20', 4, '人参', '玉兔'); - $this->get(route('business.pages.show', [ + $pageResponse = $this->get(route('business.pages.show', [ 'section' => 'other', 'page' => 'systemization-todos', - ])) + ])); + $pageResponse ->assertOk() ->assertSee('rowspan="2"', false) ->assertSee('内容序号待办事项', false) - ->assertSee('title="当前列表连续序号">1', false) - ->assertSee('title="当前列表连续序号">2', false) + ->assertSee('title="当前类型内序号">1', false) + ->assertSee('title="当前类型内序号">2', false) ->assertDontSee('1、平台收入核对') ->assertDontSee('3、退款核销') + ->assertDontSee('1、收入事项 A') + ->assertDontSee('3、成本事项') ->assertSee('收入事项 A') ->assertSee('收入事项 B'); + $pageItems = $pageResponse->viewData('content')['items']->getCollection(); + $this->assertSame( + [1, 2], + $pageItems->where('category', '收入') + ->pluck('category_sequence') + ->map(static fn (mixed $value): int => (int) $value) + ->values() + ->all(), + ); + $this->assertSame( + [1], + $pageItems->where('category', '成本') + ->pluck('category_sequence') + ->map(static fn (mixed $value): int => (int) $value) + ->values() + ->all(), + ); $response = $this->get(route('business.pages.show', [ 'section' => 'other', @@ -194,11 +218,23 @@ class SystemizationTodosTest extends FeatureTestCase $peopleResponse = $this->get(route('business.pages.show', [ 'section' => 'other', 'page' => 'systemization-todos', - 'owner' => '晨曦', - 'assignee' => '秋香', + 'assignee' => ['秋香', '山时'], ]))->assertOk(); $peopleContent = $peopleResponse->viewData('content'); - $this->assertSame(['收入事项 A'], $peopleContent['items']->pluck('title')->all()); + $this->assertEqualsCanonicalizing( + ['1、收入事项 A', '3、成本事项'], + $peopleContent['items']->pluck('title')->all(), + ); + + $overdueResponse = $this->get(route('business.pages.show', [ + 'section' => 'other', + 'page' => 'systemization-todos', + 'status' => '__overdue__', + ]))->assertOk(); + $this->assertSame( + ['4、逾期事项'], + $overdueResponse->viewData('content')['items']->pluck('title')->all(), + ); } private function createTodo(