调整系统化待办筛选与列顺序 #560
@ -8,6 +8,7 @@ use App\Models\Business\SystemizationTodoAttachment;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
@ -19,17 +20,6 @@ class SystemizationTodos extends Controller
|
||||
public function page(): array
|
||||
{
|
||||
$filters = $this->filters();
|
||||
if (
|
||||
$filters['date_from'] !== ''
|
||||
&& $filters['date_to'] !== ''
|
||||
&& $filters['date_from'] > $filters['date_to']
|
||||
) {
|
||||
[$filters['date_from'], $filters['date_to']] = [
|
||||
$filters['date_to'],
|
||||
$filters['date_from'],
|
||||
];
|
||||
}
|
||||
|
||||
$query = SystemizationTodo::query()
|
||||
->with('attachments')
|
||||
->withCount('attachments');
|
||||
@ -41,16 +31,16 @@ class SystemizationTodos extends Controller
|
||||
$query->where('category', $filters['category']);
|
||||
}
|
||||
if ($filters['owner'] !== '') {
|
||||
$query->where(function ($builder) use ($filters): void {
|
||||
$builder->where('owner', $filters['owner'])
|
||||
->orWhere('assignee', $filters['owner']);
|
||||
});
|
||||
$query->where('owner', 'like', '%' . addcslashes($filters['owner'], '%_\\') . '%');
|
||||
}
|
||||
if ($filters['date_from'] !== '') {
|
||||
$query->whereDate($filters['date_scope'], '>=', $filters['date_from']);
|
||||
if ($filters['assignee'] !== '') {
|
||||
$query->where('assignee', 'like', '%' . addcslashes($filters['assignee'], '%_\\') . '%');
|
||||
}
|
||||
if ($filters['date_to'] !== '') {
|
||||
$query->whereDate($filters['date_scope'], '<=', $filters['date_to']);
|
||||
if ($filters['start_date'] !== '') {
|
||||
$query->whereDate('start_date', $filters['start_date']);
|
||||
}
|
||||
if ($filters['due_date'] !== '') {
|
||||
$query->whereDate('due_date', $filters['due_date']);
|
||||
}
|
||||
if ($filters['keyword'] !== '') {
|
||||
$like = '%' . addcslashes($filters['keyword'], '%_\\') . '%';
|
||||
@ -85,21 +75,6 @@ class SystemizationTodos extends Controller
|
||||
->distinct()
|
||||
->orderBy('category')
|
||||
->pluck('category');
|
||||
$owners = SystemizationTodo::query()
|
||||
->whereNotNull('owner')
|
||||
->where('owner', '<>', '')
|
||||
->pluck('owner')
|
||||
->merge(
|
||||
SystemizationTodo::query()
|
||||
->whereNotNull('assignee')
|
||||
->where('assignee', '<>', '')
|
||||
->pluck('assignee')
|
||||
)
|
||||
->flatMap(static fn (string $value): array => array_map('trim', preg_split('/[、,,]/u', $value) ?: []))
|
||||
->filter()
|
||||
->unique()
|
||||
->sort()
|
||||
->values();
|
||||
|
||||
return [
|
||||
'view' => 'business.pages.systemization-todos',
|
||||
@ -107,9 +82,9 @@ class SystemizationTodos extends Controller
|
||||
'items' => $items,
|
||||
'todoFilters' => $filters,
|
||||
'todoCategories' => $categories,
|
||||
'todoOwners' => $owners,
|
||||
'todoOwners' => $this->peopleOptions('owner'),
|
||||
'todoAssignees' => $this->peopleOptions('assignee'),
|
||||
'todoStatuses' => SystemizationTodo::STATUSES,
|
||||
'todoDateScopes' => $this->dateScopes(),
|
||||
'todoSummary' => [
|
||||
'total' => (clone $base)->count(),
|
||||
'pending' => (clone $base)->where('status', SystemizationTodo::STATUS_PENDING)->count(),
|
||||
@ -229,29 +204,30 @@ class SystemizationTodos extends Controller
|
||||
|
||||
private function filters(): array
|
||||
{
|
||||
$dateScopes = $this->dateScopes();
|
||||
$dateScope = (string) request('date_scope', 'due_date');
|
||||
|
||||
return [
|
||||
'status' => in_array(request('status'), SystemizationTodo::STATUSES, true)
|
||||
? (string) request('status')
|
||||
: '',
|
||||
'category' => trim((string) request('category', '')),
|
||||
'owner' => trim((string) request('owner', '')),
|
||||
'assignee' => trim((string) request('assignee', '')),
|
||||
'start_date' => $this->normalizeDateFilter(request('start_date')),
|
||||
'due_date' => $this->normalizeDateFilter(request('due_date')),
|
||||
'keyword' => trim((string) request('keyword', '')),
|
||||
'date_scope' => array_key_exists($dateScope, $dateScopes) ? $dateScope : 'due_date',
|
||||
'date_from' => $this->normalizeDateFilter(request('date_from')),
|
||||
'date_to' => $this->normalizeDateFilter(request('date_to')),
|
||||
];
|
||||
}
|
||||
|
||||
private function dateScopes(): array
|
||||
private function peopleOptions(string $column): Collection
|
||||
{
|
||||
return [
|
||||
'due_date' => '完成时间',
|
||||
'start_date' => '开始时间',
|
||||
'created_at' => '新增时间',
|
||||
];
|
||||
return SystemizationTodo::query()
|
||||
->whereNotNull($column)
|
||||
->where($column, '<>', '')
|
||||
->pluck($column)
|
||||
->flatMap(static fn (string $value): array => array_map('trim', preg_split('/[、,,]/u', $value) ?: []))
|
||||
->filter()
|
||||
->unique()
|
||||
->sort()
|
||||
->values();
|
||||
}
|
||||
|
||||
private function normalizeDateFilter(mixed $value): string
|
||||
|
||||
@ -24,24 +24,23 @@
|
||||
.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; }
|
||||
.sys-todo-table { width:100%; min-width:88rem; border-collapse:separate; border-spacing:0; font-size:.75rem; }
|
||||
.sys-todo-table { width:100%; min-width:80rem; border-collapse:separate; border-spacing:0; font-size:.75rem; }
|
||||
.sys-todo-table th { position:sticky; top:0; z-index:4; padding:.55rem .48rem; border-bottom:1px solid var(--sys-border); background:#f3ece0; color:#3d3a35; text-align:left; white-space:nowrap; }
|
||||
.sys-todo-table td { padding:.45rem .48rem; border-bottom:1px solid #eee7da; background:#fff; color:#2f2a23; vertical-align:top; }
|
||||
.sys-todo-table tbody tr { cursor:pointer; }
|
||||
.sys-todo-table tbody tr:hover td { background:#fdfbf6; }
|
||||
.sys-todo-table tbody tr:focus-visible { outline:2px solid var(--sys-gold); outline-offset:-2px; }
|
||||
.sys-todo-table th:first-child, .sys-todo-table td:first-child { position:sticky; left:0; z-index:3; width:4rem; min-width:4rem; text-align:center; box-shadow:1px 0 0 #e5ddcf; }
|
||||
.sys-todo-table th:first-child { z-index:6; background:#f3ece0; }
|
||||
.sys-todo-table__index-cell { width:3.6rem; min-width:3.6rem; text-align:center; }
|
||||
.sys-todo-table th:last-child, .sys-todo-table td:last-child { position:sticky; right:0; z-index:3; width:5.8rem; min-width:5.8rem; background:#fff; box-shadow:-1px 0 0 #e5ddcf; }
|
||||
.sys-todo-table th:last-child { z-index:6; background:#f3ece0; }
|
||||
.sys-todo-table tbody tr:hover td:first-child, .sys-todo-table tbody tr:hover td:last-child { background:#fdfbf6; }
|
||||
.sys-todo-table tbody tr:hover td:last-child { background:#fdfbf6; }
|
||||
.sys-todo-table__index { color:#6d5630; font-weight:600; font-variant-numeric:tabular-nums; white-space:nowrap; }
|
||||
.sys-todo-table__group { vertical-align:middle; background:#fcfaf5; font-weight:600; }
|
||||
.sys-todo-table__section { min-width:14rem; max-width:20rem; }
|
||||
.sys-todo-table__section { min-width:11rem; max-width:17rem; }
|
||||
.sys-todo-table__title, .sys-todo-table__progress, .sys-todo-table__notes { line-height:1.45; white-space:pre-line; overflow-wrap:anywhere; }
|
||||
.sys-todo-table__title { min-width:20rem; max-width:27rem; font-weight:600; }
|
||||
.sys-todo-table__progress { min-width:15rem; max-width:23rem; }
|
||||
.sys-todo-table__notes { min-width:11rem; max-width:18rem; }
|
||||
.sys-todo-table__title { min-width:17rem; max-width:24rem; font-weight:600; }
|
||||
.sys-todo-table__progress { min-width:12rem; max-width:20rem; }
|
||||
.sys-todo-table__notes { min-width:9rem; max-width:16rem; }
|
||||
.sys-todo-table__clamp { display:-webkit-box; overflow:hidden; -webkit-box-orient:vertical; -webkit-line-clamp:3; }
|
||||
.sys-todo-table__section .sys-todo-table__clamp { -webkit-line-clamp:2; }
|
||||
.sys-todo-badge { display:inline-flex; align-items:center; padding:.1rem .45rem; border:1px solid #dfd6c5; border-radius:999px; background:#faf6ee; color:#51483c; white-space:nowrap; }
|
||||
@ -167,19 +166,20 @@
|
||||
<option value="{{ $category }}" @selected($todoFilters['category'] === $category)>{{ $category }}</option>
|
||||
@endforeach
|
||||
</select></label>
|
||||
<label>责任人<select name="owner">
|
||||
<option value="">全部责任人</option>
|
||||
<label>总负责人<select name="owner">
|
||||
<option value="">全部总负责人</option>
|
||||
@foreach ($todoOwners as $owner)
|
||||
<option value="{{ $owner }}" @selected($todoFilters['owner'] === $owner)>{{ $owner }}</option>
|
||||
@endforeach
|
||||
</select></label>
|
||||
<label>时间类型<select name="date_scope" data-sys-todo-filter-date-scope>
|
||||
@foreach ($todoDateScopes as $value => $label)
|
||||
<option value="{{ $value }}" @selected($todoFilters['date_scope'] === $value)>{{ $label }}</option>
|
||||
<label>责任人<select name="assignee">
|
||||
<option value="">全部责任人</option>
|
||||
@foreach ($todoAssignees as $assignee)
|
||||
<option value="{{ $assignee }}" @selected($todoFilters['assignee'] === $assignee)>{{ $assignee }}</option>
|
||||
@endforeach
|
||||
</select></label>
|
||||
<label>从<input type="date" name="date_from" value="{{ $todoFilters['date_from'] }}"></label>
|
||||
<label>至<input type="date" name="date_to" value="{{ $todoFilters['date_to'] }}"></label>
|
||||
<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>
|
||||
<div class="sys-todo-filter__actions">
|
||||
<button type="submit" class="sys-todo-page__button sys-todo-page__button--primary">筛选</button>
|
||||
@ -188,13 +188,29 @@
|
||||
</form>
|
||||
|
||||
@php
|
||||
$cleanSectionLabel = static function (?string $value): string {
|
||||
return trim((string) preg_replace(
|
||||
'/^\s*(?:\d+|[一二三四五六七八九十百]+)\s*[、..::)]\s*/u',
|
||||
'',
|
||||
(string) $value,
|
||||
));
|
||||
};
|
||||
$sectionLabels = $items
|
||||
->map(static fn ($groupItem): string => $cleanSectionLabel($groupItem->section))
|
||||
->all();
|
||||
$groupValues = [
|
||||
'category' => $items->map(static fn ($groupItem): string => trim((string) ($groupItem->category ?? '')))->all(),
|
||||
'section' => $items->map(static fn ($groupItem, int $index): string => trim((string) ($groupItem->category ?? ''))
|
||||
. "\0"
|
||||
. ($sectionLabels[$index] ?? ''))->all(),
|
||||
];
|
||||
$groupRowspans = ['category' => [], 'section' => []];
|
||||
foreach (['category', 'section'] as $groupField) {
|
||||
$start = 0;
|
||||
$previous = null;
|
||||
$count = 0;
|
||||
foreach ($items as $index => $groupItem) {
|
||||
$current = trim((string) ($groupItem->{$groupField} ?? ''));
|
||||
$current = (string) ($groupValues[$groupField][$index] ?? '');
|
||||
if ($index === 0) {
|
||||
$start = 0;
|
||||
$previous = $current;
|
||||
@ -221,13 +237,14 @@
|
||||
}
|
||||
}
|
||||
$lastItem = $items->last();
|
||||
$lastSectionLabel = $items->isNotEmpty() ? ($sectionLabels[$items->count() - 1] ?? '') : '';
|
||||
@endphp
|
||||
|
||||
<div class="sys-todo-table-wrap" tabindex="0" aria-label="待办列表,可滚动查看全部内容">
|
||||
<table class="sys-todo-table" aria-label="财务系统化待办清单">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">序号</th><th scope="col">类型</th><th scope="col">内容</th><th scope="col">待办事项</th><th scope="col">总负责人</th><th scope="col">责任人</th>
|
||||
<th scope="col">类型</th><th scope="col">内容</th><th scope="col">序号</th><th scope="col">待办事项</th><th scope="col">总负责人</th><th scope="col">责任人</th>
|
||||
<th scope="col">开始时间</th><th scope="col">完成时间</th><th scope="col">状态</th><th scope="col">完成情况</th><th scope="col">完成截图</th><th scope="col">备注</th><th scope="col">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@ -246,7 +263,7 @@
|
||||
$todoPayload = [
|
||||
'id' => $item->id,
|
||||
'category' => $item->category ?? '',
|
||||
'section' => $item->section ?? '',
|
||||
'section' => $sectionLabels[$loop->index] ?? '',
|
||||
'title' => $item->title,
|
||||
'owner' => $item->owner ?? '',
|
||||
'assignee' => $item->assignee ?? '',
|
||||
@ -270,15 +287,15 @@
|
||||
$sectionSpan = $groupRowspans['section'][$loop->index] ?? 1;
|
||||
@endphp
|
||||
<tr tabindex="0" data-sys-todo-row-edit="{{ $todoPayloadEncoded }}" title="点击整行直接编辑">
|
||||
<td><span class="sys-todo-table__index" title="当前列表连续序号">{{ $items->firstItem() + $loop->index }}</span></td>
|
||||
@if ($categorySpan !== 0)
|
||||
<td class="sys-todo-table__group" @if ($categorySpan > 1) rowspan="{{ $categorySpan }}" @endif>{{ $item->category ?: '—' }}</td>
|
||||
@endif
|
||||
@if ($sectionSpan !== 0)
|
||||
<td class="sys-todo-table__group sys-todo-table__section" @if ($sectionSpan > 1) rowspan="{{ $sectionSpan }}" @endif>
|
||||
<div class="sys-todo-table__clamp" title="{{ $item->section ?: '—' }}">{{ $item->section ?: '—' }}</div>
|
||||
<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>{{ $item->owner ?: '—' }}</td>
|
||||
<td>{{ $item->assignee ?: '—' }}</td>
|
||||
@ -329,10 +346,10 @@
|
||||
</div>
|
||||
|
||||
<div class="sys-todo-table__add">
|
||||
<span>点击表内任意事项可直接编辑;表格在框内滚动,表头、序号和操作列保持可见。</span>
|
||||
<span>点击表内任意事项可直接编辑;表格在框内滚动,表头和操作列保持可见。</span>
|
||||
<button type="button" data-sys-todo-create
|
||||
data-sys-todo-default-category="{{ $lastItem?->category ?? '' }}"
|
||||
data-sys-todo-default-section="{{ $lastItem?->section ?? '' }}">
|
||||
data-sys-todo-default-section="{{ $lastSectionLabel }}">
|
||||
<span class="material-icons-outlined" aria-hidden="true">playlist_add</span>在列表底部新增一条
|
||||
</button>
|
||||
</div>
|
||||
@ -407,7 +424,7 @@
|
||||
</dialog>
|
||||
|
||||
<datalist id="sysTodoCategories">@foreach ($todoCategories as $category)<option value="{{ $category }}">@endforeach</datalist>
|
||||
<datalist id="sysTodoOwners">@foreach ($todoOwners as $owner)<option value="{{ $owner }}">@endforeach</datalist>
|
||||
<datalist id="sysTodoOwners">@foreach ($todoOwners->merge($todoAssignees)->unique()->sort() as $person)<option value="{{ $person }}">@endforeach</datalist>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
|
||||
@ -26,7 +26,10 @@ class SystemizationTodosTest extends FeatureTestCase
|
||||
->assertSeeText('财务系统化待办')
|
||||
->assertSeeText('新增待办')
|
||||
->assertSeeText('从 Excel 导入现有待办')
|
||||
->assertSeeText('时间类型')
|
||||
->assertSeeText('总负责人')
|
||||
->assertSeeText('责任人')
|
||||
->assertSeeText('开始时间')
|
||||
->assertSeeText('完成时间')
|
||||
->assertSeeText('在列表底部新增一条')
|
||||
->assertSee('data-sys-todo-create', false)
|
||||
->assertSee('data-sys-todo-choose-files', false)
|
||||
@ -157,9 +160,9 @@ class SystemizationTodosTest extends FeatureTestCase
|
||||
{
|
||||
$this->loginAs();
|
||||
|
||||
$this->createTodo('收入', '平台收入核对', '收入事项 A', '2026-10-26', 1);
|
||||
$this->createTodo('收入', '1、平台收入核对', '收入事项 A', '2026-10-26', 1, '晨曦', '栎阳、秋香');
|
||||
$this->createTodo('收入', '平台收入核对', '收入事项 B', '2026-10-27', 2);
|
||||
$this->createTodo('成本', '退款核销', '成本事项', '2026-10-26', 3);
|
||||
$this->createTodo('成本', '3、退款核销', '成本事项', '2026-10-26', 3, '常仪', '山时');
|
||||
|
||||
$this->get(route('business.pages.show', [
|
||||
'section' => 'other',
|
||||
@ -167,26 +170,35 @@ class SystemizationTodosTest extends FeatureTestCase
|
||||
]))
|
||||
->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)
|
||||
->assertDontSee('1、平台收入核对')
|
||||
->assertDontSee('3、退款核销')
|
||||
->assertSee('收入事项 A')
|
||||
->assertSee('收入事项 B');
|
||||
|
||||
$response = $this->get(route('business.pages.show', [
|
||||
'section' => 'other',
|
||||
'page' => 'systemization-todos',
|
||||
'date_scope' => 'due_date',
|
||||
'date_from' => '2026-10-26',
|
||||
'date_to' => '2026-10-26',
|
||||
'due_date' => '2026-10-26',
|
||||
]));
|
||||
|
||||
$response->assertOk()->assertSee('收入事项 A')->assertSee('成本事项');
|
||||
$content = $response->viewData('content');
|
||||
$this->assertNotContains('收入事项 B', $content['items']->pluck('title')->all());
|
||||
$this->assertSame([
|
||||
'date_from' => '2026-10-26',
|
||||
'date_to' => '2026-10-26',
|
||||
], array_intersect_key($content['todoFilters'], array_flip(['date_from', 'date_to'])));
|
||||
'due_date' => '2026-10-26',
|
||||
], array_intersect_key($content['todoFilters'], array_flip(['due_date'])));
|
||||
|
||||
$peopleResponse = $this->get(route('business.pages.show', [
|
||||
'section' => 'other',
|
||||
'page' => 'systemization-todos',
|
||||
'owner' => '晨曦',
|
||||
'assignee' => '秋香',
|
||||
]))->assertOk();
|
||||
$peopleContent = $peopleResponse->viewData('content');
|
||||
$this->assertSame(['收入事项 A'], $peopleContent['items']->pluck('title')->all());
|
||||
}
|
||||
|
||||
private function createTodo(
|
||||
@ -195,14 +207,16 @@ class SystemizationTodosTest extends FeatureTestCase
|
||||
string $title,
|
||||
string $dueDate,
|
||||
int $sortOrder,
|
||||
string $owner = '晨曦',
|
||||
string $assignee = '栎阳',
|
||||
): SystemizationTodo {
|
||||
return SystemizationTodo::query()->create([
|
||||
'company_id' => (int) company_id(),
|
||||
'category' => $category,
|
||||
'section' => $section,
|
||||
'title' => $title,
|
||||
'owner' => '晨曦',
|
||||
'assignee' => '栎阳',
|
||||
'owner' => $owner,
|
||||
'assignee' => $assignee,
|
||||
'start_date' => '2026-10-25',
|
||||
'due_date' => $dueDate,
|
||||
'status' => SystemizationTodo::STATUS_PENDING,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user