530 lines
14 KiB
Vue
530 lines
14 KiB
Vue
<script setup lang="ts">
|
||
import { computed, inject, onMounted, ref, watch } from 'vue';
|
||
import { RouterLink } from 'vue-router';
|
||
import type { RoutineCalendarDto, RoutineTaskDto, RoutineTodayDto } from '@mento/shared';
|
||
import UiIcon from '@/components/UiIcon.vue';
|
||
import KpiCard from '@/components/routines/KpiCard.vue';
|
||
import DailyScoreCard from '@/components/routines/DailyScoreCard.vue';
|
||
import RoutineTaskRow, { type RoutineTaskRowAction } from '@/components/routines/RoutineTaskRow.vue';
|
||
import RoutineTaskModals from '@/components/routines/RoutineTaskModals.vue';
|
||
import { UiEmptyState, UiSegmented, UiSelect } from '@/components/ui';
|
||
import { api } from '@/lib/api';
|
||
import { toFaDigits } from '@/lib/jalali';
|
||
import {
|
||
ROUTINES_CTX,
|
||
addDaysKey,
|
||
apiError,
|
||
formatDayLong,
|
||
formatPercent,
|
||
isOpenStatus,
|
||
todayKey,
|
||
} from '@/lib/routines';
|
||
import { useRoutineTaskActions } from '@/lib/useRoutineTaskActions';
|
||
import { useAuthStore } from '@/stores/auth';
|
||
|
||
const shell = inject(ROUTINES_CTX)!;
|
||
const auth = useAuthStore();
|
||
|
||
const data = ref<RoutineTodayDto | null>(null);
|
||
const recent = ref<RoutineCalendarDto['days']>([]);
|
||
const loading = ref(true);
|
||
const error = ref('');
|
||
const userId = ref('');
|
||
const filter = ref<'all' | 'open' | 'done'>('all');
|
||
|
||
const viewingSelf = computed(() => !userId.value || userId.value === auth.user?.id);
|
||
|
||
async function load(silent = false) {
|
||
if (!silent) loading.value = true;
|
||
error.value = '';
|
||
const params = userId.value ? { userId: userId.value } : {};
|
||
try {
|
||
const today = todayKey();
|
||
const [todayRes, calRes] = await Promise.all([
|
||
api.get<RoutineTodayDto>('/routine-tasks/today', { params }),
|
||
api.get<RoutineCalendarDto>('/routine-tasks/calendar', {
|
||
params: { ...params, from: addDaysKey(today, -13), to: today },
|
||
}),
|
||
]);
|
||
data.value = todayRes.data;
|
||
recent.value = calRes.data.days;
|
||
} catch (e) {
|
||
error.value = apiError(e, 'بارگذاری کارهای امروز ممکن نشد.');
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
onMounted(() => load());
|
||
watch(userId, () => load());
|
||
watch(shell.refreshTick, () => load(true));
|
||
|
||
function applyLocal(updated: RoutineTaskDto) {
|
||
const d = data.value;
|
||
if (!d) return;
|
||
for (const list of [d.tasks, d.overdue]) {
|
||
const i = list.findIndex((t) => t.id === updated.id);
|
||
if (i >= 0) list[i] = updated;
|
||
}
|
||
}
|
||
|
||
const actions = useRoutineTaskActions((updated) => {
|
||
applyLocal(updated);
|
||
shell.notifyChanged();
|
||
});
|
||
|
||
function onAction(kind: RoutineTaskRowAction, task: RoutineTaskDto) {
|
||
if (kind === 'start') void actions.start(task);
|
||
else if (kind === 'complete-note') actions.openComplete(task);
|
||
else if (kind === 'reschedule') actions.openReschedule(task);
|
||
else if (kind === 'move-today') void actions.moveToday(task);
|
||
else if (kind === 'skip') actions.openSkip(task);
|
||
else actions.openCancel(task);
|
||
}
|
||
|
||
const stats = computed(() => data.value?.stats ?? { total: 0, completed: 0, remaining: 0, overdue: 0, percent: null });
|
||
|
||
const tasks = computed(() => {
|
||
const list = data.value?.tasks ?? [];
|
||
if (filter.value === 'open') return list.filter((t) => isOpenStatus(t.status));
|
||
if (filter.value === 'done') return list.filter((t) => !isOpenStatus(t.status));
|
||
return list;
|
||
});
|
||
|
||
const openTasks = computed(() => tasks.value.filter((t) => isOpenStatus(t.status)));
|
||
const closedTasks = computed(() => tasks.value.filter((t) => !isOpenStatus(t.status)));
|
||
|
||
const progressPct = computed(() => stats.value.percent ?? 0);
|
||
|
||
const filterOptions = computed(() => [
|
||
{ value: 'all' as const, label: `همه (${toFaDigits(data.value?.tasks.length ?? 0)})` },
|
||
{ value: 'open' as const, label: 'باقیمانده' },
|
||
{ value: 'done' as const, label: 'انجامشده' },
|
||
]);
|
||
|
||
const users = computed(() => shell.context.value?.users ?? []);
|
||
const showUserPicker = computed(() => !!shell.context.value?.isManager && users.value.length > 1);
|
||
|
||
const greeting = computed(() => {
|
||
if (!viewingSelf.value) {
|
||
const u = users.value.find((x) => x.id === userId.value);
|
||
return u ? `کارهای امروز ${u.fullName}` : 'کارهای امروز';
|
||
}
|
||
return 'امروز چه کارهایی داری؟';
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<div class="today">
|
||
<!-- سربرگ روز -->
|
||
<section class="day-hero">
|
||
<div class="hero-copy">
|
||
<h2>کارهای امروز</h2>
|
||
<p class="sub">{{ greeting }}</p>
|
||
<p class="date">
|
||
<UiIcon name="calendar" :size="14" />
|
||
{{ formatDayLong(data?.date ?? todayKey()) }}
|
||
</p>
|
||
</div>
|
||
<div class="hero-progress" :aria-label="`${stats.completed} از ${stats.total} کار انجام شده`">
|
||
<div class="ring" :style="{ '--p': progressPct }">
|
||
<span>{{ formatPercent(stats.percent) }}</span>
|
||
</div>
|
||
<div class="progress-copy">
|
||
<strong>{{ toFaDigits(stats.completed) }} از {{ toFaDigits(stats.total) }}</strong>
|
||
<span>کار امروز انجام شده</span>
|
||
<i class="pbar"><b :style="{ width: `${progressPct}%` }" /></i>
|
||
</div>
|
||
</div>
|
||
<div v-if="showUserPicker" class="hero-user">
|
||
<UiSelect v-model="userId" size="sm">
|
||
<option value="">کارهای خودم</option>
|
||
<option v-for="u in users.filter((x) => x.id !== auth.user?.id)" :key="u.id" :value="u.id">
|
||
{{ u.fullName }}
|
||
</option>
|
||
</UiSelect>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- کارتهای آماری -->
|
||
<section class="stat-grid">
|
||
<KpiCard label="انجام شده" :value="toFaDigits(stats.completed)" icon="check" tone="success" />
|
||
<KpiCard label="باقیمانده" :value="toFaDigits(stats.remaining)" icon="clock" tone="info" />
|
||
<KpiCard
|
||
label="عقبافتاده"
|
||
:value="toFaDigits(stats.overdue)"
|
||
icon="warning"
|
||
:tone="stats.overdue ? 'danger' : 'neutral'"
|
||
/>
|
||
<KpiCard label="درصد تکمیل" :value="formatPercent(stats.percent)" icon="target" tone="accent" />
|
||
</section>
|
||
|
||
<p v-if="error" class="error">{{ error }}</p>
|
||
<p v-if="actions.error.value" class="error">{{ actions.error.value }}</p>
|
||
|
||
<div class="layout">
|
||
<section class="main">
|
||
<div class="list-head">
|
||
<UiSegmented v-model="filter" :options="filterOptions" />
|
||
</div>
|
||
|
||
<div v-if="loading" class="skeleton">
|
||
<span v-for="i in 4" :key="i" />
|
||
</div>
|
||
|
||
<UiEmptyState
|
||
v-else-if="!data?.tasks.length"
|
||
icon="calendar-sun"
|
||
title="برای امروز کاری ثبت نشده"
|
||
description="روتینهای فعال بهصورت خودکار هر روز کار میسازند. میتوانید یک کار دستی هم اضافه کنید."
|
||
/>
|
||
|
||
<template v-else>
|
||
<div v-if="openTasks.length" class="group">
|
||
<h3 class="group-title">باقیمانده <em>{{ toFaDigits(openTasks.length) }}</em></h3>
|
||
<TransitionGroup name="rt-list" tag="div" class="rows">
|
||
<RoutineTaskRow
|
||
v-for="t in openTasks"
|
||
:key="t.id"
|
||
:task="t"
|
||
:busy="actions.busyId.value === t.id"
|
||
show-delay
|
||
@toggle="actions.toggle"
|
||
@open="shell.openTask(t.id)"
|
||
@action="onAction"
|
||
/>
|
||
</TransitionGroup>
|
||
</div>
|
||
<div v-else-if="filter !== 'done'" class="all-done">
|
||
<UiIcon name="check" :size="20" />
|
||
همه کارهای امروز انجام شد.
|
||
</div>
|
||
|
||
<div v-if="closedTasks.length" class="group">
|
||
<h3 class="group-title">انجامشده / بسته <em>{{ toFaDigits(closedTasks.length) }}</em></h3>
|
||
<TransitionGroup name="rt-list" tag="div" class="rows">
|
||
<RoutineTaskRow
|
||
v-for="t in closedTasks"
|
||
:key="t.id"
|
||
:task="t"
|
||
:busy="actions.busyId.value === t.id"
|
||
@toggle="actions.toggle"
|
||
@open="shell.openTask(t.id)"
|
||
@action="onAction"
|
||
/>
|
||
</TransitionGroup>
|
||
</div>
|
||
</template>
|
||
</section>
|
||
|
||
<aside class="side">
|
||
<DailyScoreCard :score="data?.score ?? null" :days="recent" />
|
||
|
||
<section v-if="data?.overdue.length" class="overdue-card">
|
||
<header>
|
||
<h3>
|
||
<UiIcon name="warning" :size="15" />
|
||
عقبافتاده از روزهای قبل
|
||
</h3>
|
||
<RouterLink to="/routines/overdue">همه ({{ toFaDigits(data.overdue.length) }})</RouterLink>
|
||
</header>
|
||
<div class="rows">
|
||
<RoutineTaskRow
|
||
v-for="t in data.overdue.slice(0, 4)"
|
||
:key="t.id"
|
||
:task="t"
|
||
:busy="actions.busyId.value === t.id"
|
||
show-date
|
||
@toggle="actions.toggle"
|
||
@open="shell.openTask(t.id)"
|
||
@action="onAction"
|
||
/>
|
||
</div>
|
||
</section>
|
||
</aside>
|
||
</div>
|
||
|
||
<RoutineTaskModals :actions="actions" />
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.today {
|
||
display: grid;
|
||
gap: var(--sp-4);
|
||
}
|
||
|
||
.day-hero {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
flex-wrap: wrap;
|
||
gap: var(--sp-4);
|
||
padding: var(--sp-5);
|
||
border-radius: var(--radius);
|
||
border: 1px solid color-mix(in srgb, var(--accent) 22%, var(--line));
|
||
background: linear-gradient(135deg, var(--accent-subtle), var(--surface) 70%);
|
||
}
|
||
|
||
.hero-copy h2 {
|
||
margin: 0;
|
||
font-size: var(--fs-xl);
|
||
font-weight: var(--fw-bold);
|
||
color: var(--ink);
|
||
}
|
||
|
||
.sub {
|
||
margin: 4px 0 0;
|
||
color: var(--ink-secondary);
|
||
font-size: var(--fs-base);
|
||
}
|
||
|
||
.date {
|
||
margin: 8px 0 0;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: var(--fs-sm);
|
||
color: var(--accent-deep);
|
||
font-weight: var(--fw-semibold);
|
||
}
|
||
|
||
.hero-progress {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--sp-4);
|
||
}
|
||
|
||
.ring {
|
||
--p: 0;
|
||
width: 76px;
|
||
height: 76px;
|
||
border-radius: 50%;
|
||
display: grid;
|
||
place-items: center;
|
||
background: conic-gradient(var(--accent) calc(var(--p) * 1%), var(--line-light) 0);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.ring span {
|
||
width: 60px;
|
||
height: 60px;
|
||
border-radius: 50%;
|
||
background: var(--surface);
|
||
display: grid;
|
||
place-items: center;
|
||
font-weight: var(--fw-bold);
|
||
font-size: var(--fs-md);
|
||
color: var(--accent-deep);
|
||
}
|
||
|
||
.progress-copy {
|
||
display: grid;
|
||
gap: 4px;
|
||
min-width: 160px;
|
||
}
|
||
|
||
.progress-copy strong {
|
||
font-size: var(--fs-xl);
|
||
color: var(--ink);
|
||
}
|
||
|
||
.progress-copy span {
|
||
font-size: var(--fs-sm);
|
||
color: var(--muted);
|
||
}
|
||
|
||
.pbar {
|
||
height: 6px;
|
||
border-radius: 99px;
|
||
background: var(--line-light);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.pbar b {
|
||
display: block;
|
||
height: 100%;
|
||
background: var(--accent);
|
||
border-radius: inherit;
|
||
transition: width 0.4s ease;
|
||
}
|
||
|
||
.hero-user {
|
||
width: 200px;
|
||
}
|
||
|
||
.stat-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||
gap: var(--sp-3);
|
||
}
|
||
|
||
.layout {
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1fr) 320px;
|
||
gap: var(--sp-4);
|
||
align-items: start;
|
||
}
|
||
|
||
.main {
|
||
display: grid;
|
||
gap: var(--sp-4);
|
||
min-width: 0;
|
||
}
|
||
|
||
.list-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: var(--sp-2);
|
||
}
|
||
|
||
.group {
|
||
display: grid;
|
||
gap: var(--sp-2);
|
||
}
|
||
|
||
.group-title {
|
||
margin: 0;
|
||
font-size: var(--fs-sm);
|
||
font-weight: var(--fw-semibold);
|
||
color: var(--ink-secondary);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
|
||
.group-title em {
|
||
font-style: normal;
|
||
color: var(--muted);
|
||
font-weight: var(--fw-normal);
|
||
}
|
||
|
||
.rows {
|
||
position: relative;
|
||
display: grid;
|
||
gap: var(--sp-2);
|
||
}
|
||
|
||
.all-done {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--sp-2);
|
||
padding: var(--sp-4);
|
||
border-radius: var(--radius);
|
||
background: var(--success-soft);
|
||
color: var(--success-deep);
|
||
font-weight: var(--fw-semibold);
|
||
}
|
||
|
||
.side {
|
||
display: grid;
|
||
gap: var(--sp-4);
|
||
position: sticky;
|
||
top: var(--sp-4);
|
||
}
|
||
|
||
.overdue-card {
|
||
display: grid;
|
||
gap: var(--sp-3);
|
||
padding: var(--sp-4);
|
||
border: 1px solid color-mix(in srgb, var(--danger) 20%, var(--line));
|
||
border-radius: var(--radius);
|
||
background: var(--surface);
|
||
}
|
||
|
||
.overdue-card header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.overdue-card h3 {
|
||
margin: 0;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: var(--fs-sm);
|
||
color: var(--danger);
|
||
}
|
||
|
||
.overdue-card a {
|
||
font-size: var(--fs-xs);
|
||
color: var(--accent-deep);
|
||
font-weight: var(--fw-semibold);
|
||
}
|
||
|
||
.skeleton {
|
||
display: grid;
|
||
gap: var(--sp-2);
|
||
}
|
||
|
||
.skeleton span {
|
||
height: 72px;
|
||
border-radius: var(--radius);
|
||
background: linear-gradient(90deg, var(--surface-subtle), var(--line-light), var(--surface-subtle));
|
||
background-size: 200% 100%;
|
||
animation: sk 1.2s ease-in-out infinite;
|
||
}
|
||
|
||
@keyframes sk {
|
||
to {
|
||
background-position: -200% 0;
|
||
}
|
||
}
|
||
|
||
.rt-list-move,
|
||
.rt-list-enter-active,
|
||
.rt-list-leave-active {
|
||
transition: all 0.25s ease;
|
||
}
|
||
|
||
.rt-list-enter-from,
|
||
.rt-list-leave-to {
|
||
opacity: 0;
|
||
transform: translateY(6px);
|
||
}
|
||
|
||
.rt-list-leave-active {
|
||
position: absolute;
|
||
width: 100%;
|
||
}
|
||
|
||
.error {
|
||
margin: 0;
|
||
}
|
||
|
||
@media (max-width: 1100px) {
|
||
.layout {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.side {
|
||
position: static;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 900px) {
|
||
.day-hero {
|
||
padding: var(--sp-4);
|
||
}
|
||
|
||
.hero-progress {
|
||
width: 100%;
|
||
}
|
||
|
||
.progress-copy {
|
||
flex: 1 1 auto;
|
||
}
|
||
|
||
.hero-user {
|
||
width: 100%;
|
||
}
|
||
|
||
.stat-grid {
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: var(--sp-2);
|
||
}
|
||
}
|
||
</style>
|