diff --git a/docs/TZ.md b/docs/TZ.md index ec017a3..89bae87 100644 --- a/docs/TZ.md +++ b/docs/TZ.md @@ -4,7 +4,7 @@ | | | |---|---| -| Версия ТЗ | 0.61 | +| Версия ТЗ | 0.62 | | Дата | 2026-10-09 | | Статус | На обсуждении | @@ -187,11 +187,19 @@ заголовки секций показываются только когда пустые проекты есть, иначе список выглядит как раньше. Порядок применяется **до** пагинации (3.6), чтобы секция не разъезжалась по страницам. -- Карточки проектов раскладываются **сеткой по две в ряд** (0.61): карточка несёт - бейджи и заметку, в две колонки список читается компактнее; на узких экранах — - одна колонка (ломающаяся точка 860px). Заголовок секции занимает всю ширину - строки, карточки разной высоты выравниваются по верху; скелетон загрузки - повторяет ту же сетку. +- Карточки проектов раскладываются **сеткой по две в ряд** (0.61): в две колонки + список читается компактнее, на узких экранах — одна колонка (ломающаяся точка + 860px). Заголовок секции занимает всю ширину строки, карточки разной высоты + выравниваются по верху; скелетон загрузки повторяет ту же сетку. +- **Карточка в списке — метрики, без заметки** (0.62): заметка (Markdown с ресурсами) + остаётся только на странице проекта — в списке она делала карточки разной высоты + и мешала сравнивать проекты. В шапке карточки справа — актуальность; ниже ряд + метрик: приоритет, «N в работе», «просрочено N» (нулевые метрики не показываются — + иначе ряд бейджей шумит одинаковыми «0»); полоса прогресса «Закрыто N из M» + с процентом; подвал — ближайший дедлайн по открытым задачам (просроченный красным) + и кнопка «Открыть». У проекта без задач вместо полосы — подсказка «Задач в проекте + пока нет». Метрики считаются по всем задачам проекта (как и полоса прогресса), + просрочка — по незакрытым. #### 3.8.1. Архив diff --git a/frontend/src/locales/en.ts b/frontend/src/locales/en.ts index 7b87c40..f94d626 100644 --- a/frontend/src/locales/en.ts +++ b/frontend/src/locales/en.ts @@ -414,6 +414,11 @@ prompt: 'Name of the new project:', promptLabel: 'Project name', taskCounts: 'Tasks done {done}/{total}', + noTasksYet: 'No tasks in this project yet', + progressLabel: 'Done {done} of {total}', + inProgress: '{n} in progress', + overdue: '{n} overdue', + nextDeadline: 'Next deadline: {date}', withTasks: 'With tasks', withoutTasks: 'No tasks', archived: 'Project sent to archive', diff --git a/frontend/src/locales/ru.ts b/frontend/src/locales/ru.ts index cb41625..00e1446 100644 --- a/frontend/src/locales/ru.ts +++ b/frontend/src/locales/ru.ts @@ -415,6 +415,11 @@ prompt: 'Название нового проекта:', promptLabel: 'Название проекта', taskCounts: 'Задач закрыто {done}/{total}', + noTasksYet: 'Задач в проекте пока нет', + progressLabel: 'Закрыто {done} из {total}', + inProgress: '{n} в работе', + overdue: 'просрочено {n}', + nextDeadline: 'Ближайший дедлайн: {date}', withTasks: 'С задачами', withoutTasks: 'Без задач', archived: 'Проект отправлен в архив', diff --git a/frontend/src/locales/uk.ts b/frontend/src/locales/uk.ts index 8929b85..e7c5a5b 100644 --- a/frontend/src/locales/uk.ts +++ b/frontend/src/locales/uk.ts @@ -414,6 +414,11 @@ prompt: 'Назва нового проєкту:', promptLabel: 'Назва проєкту', taskCounts: 'Задач закрито {done}/{total}', + noTasksYet: 'Завдань у проєкті поки немає', + progressLabel: 'Закрито {done} з {total}', + inProgress: '{n} у роботі', + overdue: 'прострочено {n}', + nextDeadline: 'Найближчий дедлайн: {date}', withTasks: 'Із завданнями', withoutTasks: 'Без завдань', archived: 'Проєкт надіслано до архіву', diff --git a/frontend/src/views/ProjectsView.vue b/frontend/src/views/ProjectsView.vue index 0d69536..ea0ba9c 100644 --- a/frontend/src/views/ProjectsView.vue +++ b/frontend/src/views/ProjectsView.vue @@ -7,51 +7,59 @@ import PriorityBadge from '../components/PriorityBadge.vue' import { notifyCreated } from '../gamification' import { useRealtimeRefresh } from '../realtime' -import { - relevanceLabel, - renderMarkdown, -} from '../taskui' +import { deadlineOverdue, formatDate, relevanceLabel } from '../taskui' import QuickPrompt from '../components/QuickPrompt.vue' import Pagination from '../components/Pagination.vue' -// Проекты (ТЗ 3.7): актуальность, приоритет, Markdown-заметка с ресурсами. -// Заголовок карточки — ссылка на страницу проекта; на карточке — счётчик задач. +// Проекты (ТЗ 3.8): актуальность, приоритет, заметка с ресурсами. +// Карточка списка — только метрики (0.62): заметка целиком живёт на странице +// проекта, в списке она делала карточки разной высоты и мешала сравнивать. const { t } = useI18n() const router = useRouter() const toast = useToast() -// Свёрнутость заметок на карточках: id проектов с развёрнутой заметкой -const expandedNotes = ref(new Set()) - -function toggleNote(id: number): void { - const next = new Set(expandedNotes.value) - if (next.has(id)) next.delete(id) - else next.add(id) - expandedNotes.value = next -} - const projects = ref([]) const tasks = ref([]) const error = ref('') const loading = ref(false) -// Счётчики задач по проектам: всего / завершено -const taskCounts = computed(() => { - const map = new Map() +// Метрики карточки проекта (0.62): прогресс, «в работе», просрочка, ближайший +// дедлайн. Определения согласованы с полосой прогресса — она считает все задачи +// проекта, поэтому фильтр по detail_state здесь не применяем. +type ProjectStats = { + total: number + done: number + inProgress: number + overdue: number + nextDeadline: string | null +} + +const NO_STATS: ProjectStats = { total: 0, done: 0, inProgress: 0, overdue: 0, nextDeadline: null } + +const projectStats = computed(() => { + const map = new Map() tasks.value.forEach((task) => { if (!task.project) return - const entry = map.get(task.project.id) ?? { total: 0, done: 0 } - entry.total += 1 - if (task.status === 'done') entry.done += 1 - map.set(task.project.id, entry) + const stats = map.get(task.project.id) ?? { ...NO_STATS } + stats.total += 1 + if (task.status === 'done') stats.done += 1 + if (task.status === 'in_progress') stats.inProgress += 1 + // закрытые задачи ни просроченными, ни «ближайшим дедлайном» не считаем + if (task.status !== 'done' && task.status !== 'cancelled') { + if (deadlineOverdue(task)) stats.overdue += 1 + if (task.deadline_date && (!stats.nextDeadline || task.deadline_date < stats.nextDeadline)) { + stats.nextDeadline = task.deadline_date + } + } + map.set(task.project.id, stats) }) return map }) -// Параметры для бейджа карточки: всего / закрыто / открыто -function countsOf(id: number): { total: number; done: number } { - return taskCounts.value.get(id) ?? { total: 0, done: 0 } +// Метрики проекта: у проекта без задач — нули +function statsOf(id: number): ProjectStats { + return projectStats.value.get(id) ?? NO_STATS } async function load() { @@ -76,8 +84,8 @@ // по страницам и «Без задач» окажется в середине списка const page = ref(1) const orderedProjects = computed(() => [ - ...projects.value.filter((p) => countsOf(p.id).total > 0), - ...projects.value.filter((p) => countsOf(p.id).total === 0), + ...projects.value.filter((p) => statsOf(p.id).total > 0), + ...projects.value.filter((p) => statsOf(p.id).total === 0), ]) const pagedProjects = computed(() => orderedProjects.value.slice((page.value - 1) * 30, page.value * 30), @@ -87,9 +95,11 @@ // ничем не отличался от соседей. Такие проекты уходят в отдельную секцию внизу // (заголовки показываем только когда пустые есть, иначе список выглядит как раньше). // Счётчики в заголовках — по всему списку, а не по странице (как у стека). +// Метрики считаем здесь же, чтобы карточка не звала statsOf по пять раз. const projectSections = computed(() => { - const withTasks = pagedProjects.value.filter((p) => countsOf(p.id).total > 0) - const withoutTasks = pagedProjects.value.filter((p) => countsOf(p.id).total === 0) + const rows = pagedProjects.value.map((project) => ({ project, stats: statsOf(project.id) })) + const withTasks = rows.filter((row) => row.stats.total > 0) + const withoutTasks = rows.filter((row) => row.stats.total === 0) const titled = withoutTasks.length > 0 return [ { @@ -182,40 +192,59 @@ {{ section.count }} - + +
-