Files
CRM_Montzar/apps/web/src/views/routines/RoutinesTodayView.vue
T

530 lines
14 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>