diff --git a/app/Business/SystemizationTodoWorkbookImporter.php b/app/Business/SystemizationTodoWorkbookImporter.php index e6c2b611..34659c38 100644 --- a/app/Business/SystemizationTodoWorkbookImporter.php +++ b/app/Business/SystemizationTodoWorkbookImporter.php @@ -295,7 +295,7 @@ class SystemizationTodoWorkbookImporter [$finishDate, $finishNote] = $this->dateParts($finishText); $progress = $this->cell($cells, $columns['progress'] ?? null); $notes = $this->cell($cells, $columns['notes'] ?? null); - $status = $this->status($progress, $startText, $finishText); + $status = $this->status($progress); foreach ($titles as $offset => $title) { $titleIndex = $offset + 1; @@ -674,20 +674,12 @@ class SystemizationTodoWorkbookImporter return [null, mb_substr($value, 0, 100)]; } - private function status(string $progress, string $startNote, string $finishNote): string + private function status(string $progress): string { if (str_contains($progress, '已完成')) { return SystemizationTodo::STATUS_COMPLETED; } - if ($progress !== '') { - return str_contains($progress, '延期') - ? SystemizationTodo::STATUS_DELAYED - : SystemizationTodo::STATUS_IN_PROGRESS; - } - if (str_contains($startNote, '延期') || str_contains($finishNote, '延期')) { - return SystemizationTodo::STATUS_DELAYED; - } - return SystemizationTodo::STATUS_PENDING; + return SystemizationTodo::STATUS_INCOMPLETE; } } diff --git a/app/Http/Controllers/Business/SystemizationTodos.php b/app/Http/Controllers/Business/SystemizationTodos.php index a3b2f732..baa6e38c 100644 --- a/app/Http/Controllers/Business/SystemizationTodos.php +++ b/app/Http/Controllers/Business/SystemizationTodos.php @@ -28,10 +28,12 @@ class SystemizationTodos extends Controller ->withCount('attachments'); if ($filters['status'] === self::OVERDUE_FILTER) { - $query->whereNotIn('status', [SystemizationTodo::STATUS_COMPLETED]) + $query->where('status', '<>', SystemizationTodo::STATUS_COMPLETED) ->whereNotNull('due_date') ->whereDate('due_date', '<', today()); - } elseif ($filters['status'] !== '') { + } elseif ($filters['status'] === SystemizationTodo::STATUS_INCOMPLETE) { + $query->where('status', '<>', SystemizationTodo::STATUS_COMPLETED); + } elseif ($filters['status'] === SystemizationTodo::STATUS_COMPLETED) { $query->where('status', $filters['status']); } if ($filters['category'] !== '') { @@ -103,23 +105,28 @@ class SystemizationTodos extends Controller $todo->setAttribute('section_display', $sequence['section_display']); $todo->setAttribute('type_count', $sequence['type_count']); $todo->setAttribute('content_count', $sequence['content_count']); + $todo->setAttribute('status_label', $todo->statusLabel()); + $reminder = $todo->reminder(); + $todo->setAttribute('reminder_label', $reminder['label']); + $todo->setAttribute('reminder_tone', $reminder['tone']); }); $items = $items->sortBy('hierarchy_order')->values(); $positionGroups = $this->positionGroups($sequenceMap); $base = SystemizationTodo::query(); + $totalCount = (clone $base)->count(); + $completedCount = (clone $base) + ->where('status', SystemizationTodo::STATUS_COMPLETED) + ->count(); + $incompleteCount = max(0, $totalCount - $completedCount); $categories = SystemizationTodo::query() ->whereNotNull('category') ->where('category', '<>', '') ->distinct() ->orderBy('category') ->pluck('category'); - $statusCounts = (clone $base) - ->selectRaw('status, COUNT(*) AS aggregate') - ->groupBy('status') - ->pluck('aggregate', 'status'); $overdueCount = (clone $base) - ->whereNotIn('status', [SystemizationTodo::STATUS_COMPLETED]) + ->where('status', '<>', SystemizationTodo::STATUS_COMPLETED) ->whereNotNull('due_date') ->whereDate('due_date', '<', today()) ->count(); @@ -131,12 +138,10 @@ class SystemizationTodos extends Controller ]); } foreach (SystemizationTodo::STATUSES as $status) { - if ((int) $statusCounts->get($status, 0) > 0) { - $statusOptions->push([ - 'value' => $status, - 'label' => $status, - ]); - } + $statusOptions->push([ + 'value' => $status, + 'label' => $status, + ]); } return [ @@ -151,11 +156,14 @@ class SystemizationTodos extends Controller 'todoAssignees' => $this->peopleOptions('assignee'), 'todoStatuses' => SystemizationTodo::STATUSES, 'todoStatusOptions' => $statusOptions, + 'todoReturnFilters' => base64_encode(json_encode( + $filters, + JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES, + )), 'todoSummary' => [ - 'total' => (clone $base)->count(), - 'pending' => (int) $statusCounts->get(SystemizationTodo::STATUS_PENDING, 0), - 'in_progress' => (int) $statusCounts->get(SystemizationTodo::STATUS_IN_PROGRESS, 0), - 'completed' => (int) $statusCounts->get(SystemizationTodo::STATUS_COMPLETED, 0), + 'total' => $totalCount, + 'incomplete' => $incompleteCount, + 'completed' => $completedCount, 'overdue' => $overdueCount, ], 'todoRoutes' => [ @@ -333,6 +341,7 @@ class SystemizationTodos extends Controller public function store(Request $request): RedirectResponse { $data = $this->validated($request); + $this->normalizePendingDates($request, $data); $position = max(1, (int) ($data['position'] ?? 1)); unset($data['position']); $data['company_id'] = (int) company_id(); @@ -506,6 +515,7 @@ class SystemizationTodos extends Controller public function update(Request $request, SystemizationTodo $todo): RedirectResponse { $data = $this->validated($request); + $this->normalizePendingDates($request, $data); $targetPosition = max(0, (int) ($data['position'] ?? 0)); unset($data['position']); $data['updated_by'] = user_id(); @@ -596,22 +606,30 @@ class SystemizationTodos extends Controller private function filters(): array { - $status = (string) request('status', ''); - return [ - 'status' => $status === self::OVERDUE_FILTER - || in_array($status, SystemizationTodo::STATUSES, true) - ? $status - : '', + 'status' => $this->normalizeStatusValue(request('status')), 'category' => trim((string) request('category', '')), 'owners' => $this->peopleFilter('owner'), 'assignees' => $this->peopleFilter('assignee'), - 'start_date' => $this->normalizeDateFilter(request('start_date')), - 'due_date' => $this->normalizeDateFilter(request('due_date')), + 'start_date' => $this->normalizeDateValue(request('start_date')), + 'due_date' => $this->normalizeDateValue(request('due_date')), 'keyword' => trim((string) request('keyword', '')), ]; } + private function normalizeStatusValue(mixed $value): string + { + $status = trim((string) $value); + if (in_array($status, SystemizationTodo::LEGACY_INCOMPLETE_STATUSES, true)) { + return SystemizationTodo::STATUS_INCOMPLETE; + } + + return $status === self::OVERDUE_FILTER + || in_array($status, SystemizationTodo::STATUSES, true) + ? $status + : ''; + } + private function peopleFilter(string $key): array { return collect((array) request($key, [])) @@ -661,7 +679,7 @@ class SystemizationTodos extends Controller return $options; } - private function normalizeDateFilter(mixed $value): string + private function normalizeDateValue(mixed $value): string { $value = trim((string) $value); if ($value === '') { @@ -686,8 +704,10 @@ class SystemizationTodos extends Controller 'assignee' => ['nullable', 'string', 'max:255'], 'start_date' => ['nullable', 'date_format:Y-m-d'], 'start_note' => ['nullable', 'string', 'max:50'], + 'start_pending' => ['nullable', 'boolean'], 'due_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start_date'], 'due_note' => ['nullable', 'string', 'max:50'], + 'due_pending' => ['nullable', 'boolean'], 'status' => ['required', Rule::in(SystemizationTodo::STATUSES)], 'completed_at' => ['nullable', 'date_format:Y-m-d'], 'progress' => ['nullable', 'string', 'max:10000'], @@ -699,6 +719,19 @@ class SystemizationTodos extends Controller ]); } + private function normalizePendingDates(Request $request, array &$data): void + { + foreach (['start', 'due'] as $prefix) { + if ($request->boolean($prefix . '_pending')) { + $data[$prefix . '_date'] = null; + $data[$prefix . '_note'] = '待定'; + } elseif (!empty($data[$prefix . '_date'])) { + $data[$prefix . '_note'] = null; + } + unset($data[$prefix . '_pending']); + } + } + private function normalizeCompletionDates(array &$data): void { $completed = ($data['status'] ?? '') === SystemizationTodo::STATUS_COMPLETED; @@ -743,6 +776,7 @@ class SystemizationTodos extends Controller 'section' => 'other', 'page' => 'systemization-todos', ]; + $parameters = array_merge($parameters, $this->returnedFilters()); if ($focus) { $parameters['highlight'] = $focus->id; } @@ -751,4 +785,51 @@ class SystemizationTodos extends Controller ->route('business.pages.show', $parameters) ->with($type === 'success' ? 'success' : 'error', $message); } + + private function returnedFilters(): array + { + $encoded = request('return_filters'); + if (!is_string($encoded) || $encoded === '') { + return []; + } + + $json = base64_decode($encoded, true); + $filters = $json === false ? null : json_decode($json, true); + if (!is_array($filters)) { + return []; + } + + $category = is_string($filters['category'] ?? null) + ? trim($filters['category']) + : ''; + $keyword = is_string($filters['keyword'] ?? null) + ? trim($filters['keyword']) + : ''; + $owners = collect((array) ($filters['owners'] ?? [])) + ->filter(static fn (mixed $value): bool => is_string($value)) + ->map(static fn (string $value): string => trim($value)) + ->filter() + ->unique() + ->take(50) + ->values() + ->all(); + $assignees = collect((array) ($filters['assignees'] ?? [])) + ->filter(static fn (mixed $value): bool => is_string($value)) + ->map(static fn (string $value): string => trim($value)) + ->filter() + ->unique() + ->take(50) + ->values() + ->all(); + + return array_filter([ + 'status' => $this->normalizeStatusValue($filters['status'] ?? null), + 'category' => $category, + 'owner' => $owners, + 'assignee' => $assignees, + 'start_date' => $this->normalizeDateValue($filters['start_date'] ?? null), + 'due_date' => $this->normalizeDateValue($filters['due_date'] ?? null), + 'keyword' => $keyword, + ], static fn (mixed $value): bool => $value !== '' && $value !== []); + } } diff --git a/app/Models/Business/SystemizationTodo.php b/app/Models/Business/SystemizationTodo.php index b73b540f..74c10b7a 100644 --- a/app/Models/Business/SystemizationTodo.php +++ b/app/Models/Business/SystemizationTodo.php @@ -7,21 +7,24 @@ use Illuminate\Database\Eloquent\Relations\HasMany; class SystemizationTodo extends Model { - public const STATUS_PENDING = '待办'; - - public const STATUS_IN_PROGRESS = '进行中'; + public const STATUS_INCOMPLETE = '未完成'; public const STATUS_COMPLETED = '已完成'; - public const STATUS_DELAYED = '已延期'; - public const STATUSES = [ - self::STATUS_PENDING, - self::STATUS_IN_PROGRESS, + self::STATUS_INCOMPLETE, self::STATUS_COMPLETED, - self::STATUS_DELAYED, ]; + public const LEGACY_INCOMPLETE_STATUSES = ['待办', '进行中', '已延期']; + + // Keep old constant names for imports and existing callers without rewriting data. + public const STATUS_PENDING = self::STATUS_INCOMPLETE; + + public const STATUS_IN_PROGRESS = self::STATUS_INCOMPLETE; + + public const STATUS_DELAYED = self::STATUS_INCOMPLETE; + protected $table = 'business_systemization_todos'; protected $fillable = [ @@ -65,4 +68,36 @@ class SystemizationTodo extends Model { return $this->status === self::STATUS_COMPLETED; } + + public function statusLabel(): string + { + return $this->isCompleted() ? self::STATUS_COMPLETED : self::STATUS_INCOMPLETE; + } + + public function reminder(): array + { + if ($this->isCompleted()) { + return ['label' => '已完成', 'tone' => 'muted']; + } + if ($this->due_date === null) { + return ['label' => '时间待定', 'tone' => 'muted']; + } + + $today = today()->startOfDay(); + $dueDate = $this->due_date->copy()->startOfDay(); + if ($dueDate->lt($today)) { + return [ + 'label' => '逾期 ' . $dueDate->diffInDays($today) . ' 天', + 'tone' => 'danger', + ]; + } + if ($dueDate->equalTo($today)) { + return ['label' => '今天到期', 'tone' => 'warning']; + } + + return [ + 'label' => '剩余 ' . $today->diffInDays($dueDate) . ' 天', + 'tone' => 'info', + ]; + } } diff --git a/resources/views/business/pages/systemization-todos.blade.php b/resources/views/business/pages/systemization-todos.blade.php index 10880aa0..243f61de 100644 --- a/resources/views/business/pages/systemization-todos.blade.php +++ b/resources/views/business/pages/systemization-todos.blade.php @@ -11,7 +11,7 @@ .sys-todo-page__button--primary:hover { background:#6a5129; color:#fff; } .sys-todo-page__button .material-icons-outlined { font-size:1rem; } .sys-todo-page__intro { margin:.35rem 0 0; color:var(--sys-muted); font-size:.75rem; line-height:1.6; } - .sys-todo-summary { display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:.55rem; margin:.8rem 0; } + .sys-todo-summary { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:.55rem; margin:.8rem 0; } .sys-todo-summary__item { display:flex; align-items:center; justify-content:space-between; gap:.6rem; padding:.5rem .7rem; border:1px solid #e6dfd2; border-radius:.5rem; background:#fffdf9; } .sys-todo-summary__label { color:var(--sys-muted); font-size:.72rem; } .sys-todo-summary__value { color:#171717; font-size:1rem; font-weight:700; font-variant-numeric:tabular-nums; } @@ -38,42 +38,60 @@ .sys-todo-filter__actions { display:flex; flex:0 0 auto; gap:.45rem; } .sys-todo-table-wrap { overflow-x:auto; overflow-y:visible; border:1px solid var(--sys-border); border-radius:.65rem; background:#fff; scrollbar-width:none; } .sys-todo-table-wrap::-webkit-scrollbar { height:0; } - .sys-todo-table { width:100%; min-width:74rem; 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 th:first-child { left:0; z-index:8; width:5.4rem; min-width:5.4rem; text-align:center; box-shadow:1px 0 0 #e5ddcf; } - .sys-todo-table td { padding:.72rem .6rem; border-bottom:1px solid #eee7da; background:#fff; color:#2f2a23; line-height:1.6; vertical-align:top; } + .sys-todo-table { width:108rem; min-width:108rem; table-layout:fixed; border-collapse:separate; border-spacing:0; font-size:.75rem; } + .sys-todo-table th { position:sticky; top:0; z-index:4; padding:.55rem .7rem; border-bottom:1px solid var(--sys-border); background:#f3ece0; color:#3d3a35; text-align:left; white-space:nowrap; } + .sys-todo-table th:first-child { left:0; z-index:8; width:var(--sys-todo-type-width,5.4rem); text-align:center; box-shadow:1px 0 0 #e5ddcf; } + .sys-todo-table td { height:3.5rem; padding:.72rem .7rem; border-bottom:1px solid #eee7da; background:#fff; color:#2f2a23; line-height:1.5; vertical-align:middle; } + .sys-todo-table tbody tr { height:3.5rem; } .sys-todo-table tbody tr:hover td { background:#fdfbf6; } .sys-todo-table tbody tr.is-highlighted td { background:#fff4cf !important; } .sys-todo-table [data-sys-todo-row-edit], .sys-todo-table [data-sys-todo-group-edit] { cursor:pointer; } .sys-todo-table [data-sys-todo-row-edit]:hover, .sys-todo-table [data-sys-todo-group-edit]:hover { background:#fff4cf; } .sys-todo-table [data-sys-todo-row-edit]:focus-visible, .sys-todo-table [data-sys-todo-group-edit]:focus-visible { outline:2px solid var(--sys-gold); outline-offset:-2px; } - .sys-todo-table__index-cell { width:4.8rem; min-width:4.8rem; text-align:center; } + .sys-todo-table__index-cell { text-align:center; } .sys-todo-table__index-actions { display:inline-flex; align-items:center; justify-content:center; gap:.32rem; } .sys-todo-table__index { color:#6d5630; font-weight:600; font-variant-numeric:tabular-nums; white-space:nowrap; } .sys-todo-table__insert-after { display:inline-flex; align-items:center; justify-content:center; width:1.45rem; height:1.45rem; padding:0; border:1px solid #d8c9aa; border-radius:999px; background:#fffaf0; color:var(--sys-gold); cursor:pointer; } .sys-todo-table__insert-after:hover { border-color:var(--sys-gold); background:var(--sys-gold-soft); color:#6a5129; } .sys-todo-table__insert-after:focus-visible { outline:2px solid var(--sys-gold); outline-offset:2px; } .sys-todo-table__insert-after .material-icons-outlined { font-size:.95rem; line-height:1; } - .sys-todo-table__group { padding-top:.9rem; padding-bottom:.9rem; background:#fcfaf5; font-weight:600; line-height:1.7; vertical-align:middle; } + .sys-todo-table__group { padding-top:.9rem; padding-bottom:.9rem; background:#fcfaf5; font-weight:600; line-height:1.5; vertical-align:middle; } .sys-todo-table__group-content { display:inline-flex; align-items:flex-start; gap:.38rem; min-width:0; } .sys-todo-table__group-text { min-width:0; white-space:pre-line; overflow-wrap:anywhere; } .sys-todo-table__level { display:inline-flex; flex:0 0 auto; align-items:center; justify-content:center; min-width:1.35rem; height:1.35rem; margin-top:.05rem; border:1px solid #d8c9aa; border-radius:999px; background:#fffaf0; color:var(--sys-gold); font-size:.68rem; font-weight:700; font-variant-numeric:tabular-nums; } - .sys-todo-table__type { position:sticky; left:0; z-index:3; width:5.4rem; min-width:5.4rem; text-align:center; white-space:nowrap; box-shadow:1px 0 0 #e5ddcf; } + .sys-todo-table__type { position:sticky; left:0; z-index:3; width:var(--sys-todo-type-width,5.4rem); text-align:center; white-space:normal; box-shadow:1px 0 0 #e5ddcf; } .sys-todo-table__type .sys-todo-table__group-text { white-space:normal; } - .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: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__title, .sys-todo-table__progress-notes { line-height:1.5; white-space:pre-line; overflow-wrap:anywhere; } + .sys-todo-table__title { font-weight:600; } + .sys-todo-table__inline-add { display:inline-flex; align-items:center; justify-content:center; gap:.15rem; min-height:1.65rem; margin-top:.45rem; padding:0 .45rem; border:1px solid #d8c9aa; border-radius:999px; background:#fffaf0; color:#6d5630; font:inherit; font-size:.68rem; font-weight:500; white-space:nowrap; cursor:pointer; } + .sys-todo-table__inline-add:hover { border-color:var(--sys-gold); background:var(--sys-gold-soft); color:#6a5129; } + .sys-todo-table__inline-add:focus-visible { outline:2px solid var(--sys-gold); outline-offset:2px; } + .sys-todo-table__inline-actions { display:flex; flex-wrap:wrap; justify-content:center; gap:.25rem; } + .sys-todo-table__inline-actions .sys-todo-table__inline-add { margin-top:.3rem; } + .sys-todo-table__progress-notes { display:grid; gap:.35rem; } + .sys-todo-table__detail { display:grid; gap:.15rem; } + .sys-todo-table__detail-label { color:#856632; font-size:.66rem; font-weight:600; } + .sys-todo-table__reminder { display:inline-flex; align-items:center; padding:.1rem .45rem; border:1px solid #dfd6c5; border-radius:999px; background:#faf6ee; color:#51483c; white-space:nowrap; } + .sys-todo-table__reminder.is-danger { border-color:#e7c3c3; background:#fff3f3; color:#b80000; } + .sys-todo-table__reminder.is-warning { border-color:#e5c98f; background:#fff8e8; color:#935f07; } + .sys-todo-table__reminder.is-info { border-color:#b5cad8; background:#f3f8fb; color:#006395; } + .sys-todo-table__reminder.is-muted { color:#68625a; } + .sys-todo-table__col-resizer { position:absolute; top:0; right:-4px; z-index:12; width:9px; height:100%; padding:0; border:0; background:transparent; cursor:col-resize; touch-action:none; } + .sys-todo-table__col-resizer::after { position:absolute; top:20%; right:4px; width:1px; height:60%; background:#bfb3a0; content:""; } + .sys-todo-table__col-resizer:hover::after, .sys-todo-table__col-resizer:focus-visible::after { top:0; width:2px; height:100%; background:var(--sys-gold); } + .sys-todo-table__col-resizer:focus-visible { outline:none; } .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; } - .sys-todo-badge--progress { border-color:#dbbf7a; background:#fff7df; color:#935f07; } + .sys-todo-badge--incomplete { border-color:#dbbf7a; background:#fff7df; color:#935f07; } .sys-todo-badge--completed { border-color:#bad0ac; background:#f2f8ed; color:#53793e; } - .sys-todo-badge--delayed { border-color:#e7c3c3; background:#fff3f3; color:#b80000; } - .sys-todo-badge--overdue { border-color:#e7c3c3; color:#b80000; } .sys-todo-shots { display:flex; flex-wrap:wrap; gap:.3rem; max-width:13rem; } .sys-todo-shots button { display:block; width:4.25rem; height:3.2rem; padding:0; border:1px solid #ddd4c4; border-radius:.35rem; overflow:hidden; background:#faf8f3; cursor:zoom-in; } .sys-todo-shots img { width:100%; height:100%; object-fit:cover; } - .sys-todo-table__add { margin-top:1rem; padding:.55rem .7rem; border:1px dashed #d8cdb8; border-radius:.55rem; background:#fcfaf5; color:#666; font-size:.72rem; } + .sys-todo-table__add { display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between; gap:.5rem .75rem; margin-top:1rem; padding:.55rem .7rem; border:1px dashed #d8cdb8; border-radius:.55rem; background:#fcfaf5; color:#666; font-size:.72rem; } + .sys-todo-table__add-copy { min-width:16rem; flex:1 1 30rem; } + .sys-todo-table__reset-widths { min-height:1.8rem; padding:0 .55rem; border:1px solid var(--sys-border); border-radius:.4rem; background:#fff; color:#51483c; font:inherit; font-size:.7rem; cursor:pointer; } + .sys-todo-table__reset-widths:hover { border-color:var(--sys-gold); background:var(--sys-gold-soft); } + .sys-todo-table__footer-add { background:#fcfaf5; text-align:center; } + .sys-todo-table__footer-note { padding-left:1rem; color:#666; font-size:.7rem; font-weight:400; text-align:left; } .sys-todo-scrollrail { position:fixed; bottom:.85rem; z-index:35; height:1.15rem; padding:2px; border:1px solid #d8cdb8; border-radius:999px; background:rgba(243,236,224,.96); box-shadow:0 5px 18px rgba(23,23,23,.16); opacity:0; pointer-events:none; overflow-x:scroll; overflow-y:hidden; transition:opacity 150ms ease; } .sys-todo-scrollrail.is-visible { opacity:1; pointer-events:auto; } .sys-todo-scrollrail__spacer { height:1px; } @@ -96,6 +114,8 @@ .sys-todo-dialog__body { max-height:calc(90vh - 9rem); overflow:auto; padding:1rem 1.25rem; } .sys-todo-form-grid { display:grid; grid-template-columns:repeat(12,minmax(0,1fr)); gap:.75rem 1rem; } .sys-todo-field { display:grid; gap:.3rem; min-width:0; color:#595959; font-size:.75rem; } + .sys-todo-combined-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:.65rem; padding:.65rem; border:1px solid var(--sys-border); border-radius:.55rem; background:#fcfaf5; } + .sys-todo-combined-grid label { display:grid; gap:.3rem; min-width:0; } .sys-todo-field--2 { grid-column:span 2; } .sys-todo-field--3 { grid-column:span 3; } .sys-todo-field--4 { grid-column:span 4; } @@ -165,9 +185,9 @@ .sys-todo-image-dialog__body img { max-width:100%; max-height:100%; object-fit:contain; } .sys-todo-image-dialog__foot { display:flex; justify-content:flex-end; padding:.55rem .85rem; background:#1d1d1f; } .sys-todo-image-dialog__foot a { color:#f0e4bf; font-size:.75rem; } - @media (max-width:1300px) { .sys-todo-summary { grid-template-columns:repeat(3,minmax(0,1fr)); } } + @media (max-width:1300px) { .sys-todo-summary { grid-template-columns:repeat(2,minmax(0,1fr)); } } @media (max-width:900px) { .sys-todo-filter label, .sys-todo-filter label.sys-todo-filter__keyword { flex-basis:9rem; } .sys-todo-filter__actions { width:100%; } .sys-todo-filter__actions .sys-todo-page__button { flex:1; } } - @media (max-width:720px) { .sys-todo-summary { grid-template-columns:repeat(2,minmax(0,1fr)); } .sys-todo-filter label, .sys-todo-filter label.sys-todo-filter__keyword { flex-basis:10rem; } .sys-todo-field--2,.sys-todo-field--3,.sys-todo-field--4,.sys-todo-field--6 { grid-column:span 12; } } + @media (max-width:720px) { .sys-todo-summary { grid-template-columns:repeat(2,minmax(0,1fr)); } .sys-todo-filter label, .sys-todo-filter label.sys-todo-filter__keyword { flex-basis:10rem; } .sys-todo-field--2,.sys-todo-field--3,.sys-todo-field--4,.sys-todo-field--6 { grid-column:span 12; } .sys-todo-combined-grid { grid-column:span 12; grid-template-columns:1fr; } }
@@ -177,12 +197,7 @@ 财务系统化待办
-

日常过会使用。新增事项、跟进责任人和完成情况,截图直接附在该事项下。

- -
- +

日常过会使用。在类型、内容或序号旁直接添加,筛选修改后仍保持当前筛选结果。

@@ -196,8 +211,7 @@
全部
{{ number_format($todoSummary['total']) }}
-
待办
{{ number_format($todoSummary['pending']) }}
-
进行中
{{ number_format($todoSummary['in_progress']) }}
+
未完成
{{ number_format($todoSummary['incomplete']) }}
已完成
{{ number_format($todoSummary['completed']) }}
已逾期
{{ number_format($todoSummary['overdue']) }}
@@ -206,6 +220,7 @@ 从 Excel 同步待办(新增并更新工作内容,保留完成截图)
@csrf +
@@ -337,29 +352,48 @@ @endphp
- 共 {{ number_format($items->count()) }} 条,已全部显示。点击序号旁的+可在该条之后插入,原后续序号自动顺延;点击待办事项编辑详情,点击类型或内容可单独修改该组名称。 + 共 {{ number_format($items->count()) }} 条,已全部显示。在类型、内容下方的+直接新增;拖动表头分隔线可调整列宽,双击分隔线可恢复该列。 +
- +
+ + + + + + + + + + + + + + - - + + + + + + + + + + + + @forelse ($items as $item) @php - $isOverdue = ! $item->isCompleted() - && $item->due_date !== null - && $item->due_date->isBefore(today()); - $statusClass = match ($item->status) { - \App\Models\Business\SystemizationTodo::STATUS_IN_PROGRESS => 'sys-todo-badge--progress', - \App\Models\Business\SystemizationTodo::STATUS_COMPLETED => 'sys-todo-badge--completed', - \App\Models\Business\SystemizationTodo::STATUS_DELAYED => 'sys-todo-badge--delayed', - default => '', - }; + $statusClass = $item->isCompleted() + ? 'sys-todo-badge--completed' + : 'sys-todo-badge--incomplete'; $todoPayload = [ 'id' => $item->id, 'position' => (int) $item->item_sequence, @@ -372,7 +406,7 @@ 'start_note' => $item->start_note ?? '', 'due_date' => $item->due_date?->format('Y-m-d') ?? '', 'due_note' => $item->due_note ?? '', - 'status' => $item->status, + 'status' => $item->status_label, 'completed_at' => $item->completed_at?->format('Y-m-d') ?? '', 'progress' => $item->progress ?? '', 'notes' => $item->notes ?? '', @@ -403,6 +437,15 @@ {{ (int) $item->type_sequence }}{{ $item->category_display ?: '未分类' }} + + + + @endif @if ($sectionSpan !== 0) @@ -418,6 +461,13 @@ {{ (int) $item->content_sequence }}{{ $sectionLabels[$loop->index] ?: '未填写内容' }} + @endif - + + + - - - @empty @endforelse + + + + + +
类型内容待办事项序号待办事项总负责人责任人开始时间完成时间状态完成情况完成截图备注类型内容待办事项序号待办事项总负责人责任人开始时间完成时间提醒状态完成情况 / 备注完成截图
@@ -442,12 +492,27 @@ {{ $item->owner ?: '—' }} {{ $item->assignee ?: '—' }} {{ $item->start_date?->format('Y-m-d') ?: ($item->start_note ?: '—') }} - {{ $item->due_date?->format('Y-m-d') ?: ($item->due_note ?: '—') }} - @if ($isOverdue)
已逾期@endif +
{{ $item->due_date?->format('Y-m-d') ?: ($item->due_note ?: '—') }}{{ $item->reminder_label }}{{ $item->status_label }} + @if ($item->progress || $item->notes) + @if ($item->progress) +
+ 完成情况 + {{ $item->progress }} +
+ @endif + @if ($item->notes) +
+ 备注 + {{ $item->notes }} +
+ @endif + @else + — + @endif
{{ $item->status }}{{ $item->progress ?: '—' }} @if ($item->attachments->isNotEmpty())
@@ -468,12 +533,21 @@ — @endif
{{ $item->notes ?: '—' }}
当前筛选条件下没有待办事项。
@@ -484,6 +558,7 @@
@csrf +

新增待办

@@ -539,7 +614,7 @@
@@ -551,7 +626,7 @@
@@ -559,8 +634,12 @@
- - +
完成情况与备注 +
+ + +
+
完成截图
@@ -584,6 +663,7 @@ @csrf @method('PATCH') +

修改类型

@@ -606,6 +686,7 @@ @csrf @method('DELETE') +
确认删除待办 @@ -700,9 +781,20 @@ var assigneeFilter = document.querySelector('[data-sys-todo-assignee-filter]'); var assigneeSummary = assigneeFilter.querySelector('[data-sys-todo-assignee-summary]'); var tableWrap = document.querySelector('[data-sys-todo-table-wrap]'); + var todoTable = tableWrap.querySelector('.sys-todo-table'); + var todoColumns = Array.prototype.slice.call(todoTable.querySelectorAll('[data-sys-todo-col]')); + var todoColumnResizers = Array.prototype.slice.call( + todoTable.querySelectorAll('[data-sys-todo-col-resizer]') + ); + var resetColumnsButton = document.querySelector('[data-sys-todo-reset-columns]'); var scrollRail = document.querySelector('[data-sys-todo-scrollrail]'); var scrollRailSpacer = document.querySelector('[data-sys-todo-scrollrail-spacer]'); var scrollRailFrame = null; + var columnStorageKey = 'caiwu-systemization-todo-column-widths-v1'; + var defaultColumnWidths = todoColumns.map(function (column) { + return Number(column.getAttribute('data-default-width') || 100); + }); + var columnWidths = defaultColumnWidths.slice(); function decodePayload(value) { var binary = atob(value); @@ -836,6 +928,96 @@ }); } + function applyColumnWidths(widths, persist) { + var total = 0; + columnWidths = todoColumns.map(function (column, index) { + var defaultWidth = defaultColumnWidths[index]; + var minimum = Number(column.getAttribute('data-min-width') || 64); + var width = Math.min(720, Math.max(minimum, Number(widths[index] || defaultWidth))); + column.style.width = width + 'px'; + total += width; + return width; + }); + todoTable.style.width = total + 'px'; + todoTable.style.minWidth = total + 'px'; + todoTable.style.setProperty('--sys-todo-type-width', columnWidths[0] + 'px'); + if (persist) { + try { + window.localStorage.setItem(columnStorageKey, JSON.stringify(columnWidths)); + } catch (error) { + // Column resizing still works when browser storage is unavailable. + } + } + scheduleScrollRailSync(); + } + + function resetColumnWidth(index) { + var widths = columnWidths.slice(); + widths[index] = defaultColumnWidths[index]; + applyColumnWidths(widths, true); + } + + try { + var storedColumnWidths = JSON.parse(window.localStorage.getItem(columnStorageKey) || 'null'); + if (Array.isArray(storedColumnWidths) && storedColumnWidths.length === todoColumns.length) { + applyColumnWidths(storedColumnWidths, false); + } else { + applyColumnWidths(defaultColumnWidths, false); + } + } catch (error) { + applyColumnWidths(defaultColumnWidths, false); + } + + todoColumnResizers.forEach(function (handle) { + var columnName = handle.getAttribute('data-sys-todo-col-resizer'); + var columnIndex = todoColumns.findIndex(function (column) { + return column.getAttribute('data-sys-todo-col') === columnName; + }); + if (columnIndex === -1) return; + + var drag = null; + handle.addEventListener('pointerdown', function (event) { + event.preventDefault(); + event.stopPropagation(); + drag = { + pointerId: event.pointerId, + startX: event.clientX, + startWidth: columnWidths[columnIndex], + }; + handle.setPointerCapture(event.pointerId); + document.body.style.userSelect = 'none'; + }); + handle.addEventListener('pointermove', function (event) { + if (!drag || drag.pointerId !== event.pointerId) return; + var widths = columnWidths.slice(); + widths[columnIndex] = drag.startWidth + event.clientX - drag.startX; + applyColumnWidths(widths, false); + }); + function finishDrag(event) { + if (!drag || drag.pointerId !== event.pointerId) return; + drag = null; + document.body.style.userSelect = ''; + applyColumnWidths(columnWidths, true); + } + handle.addEventListener('pointerup', finishDrag); + handle.addEventListener('pointercancel', finishDrag); + handle.addEventListener('dblclick', function (event) { + event.preventDefault(); + event.stopPropagation(); + resetColumnWidth(columnIndex); + }); + handle.addEventListener('keydown', function (event) { + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return; + event.preventDefault(); + var widths = columnWidths.slice(); + widths[columnIndex] += event.key === 'ArrowLeft' ? -16 : 16; + applyColumnWidths(widths, true); + }); + }); + resetColumnsButton.addEventListener('click', function () { + applyColumnWidths(defaultColumnWidths, true); + }); + function selectedFilesLabel() { return Array.prototype.slice.call(screenshotInput.files || []) .map(function (file) { return file.name; }) @@ -1074,7 +1256,7 @@ applyDateControl(dueDateControl, '', ''); } if (!currentTodoId && !(payload && payload.status)) { - setValue('status', '待办'); + setValue('status', '未完成'); } populatePositionOptions(); if (currentTodoId) { @@ -1154,11 +1336,23 @@ ); return; } - var create = event.target.closest('[data-sys-todo-create]'); - if (create) { + var inlineAdd = event.target.closest('[data-sys-todo-inline-add]'); + if (inlineAdd) { + event.preventDefault(); + var addMode = inlineAdd.getAttribute('data-sys-todo-add-mode') || 'todo'; openDialog({ - category: create.getAttribute('data-sys-todo-default-category') || '', - section: create.getAttribute('data-sys-todo-default-section') || '', + category: addMode === 'type' + ? '' + : (inlineAdd.getAttribute('data-sys-todo-add-category') || ''), + section: addMode === 'todo' + ? (inlineAdd.getAttribute('data-sys-todo-add-section') || '') + : '', + insertPosition: addMode === 'todo' + ? Number(inlineAdd.getAttribute('data-sys-todo-add-position') || 1) + : 1, + afterSequence: addMode === 'todo' + ? Number(inlineAdd.getAttribute('data-sys-todo-add-after-sequence') || 0) + : 0, }); return; } diff --git a/tests/Feature/Business/SystemizationTodosTest.php b/tests/Feature/Business/SystemizationTodosTest.php index c9d782b5..4ccec05a 100644 --- a/tests/Feature/Business/SystemizationTodosTest.php +++ b/tests/Feature/Business/SystemizationTodosTest.php @@ -5,6 +5,7 @@ namespace Tests\Feature\Business; use App\Business\SystemizationTodoWorkbookImporter; use App\Models\Business\SystemizationTodo; use App\Models\Business\SystemizationTodoAttachment; +use Carbon\Carbon; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Storage; use PhpOffice\PhpSpreadsheet\Spreadsheet; @@ -33,9 +34,15 @@ class SystemizationTodosTest extends FeatureTestCase ->assertSeeText('开始时间') ->assertSeeText('完成时间') ->assertSeeText('待定') - ->assertSee('data-sys-todo-create', false) + ->assertSeeText('完成情况 / 备注') + ->assertSeeText('提醒') + ->assertSee('data-sys-todo-inline-add', false) + ->assertSee('data-sys-todo-col-resizer', false) + ->assertSee('data-sys-todo-reset-columns', false) ->assertSee('data-sys-todo-date-pending="start"', false) ->assertSee('data-sys-todo-date-pending="due"', false) + ->assertSee('name="start_pending"', false) + ->assertSee('name="due_pending"', false) ->assertSee('data-sys-todo-owner-filter', false) ->assertSee('data-sys-todo-assignee-filter', false) ->assertSee('name="owner[]"', false) @@ -55,7 +62,7 @@ class SystemizationTodosTest extends FeatureTestCase ->assertDontSee('sys-todo-table__clamp', false) ->assertSeeText('+ 新增类型') ->assertSeeText('+ 新增内容') - ->assertSeeText('点击序号旁的+可在该条之后插入') + ->assertSeeText('在类型、内容下方的+直接新增') ->assertSee('data-sys-todo-choose-files', false) ->assertSee('data-sys-todo-open-delete', false) ->assertSee('id="sysTodoDeleteConfirm"', false) @@ -65,9 +72,11 @@ class SystemizationTodosTest extends FeatureTestCase ->assertDontSee('操作', false) ->assertDontSeeText('下一页') ->assertSee('id="sysTodoImageViewer"', false); + $this->assertSame(0, substr_count((string) $pageResponse->getContent(), 'data-sys-todo-create')); + $pageData = $pageResponse->viewData('content'); $this->assertSame( - 1, - substr_count((string) $pageResponse->getContent(), 'data-sys-todo-create>'), + [SystemizationTodo::STATUS_INCOMPLETE, SystemizationTodo::STATUS_COMPLETED], + $pageData['todoStatuses'], ); $storeResponse = $this->post(route('business.systemization-todos.store'), [ @@ -78,7 +87,7 @@ class SystemizationTodosTest extends FeatureTestCase 'assignee' => '栎阳', 'start_date' => '2026-10-25', 'due_date' => '2026-10-28', - 'status' => SystemizationTodo::STATUS_IN_PROGRESS, + 'status' => SystemizationTodo::STATUS_INCOMPLETE, 'progress' => '已拉取账单,待核对订单', 'notes' => '周会跟进', 'screenshots' => [ @@ -94,7 +103,7 @@ class SystemizationTodosTest extends FeatureTestCase $this->assertSame((string) $todo->id, $redirectQuery['highlight'] ?? null); $this->assertSame('收入', $todo->category); $this->assertSame('晨曦、人参', $todo->owner); - $this->assertSame(SystemizationTodo::STATUS_IN_PROGRESS, $todo->status); + $this->assertSame(SystemizationTodo::STATUS_INCOMPLETE, $todo->status); $this->assertSame(2, $todo->attachments()->count()); $this->get(route('business.pages.show', [ 'section' => 'other', @@ -372,10 +381,10 @@ class SystemizationTodosTest extends FeatureTestCase 'owner' => '晨曦', 'assignee' => '栎阳', 'start_date' => null, - 'start_note' => '待定', + 'start_pending' => 1, 'due_date' => null, - 'due_note' => '待定', - 'status' => SystemizationTodo::STATUS_PENDING, + 'due_pending' => 1, + 'status' => SystemizationTodo::STATUS_INCOMPLETE, 'progress' => '', 'notes' => '', ])->assertRedirect(); @@ -393,10 +402,10 @@ class SystemizationTodosTest extends FeatureTestCase 'owner' => '晨曦', 'assignee' => '栎阳', 'start_date' => null, - 'start_note' => '待定', + 'start_pending' => 1, 'due_date' => null, 'due_note' => '11月', - 'status' => SystemizationTodo::STATUS_IN_PROGRESS, + 'status' => SystemizationTodo::STATUS_INCOMPLETE, 'progress' => '等待平台确认', 'notes' => '', ])->assertRedirect(); @@ -407,6 +416,92 @@ class SystemizationTodosTest extends FeatureTestCase $this->assertSame('11月', $todo->due_note); } + public function test_edit_redirect_keeps_current_filters_and_highlight(): void + { + $this->loginAs(); + $todo = $this->createTodo('收入', '平台收入核对', '筛选保持测试', '2026-10-30', 1); + $returnFilters = base64_encode(json_encode([ + 'status' => SystemizationTodo::STATUS_INCOMPLETE, + 'category' => '收入', + 'owners' => ['晨曦', '人参'], + 'assignees' => ['栎阳'], + 'start_date' => '2026-10-25', + 'due_date' => '2026-10-30', + 'keyword' => '筛选', + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + + $response = $this->patch(route('business.systemization-todos.update', ['todo' => $todo->id]), [ + 'category' => '收入', + 'section' => '平台收入核对', + 'title' => '筛选保持测试(已更新)', + 'owner' => '晨曦', + 'assignee' => '栎阳', + 'start_date' => '2026-10-25', + 'due_date' => '2026-10-30', + 'status' => SystemizationTodo::STATUS_INCOMPLETE, + 'return_filters' => $returnFilters, + ])->assertRedirect(); + + $location = (string) $response->headers->get('Location'); + parse_str((string) parse_url($location, PHP_URL_QUERY), $query); + $this->assertSame(SystemizationTodo::STATUS_INCOMPLETE, $query['status'] ?? null); + $this->assertSame('收入', $query['category'] ?? null); + $this->assertSame(['晨曦', '人参'], $query['owner'] ?? null); + $this->assertSame(['栎阳'], $query['assignee'] ?? null); + $this->assertSame('2026-10-25', $query['start_date'] ?? null); + $this->assertSame('2026-10-30', $query['due_date'] ?? null); + $this->assertSame('筛选', $query['keyword'] ?? null); + $this->assertSame((string) $todo->id, $query['highlight'] ?? null); + } + + public function test_reminder_and_status_treat_legacy_open_states_as_incomplete(): void + { + Carbon::setTestNow('2026-10-26 12:00:00'); + try { + $this->loginAs(); + $this->createTodo('收入', '提醒测试', '逾期事项', '2026-10-20', 1); + $this->createTodo('收入', '提醒测试', '今天到期事项', '2026-10-26', 2); + $this->createTodo('收入', '提醒测试', '未来事项', '2026-10-30', 3); + $completed = $this->createTodo('收入', '提醒测试', '已完成事项', '2026-10-22', 4); + $completed->update([ + 'status' => SystemizationTodo::STATUS_COMPLETED, + 'completed_at' => '2026-10-22', + ]); + $legacy = $this->createTodo('收入', '提醒测试', '历史进行中事项', '2026-11-01', 5); + $legacy->update(['status' => '进行中']); + + $content = $this->get(route('business.pages.show', [ + 'section' => 'other', + 'page' => 'systemization-todos', + ]))->assertOk()->viewData('content'); + $items = $content['items']->keyBy('title'); + + $this->assertSame('逾期 6 天', $items['逾期事项']->reminder_label); + $this->assertSame('danger', $items['逾期事项']->reminder_tone); + $this->assertSame('今天到期', $items['今天到期事项']->reminder_label); + $this->assertSame('剩余 4 天', $items['未来事项']->reminder_label); + $this->assertSame('已完成', $items['已完成事项']->reminder_label); + $this->assertSame(SystemizationTodo::STATUS_INCOMPLETE, $items['历史进行中事项']->status_label); + + $incompleteTitles = $this->get(route('business.pages.show', [ + 'section' => 'other', + 'page' => 'systemization-todos', + 'status' => SystemizationTodo::STATUS_INCOMPLETE, + ]))->assertOk()->viewData('content')['items']->pluck('title'); + $this->assertContains('历史进行中事项', $incompleteTitles); + $this->assertNotContains('已完成事项', $incompleteTitles); + + $completedTitles = $this->get(route('business.pages.show', [ + 'section' => 'other', + 'page' => 'systemization-todos', + 'status' => SystemizationTodo::STATUS_COMPLETED, + ]))->assertOk()->viewData('content')['items']->pluck('title'); + $this->assertSame(['已完成事项'], $completedTitles->all()); + } finally { + Carbon::setTestNow(); + } + } + public function test_page_filters_by_date_and_merges_adjacent_category_and_section_cells(): void { $this->loginAs(); @@ -423,7 +518,9 @@ class SystemizationTodosTest extends FeatureTestCase $pageResponse ->assertOk() ->assertSee('rowspan="2"', false) - ->assertSee('内容待办事项序号待办事项', false) + ->assertSee('data-sys-todo-column="section"', false) + ->assertSee('data-sys-todo-column="sequence"', false) + ->assertSee('data-sys-todo-column="title"', false) ->assertSee('sys-todo-table__type', false) ->assertSee('title="类型序号">1', false) ->assertSee('title="内容序号">1', false) @@ -438,7 +535,7 @@ class SystemizationTodosTest extends FeatureTestCase $this->assertContains('平台收入核对', $pageContent['todoContentOptions']['收入']); $this->assertArrayNotHasKey('1、收入', $pageContent['todoContentOptions']); $this->assertSame( - ['__overdue__', '待办'], + ['__overdue__', SystemizationTodo::STATUS_INCOMPLETE, SystemizationTodo::STATUS_COMPLETED], array_column($pageContent['todoStatusOptions']->all(), 'value'), ); $pageItems = $pageContent['items']; @@ -562,7 +659,7 @@ class SystemizationTodosTest extends FeatureTestCase ->all(), ); $this->assertSame( - [$completed->id, $inProgress->id], + [$pending->id, $completed->id, $inProgress->id], $items ->where('type_sequence', 1) ->where('content_sequence', 1)