<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useToast } from 'gnexus-ui-kit/vue'
import { api, type Project, type Task } from '../api'
import PriorityBadge from '../components/PriorityBadge.vue'
import { notifyCreated } from '../gamification'
import { useRealtimeRefresh } from '../realtime'
import { deadlineOverdue, formatDate, relevanceLabel } from '../taskui'
import QuickPrompt from '../components/QuickPrompt.vue'
import Pagination from '../components/Pagination.vue'
// Проекты (ТЗ 3.8): актуальность, приоритет, заметка с ресурсами.
// Карточка списка — только метрики (0.62): заметка целиком живёт на странице
// проекта, в списке она делала карточки разной высоты и мешала сравнивать.
const { t } = useI18n()
const router = useRouter()
const toast = useToast()
const projects = ref<Project[]>([])
const tasks = ref<Task[]>([])
const error = ref('')
const loading = ref(false)
// Метрики карточки проекта (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<number, ProjectStats>()
tasks.value.forEach((task) => {
if (!task.project) return
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 statsOf(id: number): ProjectStats {
return projectStats.value.get(id) ?? NO_STATS
}
async function load() {
loading.value = true
error.value = ''
try {
;[projects.value, tasks.value] = await Promise.all([api.listProjects(), api.listTasks()])
} catch (e) {
error.value = String(e)
} finally {
loading.value = false
}
}
onMounted(load)
// Реактивность (ТЗ 3.14): счётчики зависят от задач, карточки — от проектов
useRealtimeRefresh(load, ['project.changed', 'project.deleted', 'task.changed', 'task.deleted'])
// Пагинация: по 30 проектов на страницу. Порядок — проекты с задачами сверху,
// пустые в конце (0.60): сортируем ДО пагинации, иначе секции разъедутся
// по страницам и «Без задач» окажется в середине списка
const page = ref(1)
const orderedProjects = computed(() => [
...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),
)
// Проект без задач терялся среди заполненных: одинаковый бейдж «закрыто 0/0»
// ничем не отличался от соседей. Такие проекты уходят в отдельную секцию внизу
// (заголовки показываем только когда пустые есть, иначе список выглядит как раньше).
// Счётчики в заголовках — по всему списку, а не по странице (как у стека).
// Метрики считаем здесь же, чтобы карточка не звала statsOf по пять раз.
const projectSections = computed(() => {
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 [
{
key: 'with',
titled,
icon: 'ph-folders',
label: t('projects.withTasks'),
count: projects.value.length - withoutTasks.length,
items: withTasks,
},
{
key: 'without',
titled,
icon: 'ph-folder-dotted',
label: t('projects.withoutTasks'),
count: withoutTasks.length,
items: withoutTasks,
},
].filter((s) => s.items.length > 0)
})
// Создание — модальное окно вместо window.prompt
const createOpen = ref(false)
async function createProject(name: string) {
try {
await api.createProject(name)
toast.success({ title: t('common.created') })
// наградный тост после основного (провайдер кита держит один тост)
notifyCreated()
await load()
} catch (e) {
toast.error({ title: t('common.error'), text: String(e) })
}
}
</script>
<template>
<section>
<GnPageHeader kicker="GNexus Tasks" :title="t('projects.title')">
<template #meta>{{ t('projects.count', { n: projects.length }) }}</template>
<template #actions>
<GnLoader
v-if="loading && projects.length"
circle
label=""
:aria-label="t('common.loading')"
class="refresh-loader"
/>
<GnButton variant="primary" icon="ph-plus" @click="createOpen = true">
{{ t('projects.new') }}
</GnButton>
</template>
</GnPageHeader>
<GnAlert v-if="error" variant="error">{{ error }}</GnAlert>
<!-- Скелетон карточек проектов: имя + ряд бейджей, как в реальных -->
<div v-if="loading && projects.length === 0" class="list-skeleton" aria-hidden="true">
<div v-for="i in 3" :key="i" class="card">
<div class="card-content">
<span class="skeleton skeleton-title" />
<div class="sk-badges">
<span v-for="j in 3" :key="j" class="skeleton skeleton-square" />
</div>
</div>
</div>
</div>
<GnEmptyState
v-if="!loading && projects.length === 0"
icon="ph-folders"
:title="t('projects.emptyTitle')"
:text="t('projects.emptyText')"
>
<template #actions>
<GnButton variant="primary" icon="ph-plus" @click="createOpen = true">
{{ t('projects.new') }}
</GnButton>
</template>
</GnEmptyState>
<div class="project-list">
<template v-for="section in projectSections" :key="section.key">
<div v-if="section.titled" class="list-section-title">
<i :class="`ph ${section.icon}`" aria-hidden="true" />
<span>{{ section.label }}</span>
<GnBadge :variant="section.key === 'without' ? 'neutral' : 'info'">
{{ section.count }}
</GnBadge>
</div>
<GnCard v-for="row in section.items" :key="row.project.id" class="project-card">
<template #title>
<!-- Актуальность — тихий статус в шапке справа, чтобы ряд ниже
занимали только изменчивые метрики -->
<RouterLink class="card-link" :to="`/projects/${row.project.id}`">
{{ row.project.name }}
</RouterLink>
<GnBadge
class="relevance-badge"
:variant="row.project.relevance_status === 'active' ? 'info' : 'neutral'"
>
<i class="ph ph-activity" aria-hidden="true" />{{ relevanceLabel(row.project.relevance_status) }}
</GnBadge>
</template>
<!-- Метрики: приоритет, «в работе», просрочка. Нулевые не показываем —
иначе ряд бейджей шумит одинаковыми «0» на каждой карточке -->
<div class="project-meta">
<PriorityBadge v-if="row.project.priority !== null" :priority="row.project.priority" />
<GnBadge v-if="row.stats.inProgress" variant="accent">
<i class="ph ph-circle-half" aria-hidden="true" />{{ t('projects.inProgress', { n: row.stats.inProgress }) }}
</GnBadge>
<GnBadge v-if="row.stats.overdue" variant="error">
<i class="ph ph-alarm" aria-hidden="true" />{{ t('projects.overdue', { n: row.stats.overdue }) }}
</GnBadge>
</div>
<!-- Прогресс по задачам проекта; у проекта без задач — подсказка,
иначе на карточке остаётся дыра на месте полосы -->
<GnProgress
v-if="row.stats.total > 0"
class="project-progress"
:value="row.stats.done"
:max="row.stats.total"
:label="t('projects.progressLabel', { done: row.stats.done, total: row.stats.total })"
/>
<p v-else class="card-hint">{{ t('projects.noTasksYet') }}</p>
<div class="card-foot">
<span
v-if="row.stats.nextDeadline"
class="card-deadline"
:class="{ late: row.stats.overdue }"
>
<i class="ph ph-calendar-check" aria-hidden="true" />
{{ t('projects.nextDeadline', { date: formatDate(row.stats.nextDeadline) }) }}
</span>
<GnButton
class="card-open"
variant="secondary"
size="sm"
icon="ph-arrow-square-out"
@click="router.push(`/projects/${row.project.id}`)"
>
{{ t('common.open') }}
</GnButton>
</div>
</GnCard>
</template>
</div>
<Pagination v-model:page="page" :total="projects.length" />
<QuickPrompt
v-model:open="createOpen"
:title="t('projects.prompt')"
:label="t('projects.promptLabel')"
:placeholder="t('projects.prompt')"
:confirm-text="t('common.create')"
@confirm="createProject"
/>
</section>
</template>
<style scoped>
/* Карточки проектов — по две в ряд (0.61): карточка несёт заметку и бейджи,
в две колонки список читается компактнее; скелетон повторяет ту же сетку,
чтобы загрузка не дёргала раскладку. */
.project-list,
.list-skeleton {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
align-items: start;
}
.project-list > .card,
.list-skeleton > .card {
min-width: 0;
}
@media (max-width: 860px) {
.project-list,
.list-skeleton {
grid-template-columns: minmax(0, 1fr);
}
}
/* Подпись секции списка (паттерн стека): проекты с задачами и без — 0.60;
в сетке заголовок занимает всю ширину строки */
.list-section-title {
grid-column: 1 / -1;
display: flex;
gap: 0.6rem;
align-items: center;
margin: 0.75rem 0 0;
font-weight: 600;
font-size: 1.05rem;
}
.list-section-title:first-child {
margin-top: 0;
}
.list-section-title > i {
color: var(--accent, #7aa2f7);
}
.card-link {
color: inherit;
text-decoration: none;
/* Длинное имя проекта переносится, а не выдавливает бейдж из шапки:
у шапки кита flex, и без min-width заголовок не сжимается */
min-width: 0;
overflow-wrap: anywhere;
}
.card-link:hover {
color: var(--accent, #7aa2f7);
}
/* Бейдж актуальности — справа в шапке: базовый `.card-title` кита это block,
флекс в нём только у варианта status-card, поэтому раскладываем сами */
.project-card :deep(.card-title) {
display: flex;
justify-content: space-between;
gap: 0.75rem;
align-items: center;
}
.relevance-badge {
flex: none;
}
.list-skeleton {
margin-bottom: 1rem;
}
/* карточка-скелет: как реальная карточка проекта — тянется на ширину трека */
.list-skeleton .card {
width: 100%;
max-width: none;
}
.list-skeleton .card-content > .skeleton-title {
display: block;
margin-bottom: 10px;
}
.sk-badges {
display: flex;
gap: 6px;
}
.project-meta {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
/* Полоса прогресса на всю ширину карточки (в ките .progress упёрт в max-width: 640px) */
.project-progress {
max-width: 100%;
margin-bottom: 0.75rem;
}
/* Подсказка вместо полосы у проекта без задач */
.card-hint {
margin: 0 0 0.75rem;
color: var(--gn-color-text-medium, #a9b1d6);
font-size: 0.85em;
}
/* Подвал карточки: срок слева, действие справа; без срока кнопка уезжает вправо */
.card-foot {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
justify-content: space-between;
}
.card-deadline {
display: inline-flex;
align-items: center;
gap: 0.35rem;
color: var(--gn-color-text-medium, #a9b1d6);
font-size: 0.85em;
}
.card-deadline.late {
color: var(--gn-color-error, #f7768e);
}
.card-open {
margin-left: auto;
}
</style>