完善系统化待办逾期与多选筛选 #567
@ -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', '')),
|
||||
|
||||
@ -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 @@
|
||||
<form class="sys-todo-filter" method="GET" action="{{ request()->url() }}">
|
||||
<label>状态<select name="status">
|
||||
<option value="">全部状态</option>
|
||||
<option value="{{ $todoOverdueFilter }}" @selected($todoFilters['status'] === $todoOverdueFilter)>已逾期</option>
|
||||
@foreach ($todoStatuses as $status)
|
||||
<option value="{{ $status }}" @selected($todoFilters['status'] === $status)>{{ $status }}</option>
|
||||
@endforeach
|
||||
@ -172,12 +186,31 @@
|
||||
<option value="{{ $owner }}" @selected($todoFilters['owner'] === $owner)>{{ $owner }}</option>
|
||||
@endforeach
|
||||
</select></label>
|
||||
<label>责任人<select name="assignee">
|
||||
<option value="">全部责任人</option>
|
||||
@foreach ($todoAssignees as $assignee)
|
||||
<option value="{{ $assignee }}" @selected($todoFilters['assignee'] === $assignee)>{{ $assignee }}</option>
|
||||
@endforeach
|
||||
</select></label>
|
||||
@php
|
||||
$selectedAssignees = $todoAssignees->filter(
|
||||
static fn (string $assignee): bool => in_array($assignee, $todoFilters['assignees'], true)
|
||||
);
|
||||
$assigneeSummary = $selectedAssignees->isEmpty()
|
||||
? '全部责任人'
|
||||
: '已选 ' . $selectedAssignees->count() . ' 人:' . $selectedAssignees->implode('、');
|
||||
@endphp
|
||||
<details class="sys-todo-filter__multi" data-sys-todo-assignee-filter>
|
||||
<summary>
|
||||
<span class="sys-todo-filter__multi-label">责任人</span>
|
||||
<span class="sys-todo-filter__multi-value" data-sys-todo-assignee-summary>{{ $assigneeSummary }}</span>
|
||||
<span class="material-icons-outlined" aria-hidden="true">expand_more</span>
|
||||
</summary>
|
||||
<div class="sys-todo-filter__multi-menu">
|
||||
@forelse ($todoAssignees as $assignee)
|
||||
<label>
|
||||
<input type="checkbox" name="assignee[]" value="{{ $assignee }}" @checked(in_array($assignee, $todoFilters['assignees'], true))>
|
||||
<span>{{ $assignee }}</span>
|
||||
</label>
|
||||
@empty
|
||||
<span class="sys-todo-filter__multi-empty">暂无可选责任人</span>
|
||||
@endforelse
|
||||
</div>
|
||||
</details>
|
||||
<label>开始时间<input type="date" name="start_date" value="{{ $todoFilters['start_date'] }}"></label>
|
||||
<label>完成时间<input type="date" name="due_date" value="{{ $todoFilters['due_date'] }}"></label>
|
||||
<label class="sys-todo-filter__keyword">关键词<input type="search" name="keyword" value="{{ $todoFilters['keyword'] }}" placeholder="事项、内容、责任人、进展或备注"></label>
|
||||
@ -188,7 +221,7 @@
|
||||
</form>
|
||||
|
||||
@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 @@
|
||||
<div class="sys-todo-table__clamp" title="{{ $sectionLabels[$loop->index] ?: '—' }}">{{ $sectionLabels[$loop->index] ?: '—' }}</div>
|
||||
</td>
|
||||
@endif
|
||||
<td class="sys-todo-table__index-cell"><span class="sys-todo-table__index" title="当前列表连续序号">{{ $items->firstItem() + $loop->index }}</span></td>
|
||||
<td class="sys-todo-table__title"><div class="sys-todo-table__clamp" title="{{ $item->title }}">{{ $item->title }}</div></td>
|
||||
<td class="sys-todo-table__index-cell"><span class="sys-todo-table__index" title="当前类型内序号">{{ (int) ($item->category_sequence ?? 0) }}</span></td>
|
||||
<td class="sys-todo-table__title"><div class="sys-todo-table__clamp" title="{{ $titleLabels[$loop->index] }}">{{ $titleLabels[$loop->index] }}</div></td>
|
||||
<td>{{ $item->owner ?: '—' }}</td>
|
||||
<td>{{ $item->assignee ?: '—' }}</td>
|
||||
<td>{{ $item->start_date?->format('Y-m-d') ?: ($item->start_note ?: '—') }}</td>
|
||||
@ -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();
|
||||
|
||||
@ -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('<th scope="col">内容</th><th scope="col">序号</th><th scope="col">待办事项</th>', false)
|
||||
->assertSee('title="当前列表连续序号">1</span>', false)
|
||||
->assertSee('title="当前列表连续序号">2</span>', false)
|
||||
->assertSee('title="当前类型内序号">1</span>', false)
|
||||
->assertSee('title="当前类型内序号">2</span>', 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(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user