feat: routines module; super admin subscription badge, 7-day trial and lifetime plan

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-06 19:54:38 +03:30
co-authored by Cursor
parent 3de2d0fa67
commit b3784818fc
58 changed files with 13018 additions and 18 deletions
@@ -0,0 +1,271 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import type { RoutineCalendarDto, RoutineHistoryDayDto, RoutineTaskDto } from '@mento/shared';
import { ROUTINE_TASK_STATUS_LABELS } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import { UiEmptyState } from '@/components/ui';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import {
DAY_STATE_LABELS,
DAY_STATE_SYMBOLS,
apiError,
formatDayLong,
formatPercent,
formatTime,
} from '@/lib/routines';
const props = defineProps<{ userId?: string | null; refreshKey?: number }>();
const emit = defineEmits<{ open: [taskId: string] }>();
const days = ref<RoutineHistoryDayDto[]>([]);
const loading = ref(false);
const error = ref('');
const expanded = ref<string | null>(null);
const dayTasks = ref<Record<string, RoutineTaskDto[]>>({});
async function load() {
loading.value = true;
error.value = '';
try {
const { data } = await api.get<RoutineHistoryDayDto[]>('/routines/stats/history', {
params: props.userId ? { userId: props.userId } : {},
});
days.value = data;
dayTasks.value = {};
expanded.value = null;
} catch (e) {
error.value = apiError(e, 'بارگذاری تاریخچه ممکن نشد.');
} finally {
loading.value = false;
}
}
watch([() => props.userId, () => props.refreshKey], load, { immediate: true });
async function toggle(day: string) {
if (expanded.value === day) {
expanded.value = null;
return;
}
expanded.value = day;
if (dayTasks.value[day]) return;
try {
const { data } = await api.get<RoutineCalendarDto>('/routine-tasks/calendar', {
params: { from: day, to: day, ...(props.userId ? { userId: props.userId } : {}) },
});
dayTasks.value = { ...dayTasks.value, [day]: data.tasks };
} catch {
dayTasks.value = { ...dayTasks.value, [day]: [] };
}
}
</script>
<template>
<div class="history">
<p v-if="error" class="error">{{ error }}</p>
<div v-if="loading" class="state">در حال بارگذاری…</div>
<UiEmptyState
v-else-if="!days.length"
icon="clock"
title="هنوز سابقه‌ای ثبت نشده"
description="تاریخچه انجام کارها در ۶۰ روز اخیر اینجا نمایش داده می‌شود."
/>
<ol v-else class="days">
<li v-for="d in days" :key="d.date" :class="`s-${d.state}`">
<button type="button" class="day" :aria-expanded="expanded === d.date" @click="toggle(d.date)">
<span class="sym" :title="DAY_STATE_LABELS[d.state]">{{ DAY_STATE_SYMBOLS[d.state] }}</span>
<span class="date">
<strong>{{ formatDayLong(d.date) }}</strong>
<small>
{{ toFaDigits(d.completed) }} از {{ toFaDigits(d.total) }} انجام شد
<template v-if="d.overdue"> · {{ toFaDigits(d.overdue) }} عقب‌افتاده</template>
<template v-if="d.skipped"> · {{ toFaDigits(d.skipped) }} رد شده</template>
</small>
</span>
<span class="pct">{{ formatPercent(d.percent) }}</span>
<UiIcon name="chevron" :size="16" class="chev" :class="{ open: expanded === d.date }" />
</button>
<ul v-if="expanded === d.date" class="tasks">
<li v-if="!dayTasks[d.date]" class="muted">در حال بارگذاری…</li>
<li
v-for="t in dayTasks[d.date] ?? []"
:key="t.id"
:class="`st-${t.status}`"
@click="emit('open', t.id)"
>
<span class="dot" />
<span class="t-title">{{ t.title }}</span>
<small>{{ ROUTINE_TASK_STATUS_LABELS[t.status] }}</small>
<small v-if="t.completedAt">ساعت {{ formatTime(t.completedAt) }}</small>
</li>
</ul>
</li>
</ol>
</div>
</template>
<style scoped>
.history {
display: grid;
gap: var(--sp-3);
}
.state {
padding: var(--sp-8);
text-align: center;
color: var(--muted);
}
.days {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: var(--sp-2);
}
.days > li {
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
overflow: hidden;
}
.day {
width: 100%;
display: grid;
grid-template-columns: 32px minmax(0, 1fr) auto 20px;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-3);
border: 0;
background: transparent;
font: inherit;
text-align: right;
cursor: pointer;
}
.day:hover {
background: var(--surface-hover);
}
.sym {
width: 30px;
height: 30px;
border-radius: 8px;
display: grid;
place-items: center;
font-weight: var(--fw-bold);
background: var(--surface-subtle);
color: var(--muted);
}
.s-complete .sym {
background: var(--success-soft);
color: var(--success-deep);
}
.s-partial .sym {
background: var(--warning-soft);
color: var(--warning-deep);
}
.s-missed .sym {
background: var(--danger-soft);
color: var(--danger);
}
.date {
display: grid;
gap: 2px;
}
.date strong {
font-size: var(--fs-sm);
font-weight: var(--fw-semibold);
color: var(--ink);
}
.date small {
font-size: var(--fs-xs);
color: var(--muted);
}
.pct {
font-weight: var(--fw-bold);
color: var(--ink);
}
.chev {
color: var(--muted);
transition: transform var(--transition-fast);
}
.chev.open {
transform: rotate(180deg);
}
.tasks {
list-style: none;
margin: 0;
padding: 0 var(--sp-3) var(--sp-3);
display: grid;
gap: 4px;
}
.tasks li {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px 10px;
padding: 8px 10px;
border-radius: 8px;
background: var(--surface-subtle);
font-size: var(--fs-sm);
cursor: pointer;
}
.tasks li:hover {
background: var(--accent-subtle);
}
.tasks li.muted {
cursor: default;
color: var(--muted);
}
.tasks small {
font-size: var(--fs-xs);
color: var(--muted);
}
.t-title {
flex: 1 1 auto;
min-width: 0;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--line-strong);
}
.st-completed .dot {
background: var(--success);
}
.st-overdue .dot {
background: var(--danger);
}
.st-skipped .dot,
.st-cancelled .dot {
background: var(--warning);
}
.error {
margin: 0;
}
</style>
@@ -0,0 +1,244 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { RoutineCalendarDayDto, RoutineScoreDto } from '@mento/shared';
import { toFaDigits } from '@/lib/jalali';
import {
DAY_STATE_LABELS,
DAY_STATE_SYMBOLS,
formatDayNumeric,
formatPercent,
percentTone,
weekdayIndex,
} from '@/lib/routines';
import { ROUTINE_WEEKDAY_LABELS } from '@mento/shared';
const props = defineProps<{
score: RoutineScoreDto | null;
/** روزهای اخیر برای نوار Streak (قدیمی → جدید) */
days?: RoutineCalendarDayDto[];
}>();
const rows = computed(() => {
const s = props.score;
return [
{ label: 'امروز', value: s?.today ?? null },
{ label: 'دیروز', value: s?.yesterday ?? null },
{ label: 'این هفته', value: s?.week ?? null },
{ label: 'این ماه', value: s?.month ?? null },
];
});
const strip = computed(() => props.days ?? []);
</script>
<template>
<section class="score-card">
<header>
<h3>امتیاز روزانه</h3>
<span class="streak" :title="'روزهای متوالی با انجام همه کارهای اجباری'">
<strong>{{ toFaDigits(score?.streak ?? 0) }}</strong>
روز پیاپی کامل
</span>
</header>
<div class="scores">
<div v-for="r in rows" :key="r.label" class="score" :class="`tone-${percentTone(r.value)}`">
<span>{{ r.label }}</span>
<strong>{{ formatPercent(r.value) }}</strong>
<i class="bar"><b :style="{ width: `${r.value ?? 0}%` }" /></i>
</div>
</div>
<div v-if="strip.length" class="strip-wrap">
<div class="strip" role="list" aria-label="وضعیت روزهای اخیر">
<span
v-for="d in strip"
:key="d.date"
role="listitem"
class="cell"
:class="`s-${d.state}`"
:title="`${formatDayNumeric(d.date)} — ${DAY_STATE_LABELS[d.state]}${d.total ? ` (${toFaDigits(d.completed)} از ${toFaDigits(d.total)})` : ''}`"
>
<em>{{ DAY_STATE_SYMBOLS[d.state] }}</em>
<small>{{ ROUTINE_WEEKDAY_LABELS[weekdayIndex(d.date)]?.charAt(0) }}</small>
</span>
</div>
<div class="legend">
<span><i class="s-complete">✓</i> کامل</span>
<span><i class="s-partial">△</i> ناقص</span>
<span><i class="s-missed">×</i> از دست رفته</span>
</div>
</div>
</section>
</template>
<style scoped>
.score-card {
display: grid;
gap: var(--sp-4);
padding: var(--sp-4);
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow-xs);
}
header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-2);
}
h3 {
margin: 0;
font-size: var(--fs-md);
font-weight: var(--fw-semibold);
color: var(--ink);
}
.streak {
display: inline-flex;
align-items: baseline;
gap: 5px;
font-size: var(--fs-xs);
color: var(--muted);
}
.streak strong {
font-size: var(--fs-lg);
color: var(--accent-deep);
}
.scores {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--sp-3);
}
.score {
display: grid;
grid-template-columns: 1fr auto;
align-items: baseline;
gap: 4px 8px;
}
.score span {
font-size: var(--fs-xs);
color: var(--muted);
}
.score strong {
font-size: var(--fs-md);
color: var(--ink);
font-variant-numeric: tabular-nums;
}
.bar {
grid-column: 1 / -1;
height: 5px;
border-radius: 99px;
background: var(--surface-subtle);
overflow: hidden;
}
.bar b {
display: block;
height: 100%;
border-radius: inherit;
background: var(--line-strong);
transition: width 0.4s ease;
}
.tone-good .bar b {
background: var(--success);
}
.tone-mid .bar b {
background: var(--warning);
}
.tone-low .bar b {
background: var(--danger);
}
.strip-wrap {
display: grid;
gap: var(--sp-2);
}
.strip {
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(0, 1fr);
gap: 4px;
}
.cell {
display: grid;
justify-items: center;
gap: 2px;
}
.cell em {
width: 100%;
aspect-ratio: 1;
max-width: 26px;
border-radius: 6px;
display: grid;
place-items: center;
font-style: normal;
font-size: 0.75rem;
font-weight: var(--fw-bold);
background: var(--surface-subtle);
color: var(--muted-light);
}
.cell small {
font-size: 0.62rem;
color: var(--muted-light);
}
.s-complete,
.cell.s-complete em {
background: var(--success-soft);
color: var(--success-deep);
}
.s-partial,
.cell.s-partial em {
background: var(--warning-soft);
color: var(--warning-deep);
}
.s-missed,
.cell.s-missed em {
background: var(--danger-soft);
color: var(--danger);
}
.legend {
display: flex;
flex-wrap: wrap;
gap: var(--sp-3);
font-size: var(--fs-xs);
color: var(--muted);
}
.legend span {
display: inline-flex;
align-items: center;
gap: 4px;
}
.legend i {
width: 16px;
height: 16px;
border-radius: 4px;
display: inline-grid;
place-items: center;
font-style: normal;
font-size: 0.65rem;
font-weight: var(--fw-bold);
}
</style>
@@ -0,0 +1,224 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import type { CompanyDto, LeadDto, PaginatedResponse, PersonDto } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import { UiInput, UiSelect } from '@/components/ui';
import { api } from '@/lib/api';
import { ENTITY_TYPE_LABELS } from '@/lib/routines';
import { useAuthStore } from '@/stores/auth';
export type PickedEntity = { type: 'lead' | 'person' | 'company'; id: string; label: string };
const props = withDefaults(
defineProps<{
/** محدود کردن نوع پرونده (مثلاً وقتی روتین «الزامی به لید» است) */
only?: 'lead' | 'customer' | null;
}>(),
{ only: null },
);
const model = defineModel<PickedEntity | null>({ default: null });
const auth = useAuthStore();
function moduleOn(key: string) {
const mods = auth.user?.enabledModules;
return !mods || mods.length === 0 || mods.includes(key as never);
}
const typeOptions = computed(() => {
const list: Array<PickedEntity['type']> = [];
if (props.only !== 'customer' && moduleOn('leads')) list.push('lead');
if (props.only !== 'lead' && moduleOn('customers')) list.push('person', 'company');
return list;
});
const type = ref<PickedEntity['type']>(typeOptions.value[0] ?? 'person');
const q = ref('');
const results = ref<PickedEntity[]>([]);
const loading = ref(false);
let timer: ReturnType<typeof setTimeout> | null = null;
let seq = 0;
watch(typeOptions, (opts) => {
if (!opts.includes(type.value) && opts[0]) type.value = opts[0];
});
async function search() {
const term = q.value.trim();
const mine = ++seq;
if (term.length < 2) {
results.value = [];
loading.value = false;
return;
}
loading.value = true;
try {
const params = { q: term, page: 1, limit: 8 };
let next: PickedEntity[] = [];
if (type.value === 'lead') {
const { data } = await api.get<PaginatedResponse<LeadDto>>('/leads', { params });
next = data.items.map((l) => ({
type: 'lead',
id: l.id,
label: l.title || l.contactName || l.mobile,
}));
} else if (type.value === 'person') {
const { data } = await api.get<PaginatedResponse<PersonDto>>('/people', { params });
next = data.items.map((p) => ({
type: 'person',
id: p.id,
label: `${p.firstName} ${p.lastName}`.trim(),
}));
} else {
const { data } = await api.get<PaginatedResponse<CompanyDto>>('/companies', { params });
next = data.items.map((c) => ({ type: 'company', id: c.id, label: c.name }));
}
if (mine === seq) results.value = next;
} catch {
if (mine === seq) results.value = [];
} finally {
if (mine === seq) loading.value = false;
}
}
watch([q, type], () => {
if (timer) clearTimeout(timer);
timer = setTimeout(search, 280);
});
onBeforeUnmount(() => {
if (timer) clearTimeout(timer);
});
function pick(item: PickedEntity) {
model.value = item;
q.value = '';
results.value = [];
}
</script>
<template>
<div class="ent-picker">
<div v-if="model" class="picked">
<span class="picked-type">{{ ENTITY_TYPE_LABELS[model.type] }}</span>
<strong>{{ model.label }}</strong>
<button type="button" aria-label="حذف اتصال" @click="model = null">
<UiIcon name="x" :size="14" />
</button>
</div>
<template v-else-if="typeOptions.length">
<div class="search-row">
<UiSelect v-if="typeOptions.length > 1" v-model="type" class="type-sel">
<option v-for="t in typeOptions" :key="t" :value="t">{{ ENTITY_TYPE_LABELS[t] }}</option>
</UiSelect>
<UiInput v-model="q" :placeholder="`جستجوی ${ENTITY_TYPE_LABELS[type]}…`" />
</div>
<ul v-if="results.length || loading || q.trim().length >= 2" class="results">
<li v-if="loading" class="muted">در حال جستجو…</li>
<li v-else-if="!results.length" class="muted">موردی یافت نشد</li>
<li v-for="r in results" :key="r.id">
<button type="button" @click="pick(r)">{{ r.label }}</button>
</li>
</ul>
</template>
<p v-else class="muted">ماژول لید/مشتری برای سازمان فعال نیست.</p>
</div>
</template>
<style scoped>
.ent-picker {
display: grid;
gap: var(--sp-2);
}
.search-row {
display: flex;
gap: var(--sp-2);
}
.type-sel {
width: 110px;
flex-shrink: 0;
}
.search-row > :last-child {
flex: 1 1 auto;
}
.results {
list-style: none;
margin: 0;
padding: 4px;
border: 1px solid var(--line);
border-radius: var(--radius);
max-height: 220px;
overflow-y: auto;
background: var(--surface);
}
.results li button {
width: 100%;
text-align: right;
border: 0;
background: transparent;
padding: 8px 10px;
border-radius: 8px;
cursor: pointer;
color: var(--ink);
}
.results li button:hover {
background: var(--accent-subtle);
color: var(--accent-deep);
}
.muted {
margin: 0;
padding: 8px 10px;
color: var(--muted);
font-size: var(--fs-sm);
}
.picked {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: 6px 10px;
border-radius: var(--radius);
background: var(--accent-subtle);
border: 1px solid color-mix(in srgb, var(--accent) 25%, transparent);
color: var(--accent-deep);
min-width: 0;
}
.picked strong {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.picked-type {
font-size: var(--fs-xs);
color: var(--muted);
}
.picked button {
border: 0;
background: transparent;
color: var(--muted);
cursor: pointer;
display: grid;
place-items: center;
width: 26px;
height: 26px;
border-radius: 6px;
}
.picked button:hover {
background: var(--surface);
color: var(--danger);
}
</style>
@@ -0,0 +1,151 @@
<script setup lang="ts">
import UiIcon from '@/components/UiIcon.vue';
type IconName = InstanceType<typeof UiIcon>['$props']['name'];
withDefaults(
defineProps<{
label: string;
value: string;
hint?: string;
icon?: IconName;
tone?: 'accent' | 'success' | 'warning' | 'danger' | 'info' | 'neutral';
/** تغییر نسبت به دوره قبل (درصد یا عدد)؛ مثبت = بهتر */
delta?: number | null;
deltaSuffix?: string;
}>(),
{ hint: '', icon: undefined, tone: 'neutral', delta: null, deltaSuffix: '' },
);
function fa(n: number) {
return Math.abs(n).toLocaleString('fa-IR');
}
</script>
<template>
<div class="kpi" :class="`tone-${tone}`">
<div class="kpi-top">
<span class="kpi-label">{{ label }}</span>
<span v-if="icon" class="kpi-ico"><UiIcon :name="icon" :size="16" /></span>
</div>
<strong class="kpi-value">{{ value }}</strong>
<div v-if="hint || delta != null" class="kpi-foot">
<span v-if="delta != null && delta !== 0" class="delta" :class="delta > 0 ? 'up' : 'down'">
{{ delta > 0 ? '▲' : '▼' }} {{ fa(delta) }}{{ deltaSuffix }}
</span>
<span v-else-if="delta === 0" class="delta flat">بدون تغییر</span>
<span v-if="hint" class="kpi-hint">{{ hint }}</span>
</div>
</div>
</template>
<style scoped>
.kpi {
display: grid;
gap: 6px;
padding: var(--sp-4);
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow-xs);
min-width: 0;
}
.kpi-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-2);
}
.kpi-label {
font-size: var(--fs-sm);
color: var(--muted);
font-weight: var(--fw-medium);
}
.kpi-ico {
width: 30px;
height: 30px;
border-radius: 8px;
display: grid;
place-items: center;
background: var(--surface-subtle);
color: var(--ink-secondary);
}
.tone-accent .kpi-ico {
background: var(--accent-subtle);
color: var(--accent-deep);
}
.tone-success .kpi-ico {
background: var(--success-soft);
color: var(--success-deep);
}
.tone-warning .kpi-ico {
background: var(--warning-soft);
color: var(--warning-deep);
}
.tone-danger .kpi-ico {
background: var(--danger-soft);
color: var(--danger);
}
.tone-info .kpi-ico {
background: var(--info-soft);
color: var(--info-deep);
}
.kpi-value {
font-size: var(--fs-2xl);
font-weight: var(--fw-bold);
color: var(--ink);
line-height: 1.2;
font-variant-numeric: tabular-nums;
}
.tone-danger .kpi-value {
color: var(--danger);
}
.kpi-foot {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
font-size: var(--fs-xs);
}
.kpi-hint {
color: var(--muted);
}
.delta {
font-weight: var(--fw-semibold);
}
.delta.up {
color: var(--success-deep);
}
.delta.down {
color: var(--danger);
}
.delta.flat {
color: var(--muted);
}
@media (max-width: 900px) {
.kpi {
padding: var(--sp-3);
}
.kpi-value {
font-size: var(--fs-xl);
}
}
</style>
@@ -0,0 +1,269 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import type { RoutineMonthlyReportDto, RoutineStatsQuery, RoutineTrendPointDto } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import KpiCard from '@/components/routines/KpiCard.vue';
import RoutineMonthGrid from '@/components/routines/RoutineMonthGrid.vue';
import TeamPerformanceTable from '@/components/routines/TeamPerformanceTable.vue';
import { UiButton, UiChart } from '@/components/ui';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import { daysBar, statusDonut, trendOption } from '@/lib/routineCharts';
import {
apiError,
formatPercent,
jalaliMonthRange,
shiftJalaliMonth,
todayKey,
} from '@/lib/routines';
const props = defineProps<{
view: 'me' | 'team';
filters?: RoutineStatsQuery;
refreshKey?: number;
}>();
const anchor = ref(todayKey());
const month = computed(() => jalaliMonthRange(anchor.value));
const data = ref<RoutineMonthlyReportDto | null>(null);
const loading = ref(false);
const error = ref('');
async function load() {
loading.value = true;
error.value = '';
try {
const rest = { ...(props.filters ?? {}) };
delete rest.from;
delete rest.to;
const res = await api.get<RoutineMonthlyReportDto>('/routines/stats/monthly', {
params: { ...rest, from: month.value.from, to: month.value.to, view: props.view },
});
data.value = res.data;
} catch (e) {
error.value = apiError(e, 'بارگذاری گزارش ماهانه ممکن نشد.');
} finally {
loading.value = false;
}
}
watch([() => month.value.from, () => JSON.stringify(props.filters ?? {}), () => props.refreshKey], load, {
immediate: true,
});
function diff(cur: number | null | undefined, prev: number | null | undefined) {
if (cur == null || prev == null) return null;
return cur - prev;
}
const t = computed(() => data.value?.totals ?? null);
const p = computed(() => data.value?.previous ?? null);
const trend = computed<RoutineTrendPointDto[]>(() =>
(data.value?.days ?? []).map((d) => ({ date: d.date, total: d.total, completed: d.completed, percent: d.percent })),
);
const lineOpt = computed(() => trendOption(trend.value));
const barOpt = computed(() => daysBar(data.value?.days ?? []));
const donutOpt = computed(() =>
statusDonut(data.value?.status ?? { completed: 0, notDone: 0, overdue: 0, skipped: 0, cancelled: 0 }),
);
const isCurrent = computed(() => month.value.from === jalaliMonthRange(todayKey()).from);
</script>
<template>
<div class="monthly">
<div class="head">
<div class="nav">
<UiButton variant="icon" aria-label="ماه قبل" @click="anchor = shiftJalaliMonth(anchor, -1)">
<UiIcon name="chevron-right" :size="18" />
</UiButton>
<strong>{{ month.label }}</strong>
<UiButton variant="icon" aria-label="ماه بعد" :disabled="isCurrent" @click="anchor = shiftJalaliMonth(anchor, 1)">
<UiIcon name="chevron-left" :size="18" />
</UiButton>
</div>
<span class="cmp">مقایسه با ماه قبل</span>
</div>
<p v-if="error" class="error">{{ error }}</p>
<div class="kpis" :class="{ dim: loading }">
<KpiCard
label="درصد انجام"
:value="formatPercent(t?.percent)"
icon="target"
tone="accent"
:delta="diff(t?.percent, p?.percent)"
delta-suffix="٪"
/>
<KpiCard
label="انجام‌شده"
:value="toFaDigits(t?.completed ?? 0)"
:hint="`از ${toFaDigits(t?.total ?? 0)} کار`"
icon="check"
tone="success"
:delta="diff(t?.completed, p?.completed)"
/>
<KpiCard
label="انجام‌نشده"
:value="toFaDigits(t?.notDone ?? 0)"
:hint="`عقب‌افتاده ${toFaDigits(t?.overdue ?? 0)}`"
icon="x-circle"
tone="danger"
:delta="diff(p?.notDone, t?.notDone)"
/>
<KpiCard
label="روزهای کامل"
:value="toFaDigits(t?.completeDays ?? 0)"
:hint="`ناقص ${toFaDigits(t?.partialDays ?? 0)} · از دست رفته ${toFaDigits(t?.missedDays ?? 0)}`"
icon="calendar"
tone="info"
:delta="diff(t?.completeDays, p?.completeDays)"
/>
</div>
<div class="cards">
<section class="card wide">
<h3>روند درصد انجام روزانه</h3>
<UiChart :option="lineOpt" height="240px" :loading="loading" />
</section>
<section class="card">
<h3>وضعیت کارهای ماه</h3>
<UiChart :option="donutOpt" height="240px" :loading="loading" />
</section>
<section class="card wide">
<h3>تعداد کارها در هر روز</h3>
<UiChart :option="barOpt" height="240px" :loading="loading" />
</section>
<section class="card">
<h3>نقشه حرارتی انجام</h3>
<RoutineMonthGrid :from="month.from" :to="month.to" :days="data?.days ?? []" mode="heat" compact />
<div class="heat-legend">
<span>کمتر</span>
<i class="h0" /><i class="h1" /><i class="h2" /><i class="h3" /><i class="h4" />
<span>بیشتر</span>
</div>
</section>
</div>
<section v-if="view === 'team' && data?.byUser.length" class="card">
<h3>عملکرد اعضا در این ماه</h3>
<TeamPerformanceTable :members="data.byUser" />
</section>
</div>
</template>
<style scoped>
.monthly {
display: grid;
gap: var(--sp-4);
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--sp-2);
}
.nav {
display: flex;
align-items: center;
gap: var(--sp-2);
}
.nav strong {
min-width: 120px;
text-align: center;
color: var(--ink);
}
.cmp {
font-size: var(--fs-xs);
color: var(--muted);
}
.kpis {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--sp-3);
transition: opacity var(--transition-fast);
}
.dim {
opacity: 0.55;
}
.cards {
display: grid;
grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr);
gap: var(--sp-4);
}
.card {
padding: var(--sp-4);
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
display: grid;
gap: var(--sp-3);
align-content: start;
min-width: 0;
}
.card h3 {
margin: 0;
font-size: var(--fs-sm);
font-weight: var(--fw-semibold);
color: var(--ink-secondary);
}
.heat-legend {
display: flex;
align-items: center;
gap: 4px;
font-size: var(--fs-xs);
color: var(--muted);
}
.heat-legend i {
width: 14px;
height: 14px;
border-radius: 4px;
}
.h0 {
background: var(--surface-subtle);
}
.h1 {
background: color-mix(in srgb, var(--accent) 14%, white);
}
.h2 {
background: color-mix(in srgb, var(--accent) 32%, white);
}
.h3 {
background: color-mix(in srgb, var(--accent) 55%, white);
}
.h4 {
background: color-mix(in srgb, var(--accent) 80%, white);
}
.error {
margin: 0;
}
@media (max-width: 1000px) {
.cards {
grid-template-columns: 1fr;
}
.kpis {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--sp-2);
}
}
</style>
@@ -0,0 +1,253 @@
<script setup lang="ts">
import { computed, inject, reactive, ref, watch } from 'vue';
import type {
ActivityPriority,
CreateRoutineTaskRequest,
RoutineContextDto,
RoutineEntityRefDto,
RoutineTaskDto,
RoutineTaskSource,
} from '@mento/shared';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import EntityLinkPicker, { type PickedEntity } from '@/components/routines/EntityLinkPicker.vue';
import { UiButton, UiField, UiInput, UiModal, UiSelect, UiTextarea } from '@/components/ui';
import { api } from '@/lib/api';
import {
ENTITY_TYPE_LABELS,
PRIORITY_LABELS,
ROUTINES_CTX,
apiError,
localInputToIso,
todayKey,
} from '@/lib/routines';
import { useAuthStore } from '@/stores/auth';
const props = withDefaults(
defineProps<{
open: boolean;
/** پرونده‌ای که Task از صفحه آن ساخته می‌شود */
preset?: RoutineEntityRefDto | null;
defaultDate?: string | null;
defaultAssigneeId?: string | null;
}>(),
{ preset: null, defaultDate: null, defaultAssigneeId: null },
);
const emit = defineEmits<{ close: []; created: [task: RoutineTaskDto] }>();
const auth = useAuthStore();
const shell = inject(ROUTINES_CTX, null);
const localCtx = ref<RoutineContextDto | null>(null);
const ctx = computed(() => shell?.context.value ?? localCtx.value);
const form = reactive({
title: '',
description: '',
date: todayKey(),
time: '',
priority: 'medium' as ActivityPriority,
categoryId: '',
assigneeId: '',
isMandatory: false,
entity: null as PickedEntity | null,
});
const saving = ref(false);
const error = ref('');
const priorities = Object.entries(PRIORITY_LABELS) as Array<[ActivityPriority, string]>;
async function ensureContext() {
if (ctx.value) return;
try {
const { data } = await api.get<RoutineContextDto>('/routines/context');
localCtx.value = data;
} catch {
/* بدون زمینه هم فرم کار می‌کند */
}
}
watch(
() => props.open,
(open) => {
if (!open) return;
Object.assign(form, {
title: '',
description: '',
date: props.defaultDate || todayKey(),
time: '',
priority: 'medium',
categoryId: '',
assigneeId: props.defaultAssigneeId || auth.user?.id || '',
isMandatory: false,
entity: null,
});
error.value = '';
void ensureContext();
},
{ immediate: true },
);
const assignees = computed(() => {
const c = ctx.value;
if (!c?.isManager) return [];
return c.users;
});
function sourceFor(type: RoutineEntityRefDto['type'] | undefined): RoutineTaskSource {
if (type === 'lead') return 'lead';
if (type === 'person' || type === 'company') return 'customer';
if (type === 'deal') return 'deal';
if (type === 'activity') return 'follow_up';
return 'manual';
}
async function submit() {
error.value = '';
if (form.title.trim().length < 2) {
error.value = 'عنوان کار را وارد کنید.';
return;
}
const link = props.preset ?? form.entity;
const body: CreateRoutineTaskRequest = {
title: form.title.trim(),
description: form.description.trim() || null,
priority: form.priority,
categoryId: form.categoryId || null,
assigneeId: form.assigneeId || null,
isMandatory: form.isMandatory,
source: sourceFor(link?.type),
scheduledFor: form.date || todayKey(),
dueAt: form.time ? localInputToIso(form.date || todayKey(), form.time) : null,
};
if (link) (body as unknown as Record<string, unknown>)[`${link.type}Id`] = link.id;
saving.value = true;
try {
const { data } = await api.post<RoutineTaskDto>('/routine-tasks', body);
emit('created', data);
shell?.notifyChanged();
emit('close');
} catch (e) {
error.value = apiError(e, 'ثبت کار ممکن نشد.');
} finally {
saving.value = false;
}
}
</script>
<template>
<UiModal :open="open" title="کار جدید" @close="emit('close')">
<form class="qt-form" @submit.prevent="submit">
<UiField label="عنوان" required>
<UiInput v-model="form.title" placeholder="مثلاً: تماس با مشتری برای پیگیری پیش‌فاکتور" :maxlength="200" />
</UiField>
<UiField label="توضیحات">
<UiTextarea v-model="form.description" :rows="2" :maxlength="2000" />
</UiField>
<div class="grid-2">
<UiField label="تاریخ" required>
<JalaliDateInput v-model="form.date" mode="date" :clearable="false" />
</UiField>
<UiField label="ساعت" hint="خالی = تا پایان روز">
<JalaliDateInput v-model="form.time" mode="time" />
</UiField>
<UiField label="اولویت">
<UiSelect v-model="form.priority">
<option v-for="[k, label] in priorities" :key="k" :value="k">{{ label }}</option>
</UiSelect>
</UiField>
<UiField label="دسته‌بندی">
<UiSelect v-model="form.categoryId">
<option value="">بدون دسته</option>
<option v-for="c in ctx?.categories ?? []" :key="c.id" :value="c.id">{{ c.name }}</option>
</UiSelect>
</UiField>
<UiField v-if="assignees.length > 1" label="مسئول" class="span-2">
<UiSelect v-model="form.assigneeId">
<option v-for="u in assignees" :key="u.id" :value="u.id">
{{ u.fullName }}{{ u.id === auth.user?.id ? ' (خودم)' : '' }}
</option>
</UiSelect>
</UiField>
</div>
<UiField label="اتصال به پرونده">
<div v-if="preset" class="preset">
<span>{{ ENTITY_TYPE_LABELS[preset.type] }}</span>
<strong>{{ preset.label }}</strong>
</div>
<EntityLinkPicker v-else v-model="form.entity" />
</UiField>
<label class="check">
<input v-model="form.isMandatory" type="checkbox" />
<span>کار اجباری (در امتیاز روزانه و Streak حساب می‌شود)</span>
</label>
<p v-if="error" class="error">{{ error }}</p>
</form>
<template #footer>
<UiButton :loading="saving" @click="submit">ثبت کار</UiButton>
<UiButton variant="ghost" @click="emit('close')">انصراف</UiButton>
</template>
</UiModal>
</template>
<style scoped>
.qt-form {
display: grid;
gap: var(--sp-4);
}
.grid-2 {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--sp-3);
}
.span-2 {
grid-column: 1 / -1;
}
.preset {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: 8px 12px;
border-radius: var(--radius);
background: var(--accent-subtle);
color: var(--accent-deep);
}
.preset span {
font-size: var(--fs-xs);
color: var(--muted);
}
.check {
display: flex;
align-items: center;
gap: var(--sp-2);
font-size: var(--fs-sm);
color: var(--ink-secondary);
cursor: pointer;
}
.check input {
width: 18px;
height: 18px;
accent-color: var(--accent);
}
.error {
margin: 0;
}
@media (max-width: 600px) {
.grid-2 {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,301 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import type { RoutineRecurrence } from '@mento/shared';
import { ROUTINE_RECURRENCES, ROUTINE_RECURRENCE_LABELS, ROUTINE_WEEKDAY_LABELS } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { UiButton, UiField } from '@/components/ui';
import { toFaDigits } from '@/lib/jalali';
import { formatDayNumeric } from '@/lib/routines';
export type RecurrenceValue = {
recurrence: RoutineRecurrence;
weekdays: number[];
intervalDays: number | null;
monthDay: number | null;
specificDates: string[];
};
const model = defineModel<RecurrenceValue>({ required: true });
function patch(p: Partial<RecurrenceValue>) {
model.value = { ...model.value, ...p };
}
function setType(r: RoutineRecurrence) {
const next: Partial<RecurrenceValue> = { recurrence: r };
if (r === 'weekdays' && !model.value.weekdays.length) next.weekdays = [0, 1, 2, 3, 4];
if ((r === 'weekly' || r === 'biweekly') && !model.value.weekdays.length) next.weekdays = [0];
if (r === 'every_n_days' && !model.value.intervalDays) next.intervalDays = 2;
if (r === 'monthly' && !model.value.monthDay) next.monthDay = 1;
patch(next);
}
function toggleDay(d: number) {
const set = new Set(model.value.weekdays);
if (set.has(d)) set.delete(d);
else set.add(d);
patch({ weekdays: [...set].sort((a, b) => a - b) });
}
const needsWeekdays = computed(() => ['weekdays', 'weekly', 'biweekly'].includes(model.value.recurrence));
function setIntervalDays(raw: string) {
const n = Number(raw.replace(/[۰-۹]/g, (c) => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(c))));
patch({ intervalDays: Number.isFinite(n) && n > 0 ? Math.min(365, Math.floor(n)) : null });
}
const monthDays = Array.from({ length: 31 }, (_, i) => i + 1);
const newDate = ref('');
function addDate() {
if (!newDate.value) return;
const set = new Set(model.value.specificDates);
set.add(newDate.value);
patch({ specificDates: [...set].sort() });
newDate.value = '';
}
function removeDate(d: string) {
patch({ specificDates: model.value.specificDates.filter((x) => x !== d) });
}
</script>
<template>
<div class="rec">
<div class="types" role="radiogroup" aria-label="الگوی تکرار">
<button
v-for="r in ROUTINE_RECURRENCES"
:key="r"
type="button"
role="radio"
class="type"
:class="{ on: model.recurrence === r }"
:aria-checked="model.recurrence === r"
@click="setType(r)"
>
{{ ROUTINE_RECURRENCE_LABELS[r] }}
</button>
</div>
<div v-if="needsWeekdays" class="sub">
<span class="sub-label">
{{ model.recurrence === 'weekdays' ? 'روزهای انجام' : 'روز(های) هفته' }}
</span>
<div class="days">
<button
v-for="(label, i) in ROUTINE_WEEKDAY_LABELS"
:key="i"
type="button"
class="day"
:class="{ on: model.weekdays.includes(i) }"
:aria-pressed="model.weekdays.includes(i)"
@click="toggleDay(i)"
>
{{ label }}
</button>
</div>
<p v-if="model.recurrence === 'biweekly'" class="hint">
هفته اول از تاریخ شروع حساب می‌شود و یک هفته در میان تکرار می‌شود.
</p>
</div>
<div v-else-if="model.recurrence === 'every_n_days'" class="sub inline">
<span class="sub-label">هر</span>
<input
class="num"
type="number"
min="1"
max="365"
inputmode="numeric"
:value="model.intervalDays ?? ''"
aria-label="فاصله تکرار به روز"
@input="setIntervalDays(($event.target as HTMLInputElement).value)"
/>
<span class="sub-label">روز یک‌بار (از تاریخ شروع)</span>
</div>
<div v-else-if="model.recurrence === 'monthly'" class="sub">
<span class="sub-label">روز ماه (شمسی)</span>
<div class="mdays">
<button
v-for="d in monthDays"
:key="d"
type="button"
class="mday"
:class="{ on: model.monthDay === d }"
@click="patch({ monthDay: d })"
>
{{ toFaDigits(d) }}
</button>
</div>
<p class="hint">اگر ماهی آن روز را نداشته باشد (مثل ۳۱ در ماه‌های پاییز)، آخرین روز ماه در نظر گرفته می‌شود.</p>
</div>
<div v-else-if="model.recurrence === 'specific_dates'" class="sub">
<div class="add-date">
<UiField label="افزودن تاریخ">
<JalaliDateInput v-model="newDate" mode="date" />
</UiField>
<UiButton variant="outline" :disabled="!newDate" @click="addDate">
<template #icon><UiIcon name="plus" :size="15" /></template>
افزودن
</UiButton>
</div>
<div v-if="model.specificDates.length" class="dates">
<span v-for="d in model.specificDates" :key="d" class="date-chip">
{{ formatDayNumeric(d) }}
<button type="button" aria-label="حذف تاریخ" @click="removeDate(d)">
<UiIcon name="x" :size="12" />
</button>
</span>
</div>
<p v-else class="hint">هنوز تاریخی اضافه نشده.</p>
</div>
</div>
</template>
<style scoped>
.rec {
display: grid;
gap: var(--sp-3);
}
.types {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.type,
.day,
.mday {
min-height: 34px;
padding: 0 12px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
color: var(--ink-secondary);
font: inherit;
font-size: var(--fs-sm);
cursor: pointer;
transition: all var(--transition-fast);
}
.type:hover,
.day:hover,
.mday:hover {
border-color: var(--line-strong);
}
.type.on,
.day.on,
.mday.on {
border-color: var(--accent);
background: var(--accent-subtle);
color: var(--accent-deep);
font-weight: var(--fw-semibold);
}
.sub {
display: grid;
gap: var(--sp-2);
padding: var(--sp-3);
border-radius: var(--radius);
background: var(--surface-subtle);
}
.sub.inline {
display: flex;
align-items: center;
flex-wrap: wrap;
}
.sub-label {
font-size: var(--fs-sm);
color: var(--ink-secondary);
}
.num {
width: 80px;
height: var(--control-h);
padding: 0 10px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
text-align: center;
}
.num:focus {
outline: 2px solid var(--accent-soft);
border-color: var(--accent);
}
.days {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.mdays {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(40px, 1fr));
gap: 4px;
max-width: 420px;
}
.mday {
padding: 0;
}
.hint {
margin: 0;
font-size: var(--fs-xs);
color: var(--muted);
}
.add-date {
display: flex;
align-items: flex-end;
gap: var(--sp-2);
flex-wrap: wrap;
}
.add-date > :first-child {
flex: 1 1 200px;
}
.dates {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.date-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 4px 3px 10px;
border-radius: 99px;
background: var(--surface);
border: 1px solid var(--line);
font-size: var(--fs-xs);
}
.date-chip button {
width: 20px;
height: 20px;
border: 0;
border-radius: 50%;
background: transparent;
color: var(--muted);
display: grid;
place-items: center;
cursor: pointer;
}
.date-chip button:hover {
background: var(--danger-soft);
color: var(--danger);
}
</style>
@@ -0,0 +1,200 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import type { RoutineEntityRefDto, RoutineTaskDto } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import QuickTaskModal from '@/components/routines/QuickTaskModal.vue';
import RoutineTaskRow, { type RoutineTaskRowAction } from '@/components/routines/RoutineTaskRow.vue';
import RoutineTaskModals from '@/components/routines/RoutineTaskModals.vue';
import { UiButton } from '@/components/ui';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import { apiError, isOpenStatus } from '@/lib/routines';
import { useRoutineTaskActions } from '@/lib/useRoutineTaskActions';
import { useAuthStore } from '@/stores/auth';
const props = withDefaults(
defineProps<{
entity: RoutineEntityRefDto;
title?: string;
}>(),
{ title: 'کارهای مرتبط' },
);
const router = useRouter();
const auth = useAuthStore();
const enabled = computed(() => {
const mods = auth.user?.enabledModules;
return !mods || mods.includes('routines');
});
const items = ref<RoutineTaskDto[]>([]);
const loading = ref(false);
const error = ref('');
const showClosed = ref(false);
const quickOpen = ref(false);
async function load() {
if (!enabled.value || !props.entity.id) return;
loading.value = true;
error.value = '';
try {
const { data } = await api.get<RoutineTaskDto[]>('/routine-tasks/by-entity', {
params: { [`${props.entity.type}Id`]: props.entity.id },
});
items.value = data;
} catch (e) {
error.value = apiError(e, 'بارگذاری کارهای مرتبط ممکن نشد.');
} finally {
loading.value = false;
}
}
watch(() => `${props.entity.type}:${props.entity.id}`, load, { immediate: true });
const actions = useRoutineTaskActions((updated) => {
const i = items.value.findIndex((t) => t.id === updated.id);
if (i >= 0) items.value[i] = updated;
});
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 open = computed(() => items.value.filter((t) => isOpenStatus(t.status)));
const closed = computed(() => items.value.filter((t) => !isOpenStatus(t.status)));
function openTask(t: RoutineTaskDto) {
void router.push({ path: '/routines', query: { task: t.id } });
}
function onCreated(t: RoutineTaskDto) {
items.value = [t, ...items.value];
}
</script>
<template>
<section v-if="enabled" class="related">
<header>
<h3>
<UiIcon name="check" :size="16" />
{{ title }}
<em v-if="open.length">{{ toFaDigits(open.length) }} باز</em>
</h3>
<UiButton size="sm" variant="outline" @click="quickOpen = true">
<template #icon><UiIcon name="plus" :size="14" /></template>
ایجاد Task
</UiButton>
</header>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="actions.error.value" class="error">{{ actions.error.value }}</p>
<div v-if="loading && !items.length" class="muted">در حال بارگذاری…</div>
<p v-else-if="!items.length" class="muted">هنوز کاری برای این پرونده ثبت نشده.</p>
<div v-else class="rows">
<RoutineTaskRow
v-for="t in open"
:key="t.id"
:task="t"
:busy="actions.busyId.value === t.id"
show-date
show-assignee
show-delay
@toggle="actions.toggle"
@open="openTask"
@action="onAction"
/>
<button v-if="closed.length" type="button" class="toggle-closed" @click="showClosed = !showClosed">
{{ showClosed ? 'پنهان کردن' : 'نمایش' }} {{ toFaDigits(closed.length) }} کار انجام‌شده / بسته
</button>
<template v-if="showClosed">
<RoutineTaskRow
v-for="t in closed"
:key="t.id"
:task="t"
:busy="actions.busyId.value === t.id"
show-date
show-assignee
@toggle="actions.toggle"
@open="openTask"
@action="onAction"
/>
</template>
</div>
<QuickTaskModal :open="quickOpen" :preset="entity" @close="quickOpen = false" @created="onCreated" />
<RoutineTaskModals :actions="actions" />
</section>
</template>
<style scoped>
.related {
display: grid;
gap: var(--sp-3);
padding: var(--sp-4);
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
}
header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-2);
}
h3 {
margin: 0;
display: inline-flex;
align-items: center;
gap: 6px;
font-size: var(--fs-md);
font-weight: var(--fw-semibold);
color: var(--ink);
}
h3 em {
font-style: normal;
font-size: var(--fs-xs);
font-weight: var(--fw-medium);
padding: 1px 8px;
border-radius: 99px;
background: var(--accent-subtle);
color: var(--accent-deep);
}
.rows {
display: grid;
gap: var(--sp-2);
}
.muted {
margin: 0;
color: var(--muted);
font-size: var(--fs-sm);
}
.toggle-closed {
justify-self: start;
border: 0;
background: transparent;
color: var(--accent-deep);
font: inherit;
font-size: var(--fs-xs);
font-weight: var(--fw-semibold);
cursor: pointer;
padding: 4px 0;
}
.error {
margin: 0;
}
</style>
@@ -0,0 +1,265 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { RoutineCalendarDayDto } from '@mento/shared';
import { ROUTINE_WEEKDAY_LABELS } from '@mento/shared';
import { dateToJalali, toFaDigits } from '@/lib/jalali';
import { DAY_STATE_LABELS, addDaysKey, keyToDate, todayKey, weekdayIndex } from '@/lib/routines';
const props = withDefaults(
defineProps<{
from: string;
to: string;
days: RoutineCalendarDayDto[];
selected?: string | null;
/** heat = رنگ بر اساس درصد (گزارش ماهانه) */
mode?: 'state' | 'heat';
compact?: boolean;
}>(),
{ selected: null, mode: 'state', compact: false },
);
const emit = defineEmits<{ select: [day: string] }>();
const byDate = computed(() => new Map(props.days.map((d) => [d.date, d])));
const today = todayKey();
const cells = computed(() => {
const out: Array<{ key: string; blank: boolean; day?: RoutineCalendarDayDto; jd: number; future: boolean }> = [];
const lead = weekdayIndex(props.from);
for (let i = 0; i < lead; i++) out.push({ key: `b${i}`, blank: true, jd: 0, future: false });
for (let k = props.from; k <= props.to; k = addDaysKey(k, 1)) {
out.push({
key: k,
blank: false,
day: byDate.value.get(k),
jd: dateToJalali(keyToDate(k)).jd,
future: k > today,
});
}
return out;
});
function heatLevel(d?: RoutineCalendarDayDto): number {
if (!d || !d.total || d.percent == null) return 0;
if (d.percent >= 90) return 4;
if (d.percent >= 70) return 3;
if (d.percent >= 40) return 2;
return 1;
}
function title(c: (typeof cells.value)[number]) {
const d = c.day;
if (!d || !d.total) return 'بدون کار';
return `${DAY_STATE_LABELS[d.state]} — ${toFaDigits(d.completed)} از ${toFaDigits(d.total)} انجام شده`;
}
</script>
<template>
<div class="mgrid" :class="[mode, { compact }]">
<div v-for="w in ROUTINE_WEEKDAY_LABELS" :key="w" class="wd">{{ compact ? w.charAt(0) : w }}</div>
<template v-for="c in cells" :key="c.key">
<span v-if="c.blank" class="cell blank" />
<button
v-else
type="button"
class="cell"
:class="[
mode === 'state' ? `s-${c.future && !c.day?.total ? 'future' : (c.day?.state ?? 'empty')}` : `h-${heatLevel(c.day)}`,
{ sel: selected === c.key, today: c.key === today },
]"
:title="title(c)"
@click="emit('select', c.key)"
>
<span class="num">{{ toFaDigits(c.jd) }}</span>
<template v-if="!compact && c.day?.total">
<span class="frac">{{ toFaDigits(c.day.completed) }}/{{ toFaDigits(c.day.total) }}</span>
<i v-if="mode === 'state'" class="dot" />
</template>
</button>
</template>
</div>
</template>
<style scoped>
.mgrid {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 6px;
}
.wd {
text-align: center;
font-size: var(--fs-xs);
color: var(--muted);
font-weight: var(--fw-semibold);
padding-bottom: 2px;
}
.cell {
position: relative;
min-height: 74px;
padding: 8px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: space-between;
gap: 4px;
cursor: pointer;
font: inherit;
text-align: right;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.cell.blank {
border: 0;
background: transparent;
cursor: default;
}
.cell:not(.blank):hover {
border-color: var(--line-strong);
}
.cell.sel {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-soft);
}
.cell.today .num {
background: var(--accent);
color: #fff;
}
.num {
min-width: 24px;
height: 24px;
padding: 0 4px;
border-radius: 99px;
display: inline-grid;
place-items: center;
font-size: var(--fs-sm);
font-weight: var(--fw-semibold);
color: var(--ink);
}
.frac {
font-size: var(--fs-xs);
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.dot {
position: absolute;
top: 10px;
inset-inline-end: 10px;
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--line-strong);
}
/* وضعیت روز */
.s-complete {
background: color-mix(in srgb, var(--success-soft) 45%, var(--surface));
}
.s-complete .dot {
background: var(--success);
}
.s-partial {
background: color-mix(in srgb, var(--warning-soft) 45%, var(--surface));
}
.s-partial .dot {
background: var(--warning);
}
.s-missed {
background: color-mix(in srgb, var(--danger-soft) 45%, var(--surface));
}
.s-missed .dot {
background: var(--danger);
}
.s-future {
background: var(--surface);
}
.s-empty {
background: var(--surface);
}
/* Heatmap */
.h-0 {
background: var(--surface-subtle);
}
.h-1 {
background: color-mix(in srgb, var(--accent) 14%, white);
}
.h-2 {
background: color-mix(in srgb, var(--accent) 32%, white);
}
.h-3 {
background: color-mix(in srgb, var(--accent) 55%, white);
}
.h-4 {
background: color-mix(in srgb, var(--accent) 80%, white);
}
.h-3 .num,
.h-4 .num,
.h-3 .frac,
.h-4 .frac {
color: #fff;
}
.compact {
gap: 4px;
}
.compact .cell {
min-height: 0;
aspect-ratio: 1;
padding: 2px;
align-items: center;
justify-content: center;
border-color: transparent;
}
.compact .num {
font-size: 0.7rem;
min-width: 0;
height: auto;
}
@media (max-width: 700px) {
.mgrid {
gap: 3px;
}
.cell {
min-height: 54px;
padding: 4px;
align-items: center;
}
.frac {
font-size: 0.62rem;
}
.dot {
position: static;
width: 7px;
height: 7px;
}
}
</style>
@@ -0,0 +1,663 @@
<script setup lang="ts">
import { computed, inject, reactive, ref, watch } from 'vue';
import { RouterLink } from 'vue-router';
import type {
ActivityPriority,
RoutineTaskDetailDto,
RoutineTaskDto,
RoutineTaskEventDto,
UpdateRoutineTaskRequest,
} from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import UserAvatar from '@/components/UserAvatar.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import RoutineTaskModals from '@/components/routines/RoutineTaskModals.vue';
import {
UiBadge,
UiButton,
UiDrawer,
UiField,
UiInput,
UiSelect,
UiTextarea,
} from '@/components/ui';
import { api } from '@/lib/api';
import { formatJalaliDayClock } from '@/lib/jalali';
import {
ENTITY_TYPE_LABELS,
PRIORITY_LABELS,
PRIORITY_VARIANTS,
ROUTINES_CTX,
ROUTINE_ENTITY_LINK_LABELS,
ROUTINE_TASK_SOURCE_LABELS,
ROUTINE_TASK_STATUS_LABELS,
STATUS_VARIANTS,
apiError,
entityRoute,
formatDayLong,
formatDelay,
formatTime,
hasExplicitTime,
isOpenStatus,
isoToLocalInput,
localInputToIso,
} from '@/lib/routines';
import { useRoutineTaskActions } from '@/lib/useRoutineTaskActions';
const props = defineProps<{ taskId: string | null }>();
const emit = defineEmits<{ close: []; changed: [task: RoutineTaskDto] }>();
const shell = inject(ROUTINES_CTX, null);
const task = ref<RoutineTaskDetailDto | null>(null);
const loading = ref(false);
const loadError = ref('');
const editing = ref(false);
const saving = ref(false);
const editError = ref('');
const edit = reactive({
title: '',
description: '',
priority: 'medium' as ActivityPriority,
categoryId: '',
assigneeId: '',
date: '',
time: '',
});
async function load() {
if (!props.taskId) return;
loading.value = true;
loadError.value = '';
try {
const { data } = await api.get<RoutineTaskDetailDto>(`/routine-tasks/${props.taskId}`);
task.value = data;
} catch (e) {
loadError.value = apiError(e, 'کار پیدا نشد.');
task.value = null;
} finally {
loading.value = false;
}
}
watch(
() => props.taskId,
(id) => {
editing.value = false;
if (id) void load();
else task.value = null;
},
{ immediate: true },
);
const actions = useRoutineTaskActions((updated) => {
emit('changed', updated);
shell?.notifyChanged();
void load();
});
const isOpen = computed(() => (task.value ? isOpenStatus(task.value.status) : false));
const done = computed(() => task.value?.status === 'completed');
const canManage = computed(() => !!shell?.context.value?.isManager);
const priorities = Object.entries(PRIORITY_LABELS) as Array<[ActivityPriority, string]>;
function startEdit() {
const t = task.value;
if (!t) return;
const due = isoToLocalInput(t.dueAt);
Object.assign(edit, {
title: t.title,
description: t.description ?? '',
priority: t.priority,
categoryId: t.category?.id ?? '',
assigneeId: t.assignee?.id ?? '',
date: due.date || t.scheduledFor,
time: due.time === '23:59' ? '' : due.time,
});
editError.value = '';
editing.value = true;
}
async function saveEdit() {
const t = task.value;
if (!t) return;
if (edit.title.trim().length < 2) {
editError.value = 'عنوان را وارد کنید.';
return;
}
const body: UpdateRoutineTaskRequest = {
title: edit.title.trim(),
description: edit.description.trim() || null,
priority: edit.priority,
categoryId: edit.categoryId || null,
};
if (!t.routineId) {
const dueAt = localInputToIso(edit.date, edit.time);
if (dueAt && dueAt !== t.dueAt) body.dueAt = dueAt;
}
if (canManage.value && edit.assigneeId && edit.assigneeId !== t.assignee?.id) {
body.assigneeId = edit.assigneeId;
}
saving.value = true;
editError.value = '';
try {
const { data } = await api.patch<RoutineTaskDto>(`/routine-tasks/${t.id}`, body);
emit('changed', data);
shell?.notifyChanged();
editing.value = false;
await load();
} catch (e) {
editError.value = apiError(e, 'ذخیره ممکن نشد.');
} finally {
saving.value = false;
}
}
function eventText(ev: RoutineTaskEventDto): string {
switch (ev.type) {
case 'created':
return 'کار ایجاد شد';
case 'status_changed':
return ev.toStatus
? `وضعیت ${ev.fromStatus ? `از «${ROUTINE_TASK_STATUS_LABELS[ev.fromStatus]}» ` : ''}به «${ROUTINE_TASK_STATUS_LABELS[ev.toStatus]}» تغییر کرد`
: 'وضعیت تغییر کرد';
case 'rescheduled':
return 'زمان کار تغییر کرد';
case 'reassigned':
return 'مسئول کار تغییر کرد';
case 'note':
return 'گزارش نتیجه ثبت شد';
default:
return 'کار ویرایش شد';
}
}
</script>
<template>
<UiDrawer :open="!!taskId" title="جزئیات کار" width="460px" @close="emit('close')">
<div v-if="loading && !task" class="state">در حال بارگذاری…</div>
<div v-else-if="loadError" class="state error">{{ loadError }}</div>
<div v-else-if="task" class="td">
<!-- عنوان و وضعیت -->
<section class="td-head">
<div class="badges">
<UiBadge :variant="STATUS_VARIANTS[task.status]">{{ ROUTINE_TASK_STATUS_LABELS[task.status] }}</UiBadge>
<UiBadge :variant="PRIORITY_VARIANTS[task.priority]" size="sm">
اولویت {{ PRIORITY_LABELS[task.priority] }}
</UiBadge>
<UiBadge v-if="task.isMandatory" variant="warning" size="sm">اجباری</UiBadge>
</div>
<h3 class="td-title" :class="{ done }">{{ task.title }}</h3>
<p v-if="task.description" class="td-desc">{{ task.description }}</p>
</section>
<!-- اکشن اصلی -->
<section v-if="!editing" class="td-actions">
<UiButton
v-if="isOpen"
variant="success"
block
size="lg"
:loading="actions.busyId.value === task.id"
@click="actions.toggle(task)"
>
<template #icon><UiIcon name="check-plain" :size="18" /></template>
انجام شد
</UiButton>
<UiButton
v-else-if="task.status !== 'cancelled' || canManage"
variant="outline"
block
:loading="actions.busyId.value === task.id"
@click="actions.toggle(task)"
>
<template #icon><UiIcon name="refresh" :size="16" /></template>
برگرداندن به انجام‌نشده
</UiButton>
<div class="td-actions-row">
<UiButton v-if="task.status === 'todo'" variant="ghost" size="sm" @click="actions.start(task)">
شروع کار
</UiButton>
<UiButton v-if="isOpen || done" variant="ghost" size="sm" @click="actions.openComplete(task)">
{{ done ? 'ویرایش گزارش' : 'انجام با گزارش' }}
</UiButton>
<UiButton v-if="isOpen" variant="ghost" size="sm" @click="actions.openReschedule(task)">
تغییر زمان
</UiButton>
<UiButton v-if="task.status === 'overdue'" variant="ghost" size="sm" @click="actions.moveToday(task)">
انتقال به امروز
</UiButton>
<UiButton v-if="isOpen && task.rules.allowSkip" variant="ghost" size="sm" @click="actions.openSkip(task)">
رد کردن
</UiButton>
<UiButton v-if="isOpen" variant="ghost" size="sm" class="danger-text" @click="actions.openCancel(task)">
لغو
</UiButton>
</div>
<p v-if="actions.error.value" class="error">{{ actions.error.value }}</p>
</section>
<!-- ویرایش -->
<section v-if="editing" class="td-edit">
<UiField label="عنوان" required>
<UiInput v-model="edit.title" :maxlength="200" />
</UiField>
<UiField label="توضیحات">
<UiTextarea v-model="edit.description" :rows="3" />
</UiField>
<div class="grid-2">
<UiField label="اولویت">
<UiSelect v-model="edit.priority">
<option v-for="[k, label] in priorities" :key="k" :value="k">{{ label }}</option>
</UiSelect>
</UiField>
<UiField label="دسته‌بندی">
<UiSelect v-model="edit.categoryId">
<option value="">بدون دسته</option>
<option v-for="c in shell?.context.value?.categories ?? []" :key="c.id" :value="c.id">
{{ c.name }}
</option>
</UiSelect>
</UiField>
<template v-if="!task.routineId">
<UiField label="تاریخ">
<JalaliDateInput v-model="edit.date" mode="date" :clearable="false" />
</UiField>
<UiField label="ساعت">
<JalaliDateInput v-model="edit.time" mode="time" />
</UiField>
</template>
<UiField v-if="canManage" label="مسئول" class="span-2">
<UiSelect v-model="edit.assigneeId">
<option v-for="u in shell?.context.value?.users ?? []" :key="u.id" :value="u.id">
{{ u.fullName }}
</option>
</UiSelect>
</UiField>
</div>
<p v-if="editError" class="error">{{ editError }}</p>
<div class="td-edit-actions">
<UiButton :loading="saving" @click="saveEdit">ذخیره</UiButton>
<UiButton variant="ghost" @click="editing = false">انصراف</UiButton>
</div>
</section>
<!-- مشخصات -->
<section class="td-info">
<div class="info-row">
<span>مسئول</span>
<strong v-if="task.assignee" class="with-avatar">
<UserAvatar :name="task.assignee.fullName" :user-id="task.assignee.id" :size="22" />
{{ task.assignee.fullName }}
</strong>
<strong v-else>—</strong>
</div>
<div class="info-row">
<span>روز برنامه</span>
<strong>{{ formatDayLong(task.scheduledFor) }}</strong>
</div>
<div class="info-row">
<span>مهلت</span>
<strong>
{{ hasExplicitTime(task) ? `ساعت ${formatTime(task.dueAt)}` : 'تا پایان روز' }}
<em v-if="task.status === 'overdue'" class="late">({{ formatDelay(task.dueAt) }} تأخیر)</em>
</strong>
</div>
<div v-if="task.completedAt" class="info-row">
<span>زمان انجام</span>
<strong class="ok">{{ formatJalaliDayClock(new Date(task.completedAt)) }}</strong>
</div>
<div v-if="task.category" class="info-row">
<span>دسته‌بندی</span>
<strong class="cat"><i :style="{ background: task.category.color }" />{{ task.category.name }}</strong>
</div>
<div class="info-row">
<span>منبع</span>
<strong>{{ ROUTINE_TASK_SOURCE_LABELS[task.source] }}</strong>
</div>
<div v-if="task.createdBy" class="info-row">
<span>ثبت‌کننده</span>
<strong>{{ task.createdBy.fullName }}</strong>
</div>
</section>
<section v-if="task.entities.length" class="td-block">
<h4>پرونده‌های مرتبط</h4>
<div class="ents">
<RouterLink v-for="ent in task.entities" :key="`${ent.type}-${ent.id}`" :to="entityRoute(ent)" class="ent">
<span>{{ ENTITY_TYPE_LABELS[ent.type] }}</span>
<strong>{{ ent.label }}</strong>
<UiIcon name="chevron-left" :size="14" />
</RouterLink>
</div>
</section>
<section v-if="task.resultNote || task.skipReason" class="td-block">
<h4>{{ task.resultNote ? 'گزارش نتیجه' : 'دلیل رد شدن' }}</h4>
<p class="note">{{ task.resultNote || task.skipReason }}</p>
</section>
<section v-if="task.routineId" class="td-block">
<h4>قوانین این روتین</h4>
<div class="rules">
<span :class="{ on: task.rules.allowSkip }">{{ task.rules.allowSkip ? 'رد کردن مجاز' : 'رد کردن غیرمجاز' }}</span>
<span v-if="task.rules.skipReasonRequired" class="on">دلیل رد الزامی</span>
<span v-if="task.rules.reportRequired" class="on">گزارش الزامی</span>
<span :class="{ on: task.rules.allowAfterDeadline }">
{{ task.rules.allowAfterDeadline ? 'انجام بعد از مهلت مجاز' : 'انجام بعد از مهلت غیرمجاز' }}
</span>
<span v-if="task.rules.entityLink !== 'none'">
اتصال پرونده: {{ ROUTINE_ENTITY_LINK_LABELS[task.rules.entityLink] }}
</span>
</div>
</section>
<section class="td-block">
<h4>تاریخچه تغییرات</h4>
<ol class="events">
<li v-for="ev in task.events" :key="ev.id">
<span class="dot" :class="ev.toStatus ? `st-${ev.toStatus}` : ''" />
<div>
<p>{{ eventText(ev) }}</p>
<p v-if="ev.note" class="ev-note">{{ ev.note }}</p>
<small>
{{ ev.actor?.fullName ?? 'سیستم' }} · {{ formatJalaliDayClock(new Date(ev.createdAt)) }}
</small>
</div>
</li>
</ol>
</section>
</div>
<template v-if="task && !editing" #footer>
<UiButton variant="ghost" size="sm" @click="startEdit">
<template #icon><UiIcon name="pencil" :size="14" /></template>
ویرایش
</UiButton>
</template>
</UiDrawer>
<RoutineTaskModals :actions="actions" />
</template>
<style scoped>
.state {
padding: var(--sp-8) 0;
text-align: center;
color: var(--muted);
}
.td {
display: grid;
gap: var(--sp-5);
}
.td-head {
display: grid;
gap: var(--sp-2);
}
.badges {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.td-title {
margin: 0;
font-size: var(--fs-lg);
font-weight: var(--fw-bold);
color: var(--ink);
line-height: 1.6;
}
.td-title.done {
color: var(--muted);
text-decoration: line-through;
}
.td-desc {
margin: 0;
color: var(--ink-secondary);
font-size: var(--fs-sm);
line-height: 1.8;
white-space: pre-wrap;
}
.td-actions {
display: grid;
gap: var(--sp-2);
}
.td-actions-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.danger-text {
color: var(--danger) !important;
}
.td-edit {
display: grid;
gap: var(--sp-3);
padding: var(--sp-3);
border: 1px dashed var(--line-strong);
border-radius: var(--radius);
}
.grid-2 {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--sp-3);
}
.span-2 {
grid-column: 1 / -1;
}
.td-edit-actions {
display: flex;
gap: var(--sp-2);
}
.td-info {
display: grid;
border: 1px solid var(--line);
border-radius: var(--radius);
overflow: hidden;
}
.info-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-3);
padding: 10px 12px;
font-size: var(--fs-sm);
}
.info-row + .info-row {
border-top: 1px solid var(--line-light);
}
.info-row > span {
color: var(--muted);
}
.info-row strong {
color: var(--ink);
font-weight: var(--fw-medium);
text-align: left;
}
.with-avatar,
.cat {
display: inline-flex;
align-items: center;
gap: 6px;
}
.cat i {
width: 9px;
height: 9px;
border-radius: 50%;
}
.late {
color: var(--danger);
font-style: normal;
font-size: var(--fs-xs);
}
.ok {
color: var(--success-deep) !important;
}
.td-block {
display: grid;
gap: var(--sp-2);
}
.td-block h4 {
margin: 0;
font-size: var(--fs-sm);
font-weight: var(--fw-semibold);
color: var(--ink-secondary);
}
.ents {
display: grid;
gap: 6px;
}
.ent {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: var(--radius);
color: var(--ink);
}
.ent:hover {
border-color: var(--accent);
background: var(--accent-subtle);
}
.ent span {
font-size: var(--fs-xs);
color: var(--muted);
}
.ent strong {
flex: 1 1 auto;
font-weight: var(--fw-medium);
}
.note {
margin: 0;
padding: var(--sp-3);
border-radius: var(--radius);
background: var(--surface-subtle);
font-size: var(--fs-sm);
line-height: 1.8;
white-space: pre-wrap;
}
.rules {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.rules span {
padding: 3px 10px;
border-radius: var(--radius-full);
background: var(--surface-subtle);
color: var(--muted);
font-size: var(--fs-xs);
}
.rules span.on {
background: var(--accent-subtle);
color: var(--accent-deep);
}
.events {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: var(--sp-3);
}
.events li {
display: grid;
grid-template-columns: 12px minmax(0, 1fr);
gap: var(--sp-2);
position: relative;
}
.events li:not(:last-child)::after {
content: '';
position: absolute;
inset-inline-start: 5px;
top: 16px;
bottom: calc(-1 * var(--sp-3));
width: 2px;
background: var(--line-light);
}
.events .dot {
width: 12px;
height: 12px;
margin-top: 4px;
border-radius: 50%;
background: var(--line-strong);
border: 2px solid var(--surface);
box-shadow: 0 0 0 1px var(--line);
}
.dot.st-completed {
background: var(--success);
}
.dot.st-overdue {
background: var(--danger);
}
.dot.st-skipped {
background: var(--warning);
}
.dot.st-in_progress {
background: var(--info);
}
.events p {
margin: 0;
font-size: var(--fs-sm);
color: var(--ink);
}
.events .ev-note {
margin-top: 2px;
color: var(--ink-secondary);
font-size: var(--fs-xs);
}
.events small {
color: var(--muted);
font-size: var(--fs-xs);
}
@media (max-width: 600px) {
.grid-2 {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,137 @@
<script setup lang="ts">
import { computed } from 'vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { UiButton, UiField, UiModal, UiTextarea } from '@/components/ui';
import type { RoutineTaskActions } from '@/lib/useRoutineTaskActions';
const props = defineProps<{ actions: RoutineTaskActions }>();
const modal = computed(() => props.actions.modal);
const title = computed(() => {
switch (modal.value.kind) {
case 'complete':
return modal.value.task?.status === 'completed' ? 'گزارش نتیجه' : 'ثبت انجام کار';
case 'skip':
return 'رد کردن کار';
case 'reschedule':
return 'تغییر زمان کار';
case 'cancel':
return 'لغو کار';
default:
return '';
}
});
const noteRequired = computed(() => {
const t = modal.value.task;
if (!t) return false;
if (modal.value.kind === 'complete') return t.rules.reportRequired;
if (modal.value.kind === 'skip') return t.rules.skipReasonRequired;
return false;
});
const noteLabel = computed(() => {
if (modal.value.kind === 'complete') return 'نتیجه / گزارش';
if (modal.value.kind === 'skip') return 'دلیل رد کردن';
return 'توضیح (اختیاری)';
});
const placeholder = computed(() => {
if (modal.value.kind === 'complete') return 'مثلاً: با مشتری تماس گرفته شد و قرار جلسه برای فردا تنظیم شد.';
if (modal.value.kind === 'skip') return 'چرا این کار امروز انجام نمی‌شود؟';
return '';
});
const submitLabel = computed(() => {
switch (modal.value.kind) {
case 'complete':
return modal.value.task?.status === 'completed' ? 'ذخیره گزارش' : 'انجام شد';
case 'skip':
return 'رد کردن';
case 'reschedule':
return 'ذخیره زمان جدید';
case 'cancel':
return 'لغو کار';
default:
return 'ذخیره';
}
});
</script>
<template>
<UiModal :open="!!modal.kind" :title="title" nest @close="actions.close()">
<form class="rt-modal" @submit.prevent="actions.submit()">
<p v-if="modal.task" class="task-name">{{ modal.task.title }}</p>
<template v-if="modal.kind === 'reschedule'">
<div class="when">
<UiField label="تاریخ" required>
<JalaliDateInput v-model="modal.date" mode="date" required :clearable="false" />
</UiField>
<UiField label="ساعت" hint="خالی = تا پایان روز">
<JalaliDateInput v-model="modal.time" mode="time" />
</UiField>
</div>
</template>
<UiField v-else :label="noteLabel" :required="noteRequired">
<UiTextarea v-model="modal.note" :rows="4" :placeholder="placeholder" :maxlength="2000" />
</UiField>
<p v-if="modal.kind === 'cancel'" class="hint">
کار لغو شده در محاسبه درصد انجام حساب نمی‌شود.
</p>
<p v-if="modal.error" class="error">{{ modal.error }}</p>
</form>
<template #footer>
<UiButton
:variant="modal.kind === 'cancel' ? 'danger' : modal.kind === 'complete' ? 'success' : 'primary'"
:loading="modal.saving"
@click="actions.submit()"
>
{{ submitLabel }}
</UiButton>
<UiButton variant="ghost" @click="actions.close()">انصراف</UiButton>
</template>
</UiModal>
</template>
<style scoped>
.rt-modal {
display: grid;
gap: var(--sp-4);
}
.task-name {
margin: 0;
padding: var(--sp-2) var(--sp-3);
border-radius: var(--radius);
background: var(--surface-subtle);
font-weight: var(--fw-semibold);
color: var(--ink);
}
.when {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--sp-3);
}
.hint {
margin: 0;
font-size: var(--fs-xs);
color: var(--muted);
}
.error {
margin: 0;
}
@media (max-width: 600px) {
.when {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,440 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { RouterLink } from 'vue-router';
import type { RoutineTaskDto } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import UserAvatar from '@/components/UserAvatar.vue';
import { UiBadge, UiDropdownMenu } from '@/components/ui';
import {
ENTITY_TYPE_LABELS,
PRIORITY_LABELS,
PRIORITY_VARIANTS,
ROUTINE_TASK_SOURCE_LABELS,
ROUTINE_TASK_STATUS_LABELS,
STATUS_VARIANTS,
entityRoute,
formatDayNumeric,
formatDelay,
formatTime,
hasExplicitTime,
isOpenStatus,
} from '@/lib/routines';
export type RoutineTaskRowAction =
| 'start'
| 'complete-note'
| 'reschedule'
| 'move-today'
| 'skip'
| 'cancel';
const props = withDefaults(
defineProps<{
task: RoutineTaskDto;
busy?: boolean;
showAssignee?: boolean;
showDate?: boolean;
showDelay?: boolean;
}>(),
{ busy: false, showAssignee: false, showDate: false, showDelay: false },
);
const emit = defineEmits<{
toggle: [task: RoutineTaskDto];
open: [task: RoutineTaskDto];
action: [kind: RoutineTaskRowAction, task: RoutineTaskDto];
}>();
const menuOpen = ref(false);
const done = computed(() => props.task.status === 'completed');
const closed = computed(() => props.task.status === 'skipped' || props.task.status === 'cancelled');
const open = computed(() => isOpenStatus(props.task.status));
const showTime = computed(() => hasExplicitTime(props.task));
const checkLabel = computed(() =>
done.value ? `برگرداندن «${props.task.title}» به حالت انجام‌نشده` : `علامت‌زدن «${props.task.title}» به‌عنوان انجام‌شده`,
);
function act(kind: RoutineTaskRowAction) {
emit('action', kind, props.task);
}
</script>
<template>
<article
class="rt-row"
:class="[`st-${task.status}`, { done, closed, busy }]"
@click="emit('open', task)"
>
<button
type="button"
class="rt-check"
:class="{ on: done }"
:disabled="busy || closed"
:aria-pressed="done"
:aria-label="checkLabel"
@click.stop="emit('toggle', task)"
>
<span class="box">
<UiIcon v-if="done" name="check-plain" :size="16" />
<span v-else-if="busy" class="spin" aria-hidden="true" />
</span>
</button>
<div class="rt-main">
<div class="rt-title-line">
<h3 class="rt-title">{{ task.title }}</h3>
<UiIcon v-if="task.isMandatory" class="mand" name="flag" :size="13" title="اجباری" />
</div>
<p v-if="task.description" class="rt-desc">{{ task.description }}</p>
<div class="rt-meta">
<span v-if="showDate" class="meta-item">
<UiIcon name="calendar" :size="13" />
{{ formatDayNumeric(task.scheduledFor) }}
</span>
<span v-if="showTime" class="meta-item">
<UiIcon name="clock" :size="13" />
{{ formatTime(task.dueAt) }}
</span>
<span v-if="showDelay && task.status === 'overdue'" class="meta-item late">
<UiIcon name="warning" :size="13" />
{{ formatDelay(task.dueAt) }} تأخیر
</span>
<span v-if="done && task.completedAt" class="meta-item ok">
<UiIcon name="check" :size="13" />
{{ formatTime(task.completedAt) }}
</span>
<span v-if="task.category" class="cat-chip">
<i :style="{ background: task.category.color }" />
{{ task.category.name }}
</span>
<UiBadge size="sm" :variant="PRIORITY_VARIANTS[task.priority]">
{{ PRIORITY_LABELS[task.priority] }}
</UiBadge>
<UiBadge v-if="task.status !== 'todo'" size="sm" :variant="STATUS_VARIANTS[task.status]">
{{ ROUTINE_TASK_STATUS_LABELS[task.status] }}
</UiBadge>
<span class="src-chip" :title="`منبع: ${ROUTINE_TASK_SOURCE_LABELS[task.source]}`">
<UiIcon :name="task.source === 'routine' ? 'refresh' : 'pin'" :size="12" />
{{ ROUTINE_TASK_SOURCE_LABELS[task.source] }}
</span>
<RouterLink
v-for="ent in task.entities"
:key="`${ent.type}-${ent.id}`"
class="ent-link"
:to="entityRoute(ent)"
@click.stop
>
<span class="ent-type">{{ ENTITY_TYPE_LABELS[ent.type] }}</span>
{{ ent.label }}
</RouterLink>
</div>
</div>
<div class="rt-side" @click.stop>
<span v-if="showAssignee && task.assignee" class="assignee" :title="task.assignee.fullName">
<UserAvatar :name="task.assignee.fullName" :user-id="task.assignee.id" :size="26" />
<span class="assignee-name">{{ task.assignee.fullName }}</span>
</span>
<UiDropdownMenu v-model:open="menuOpen" align="end">
<template #trigger="{ toggle }">
<button type="button" class="rt-more" aria-label="عملیات بیشتر" @click="toggle">
<UiIcon name="dots" :size="18" />
</button>
</template>
<button type="button" @click="emit('open', task)">جزئیات</button>
<button v-if="task.status === 'todo'" type="button" @click="act('start')">شروع کار</button>
<button v-if="open || done" type="button" @click="act('complete-note')">
{{ done ? 'ویرایش گزارش نتیجه' : 'انجام با ثبت گزارش' }}
</button>
<button v-if="open" type="button" @click="act('reschedule')">تغییر زمان</button>
<button v-if="task.status === 'overdue'" type="button" @click="act('move-today')">انتقال به امروز</button>
<button v-if="open && task.rules.allowSkip" type="button" @click="act('skip')">رد کردن</button>
<button v-if="open" type="button" class="danger" @click="act('cancel')">لغو کار</button>
</UiDropdownMenu>
</div>
</article>
</template>
<style scoped>
.rt-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: start;
gap: var(--sp-3);
padding: var(--sp-3) var(--sp-4);
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
cursor: pointer;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast), opacity var(--transition-fast);
}
.rt-row:hover {
border-color: var(--line-strong);
box-shadow: var(--shadow-xs);
}
.rt-row.st-overdue {
border-inline-start: 3px solid var(--danger);
}
.rt-row.done {
background: color-mix(in srgb, var(--success-soft) 28%, var(--surface));
}
.rt-row.closed {
opacity: 0.62;
}
.rt-check {
width: 40px;
height: 40px;
margin: -6px;
padding: 0;
border: 0;
background: transparent;
display: grid;
place-items: center;
cursor: pointer;
border-radius: var(--radius);
}
.rt-check:disabled {
cursor: not-allowed;
}
.rt-check:focus-visible {
outline: 2px solid var(--accent);
}
.rt-check .box {
width: 24px;
height: 24px;
border-radius: 7px;
border: 2px solid var(--line-strong);
background: var(--surface);
display: grid;
place-items: center;
color: #fff;
transition: background var(--transition-fast), border-color var(--transition-fast), transform 0.12s ease;
}
.rt-check:not(:disabled):hover .box {
border-color: var(--accent);
}
.rt-check:active .box {
transform: scale(0.92);
}
.rt-check.on .box {
background: var(--success);
border-color: var(--success);
}
.st-overdue .rt-check .box {
border-color: color-mix(in srgb, var(--danger) 55%, white);
}
.spin {
width: 12px;
height: 12px;
border: 2px solid var(--line);
border-top-color: var(--accent);
border-radius: 50%;
animation: rt-spin 0.7s linear infinite;
}
@keyframes rt-spin {
to {
transform: rotate(360deg);
}
}
.rt-main {
min-width: 0;
display: grid;
gap: 4px;
}
.rt-title-line {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.rt-title {
margin: 0;
font-size: var(--fs-base);
font-weight: var(--fw-semibold);
color: var(--ink);
line-height: 1.5;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.done .rt-title {
color: var(--muted);
text-decoration: line-through;
text-decoration-color: color-mix(in srgb, var(--muted) 60%, transparent);
}
.mand {
color: var(--warning-deep);
flex-shrink: 0;
}
.rt-desc {
margin: 0;
font-size: var(--fs-sm);
color: var(--muted);
line-height: 1.6;
display: -webkit-box;
-webkit-line-clamp: 1;
line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.rt-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px 10px;
margin-top: 2px;
font-size: var(--fs-xs);
color: var(--muted);
}
.meta-item {
display: inline-flex;
align-items: center;
gap: 4px;
white-space: nowrap;
}
.meta-item.late {
color: var(--danger);
font-weight: var(--fw-semibold);
}
.meta-item.ok {
color: var(--success-deep);
}
.cat-chip,
.src-chip {
display: inline-flex;
align-items: center;
gap: 5px;
white-space: nowrap;
}
.cat-chip i {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.src-chip {
color: var(--muted-light);
}
.ent-link {
display: inline-flex;
align-items: center;
gap: 5px;
max-width: 220px;
padding: 1px 8px;
border-radius: var(--radius-full);
background: var(--accent-subtle);
color: var(--accent-deep);
font-weight: var(--fw-medium);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ent-link:hover {
background: var(--accent-soft);
}
.ent-type {
color: var(--muted);
font-weight: var(--fw-normal);
}
.rt-side {
display: flex;
align-items: center;
gap: var(--sp-2);
}
.assignee {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: var(--fs-xs);
color: var(--ink-secondary);
max-width: 150px;
}
.assignee-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rt-more {
width: 32px;
height: 32px;
border: 0;
border-radius: var(--radius);
background: transparent;
color: var(--muted);
display: grid;
place-items: center;
cursor: pointer;
}
.rt-more:hover {
background: var(--surface-subtle);
color: var(--ink);
}
:global(.ui-dd-panel button.danger) {
color: var(--danger);
}
@media (max-width: 900px) {
.rt-row {
padding: var(--sp-3);
gap: var(--sp-2);
}
.rt-check {
width: 48px;
height: 48px;
margin: -8px -6px;
}
.rt-check .box {
width: 28px;
height: 28px;
border-radius: 8px;
}
.rt-title {
white-space: normal;
}
.assignee-name {
display: none;
}
}
</style>
@@ -0,0 +1,191 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import type { RoutineMemberStatDto } from '@mento/shared';
import UserAvatar from '@/components/UserAvatar.vue';
import { toFaDigits } from '@/lib/jalali';
import { formatMinutes, formatPercent, percentTone } from '@/lib/routines';
const props = defineProps<{ members: RoutineMemberStatDto[] }>();
const emit = defineEmits<{ select: [userId: string] }>();
type SortKey = 'name' | 'total' | 'completed' | 'overdue' | 'skipped' | 'percent' | 'streak' | 'avg';
const sortKey = ref<SortKey>('percent');
const sortDir = ref<'asc' | 'desc'>('desc');
const columns: Array<{ key: SortKey; label: string }> = [
{ key: 'name', label: 'کاربر' },
{ key: 'total', label: 'کل کارها' },
{ key: 'completed', label: 'انجام شده' },
{ key: 'overdue', label: 'عقب‌افتاده' },
{ key: 'skipped', label: 'رد شده' },
{ key: 'percent', label: 'درصد انجام' },
{ key: 'streak', label: 'روز پیاپی کامل' },
{ key: 'avg', label: 'میانگین زمان انجام' },
];
function value(m: RoutineMemberStatDto, key: SortKey): number | string {
switch (key) {
case 'name':
return m.user.fullName;
case 'percent':
return m.percent ?? -1;
case 'avg':
return m.avgCompletionMinutes ?? Number.MAX_SAFE_INTEGER;
default:
return m[key];
}
}
const rows = computed(() => {
const dir = sortDir.value === 'asc' ? 1 : -1;
return [...props.members].sort((a, b) => {
const va = value(a, sortKey.value);
const vb = value(b, sortKey.value);
if (typeof va === 'string' && typeof vb === 'string') return va.localeCompare(vb, 'fa') * dir;
return ((va as number) - (vb as number)) * dir;
});
});
function sortBy(key: SortKey) {
if (sortKey.value === key) sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc';
else {
sortKey.value = key;
sortDir.value = key === 'name' || key === 'avg' ? 'asc' : 'desc';
}
}
</script>
<template>
<div class="crm-table-container">
<table class="crm-table">
<thead>
<tr>
<th
v-for="c in columns"
:key="c.key"
:aria-sort="sortKey === c.key ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none'"
>
<button type="button" class="sort" :class="{ on: sortKey === c.key }" @click="sortBy(c.key)">
{{ c.label }}
<span class="arrow">{{ sortKey === c.key ? (sortDir === 'asc' ? '▲' : '▼') : '↕' }}</span>
</button>
</th>
</tr>
</thead>
<tbody>
<tr v-for="m in rows" :key="m.user.id" class="clickable" @click="emit('select', m.user.id)">
<td>
<span class="who">
<UserAvatar :name="m.user.fullName" :user-id="m.user.id" :size="28" />
{{ m.user.fullName }}
</span>
</td>
<td>{{ toFaDigits(m.total) }}</td>
<td class="ok">{{ toFaDigits(m.completed) }}</td>
<td :class="{ bad: m.overdue > 0 }">{{ toFaDigits(m.overdue) }}</td>
<td>{{ toFaDigits(m.skipped) }}</td>
<td>
<span class="pct" :class="`tone-${percentTone(m.percent)}`">
<strong>{{ formatPercent(m.percent) }}</strong>
<i><b :style="{ width: `${m.percent ?? 0}%` }" /></i>
</span>
</td>
<td>{{ toFaDigits(m.streak) }}</td>
<td class="nowrap">{{ formatMinutes(m.avgCompletionMinutes) }}</td>
</tr>
<tr v-if="!rows.length">
<td colspan="8" class="empty">داده‌ای برای این بازه نیست.</td>
</tr>
</tbody>
</table>
</div>
</template>
<style scoped>
.sort {
display: inline-flex;
align-items: center;
gap: 4px;
border: 0;
background: transparent;
color: inherit;
font: inherit;
font-weight: inherit;
cursor: pointer;
padding: 0;
white-space: nowrap;
}
.sort.on {
color: var(--accent-deep);
}
.arrow {
font-size: 0.6rem;
opacity: 0.7;
}
.clickable {
cursor: pointer;
}
.who {
display: inline-flex;
align-items: center;
gap: 8px;
white-space: nowrap;
font-weight: var(--fw-medium);
}
.ok {
color: var(--success-deep);
}
.bad {
color: var(--danger);
font-weight: var(--fw-semibold);
}
.pct {
display: grid;
grid-template-columns: 44px 80px;
align-items: center;
gap: 8px;
}
.pct i {
height: 6px;
border-radius: 99px;
background: var(--surface-subtle);
overflow: hidden;
}
.pct b {
display: block;
height: 100%;
background: var(--line-strong);
}
.tone-good b {
background: var(--success);
}
.tone-mid b {
background: var(--warning);
}
.tone-low b {
background: var(--danger);
}
.nowrap {
white-space: nowrap;
}
.empty {
text-align: center;
color: var(--muted);
padding: var(--sp-6);
}
</style>
@@ -0,0 +1,301 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import type { RoutinePeriodTotalsDto, RoutineStatsQuery, RoutineWeeklyReportDto } from '@mento/shared';
import { ROUTINE_WEEKDAY_LABELS } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import KpiCard from '@/components/routines/KpiCard.vue';
import { UiButton, UiChart } from '@/components/ui';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import { daysBar } from '@/lib/routineCharts';
import {
DAY_STATE_LABELS,
DAY_STATE_SYMBOLS,
addDaysKey,
apiError,
formatDayNumeric,
formatPercent,
todayKey,
weekStart,
} from '@/lib/routines';
const props = defineProps<{
view: 'me' | 'team';
filters?: RoutineStatsQuery;
refreshKey?: number;
}>();
const anchor = ref(weekStart(todayKey()));
const data = ref<RoutineWeeklyReportDto | null>(null);
const loading = ref(false);
const error = ref('');
async function load() {
loading.value = true;
error.value = '';
try {
const rest = { ...(props.filters ?? {}) };
delete rest.from;
delete rest.to;
const res = await api.get<RoutineWeeklyReportDto>('/routines/stats/weekly', {
params: { ...rest, from: anchor.value, view: props.view },
});
data.value = res.data;
} catch (e) {
error.value = apiError(e, 'بارگذاری گزارش هفتگی ممکن نشد.');
} finally {
loading.value = false;
}
}
watch([anchor, () => JSON.stringify(props.filters ?? {}), () => props.refreshKey], load, { immediate: true });
function diff(cur: number | null, prev: number | null) {
if (cur == null || prev == null) return null;
return cur - prev;
}
const t = computed<RoutinePeriodTotalsDto | null>(() => data.value?.totals ?? null);
const p = computed<RoutinePeriodTotalsDto | null>(() => data.value?.previous ?? null);
const chart = computed(() =>
daysBar(
data.value?.days ?? [],
(data.value?.days ?? []).map((_, i) => ROUTINE_WEEKDAY_LABELS[i]!),
),
);
const isCurrent = computed(() => anchor.value === weekStart(todayKey()));
</script>
<template>
<div class="weekly">
<div class="head">
<div class="nav">
<UiButton variant="icon" aria-label="هفته قبل" @click="anchor = addDaysKey(anchor, -7)">
<UiIcon name="chevron-right" :size="18" />
</UiButton>
<strong>
{{ data ? `${formatDayNumeric(data.from)} تا ${formatDayNumeric(data.to)}` : '…' }}
</strong>
<UiButton variant="icon" aria-label="هفته بعد" :disabled="isCurrent" @click="anchor = addDaysKey(anchor, 7)">
<UiIcon name="chevron-left" :size="18" />
</UiButton>
</div>
<span class="cmp">مقایسه با هفته قبل</span>
</div>
<p v-if="error" class="error">{{ error }}</p>
<div class="kpis" :class="{ dim: loading }">
<KpiCard
label="درصد انجام"
:value="formatPercent(t?.percent)"
icon="target"
tone="accent"
:delta="diff(t?.percent ?? null, p?.percent ?? null)"
delta-suffix="٪"
/>
<KpiCard
label="کارهای انجام‌شده"
:value="toFaDigits(t?.completed ?? 0)"
:hint="`از ${toFaDigits(t?.total ?? 0)}`"
icon="check"
tone="success"
:delta="diff(t?.completed ?? null, p?.completed ?? null)"
/>
<KpiCard
label="عقب‌افتاده"
:value="toFaDigits(t?.overdue ?? 0)"
icon="warning"
tone="danger"
:delta="diff(p?.overdue ?? null, t?.overdue ?? null)"
/>
<KpiCard
label="روزهای کامل"
:value="toFaDigits(t?.completeDays ?? 0)"
:hint="`ناقص ${toFaDigits(t?.partialDays ?? 0)} · از دست رفته ${toFaDigits(t?.missedDays ?? 0)}`"
icon="calendar"
tone="info"
:delta="diff(t?.completeDays ?? null, p?.completeDays ?? null)"
/>
</div>
<div class="cards">
<section class="card">
<h3>انجام کارها در روزهای هفته</h3>
<UiChart :option="chart" height="260px" :loading="loading" />
</section>
<section class="card">
<h3>وضعیت روزها</h3>
<ul class="days">
<li v-for="(d, i) in data?.days ?? []" :key="d.date" :class="`s-${d.state}`">
<span class="sym">{{ DAY_STATE_SYMBOLS[d.state] }}</span>
<span class="lbl">
<strong>{{ ROUTINE_WEEKDAY_LABELS[i] }}</strong>
<small>{{ formatDayNumeric(d.date) }}</small>
</span>
<span class="val">
<template v-if="d.total">{{ toFaDigits(d.completed) }}/{{ toFaDigits(d.total) }} · {{ formatPercent(d.percent) }}</template>
<template v-else>{{ DAY_STATE_LABELS.empty }}</template>
</span>
</li>
</ul>
<p class="avg">
میانگین درصد روزانه: <strong>{{ formatPercent(t?.avgPercent) }}</strong>
<template v-if="p?.avgPercent != null"> (هفته قبل {{ formatPercent(p.avgPercent) }})</template>
</p>
</section>
</div>
</div>
</template>
<style scoped>
.weekly {
display: grid;
gap: var(--sp-4);
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--sp-2);
}
.nav {
display: flex;
align-items: center;
gap: var(--sp-2);
}
.nav strong {
min-width: 170px;
text-align: center;
color: var(--ink);
}
.cmp {
font-size: var(--fs-xs);
color: var(--muted);
}
.kpis {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--sp-3);
transition: opacity var(--transition-fast);
}
.dim {
opacity: 0.55;
}
.cards {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);
gap: var(--sp-4);
}
.card {
padding: var(--sp-4);
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
display: grid;
gap: var(--sp-3);
align-content: start;
min-width: 0;
}
.card h3 {
margin: 0;
font-size: var(--fs-sm);
font-weight: var(--fw-semibold);
color: var(--ink-secondary);
}
.days {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 4px;
}
.days li {
display: grid;
grid-template-columns: 28px 1fr auto;
align-items: center;
gap: var(--sp-2);
padding: 6px 8px;
border-radius: 8px;
background: var(--surface-subtle);
}
.sym {
width: 24px;
height: 24px;
border-radius: 6px;
display: grid;
place-items: center;
font-weight: var(--fw-bold);
font-size: 0.75rem;
background: var(--surface);
color: var(--muted-light);
}
.s-complete .sym {
background: var(--success-soft);
color: var(--success-deep);
}
.s-partial .sym {
background: var(--warning-soft);
color: var(--warning-deep);
}
.s-missed .sym {
background: var(--danger-soft);
color: var(--danger);
}
.lbl {
display: grid;
}
.lbl strong {
font-size: var(--fs-sm);
font-weight: var(--fw-medium);
}
.lbl small,
.val {
font-size: var(--fs-xs);
color: var(--muted);
}
.avg {
margin: 0;
font-size: var(--fs-xs);
color: var(--muted);
}
.avg strong {
color: var(--ink);
}
.error {
margin: 0;
}
@media (max-width: 1000px) {
.cards {
grid-template-columns: 1fr;
}
.kpis {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--sp-2);
}
}
</style>
+181
View File
@@ -0,0 +1,181 @@
<script setup lang="ts">
import { onUnmounted, watch } from 'vue';
import UiIcon from '@/components/UiIcon.vue';
const props = withDefaults(
defineProps<{
open: boolean;
title?: string;
width?: string;
}>(),
{ title: '', width: '440px' },
);
const emit = defineEmits<{ close: [] }>();
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') emit('close');
}
watch(
() => props.open,
(open) => {
if (open) window.addEventListener('keydown', onKey);
else window.removeEventListener('keydown', onKey);
},
{ immediate: true },
);
onUnmounted(() => window.removeEventListener('keydown', onKey));
</script>
<template>
<Teleport to="body">
<Transition name="ui-drawer">
<div v-if="open" class="ui-drawer-layer">
<button class="ui-drawer-dim" type="button" aria-label="بستن" @click="emit('close')" />
<aside class="ui-drawer" role="dialog" aria-modal="true" :aria-label="title" :style="{ '--drawer-w': width }">
<header class="ui-drawer-head">
<slot name="header">
<h2>{{ title }}</h2>
</slot>
<button type="button" class="ui-drawer-close" aria-label="بستن" @click="emit('close')">
<UiIcon name="x" :size="18" />
</button>
</header>
<div class="ui-drawer-body">
<slot />
</div>
<footer v-if="$slots.footer" class="ui-drawer-foot">
<slot name="footer" />
</footer>
</aside>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.ui-drawer-layer {
position: fixed;
inset: 0;
/* زیر UiModal (۸۰) تا مودال‌های داخل دراور رویش باز شوند */
z-index: 75;
}
.ui-drawer-dim {
position: absolute;
inset: 0;
border: 0;
padding: 0;
background: rgba(15, 23, 42, 0.28);
cursor: default;
}
/* RTL: inset-inline-start = سمت راست صفحه */
.ui-drawer {
position: absolute;
top: 0;
bottom: 0;
inset-inline-start: 0;
width: min(var(--drawer-w), 100vw);
display: flex;
flex-direction: column;
background: var(--surface);
box-shadow: var(--shadow-xl);
border-inline-end: 1px solid var(--line);
}
.ui-drawer-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-3);
padding: var(--sp-4);
border-bottom: 1px solid var(--line);
flex-shrink: 0;
}
.ui-drawer-head h2 {
margin: 0;
font-size: var(--fs-md);
font-weight: var(--fw-bold);
color: var(--ink);
}
.ui-drawer-close {
width: 36px;
height: 36px;
flex-shrink: 0;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
color: var(--muted);
display: grid;
place-items: center;
cursor: pointer;
}
.ui-drawer-close:hover {
background: var(--surface-subtle);
color: var(--ink);
}
.ui-drawer-body {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
padding: var(--sp-4);
}
.ui-drawer-foot {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--sp-2);
padding: var(--sp-3) var(--sp-4);
border-top: 1px solid var(--line);
flex-shrink: 0;
}
.ui-drawer-enter-active,
.ui-drawer-leave-active {
transition: opacity 0.2s ease;
}
.ui-drawer-enter-active .ui-drawer,
.ui-drawer-leave-active .ui-drawer {
transition: transform 0.22s ease;
}
.ui-drawer-enter-from,
.ui-drawer-leave-to {
opacity: 0;
}
.ui-drawer-enter-from .ui-drawer,
.ui-drawer-leave-to .ui-drawer {
transform: translateX(100%);
}
@media (max-width: 900px) {
.ui-drawer {
top: auto;
inset-inline: 0;
width: 100%;
max-height: 92dvh;
border-radius: 18px 18px 0 0;
border: 0;
}
.ui-drawer-foot {
padding-bottom: calc(var(--sp-3) + env(safe-area-inset-bottom, 0px));
}
.ui-drawer-enter-from .ui-drawer,
.ui-drawer-leave-to .ui-drawer {
transform: translateY(100%);
}
}
</style>
+1
View File
@@ -5,6 +5,7 @@ export { default as UiSelect } from './UiSelect.vue';
export { default as UiField } from './UiField.vue';
export { default as UiCard } from './UiCard.vue';
export { default as UiModal } from './UiModal.vue';
export { default as UiDrawer } from './UiDrawer.vue';
export { default as UiBusyOverlay } from './UiBusyOverlay.vue';
export { default as UiPageHeader } from './UiPageHeader.vue';
export { default as UiBadge } from './UiBadge.vue';
+41 -1
View File
@@ -38,6 +38,7 @@ const hubStack = ref<NavHubKey[]>([]);
const isMobileNav = ref(false);
const globalSearch = ref('');
const activityBadge = ref(0);
const routineBadge = ref(0);
function pathIn(prefixes: string[]) {
return prefixes.some(
@@ -67,6 +68,7 @@ const salesActive = computed(() =>
);
const activitiesActive = computed(() => pathIn(['/activities']));
const notesActive = computed(() => route.path === '/notes' || route.path.startsWith('/notes/'));
const routinesActive = computed(() => pathIn(['/routines']));
const conversationsActive = computed(() => pathIn(['/conversations']));
const supportActive = computed(() => pathIn(['/tickets']));
const automationsActive = computed(
@@ -100,6 +102,7 @@ const settingsActive = computed(() =>
);
const moreSectionActive = computed(
() =>
routinesActive.value ||
notesActive.value ||
conversationsActive.value ||
supportActive.value ||
@@ -285,6 +288,16 @@ async function loadActivityBadge() {
}
}
async function loadRoutineBadge() {
if (!moduleAllowed('routines')) return;
try {
const { data } = await api.get<{ remaining: number; overdue: number }>('/routine-tasks/badge');
routineBadge.value = data.remaining + data.overdue;
} catch {
routineBadge.value = 0;
}
}
async function loadChatBadge() {
if (!moduleAllowed('conversations')) return;
try {
@@ -374,9 +387,11 @@ onMounted(() => {
}
void refreshNotifs();
void loadActivityBadge();
void loadRoutineBadge();
void loadChatBadge();
pollTimer = setInterval(() => {
void refreshNotifs();
void loadRoutineBadge();
void refreshUnreadTotal().catch(() => undefined);
}, 45000);
mobileMq = window.matchMedia('(max-width: 900px)');
@@ -510,6 +525,19 @@ onUnmounted(() => {
</button>
</div>
<RouterLink
v-if="moduleAllowed('routines')"
to="/routines"
class="nav-item"
title="کارهای روتین"
:class="{ 'router-link-active': routinesActive }"
@click="closeHub(); closeMenu()"
>
<UiIcon name="refresh" :size="collapsed ? 20 : 18" />
<span>کارهای روتین</span>
<span v-if="routineBadge" class="nav-badge">{{ toFaDigits(routineBadge) }}</span>
</RouterLink>
<RouterLink
v-if="moduleAllowed('conversations')"
to="/conversations"
@@ -725,7 +753,8 @@ onUnmounted(() => {
:title="`انقضا: ${subscription.expiresAt}`"
>
<UiIcon name="clock" :size="15" />
<span>
<span v-if="subscription.planCode === 'lifetime'">اشتراک دائمی</span>
<span v-else>
{{ toFaDigits(subscription.daysRemaining) }} روز باقی‌مانده
</span>
</div>
@@ -858,6 +887,17 @@ onUnmounted(() => {
<button class="round-btn sm" type="button" aria-label="بستن" @click="closeMore">✕</button>
</header>
<nav class="more-body">
<RouterLink
v-if="moduleAllowed('routines')"
to="/routines"
class="nav-item"
:class="{ 'router-link-active': routinesActive }"
@click="closeHub(); closeMore()"
>
<UiIcon name="refresh" :size="18" />
<span>کارهای روتین</span>
<span v-if="routineBadge" class="nav-badge">{{ toFaDigits(routineBadge) }}</span>
</RouterLink>
<RouterLink to="/notes" class="nav-item" @click="closeHub(); closeMore()">
<UiIcon name="document" :size="18" />
<span>یادداشت‌ها</span>
+248
View File
@@ -0,0 +1,248 @@
import type {
RoutineCalendarDayDto,
RoutineCategoryStatDto,
RoutineMemberStatDto,
RoutineStatusBreakdownDto,
RoutineTrendPointDto,
} from '@mento/shared';
import type { UiChartOption } from '@/components/ui';
import { toFaDigits } from '@/lib/jalali';
import { formatDayNumeric } from '@/lib/routines';
const FONT = 'IRANSans, Tahoma, sans-serif';
const AXIS_LABEL = { color: '#64748b', fontFamily: FONT, fontSize: 11 };
const SPLIT = { lineStyle: { color: '#f1f5f9', type: 'dashed' as const } };
const LEGEND = { bottom: 0, left: 'center', textStyle: { fontFamily: FONT, fontSize: 11, color: '#475569' } };
export const STATUS_COLORS = {
completed: '#10b981',
notDone: '#94a3b8',
overdue: '#ef4444',
skipped: '#f59e0b',
cancelled: '#cbd5e1',
};
/* eslint-disable @typescript-eslint/no-explicit-any */
/** روند درصد انجام روزانه */
export function trendOption(trend: RoutineTrendPointDto[]): UiChartOption {
return {
textStyle: { fontFamily: FONT },
tooltip: {
trigger: 'axis',
formatter: (params: any) => {
const item = Array.isArray(params) ? params[0] : params;
const p = trend[item?.dataIndex ?? -1];
if (!p) return '';
return `${formatDayNumeric(p.date)}<br/>درصد انجام: <strong>${p.percent == null ? '—' : `${toFaDigits(p.percent)}٪`}</strong><br/>${toFaDigits(p.completed)} از ${toFaDigits(p.total)}`;
},
},
grid: { left: 8, right: 16, top: 16, bottom: 8, containLabel: true },
xAxis: {
type: 'category',
boundaryGap: false,
data: trend.map((p) => formatDayNumeric(p.date)),
axisLine: { lineStyle: { color: '#cbd5e1' } },
axisLabel: { ...AXIS_LABEL, hideOverlap: true },
},
yAxis: {
type: 'value',
min: 0,
max: 100,
splitLine: SPLIT,
axisLabel: { ...AXIS_LABEL, formatter: (v: number) => `${toFaDigits(v)}٪` },
},
series: [
{
name: 'درصد انجام',
type: 'line',
smooth: true,
connectNulls: true,
showSymbol: false,
itemStyle: { color: '#14b8a6' },
lineStyle: { width: 3, color: '#0d9488' },
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(20, 184, 166, 0.3)' },
{ offset: 1, color: 'rgba(20, 184, 166, 0.01)' },
],
},
},
data: trend.map((p) => p.percent),
},
],
};
}
/** دونات وضعیت کارها */
export function statusDonut(s: RoutineStatusBreakdownDto): UiChartOption {
const rows = [
{ name: 'انجام شده', value: s.completed, color: STATUS_COLORS.completed },
{ name: 'انجام نشده', value: s.notDone, color: STATUS_COLORS.notDone },
{ name: 'عقب‌افتاده', value: s.overdue, color: STATUS_COLORS.overdue },
{ name: 'رد شده', value: s.skipped, color: STATUS_COLORS.skipped },
].filter((r) => r.value > 0);
return {
textStyle: { fontFamily: FONT },
tooltip: {
trigger: 'item',
formatter: (p: any) => `${p.name}: <strong>${toFaDigits(p.value)}</strong> (${toFaDigits(p.percent)}٪)`,
},
legend: LEGEND,
series: [
{
name: 'وضعیت',
type: 'pie',
radius: ['48%', '72%'],
center: ['50%', '44%'],
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
label: { show: false },
data: rows.map((r) => ({ name: r.name, value: r.value, itemStyle: { color: r.color } })),
},
],
};
}
/** میله‌ای انجام به تفکیک دسته */
export function categoryBar(rows: RoutineCategoryStatDto[]): UiChartOption {
return {
textStyle: { fontFamily: FONT },
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: (params: any) => {
const item = Array.isArray(params) ? params[0] : params;
const r = rows[item?.dataIndex ?? -1];
if (!r) return '';
return `${r.name}<br/>${toFaDigits(r.completed)} از ${toFaDigits(r.total)} · <strong>${r.percent == null ? '—' : `${toFaDigits(r.percent)}٪`}</strong>`;
},
},
grid: { left: 8, right: 16, top: 16, bottom: 8, containLabel: true },
xAxis: {
type: 'category',
data: rows.map((r) => r.name),
axisLine: { lineStyle: { color: '#cbd5e1' } },
axisLabel: { ...AXIS_LABEL, interval: 0 },
},
yAxis: {
type: 'value',
min: 0,
max: 100,
splitLine: SPLIT,
axisLabel: { ...AXIS_LABEL, formatter: (v: number) => `${toFaDigits(v)}٪` },
},
series: [
{
name: 'درصد انجام',
type: 'bar',
barMaxWidth: 36,
data: rows.map((r) => ({
value: r.percent ?? 0,
itemStyle: { color: r.color, borderRadius: [6, 6, 0, 0] },
})),
},
],
};
}
/** میله‌ای انجام‌شده/کل به تفکیک روز */
export function daysBar(days: RoutineCalendarDayDto[], labels?: string[]): UiChartOption {
return {
textStyle: { fontFamily: FONT },
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: (params: any) => {
const list = Array.isArray(params) ? params : [params];
const d = days[list[0]?.dataIndex ?? -1];
if (!d) return '';
return `${formatDayNumeric(d.date)}<br/>انجام شده: <strong>${toFaDigits(d.completed)}</strong> از ${toFaDigits(d.total)}`;
},
},
legend: LEGEND,
grid: { left: 8, right: 16, top: 16, bottom: 32, containLabel: true },
xAxis: {
type: 'category',
data: labels ?? days.map((d) => formatDayNumeric(d.date)),
axisLine: { lineStyle: { color: '#cbd5e1' } },
axisLabel: { ...AXIS_LABEL, hideOverlap: true },
},
yAxis: {
type: 'value',
minInterval: 1,
splitLine: SPLIT,
axisLabel: { ...AXIS_LABEL, formatter: (v: number) => toFaDigits(v) },
},
series: [
{
name: 'انجام شده',
type: 'bar',
stack: 'all',
barMaxWidth: 28,
itemStyle: { color: STATUS_COLORS.completed },
data: days.map((d) => d.completed),
},
{
name: 'باقی‌مانده',
type: 'bar',
stack: 'all',
barMaxWidth: 28,
itemStyle: { color: '#e2e8f0', borderRadius: [6, 6, 0, 0] },
data: days.map((d) => Math.max(0, d.total - d.completed)),
},
],
};
}
/** میله‌ای افقی مقایسه اعضا */
export function membersBar(members: RoutineMemberStatDto[]): UiChartOption {
const rows = [...members].filter((m) => m.total > 0).sort((a, b) => (a.percent ?? 0) - (b.percent ?? 0));
return {
textStyle: { fontFamily: FONT },
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: (params: any) => {
const item = Array.isArray(params) ? params[0] : params;
const m = rows[item?.dataIndex ?? -1];
if (!m) return '';
return `${m.user.fullName}<br/>${toFaDigits(m.completed)} از ${toFaDigits(m.total)} · <strong>${m.percent == null ? '—' : `${toFaDigits(m.percent)}٪`}</strong>`;
},
},
grid: { left: 8, right: 24, top: 8, bottom: 8, containLabel: true },
xAxis: {
type: 'value',
min: 0,
max: 100,
splitLine: SPLIT,
axisLabel: { ...AXIS_LABEL, formatter: (v: number) => `${toFaDigits(v)}٪` },
},
yAxis: {
type: 'category',
data: rows.map((m) => m.user.fullName),
axisLabel: AXIS_LABEL,
axisLine: { show: false },
axisTick: { show: false },
},
series: [
{
name: 'درصد انجام',
type: 'bar',
barMaxWidth: 18,
data: rows.map((m) => ({
value: m.percent ?? 0,
itemStyle: {
borderRadius: [0, 6, 6, 0],
color: (m.percent ?? 0) >= 85 ? '#10b981' : (m.percent ?? 0) >= 60 ? '#f59e0b' : '#ef4444',
},
})),
},
],
};
}
+256
View File
@@ -0,0 +1,256 @@
import axios from 'axios';
import type { InjectionKey, Ref } from 'vue';
import type {
ActivityPriority,
RoutineContextDto,
RoutineDayState,
RoutineEntityRefDto,
RoutineTaskDto,
RoutineTaskStatus,
} from '@mento/shared';
import {
addJalaliMonths,
dateToJalali,
formatJalaliLong,
formatJalaliShort,
jalaliMonthLength,
jalaliMonthName,
jalaliToDate,
toFaDigits,
} from '@/lib/jalali';
export {
ROUTINE_ENTITY_LINK_LABELS,
ROUTINE_RECURRENCE_LABELS,
ROUTINE_TASK_SOURCE_LABELS,
ROUTINE_TASK_STATUS_LABELS,
ROUTINE_WEEKDAY_LABELS,
} from '@mento/shared';
type BadgeVariant = 'neutral' | 'accent' | 'success' | 'warning' | 'danger' | 'info';
export const PRIORITY_LABELS: Record<ActivityPriority, string> = {
very_low: 'خیلی کم',
low: 'کم',
medium: 'متوسط',
high: 'زیاد',
very_high: 'خیلی زیاد',
};
export const PRIORITY_VARIANTS: Record<ActivityPriority, BadgeVariant> = {
very_low: 'neutral',
low: 'neutral',
medium: 'info',
high: 'warning',
very_high: 'danger',
};
export const STATUS_VARIANTS: Record<RoutineTaskStatus, BadgeVariant> = {
todo: 'neutral',
in_progress: 'info',
completed: 'success',
overdue: 'danger',
skipped: 'warning',
cancelled: 'neutral',
};
export const DAY_STATE_LABELS: Record<RoutineDayState, string> = {
complete: 'روز کامل',
partial: 'روز ناقص',
missed: 'روز از دست رفته',
empty: 'بدون کار',
};
export const DAY_STATE_SYMBOLS: Record<RoutineDayState, string> = {
complete: '✓',
partial: '△',
missed: '×',
empty: '·',
};
/** زمینه مشترک بخش کارهای روتین (کاربران، دسته‌ها، نقش مدیر، رفرش) */
export type RoutinesShellContext = {
context: Ref<RoutineContextDto | null>;
refreshTick: Ref<number>;
notifyChanged: () => void;
openTask: (id: string) => void;
};
export const ROUTINES_CTX: InjectionKey<RoutinesShellContext> = Symbol('routines-ctx');
/* ───────── تاریخ‌ها (کلید روز YYYY-MM-DD به وقت تهران) ───────── */
const TEHRAN_DAY = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Tehran',
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
const TEHRAN_TIME = new Intl.DateTimeFormat('en-GB', {
timeZone: 'Asia/Tehran',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
});
export function todayKey(): string {
return TEHRAN_DAY.format(new Date());
}
export function dayKeyOf(iso: string | Date): string {
return TEHRAN_DAY.format(typeof iso === 'string' ? new Date(iso) : iso);
}
/** Date محلی (نیمه‌شب) برای نمایش شمسی */
export function keyToDate(key: string): Date {
const [y, m, d] = key.split('-').map(Number);
return new Date(y!, m! - 1, d!);
}
export function dateToKey(d: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
export function addDaysKey(key: string, n: number): string {
const d = keyToDate(key);
d.setDate(d.getDate() + n);
return dateToKey(d);
}
/** ۰ = شنبه */
export function weekdayIndex(key: string): number {
return (keyToDate(key).getDay() + 1) % 7;
}
export function weekStart(key: string): string {
return addDaysKey(key, -weekdayIndex(key));
}
export function jalaliMonthRange(key: string): { from: string; to: string; label: string } {
const { jy, jm } = dateToJalali(keyToDate(key));
const from = dateToKey(jalaliToDate(jy, jm, 1));
const to = dateToKey(jalaliToDate(jy, jm, jalaliMonthLength(jy, jm)));
return { from, to, label: `${jalaliMonthName(jm)} ${toFaDigits(jy)}` };
}
export function shiftJalaliMonth(key: string, delta: number): string {
const { jy, jm } = dateToJalali(keyToDate(key));
const next = addJalaliMonths(jy, jm, delta);
return dateToKey(jalaliToDate(next.jy, next.jm, 1));
}
export function formatDayLong(key: string): string {
return formatJalaliLong(keyToDate(key));
}
export function formatDayShort(key: string): string {
return formatJalaliShort(keyToDate(key));
}
export function formatDayNumeric(key: string): string {
const { jm, jd } = dateToJalali(keyToDate(key));
return `${toFaDigits(jd)} ${jalaliMonthName(jm)}`;
}
export function formatTime(iso: string | null | undefined): string {
if (!iso) return '—';
return toFaDigits(TEHRAN_TIME.format(new Date(iso)));
}
export function timeOf(iso: string | null | undefined): string {
if (!iso) return '';
return TEHRAN_TIME.format(new Date(iso));
}
/** زمان پایان روز بدون ساعت مشخص (۲۳:۵۹) در UI نمایش داده نمی‌شود */
export function hasExplicitTime(task: Pick<RoutineTaskDto, 'dueAt'>): boolean {
return !!task.dueAt && timeOf(task.dueAt) !== '23:59';
}
export function formatDelay(iso: string | null | undefined): string {
if (!iso) return '—';
const diff = Date.now() - new Date(iso).getTime();
if (diff <= 0) return '—';
const minutes = Math.floor(diff / 60_000);
if (minutes < 60) return `${toFaDigits(minutes)} دقیقه`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${toFaDigits(hours)} ساعت`;
return `${toFaDigits(Math.floor(hours / 24))} روز`;
}
export function formatMinutes(min: number | null | undefined): string {
if (min == null) return '—';
if (min < 60) return `${toFaDigits(min)} دقیقه`;
const h = Math.floor(min / 60);
const m = min % 60;
return m ? `${toFaDigits(h)} ساعت و ${toFaDigits(m)} دقیقه` : `${toFaDigits(h)} ساعت`;
}
export function formatPercent(p: number | null | undefined): string {
return p == null ? '—' : `${toFaDigits(p)}٪`;
}
export function percentTone(p: number | null | undefined): 'good' | 'mid' | 'low' | 'none' {
if (p == null) return 'none';
if (p >= 85) return 'good';
if (p >= 60) return 'mid';
return 'low';
}
export function isoToLocalInput(iso: string | null | undefined): { date: string; time: string } {
if (!iso) return { date: '', time: '' };
return { date: dayKeyOf(iso), time: timeOf(iso) };
}
/** ورودی تاریخ/ساعت همیشه به وقت تهران تفسیر می‌شود */
export function localInputToIso(date: string, time: string): string | null {
if (!date) return null;
const d = new Date(`${date}T${time || '23:59'}:00+03:30`);
return Number.isNaN(d.getTime()) ? null : d.toISOString();
}
/* ───────── پیوند پرونده‌ها ───────── */
export const ENTITY_TYPE_LABELS: Record<RoutineEntityRefDto['type'], string> = {
lead: 'لید',
person: 'مشتری',
company: 'شرکت',
deal: 'معامله',
invoice: 'فاکتور',
activity: 'پیگیری',
};
export function entityRoute(ref: RoutineEntityRefDto): string {
switch (ref.type) {
case 'lead':
return `/leads/${ref.id}`;
case 'person':
return `/contacts/people/${ref.id}`;
case 'company':
return `/contacts/companies/${ref.id}`;
case 'deal':
return `/deals/${ref.id}`;
case 'invoice':
return `/invoices/${ref.id}`;
case 'activity':
return `/activities/${ref.id}`;
default:
return '/';
}
}
export function apiError(e: unknown, fallback: string): string {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
if (Array.isArray(msg)) return msg.join('، ');
if (msg) return msg;
if (!e.response) return 'اتصال به سرور برقرار نشد.';
}
return fallback;
}
export function isOpenStatus(status: RoutineTaskStatus): boolean {
return status === 'todo' || status === 'in_progress' || status === 'overdue';
}
+131
View File
@@ -0,0 +1,131 @@
import { reactive, ref } from 'vue';
import type { RoutineTaskDto } from '@mento/shared';
import { api } from '@/lib/api';
import { apiError, isoToLocalInput, localInputToIso, todayKey } from '@/lib/routines';
export type RoutineTaskModalKind = 'complete' | 'skip' | 'reschedule' | 'cancel';
/**
* اکشن‌های مشترک روی یک Task (تیک، رد، جابه‌جایی، لغو) + وضعیت مودال‌ها.
* `onChanged` بعد از هر تغییر موفق با Task به‌روزشده صدا زده می‌شود.
*/
export function useRoutineTaskActions(onChanged: (task: RoutineTaskDto) => void) {
const busyId = ref<string | null>(null);
const error = ref('');
const modal = reactive({
kind: null as RoutineTaskModalKind | null,
task: null as RoutineTaskDto | null,
note: '',
date: '',
time: '',
saving: false,
error: '',
});
async function run(task: RoutineTaskDto, path: string, body: object = {}) {
busyId.value = task.id;
error.value = '';
try {
const { data } = await api.post<RoutineTaskDto>(`/routine-tasks/${task.id}/${path}`, body);
onChanged(data);
return data;
} catch (e) {
error.value = apiError(e, 'انجام عملیات ممکن نشد.');
return null;
} finally {
busyId.value = null;
}
}
function open(kind: RoutineTaskModalKind, task: RoutineTaskDto) {
const due = isoToLocalInput(task.dueAt);
Object.assign(modal, {
kind,
task,
note: kind === 'complete' ? (task.resultNote ?? '') : '',
date: kind === 'reschedule' ? due.date || todayKey() : '',
time: kind === 'reschedule' ? (due.time === '23:59' ? '' : due.time) : '',
saving: false,
error: '',
});
}
function close() {
modal.kind = null;
modal.task = null;
}
/** تیک یک‌کلیکی؛ اگر گزارش اجباری باشد مودال نتیجه باز می‌شود */
async function toggle(task: RoutineTaskDto) {
if (busyId.value) return;
if (task.status === 'completed') return run(task, 'uncomplete');
if (task.status === 'cancelled' || task.status === 'skipped') return null;
if (task.rules.reportRequired) {
open('complete', task);
return null;
}
return run(task, 'complete');
}
async function submit() {
const task = modal.task;
if (!task || !modal.kind) return;
modal.error = '';
let path = '';
let body: object = {};
if (modal.kind === 'complete') {
if (task.rules.reportRequired && !modal.note.trim()) {
modal.error = 'ثبت گزارش برای این کار الزامی است.';
return;
}
path = 'complete';
body = { note: modal.note.trim() || null };
} else if (modal.kind === 'skip') {
if (task.rules.skipReasonRequired && !modal.note.trim()) {
modal.error = 'ثبت دلیل رد کردن الزامی است.';
return;
}
path = 'skip';
body = { reason: modal.note.trim() || null };
} else if (modal.kind === 'cancel') {
path = 'cancel';
body = { reason: modal.note.trim() || null };
} else {
const dueAt = localInputToIso(modal.date, modal.time);
if (!dueAt) {
modal.error = 'تاریخ جدید را انتخاب کنید.';
return;
}
path = 'reschedule';
body = { dueAt };
}
modal.saving = true;
try {
const { data } = await api.post<RoutineTaskDto>(`/routine-tasks/${task.id}/${path}`, body);
onChanged(data);
close();
} catch (e) {
modal.error = apiError(e, 'ذخیره ممکن نشد.');
} finally {
modal.saving = false;
}
}
return {
busyId,
error,
modal,
toggle,
start: (task: RoutineTaskDto) => run(task, 'start'),
moveToday: (task: RoutineTaskDto) => run(task, 'move-today'),
openComplete: (task: RoutineTaskDto) => open('complete', task),
openSkip: (task: RoutineTaskDto) => open('skip', task),
openReschedule: (task: RoutineTaskDto) => open('reschedule', task),
openCancel: (task: RoutineTaskDto) => open('cancel', task),
close,
submit,
};
}
export type RoutineTaskActions = ReturnType<typeof useRoutineTaskActions>;
+56
View File
@@ -132,6 +132,62 @@ export const router = createRouter({
name: 'activity-detail',
component: () => import('@/views/ActivityDetailView.vue'),
},
{
path: 'routines',
component: () => import('@/views/routines/RoutinesShellView.vue'),
children: [
{
path: '',
name: 'routines-today',
component: () => import('@/views/routines/RoutinesTodayView.vue'),
},
{
path: 'overdue',
name: 'routines-overdue',
component: () => import('@/views/routines/RoutinesOverdueView.vue'),
},
{
path: 'calendar',
name: 'routines-calendar',
component: () => import('@/views/routines/RoutinesCalendarView.vue'),
},
{
path: 'list',
name: 'routines-list',
component: () => import('@/views/routines/RoutinesListView.vue'),
},
{
path: 'me',
name: 'routines-me',
component: () => import('@/views/routines/RoutinesMyPerformanceView.vue'),
},
{
path: 'team',
name: 'routines-team',
component: () => import('@/views/routines/RoutinesTeamView.vue'),
},
{
path: 'manage',
name: 'routines-manage',
component: () => import('@/views/routines/RoutinesManageView.vue'),
},
{
path: 'manage/new',
name: 'routine-new',
component: () => import('@/views/routines/RoutineFormView.vue'),
},
{
path: 'manage/:id/edit',
name: 'routine-edit',
component: () => import('@/views/routines/RoutineFormView.vue'),
},
{
path: 'settings',
name: 'routines-settings',
component: () => import('@/views/routines/RoutinesSettingsView.vue'),
},
],
},
{
path: 'quotes',
name: 'quotes',
+26
View File
@@ -223,6 +223,29 @@ async function remove() {
}
}
const routinesEnabled = computed(() => {
const mods = auth.user?.enabledModules;
return !mods || mods.includes('routines');
});
const converting = ref(false);
/** پیگیری → Task در «کارهای روتین» (تکراری نمی‌سازد) */
async function convertToTask() {
if (!id.value || converting.value) return;
converting.value = true;
error.value = '';
try {
const { data } = await api.post<{ id: string }>(`/routine-tasks/from-activity/${id.value}`);
moreOpen.value = false;
void router.push({ path: '/routines', query: { task: data.id } });
} catch (e) {
const msg = (e as { response?: { data?: { message?: string | string[] } } }).response?.data?.message;
error.value = Array.isArray(msg) ? msg.join('، ') : (msg ?? 'تبدیل به Task ممکن نشد.');
} finally {
converting.value = false;
}
}
watch(id, () => {
commentOpen.value = false;
postponeOpen.value = false;
@@ -247,6 +270,9 @@ onMounted(() => {
</button>
<div v-if="moreOpen && activity" class="menu">
<RouterLink :to="{ name: 'activity-edit', params: { id } }">ویرایش</RouterLink>
<button v-if="routinesEnabled" type="button" :disabled="converting" @click="convertToTask">
تبدیل به Task
</button>
<button type="button" class="danger" @click="remove">حذف</button>
</div>
</div>
+2
View File
@@ -6,6 +6,7 @@ import { api } from '@/lib/api';
import type { CompanyDto, CreateCompanyRequest } from '@mento/shared';
import CustomFieldsSection from '@/components/CustomFieldsSection.vue';
import EntityTimeline from '@/components/EntityTimeline.vue';
import RelatedTasksPanel from '@/components/routines/RelatedTasksPanel.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { formatLocalDateInput, parseLocalInput } from '@/lib/jalali';
import {
@@ -347,6 +348,7 @@ onMounted(load);
</UiCard>
<CustomFieldsSection entity-type="company" v-model="customFields" />
<RelatedTasksPanel v-if="!isNew && id" :entity="{ type: 'company', id, label: name }" />
<EntityTimeline v-if="!isNew && id" entity-type="company" :entity-id="id" />
<div class="form-bottom-actions">
+8
View File
@@ -8,6 +8,7 @@ import { toFaDigits } from '@/lib/jalali';
import { currencySuffix, formatMoney } from '@/lib/money';
import CustomFieldsSection from '@/components/CustomFieldsSection.vue';
import EntityTimeline from '@/components/EntityTimeline.vue';
import RelatedTasksPanel from '@/components/routines/RelatedTasksPanel.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import MoneyInput from '@/components/MoneyInput.vue';
import UiIcon from '@/components/UiIcon.vue';
@@ -813,6 +814,13 @@ onMounted(load);
/>
</div>
<RelatedTasksPanel
v-if="!isNew && id"
class="timeline"
:class="{ hide: tab !== 'main' }"
:entity="{ type: 'deal', id, label: title }"
/>
<EntityTimeline
v-if="!isNew && id"
class="timeline"
+3
View File
@@ -15,6 +15,7 @@ import {
UiPageHeader,
UiSelect,
} from '@/components/ui';
import RelatedTasksPanel from '@/components/routines/RelatedTasksPanel.vue';
import type {
ConvertLeadResult,
LeadDto,
@@ -391,6 +392,8 @@ onMounted(load);
</dl>
</UiCard>
<RelatedTasksPanel :entity="{ type: 'lead', id: lead.id, label: lead.title }" />
<UiCard title="جدول زمانی">
<ul v-if="lead.events.length" class="timeline">
<li v-for="ev in lead.events" :key="ev.id">
+8 -1
View File
@@ -24,6 +24,7 @@ import ProfileEmptyState from '@/components/person-profile/ProfileEmptyState.vue
import ProfileMoreMenu from '@/components/person-profile/ProfileMoreMenu.vue';
import PersonAssetsPanel from '@/components/person-assets/PersonAssetsPanel.vue';
import InternalPartyBalance from '@/components/person-assets/InternalPartyBalance.vue';
import RelatedTasksPanel from '@/components/routines/RelatedTasksPanel.vue';
import type { ProfileMoreItem } from '@/components/person-profile/ProfileMoreMenu.vue';
import type {
ActivityDto,
@@ -44,7 +45,7 @@ import type {
} from '@mento/shared';
import { PERSON_MOOD_LABELS } from '@/lib/person-mood';
type TabKey = 'activities' | 'deals' | 'cards' | 'assets' | 'info';
type TabKey = 'activities' | 'tasks' | 'deals' | 'cards' | 'assets' | 'info';
type TimelineTone = 'call' | 'quote' | 'note' | 'meeting' | 'task';
type HistoryFilter = 'all' | TimelineTone;
@@ -67,6 +68,7 @@ type AttachmentRow = {
const TABS: Array<{ key: TabKey; label: string }> = [
{ key: 'activities', label: 'فعالیت‌ها' },
{ key: 'tasks', label: 'کارهای مرتبط' },
{ key: 'deals', label: 'معاملات' },
{ key: 'cards', label: 'کارت‌ها' },
{ key: 'assets', label: 'دارایی' },
@@ -389,6 +391,7 @@ function readTab() {
const q = route.query.tab;
if (
q === 'activities' ||
q === 'tasks' ||
q === 'deals' ||
q === 'cards' ||
q === 'assets' ||
@@ -928,6 +931,10 @@ watch(() => route.query.tab, readTab, { immediate: true });
</div>
</div>
<div v-if="tab === 'tasks'" class="panel">
<RelatedTasksPanel :entity="{ type: 'person', id, label: fullName }" />
</div>
<div v-show="tab === 'assets'" class="panel assets-panel">
<InternalPartyBalance :person-id="id" />
<PersonAssetsPanel :person-id="id" @link-changed="onTahesabLinkChanged" />
+6 -2
View File
@@ -1,6 +1,10 @@
<script setup lang="ts">
import type { BillingAccountDto, BillingPlanCode } from '@mento/shared';
import { BILLING_PLAN_CODES, BILLING_PLAN_LABELS } from '@mento/shared';
import {
BILLING_PLAN_CODES,
BILLING_PLAN_LABELS,
isPermanentBillingPlan,
} from '@mento/shared';
import { toFaDigits } from '@/lib/jalali';
defineProps<{
@@ -20,7 +24,7 @@ const emit = defineEmits<{
<p v-if="billing" class="muted">
{{ BILLING_PLAN_LABELS[billing.planCode] }}
· {{ billing.status }}
· {{ toFaDigits(billing.daysRemaining) }} روز
· {{ isPermanentBillingPlan(billing.planCode) ? 'بدون انقضا' : `${toFaDigits(billing.daysRemaining)} روز` }}
· سقف محیط {{ toFaDigits(billing.maxOrganizations) }}
</p>
<p v-else class="muted">هنوز پلن ندارد — یکی از پلن‌ها را اعمال کنید.</p>
@@ -20,6 +20,7 @@ import {
USER_ROLE_LABELS,
USER_ROLES,
isBusinessField,
isPermanentBillingPlan,
} from '@mento/shared';
import { platformApi } from '@/lib/platform-api';
import { BUSINESS_FIELDS, BUSINESS_FIELD_LABELS } from '@/lib/business-fields';
@@ -89,6 +90,27 @@ function resetCreateForm() {
createForm.ownerMobile = '';
}
function subscriptionBadge(org: PlatformOrganizationDto): {
label: string;
tone: 'ok' | 'warn' | 'expired' | 'none';
} {
const sub = org.subscription;
if (!sub) return { label: 'بدون اشتراک', tone: 'none' };
const plan = BILLING_PLAN_LABELS[sub.planCode] ?? sub.planCode;
if (sub.status === 'suspended') return { label: `${plan} · معلق`, tone: 'expired' };
if (isPermanentBillingPlan(sub.planCode)) return { label: plan, tone: 'ok' };
if (sub.status === 'expired' || sub.daysRemaining <= 0) {
return {
label: `${plan} · منقضی شده (${formatJalaliNumeric(new Date(sub.expiresAt))})`,
tone: 'expired',
};
}
return {
label: `${plan} · ${toFaDigits(sub.daysRemaining)} روز مانده`,
tone: sub.daysRemaining <= 3 ? 'warn' : 'ok',
};
}
async function load() {
loading.value = true;
error.value = '';
@@ -356,6 +378,11 @@ async function applyPlan(userId: string, planCode: BillingPlanCode) {
);
detail.value = data;
}
const { data: list } = await platformApi.get<PlatformOrganizationDto[]>(
'/platform/organizations',
{ params: query.value.trim() ? { q: query.value.trim() } : undefined },
);
items.value = list;
success.value = `پلن «${BILLING_PLAN_LABELS[planCode]}» اعمال شد`;
} catch (e) {
error.value = apiError(e, 'اعمال پلن ناموفق بود');
@@ -438,6 +465,9 @@ onMounted(() => {
</div>
<div class="meta">
<em :class="org.isActive ? 'on' : 'off'">{{ org.isActive ? 'فعال' : 'غیرفعال' }}</em>
<b class="sub-badge" :class="subscriptionBadge(org).tone">
{{ subscriptionBadge(org).label }}
</b>
<small>
{{ toFaDigits(org.memberCount) }} عضو ·
{{ toFaDigits(org.personCount) }} شخص ·
@@ -779,6 +809,40 @@ label span {
text-align: end;
}
.sub-badge {
display: inline-block;
margin-inline-start: 0.4rem;
padding: 0.12rem 0.5rem;
border-radius: 999px;
font-size: 0.74rem;
font-weight: 700;
border: 1px solid transparent;
}
.sub-badge.ok {
color: var(--success-deep);
background: #ecfdf5;
border-color: #a7f3d0;
}
.sub-badge.warn {
color: #b45309;
background: #fffbeb;
border-color: #fde68a;
}
.sub-badge.expired {
color: var(--danger);
background: #fef2f2;
border-color: #fecaca;
}
.sub-badge.none {
color: var(--muted);
background: #f1f5f9;
border-color: var(--line);
}
.actions {
display: flex;
gap: 0.45rem;
@@ -0,0 +1,530 @@
<script setup lang="ts">
import { computed, inject, onMounted, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import type { ActivityPriority, RoutineDto, RoutineEntityLink, SaveRoutineRequest } from '@mento/shared';
import { ROUTINE_ENTITY_LINK_LABELS, ROUTINE_WEEKDAY_LABELS } from '@mento/shared';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import RecurrencePicker, { type RecurrenceValue } from '@/components/routines/RecurrencePicker.vue';
import {
UiButton,
UiCard,
UiField,
UiInput,
UiPageHeader,
UiSegmented,
UiSelect,
UiTextarea,
} from '@/components/ui';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import { PRIORITY_LABELS, ROUTINES_CTX, apiError, formatDayNumeric, todayKey } from '@/lib/routines';
const shell = inject(ROUTINES_CTX)!;
const route = useRoute();
const router = useRouter();
const id = computed(() => (typeof route.params.id === 'string' ? route.params.id : null));
const isEdit = computed(() => !!id.value);
const form = reactive({
title: '',
description: '',
target: 'user' as 'user' | 'team',
assigneeId: '',
teamId: '',
categoryId: '',
priority: 'medium' as ActivityPriority,
timeOfDay: '09:00',
durationMinutes: '' as string | number,
startDate: todayKey(),
endDate: '',
isMandatory: true,
allowSkip: false,
skipReasonRequired: true,
reportRequired: false,
allowAfterDeadline: true,
carryOverOverdue: false,
entityLink: 'none' as RoutineEntityLink,
reminderMinutesBefore: '' as string,
isActive: true,
});
const recurrence = ref<RecurrenceValue>({
recurrence: 'daily',
weekdays: [],
intervalDays: null,
monthDay: null,
specificDates: [],
});
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const targetOptions = [
{ value: 'user' as const, label: 'یک کاربر' },
{ value: 'team' as const, label: 'همه اعضای یک تیم' },
];
const priorities = Object.entries(PRIORITY_LABELS) as Array<[ActivityPriority, string]>;
const entityLinks = Object.entries(ROUTINE_ENTITY_LINK_LABELS) as Array<[RoutineEntityLink, string]>;
const reminderOptions = [
{ value: '', label: 'طبق تنظیمات هر کاربر' },
{ value: '0', label: 'سر ساعت' },
{ value: '10', label: '۱۰ دقیقه قبل' },
{ value: '15', label: '۱۵ دقیقه قبل' },
{ value: '30', label: '۳۰ دقیقه قبل' },
{ value: '60', label: '۱ ساعت قبل' },
{ value: '120', label: '۲ ساعت قبل' },
];
function fill(r: RoutineDto) {
Object.assign(form, {
title: r.title,
description: r.description ?? '',
target: r.team ? 'team' : 'user',
assigneeId: r.assignee?.id ?? '',
teamId: r.team?.id ?? '',
categoryId: r.category?.id ?? '',
priority: r.priority,
timeOfDay: r.timeOfDay,
durationMinutes: r.durationMinutes ?? '',
startDate: r.startDate,
endDate: r.endDate ?? '',
isMandatory: r.isMandatory,
allowSkip: r.allowSkip,
skipReasonRequired: r.skipReasonRequired,
reportRequired: r.reportRequired,
allowAfterDeadline: r.allowAfterDeadline,
carryOverOverdue: r.carryOverOverdue,
entityLink: r.entityLink,
reminderMinutesBefore: r.reminderMinutesBefore == null ? '' : String(r.reminderMinutesBefore),
isActive: r.isActive,
});
recurrence.value = {
recurrence: r.recurrence,
weekdays: [...r.weekdays],
intervalDays: r.intervalDays,
monthDay: r.monthDay,
specificDates: [...r.specificDates],
};
}
onMounted(async () => {
if (!id.value) return;
loading.value = true;
try {
const { data } = await api.get<RoutineDto>(`/routines/${id.value}`);
fill(data);
} catch (e) {
error.value = apiError(e, 'روتین پیدا نشد.');
} finally {
loading.value = false;
}
});
const users = computed(() => shell.context.value?.users ?? []);
const teams = computed(() => shell.context.value?.teams ?? []);
const categories = computed(() => shell.context.value?.categories ?? []);
function joinFa(items: string[]) {
if (items.length <= 1) return items.join('');
return `${items.slice(0, -1).join('، ')} و ${items[items.length - 1]}`;
}
const summary = computed(() => {
const r = recurrence.value;
const days = r.weekdays.map((d) => ROUTINE_WEEKDAY_LABELS[d]!);
let rec = '';
switch (r.recurrence) {
case 'daily':
rec = 'هر روز';
break;
case 'weekdays':
case 'weekly':
rec = days.length ? `هر ${joinFa(days)}` : 'روز هفته انتخاب نشده';
break;
case 'biweekly':
rec = days.length ? `یک هفته در میان، ${joinFa(days)}` : 'روز هفته انتخاب نشده';
break;
case 'monthly':
rec = r.monthDay === 1 ? 'اول هر ماه' : `روز ${toFaDigits(r.monthDay ?? 1)} هر ماه`;
break;
case 'every_n_days':
rec = `هر ${toFaDigits(r.intervalDays ?? 1)} روز یک‌بار`;
break;
case 'specific_dates':
rec = `${toFaDigits(r.specificDates.length)} تاریخ مشخص`;
break;
}
const who =
form.target === 'team'
? teams.value.find((t) => t.id === form.teamId)?.name
? `برای همه اعضای تیم «${teams.value.find((t) => t.id === form.teamId)?.name}»`
: ''
: users.value.find((u) => u.id === form.assigneeId)?.fullName
? `برای ${users.value.find((u) => u.id === form.assigneeId)?.fullName}`
: '';
return `${rec}، ساعت ${toFaDigits(form.timeOfDay || '—')}${who ? ` ${who}` : ''} · از ${formatDayNumeric(form.startDate)}${form.endDate ? ` تا ${formatDayNumeric(form.endDate)}` : ''}`;
});
function validate(): string | null {
if (form.title.trim().length < 2) return 'عنوان روتین را وارد کنید.';
if (form.target === 'user' && !form.assigneeId) return 'مسئول روتین را انتخاب کنید.';
if (form.target === 'team' && !form.teamId) return 'تیم را انتخاب کنید.';
if (!/^\d{2}:\d{2}$/.test(form.timeOfDay)) return 'ساعت انجام را وارد کنید.';
if (!form.startDate) return 'تاریخ شروع را انتخاب کنید.';
if (form.endDate && form.endDate < form.startDate) return 'تاریخ پایان نباید قبل از تاریخ شروع باشد.';
const r = recurrence.value;
if (['weekdays', 'weekly', 'biweekly'].includes(r.recurrence) && !r.weekdays.length) {
return 'حداقل یک روز هفته را انتخاب کنید.';
}
if (r.recurrence === 'every_n_days' && !r.intervalDays) return 'فاصله تکرار را وارد کنید.';
if (r.recurrence === 'specific_dates' && !r.specificDates.length) return 'حداقل یک تاریخ اضافه کنید.';
return null;
}
async function save() {
error.value = validate() ?? '';
if (error.value) return;
const r = recurrence.value;
const duration = Number(form.durationMinutes);
const body: SaveRoutineRequest = {
title: form.title.trim(),
description: form.description.trim() || null,
assigneeId: form.target === 'user' ? form.assigneeId : null,
teamId: form.target === 'team' ? form.teamId : null,
categoryId: form.categoryId || null,
priority: form.priority,
timeOfDay: form.timeOfDay,
durationMinutes: duration > 0 ? Math.floor(duration) : null,
startDate: form.startDate,
endDate: form.endDate || null,
recurrence: r.recurrence,
weekdays: ['weekdays', 'weekly', 'biweekly'].includes(r.recurrence) ? r.weekdays : [],
intervalDays: r.recurrence === 'every_n_days' ? r.intervalDays : null,
monthDay: r.recurrence === 'monthly' ? r.monthDay : null,
specificDates: r.recurrence === 'specific_dates' ? r.specificDates : [],
isMandatory: form.isMandatory,
allowSkip: form.allowSkip,
skipReasonRequired: form.allowSkip && form.skipReasonRequired,
reportRequired: form.reportRequired,
allowAfterDeadline: form.allowAfterDeadline,
carryOverOverdue: form.carryOverOverdue,
entityLink: form.entityLink,
reminderMinutesBefore: form.reminderMinutesBefore === '' ? null : Number(form.reminderMinutesBefore),
isActive: form.isActive,
};
saving.value = true;
try {
if (isEdit.value) await api.patch(`/routines/${id.value}`, body);
else await api.post('/routines', body);
shell.notifyChanged();
void router.push('/routines/manage');
} catch (e) {
error.value = apiError(e, 'ذخیره روتین ممکن نشد.');
} finally {
saving.value = false;
}
}
</script>
<template>
<div class="rform">
<UiPageHeader
:title="isEdit ? 'ویرایش روتین' : 'روتین جدید'"
lead="کاری که باید به‌صورت منظم انجام شود؛ هر روزِ تکرار، یک کار برای مسئول ساخته می‌شود."
back-to="/routines/manage"
/>
<div v-if="loading" class="state">در حال بارگذاری…</div>
<form v-else class="grid" @submit.prevent="save">
<div class="col">
<UiCard>
<h3 class="sec-title">اطلاعات پایه</h3>
<div class="fields">
<UiField label="عنوان" required>
<UiInput v-model="form.title" placeholder="مثلاً: تماس با لیدهای جدید" :maxlength="200" />
</UiField>
<UiField label="توضیحات">
<UiTextarea v-model="form.description" :rows="3" placeholder="جزئیات یا دستورالعمل انجام کار" />
</UiField>
<div class="grid-2">
<UiField label="دسته‌بندی">
<UiSelect v-model="form.categoryId">
<option value="">بدون دسته</option>
<option v-for="c in categories" :key="c.id" :value="c.id">{{ c.name }}</option>
</UiSelect>
</UiField>
<UiField label="اولویت">
<UiSelect v-model="form.priority">
<option v-for="[k, label] in priorities" :key="k" :value="k">{{ label }}</option>
</UiSelect>
</UiField>
</div>
</div>
</UiCard>
<UiCard>
<h3 class="sec-title">مسئول</h3>
<div class="fields">
<UiSegmented v-model="form.target" :options="targetOptions" />
<UiField v-if="form.target === 'user'" label="کاربر" required>
<UiSelect v-model="form.assigneeId">
<option value="" disabled>انتخاب کنید</option>
<option v-for="u in users" :key="u.id" :value="u.id">{{ u.fullName }}</option>
</UiSelect>
</UiField>
<UiField v-else label="تیم" required hint="برای هر عضو فعال تیم یک کار جداگانه ساخته می‌شود.">
<UiSelect v-model="form.teamId">
<option value="" disabled>انتخاب کنید</option>
<option v-for="t in teams" :key="t.id" :value="t.id">{{ t.name }}</option>
</UiSelect>
</UiField>
</div>
</UiCard>
<UiCard>
<h3 class="sec-title">زمان‌بندی و تکرار</h3>
<div class="fields">
<div class="grid-2">
<UiField label="ساعت انجام" required>
<JalaliDateInput v-model="form.timeOfDay" mode="time" :clearable="false" />
</UiField>
<UiField label="مدت تقریبی (دقیقه)">
<UiInput v-model="form.durationMinutes" type="number" placeholder="اختیاری" />
</UiField>
<UiField label="تاریخ شروع" required>
<JalaliDateInput v-model="form.startDate" mode="date" :clearable="false" />
</UiField>
<UiField label="تاریخ پایان" hint="خالی = بدون پایان">
<JalaliDateInput v-model="form.endDate" mode="date" />
</UiField>
</div>
<UiField label="الگوی تکرار" required>
<RecurrencePicker v-model="recurrence" />
</UiField>
</div>
</UiCard>
</div>
<div class="col side">
<UiCard>
<h3 class="sec-title">قوانین انجام</h3>
<div class="toggles">
<label class="tg">
<input v-model="form.isMandatory" type="checkbox" />
<span>
<strong>اجباری</strong>
<small>در امتیاز روزانه و روزهای پیاپی کامل حساب می‌شود.</small>
</span>
</label>
<label class="tg">
<input v-model="form.allowSkip" type="checkbox" />
<span>
<strong>امکان رد کردن</strong>
<small>کاربر می‌تواند این کار را برای یک روز رد کند.</small>
</span>
</label>
<label v-if="form.allowSkip" class="tg nested">
<input v-model="form.skipReasonRequired" type="checkbox" />
<span><strong>ثبت دلیل رد کردن الزامی باشد</strong></span>
</label>
<label class="tg">
<input v-model="form.reportRequired" type="checkbox" />
<span>
<strong>گزارش نتیجه الزامی</strong>
<small>قبل از انجام‌شدن، کاربر باید نتیجه را بنویسد.</small>
</span>
</label>
<label class="tg">
<input v-model="form.allowAfterDeadline" type="checkbox" />
<span>
<strong>انجام بعد از مهلت مجاز باشد</strong>
<small>اگر خاموش باشد، کار عقب‌افتاده فقط توسط مدیر بسته می‌شود.</small>
</span>
</label>
<label class="tg">
<input v-model="form.carryOverOverdue" type="checkbox" />
<span>
<strong>انتقال به روز بعد</strong>
<small>کار انجام‌نشده در فهرست «کارهای امروز» روز بعد هم نمایش داده می‌شود.</small>
</span>
</label>
</div>
<UiField label="اتصال به پرونده" class="mt">
<UiSelect v-model="form.entityLink">
<option v-for="[k, label] in entityLinks" :key="k" :value="k">{{ label }}</option>
</UiSelect>
</UiField>
</UiCard>
<UiCard>
<h3 class="sec-title">یادآوری و وضعیت</h3>
<div class="fields">
<UiField label="یادآوری">
<UiSelect v-model="form.reminderMinutesBefore">
<option v-for="o in reminderOptions" :key="o.value" :value="o.value">{{ o.label }}</option>
</UiSelect>
</UiField>
<label class="tg">
<input v-model="form.isActive" type="checkbox" />
<span><strong>روتین فعال است</strong></span>
</label>
</div>
</UiCard>
<div class="summary">
<span>خلاصه</span>
<p>{{ summary }}</p>
</div>
<p v-if="error" class="error">{{ error }}</p>
<div class="actions">
<UiButton type="submit" :loading="saving" block>{{ isEdit ? 'ذخیره تغییرات' : 'ایجاد روتین' }}</UiButton>
<UiButton variant="ghost" block @click="router.push('/routines/manage')">انصراف</UiButton>
</div>
</div>
</form>
</div>
</template>
<style scoped>
.rform {
display: grid;
gap: var(--sp-4);
}
.state {
padding: var(--sp-8);
text-align: center;
color: var(--muted);
}
.grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 360px;
gap: var(--sp-4);
align-items: start;
}
.col {
display: grid;
gap: var(--sp-4);
min-width: 0;
}
.side {
position: sticky;
top: var(--sp-4);
}
.sec-title {
margin: 0 0 var(--sp-4);
font-size: var(--fs-md);
font-weight: var(--fw-semibold);
color: var(--ink);
}
.fields {
display: grid;
gap: var(--sp-4);
}
.grid-2 {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--sp-3);
}
.toggles {
display: grid;
gap: var(--sp-3);
}
.tg {
display: flex;
align-items: flex-start;
gap: var(--sp-2);
cursor: pointer;
}
.tg.nested {
margin-inline-start: 26px;
}
.tg input {
width: 18px;
height: 18px;
margin-top: 2px;
accent-color: var(--accent);
flex-shrink: 0;
}
.tg span {
display: grid;
gap: 2px;
}
.tg strong {
font-size: var(--fs-sm);
font-weight: var(--fw-medium);
color: var(--ink);
}
.tg small {
font-size: var(--fs-xs);
color: var(--muted);
line-height: 1.6;
}
.mt {
margin-top: var(--sp-4);
}
.summary {
padding: var(--sp-3) var(--sp-4);
border-radius: var(--radius);
background: var(--accent-subtle);
border: 1px solid color-mix(in srgb, var(--accent) 25%, transparent);
}
.summary span {
font-size: var(--fs-xs);
color: var(--accent-deep);
font-weight: var(--fw-semibold);
}
.summary p {
margin: 4px 0 0;
font-size: var(--fs-sm);
color: var(--ink);
line-height: 1.8;
}
.actions {
display: grid;
gap: var(--sp-2);
}
.error {
margin: 0;
}
@media (max-width: 1100px) {
.grid {
grid-template-columns: 1fr;
}
.side {
position: static;
}
}
@media (max-width: 600px) {
.grid-2 {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,553 @@
<script setup lang="ts">
import { computed, inject, onMounted, ref, watch } from 'vue';
import type { RoutineCalendarDayDto, RoutineCalendarDto, RoutineTaskDto } from '@mento/shared';
import { ROUTINE_WEEKDAY_LABELS } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import RoutineMonthGrid from '@/components/routines/RoutineMonthGrid.vue';
import RoutineTaskRow, { type RoutineTaskRowAction } from '@/components/routines/RoutineTaskRow.vue';
import RoutineTaskModals from '@/components/routines/RoutineTaskModals.vue';
import { UiButton, UiEmptyState, UiSegmented, UiSelect } from '@/components/ui';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import {
DAY_STATE_LABELS,
ROUTINES_CTX,
addDaysKey,
apiError,
formatDayLong,
formatDayNumeric,
formatPercent,
formatTime,
hasExplicitTime,
jalaliMonthRange,
shiftJalaliMonth,
todayKey,
weekStart,
} from '@/lib/routines';
import { useRoutineTaskActions } from '@/lib/useRoutineTaskActions';
type View = 'day' | 'week' | 'month';
const shell = inject(ROUTINES_CTX)!;
const view = ref<View>('month');
const anchor = ref(todayKey());
const selected = ref(todayKey());
const userId = ref('');
const cal = ref<RoutineCalendarDto>({ days: [], tasks: [] });
const loading = ref(true);
const error = ref('');
const viewOptions = [
{ value: 'day' as const, label: 'روز' },
{ value: 'week' as const, label: 'هفته' },
{ value: 'month' as const, label: 'ماه' },
];
const range = computed(() => {
if (view.value === 'day') return { from: anchor.value, to: anchor.value, label: formatDayLong(anchor.value) };
if (view.value === 'week') {
const from = weekStart(anchor.value);
const to = addDaysKey(from, 6);
return { from, to, label: `${formatDayNumeric(from)} تا ${formatDayNumeric(to)}` };
}
return jalaliMonthRange(anchor.value);
});
async function load(silent = false) {
if (!silent) loading.value = true;
error.value = '';
try {
const { data } = await api.get<RoutineCalendarDto>('/routine-tasks/calendar', {
params: {
from: range.value.from,
to: range.value.to,
...(userId.value ? { userId: userId.value } : {}),
},
});
cal.value = data;
} catch (e) {
error.value = apiError(e, 'بارگذاری تقویم ممکن نشد.');
} finally {
loading.value = false;
}
}
watch(view, (v) => {
if (v !== 'month') anchor.value = selected.value;
});
onMounted(() => load());
watch([() => range.value.from, () => range.value.to, userId], () => load());
watch(shell.refreshTick, () => load(true));
function move(delta: number) {
if (view.value === 'day') anchor.value = addDaysKey(anchor.value, delta);
else if (view.value === 'week') anchor.value = addDaysKey(anchor.value, delta * 7);
else anchor.value = shiftJalaliMonth(anchor.value, delta);
if (view.value === 'day') selected.value = anchor.value;
}
function goToday() {
anchor.value = todayKey();
selected.value = todayKey();
}
function selectDay(day: string) {
selected.value = day;
}
function openDay(day: string) {
anchor.value = day;
selected.value = day;
view.value = 'day';
}
const tasksByDay = computed(() => {
const map = new Map<string, RoutineTaskDto[]>();
for (const t of cal.value.tasks) {
const list = map.get(t.scheduledFor) ?? [];
list.push(t);
map.set(t.scheduledFor, list);
}
return map;
});
const dayMap = computed(() => new Map(cal.value.days.map((d) => [d.date, d])));
const weekDays = computed(() => {
const out: Array<{ key: string; day?: RoutineCalendarDayDto; tasks: RoutineTaskDto[] }> = [];
for (let i = 0; i < 7; i++) {
const key = addDaysKey(range.value.from, i);
out.push({ key, day: dayMap.value.get(key), tasks: tasksByDay.value.get(key) ?? [] });
}
return out;
});
const focusDay = computed(() => (view.value === 'day' ? anchor.value : selected.value));
const focusTasks = computed(() => tasksByDay.value.get(focusDay.value) ?? []);
const focusStat = computed(() => dayMap.value.get(focusDay.value));
const actions = useRoutineTaskActions((updated) => {
const i = cal.value.tasks.findIndex((t) => t.id === updated.id);
if (i >= 0) cal.value.tasks[i] = 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 users = computed(() => shell.context.value?.users ?? []);
const isManager = computed(() => !!shell.context.value?.isManager);
const today = todayKey();
</script>
<template>
<div class="calendar">
<div class="cal-toolbar">
<div class="nav">
<UiButton variant="icon" aria-label="قبلی" @click="move(-1)">
<UiIcon name="chevron-right" :size="18" />
</UiButton>
<strong class="range-label">{{ range.label }}</strong>
<UiButton variant="icon" aria-label="بعدی" @click="move(1)">
<UiIcon name="chevron-left" :size="18" />
</UiButton>
<UiButton variant="ghost" size="sm" @click="goToday">امروز</UiButton>
</div>
<div class="tools">
<UiSelect v-if="isManager && users.length > 1" v-model="userId" size="sm" class="who">
<option value="">تقویم خودم</option>
<option v-for="u in users" :key="u.id" :value="u.id">{{ u.fullName }}</option>
</UiSelect>
<UiSegmented v-model="view" :options="viewOptions" />
</div>
</div>
<div class="legend">
<span><i class="lg complete" /> روز کامل</span>
<span><i class="lg partial" /> بخشی انجام شده</span>
<span><i class="lg missed" /> انجام نشده</span>
<span><i class="lg empty" /> بدون کار</span>
</div>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="actions.error.value" class="error">{{ actions.error.value }}</p>
<div class="cal-body" :class="{ dim: loading }">
<!-- ماه -->
<div v-if="view === 'month'" class="month-layout">
<RoutineMonthGrid
:from="range.from"
:to="range.to"
:days="cal.days"
:selected="selected"
@select="selectDay"
/>
</div>
<!-- هفته -->
<div v-else-if="view === 'week'" class="week">
<div
v-for="(d, i) in weekDays"
:key="d.key"
class="week-col"
:class="[`s-${d.day?.state ?? 'empty'}`, { today: d.key === today, sel: d.key === selected }]"
>
<button type="button" class="week-head" @click="openDay(d.key)">
<span>{{ ROUTINE_WEEKDAY_LABELS[i] }}</span>
<strong>{{ formatDayNumeric(d.key) }}</strong>
<em v-if="d.day?.total">{{ toFaDigits(d.day.completed) }}/{{ toFaDigits(d.day.total) }}</em>
</button>
<ul class="week-tasks">
<li
v-for="t in d.tasks"
:key="t.id"
:class="`st-${t.status}`"
@click="shell.openTask(t.id)"
>
<span class="wt-dot" />
<span class="wt-title">{{ t.title }}</span>
<small v-if="hasExplicitTime(t)">{{ formatTime(t.dueAt) }}</small>
</li>
<li v-if="!d.tasks.length" class="none">—</li>
</ul>
</div>
</div>
</div>
<!-- جزئیات روز انتخاب‌شده / نمای روز -->
<section v-if="view !== 'week'" class="day-panel">
<header>
<div>
<h3>{{ formatDayLong(focusDay) }}</h3>
<p v-if="focusStat?.total" class="day-sub">
<span :class="`chip s-${focusStat.state}`">{{ DAY_STATE_LABELS[focusStat.state] }}</span>
{{ toFaDigits(focusStat.completed) }} از {{ toFaDigits(focusStat.total) }} انجام شده
· {{ formatPercent(focusStat.percent) }}
</p>
</div>
<UiButton v-if="view === 'month'" variant="ghost" size="sm" @click="openDay(focusDay)">نمای روز</UiButton>
</header>
<div v-if="focusTasks.length" class="rows">
<RoutineTaskRow
v-for="t in focusTasks"
:key="t.id"
:task="t"
:busy="actions.busyId.value === t.id"
:show-assignee="!!userId"
@toggle="actions.toggle"
@open="shell.openTask(t.id)"
@action="onAction"
/>
</div>
<UiEmptyState
v-else
icon="calendar"
title="کاری برای این روز نیست"
:description="focusDay > today ? 'کارهای روتین روزهای آینده صبح همان روز ساخته می‌شوند.' : undefined"
/>
</section>
<RoutineTaskModals :actions="actions" />
</div>
</template>
<style scoped>
.calendar {
display: grid;
gap: var(--sp-4);
}
.cal-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--sp-3);
}
.nav {
display: flex;
align-items: center;
gap: var(--sp-2);
}
.range-label {
min-width: 150px;
text-align: center;
font-size: var(--fs-md);
color: var(--ink);
}
.tools {
display: flex;
align-items: center;
gap: var(--sp-2);
flex-wrap: wrap;
}
.who {
width: 200px;
}
.legend {
display: flex;
flex-wrap: wrap;
gap: var(--sp-4);
font-size: var(--fs-xs);
color: var(--muted);
}
.legend span {
display: inline-flex;
align-items: center;
gap: 6px;
}
.lg {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
}
.lg.complete {
background: var(--success);
}
.lg.partial {
background: var(--warning);
}
.lg.missed {
background: var(--danger);
}
.lg.empty {
background: var(--surface);
border: 1px solid var(--line-strong);
}
.cal-body {
transition: opacity var(--transition-fast);
}
.cal-body.dim {
opacity: 0.55;
}
.week {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 6px;
}
.week-col {
display: grid;
grid-template-rows: auto 1fr;
min-height: 260px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
overflow: hidden;
}
.week-col.today {
border-color: var(--accent);
}
.week-head {
display: grid;
gap: 2px;
padding: 8px;
border: 0;
border-bottom: 1px solid var(--line-light);
background: var(--surface-subtle);
text-align: right;
font: inherit;
cursor: pointer;
}
.s-complete .week-head {
background: var(--success-soft);
}
.s-partial .week-head {
background: var(--warning-soft);
}
.s-missed .week-head {
background: var(--danger-soft);
}
.week-head span {
font-size: var(--fs-xs);
color: var(--muted);
}
.week-head strong {
font-size: var(--fs-sm);
color: var(--ink);
}
.week-head em {
font-style: normal;
font-size: var(--fs-xs);
color: var(--ink-secondary);
}
.week-tasks {
list-style: none;
margin: 0;
padding: 6px;
display: grid;
align-content: start;
gap: 4px;
}
.week-tasks li {
display: grid;
grid-template-columns: 8px minmax(0, 1fr);
align-items: center;
gap: 2px 6px;
padding: 6px;
border-radius: 6px;
font-size: var(--fs-xs);
cursor: pointer;
background: var(--surface-subtle);
}
.week-tasks li:hover {
background: var(--accent-subtle);
}
.week-tasks li small {
grid-column: 2;
color: var(--muted);
}
.week-tasks li.none {
background: transparent;
color: var(--muted-light);
cursor: default;
text-align: center;
display: block;
}
.wt-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--line-strong);
}
.st-completed .wt-dot {
background: var(--success);
}
.st-completed .wt-title {
text-decoration: line-through;
color: var(--muted);
}
.st-overdue .wt-dot {
background: var(--danger);
}
.st-skipped .wt-dot,
.st-cancelled .wt-dot {
background: var(--warning);
}
.wt-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.day-panel {
display: grid;
gap: var(--sp-3);
padding: var(--sp-4);
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
}
.day-panel header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--sp-2);
}
.day-panel h3 {
margin: 0;
font-size: var(--fs-md);
color: var(--ink);
}
.day-sub {
margin: 4px 0 0;
font-size: var(--fs-sm);
color: var(--muted);
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
}
.chip {
padding: 1px 8px;
border-radius: 99px;
font-size: var(--fs-xs);
font-weight: var(--fw-semibold);
background: var(--surface-subtle);
}
.chip.s-complete {
background: var(--success-soft);
color: var(--success-deep);
}
.chip.s-partial {
background: var(--warning-soft);
color: var(--warning-deep);
}
.chip.s-missed {
background: var(--danger-soft);
color: var(--danger);
}
.rows {
display: grid;
gap: var(--sp-2);
}
.error {
margin: 0;
}
@media (max-width: 900px) {
.week {
grid-template-columns: 1fr;
}
.week-col {
min-height: 0;
}
.tools,
.who {
width: 100%;
}
}
</style>
@@ -0,0 +1,255 @@
<script setup lang="ts">
import { computed, inject, onMounted, ref, watch } from 'vue';
import type { RoutineDto } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import { UiBadge, UiEmptyState, UiSearch } from '@/components/ui';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import {
PRIORITY_LABELS,
PRIORITY_VARIANTS,
ROUTINES_CTX,
apiError,
formatDayNumeric,
formatPercent,
percentTone,
} from '@/lib/routines';
const shell = inject(ROUTINES_CTX)!;
const items = ref<RoutineDto[]>([]);
const loading = ref(true);
const error = ref('');
const q = ref('');
async function load() {
loading.value = true;
error.value = '';
try {
const { data } = await api.get<RoutineDto[]>('/routines', { params: { scope: 'mine' } });
items.value = data;
} catch (e) {
error.value = apiError(e, 'بارگذاری روتین‌ها ممکن نشد.');
} finally {
loading.value = false;
}
}
onMounted(load);
watch(shell.refreshTick, load);
const filtered = computed(() => {
const term = q.value.trim();
if (!term) return items.value;
return items.value.filter((r) => r.title.includes(term) || (r.description ?? '').includes(term));
});
</script>
<template>
<div class="rlist">
<div class="toolbar">
<p class="lead">روتین‌هایی که به شما یا تیم شما سپرده شده‌اند. کارهای هر روز از روی این‌ها ساخته می‌شوند.</p>
<UiSearch v-model="q" placeholder="جستجوی روتین…" class="search" />
</div>
<p v-if="error" class="error">{{ error }}</p>
<div v-if="loading" class="state">در حال بارگذاری…</div>
<UiEmptyState
v-else-if="!filtered.length"
icon="refresh"
:title="q ? 'روتینی با این عنوان پیدا نشد' : 'هنوز روتینی برای شما تعریف نشده'"
description="روتین‌ها توسط مدیر تیم تعریف می‌شوند و هر روز به‌صورت خودکار در «کارهای امروز» ظاهر می‌شوند."
/>
<div v-else class="cards">
<article v-for="r in filtered" :key="r.id" class="rcard">
<header>
<span class="ico" :style="{ background: r.category?.color ?? 'var(--accent)' }">
<UiIcon name="refresh" :size="16" />
</span>
<div class="titles">
<h3>{{ r.title }}</h3>
<p>{{ r.recurrenceLabel }} · ساعت {{ toFaDigits(r.timeOfDay) }}</p>
</div>
</header>
<p v-if="r.description" class="desc">{{ r.description }}</p>
<div class="tags">
<UiBadge v-if="r.category" size="sm">{{ r.category.name }}</UiBadge>
<UiBadge size="sm" :variant="PRIORITY_VARIANTS[r.priority]">{{ PRIORITY_LABELS[r.priority] }}</UiBadge>
<UiBadge v-if="r.isMandatory" size="sm" variant="warning">اجباری</UiBadge>
<UiBadge v-if="r.reportRequired" size="sm" variant="info">گزارش الزامی</UiBadge>
<UiBadge v-if="r.team" size="sm" variant="accent">تیم {{ r.team.name }}</UiBadge>
</div>
<footer>
<div class="exec" :class="`tone-${percentTone(r.executionRate)}`">
<span>اجرا در ۳۰ روز اخیر</span>
<strong>{{ formatPercent(r.executionRate) }}</strong>
<i class="bar"><b :style="{ width: `${r.executionRate ?? 0}%` }" /></i>
</div>
<small class="since">
از {{ formatDayNumeric(r.startDate) }}{{ r.endDate ? ` تا ${formatDayNumeric(r.endDate)}` : '' }}
</small>
</footer>
</article>
</div>
</div>
</template>
<style scoped>
.rlist {
display: grid;
gap: var(--sp-4);
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--sp-3);
}
.lead {
margin: 0;
font-size: var(--fs-sm);
color: var(--muted);
}
.search {
width: 260px;
}
.state {
padding: var(--sp-8);
text-align: center;
color: var(--muted);
}
.cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: var(--sp-3);
}
.rcard {
display: grid;
gap: var(--sp-3);
align-content: start;
padding: var(--sp-4);
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
box-shadow: var(--shadow-xs);
}
.rcard header {
display: flex;
align-items: center;
gap: var(--sp-3);
}
.ico {
width: 36px;
height: 36px;
border-radius: 10px;
display: grid;
place-items: center;
color: #fff;
flex-shrink: 0;
}
.titles {
min-width: 0;
}
.titles h3 {
margin: 0;
font-size: var(--fs-base);
font-weight: var(--fw-semibold);
color: var(--ink);
}
.titles p {
margin: 2px 0 0;
font-size: var(--fs-xs);
color: var(--muted);
}
.desc {
margin: 0;
font-size: var(--fs-sm);
color: var(--ink-secondary);
line-height: 1.7;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.rcard footer {
display: grid;
gap: 6px;
padding-top: var(--sp-3);
border-top: 1px solid var(--line-light);
}
.exec {
display: grid;
grid-template-columns: 1fr auto;
gap: 4px;
font-size: var(--fs-xs);
color: var(--muted);
}
.exec strong {
color: var(--ink);
}
.bar {
grid-column: 1 / -1;
height: 5px;
border-radius: 99px;
background: var(--surface-subtle);
overflow: hidden;
}
.bar b {
display: block;
height: 100%;
background: var(--line-strong);
}
.tone-good .bar b {
background: var(--success);
}
.tone-mid .bar b {
background: var(--warning);
}
.tone-low .bar b {
background: var(--danger);
}
.since {
color: var(--muted-light);
font-size: var(--fs-xs);
}
.error {
margin: 0;
}
@media (max-width: 900px) {
.search {
width: 100%;
}
.cards {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,673 @@
<script setup lang="ts">
import { computed, inject, onMounted, reactive, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import type { RoutineCategoryDto, RoutineDto } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import UserAvatar from '@/components/UserAvatar.vue';
import {
UiBadge,
UiButton,
UiDropdownMenu,
UiEmptyState,
UiField,
UiInput,
UiModal,
UiSearch,
UiSegmented,
UiSelect,
} from '@/components/ui';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import { ROUTINES_CTX, apiError, formatPercent, percentTone } from '@/lib/routines';
const shell = inject(ROUTINES_CTX)!;
const router = useRouter();
const items = ref<RoutineDto[]>([]);
const loading = ref(true);
const error = ref('');
const q = ref('');
const status = ref<'all' | 'active' | 'inactive'>('all');
const assignee = ref('');
const busyId = ref<string | null>(null);
const menuFor = ref<string | null>(null);
async function load() {
loading.value = true;
error.value = '';
try {
const { data } = await api.get<RoutineDto[]>('/routines', { params: { scope: 'all' } });
items.value = data;
} catch (e) {
error.value = apiError(e, 'بارگذاری روتین‌ها ممکن نشد.');
} finally {
loading.value = false;
}
}
onMounted(load);
watch(shell.refreshTick, load);
const statusOptions = [
{ value: 'all' as const, label: 'همه' },
{ value: 'active' as const, label: 'فعال' },
{ value: 'inactive' as const, label: 'غیرفعال' },
];
const filtered = computed(() => {
const term = q.value.trim();
return items.value.filter((r) => {
if (status.value === 'active' && !r.isActive) return false;
if (status.value === 'inactive' && r.isActive) return false;
if (assignee.value && r.assignee?.id !== assignee.value && `team:${r.team?.id}` !== assignee.value) return false;
if (term && !r.title.includes(term)) return false;
return true;
});
});
const counts = computed(() => ({
total: items.value.length,
active: items.value.filter((r) => r.isActive).length,
}));
function replace(updated: RoutineDto) {
const i = items.value.findIndex((r) => r.id === updated.id);
if (i >= 0) items.value[i] = updated;
else items.value.unshift(updated);
}
async function toggle(r: RoutineDto) {
busyId.value = r.id;
error.value = '';
try {
const { data } = await api.patch<RoutineDto>(`/routines/${r.id}/toggle`, { isActive: !r.isActive });
replace(data);
shell.notifyChanged();
} catch (e) {
error.value = apiError(e, 'تغییر وضعیت ممکن نشد.');
} finally {
busyId.value = null;
}
}
async function duplicate(r: RoutineDto) {
busyId.value = r.id;
error.value = '';
try {
const { data } = await api.post<RoutineDto>(`/routines/${r.id}/duplicate`);
replace(data);
} catch (e) {
error.value = apiError(e, 'کپی روتین ممکن نشد.');
} finally {
busyId.value = null;
}
}
/* حذف */
const removing = ref<RoutineDto | null>(null);
const removeBusy = ref(false);
async function confirmRemove() {
const r = removing.value;
if (!r) return;
removeBusy.value = true;
try {
await api.delete(`/routines/${r.id}`);
items.value = items.value.filter((x) => x.id !== r.id);
removing.value = null;
shell.notifyChanged();
} catch (e) {
error.value = apiError(e, 'حذف روتین ممکن نشد.');
removing.value = null;
} finally {
removeBusy.value = false;
}
}
/* تغییر مسئول */
const reassign = reactive({ routine: null as RoutineDto | null, target: '', saving: false, error: '' });
function openReassign(r: RoutineDto) {
Object.assign(reassign, {
routine: r,
target: r.team ? `team:${r.team.id}` : (r.assignee?.id ?? ''),
saving: false,
error: '',
});
}
async function saveReassign() {
const r = reassign.routine;
if (!r || !reassign.target) {
reassign.error = 'مسئول را انتخاب کنید.';
return;
}
const body = reassign.target.startsWith('team:')
? { teamId: reassign.target.slice(5), assigneeId: null }
: { assigneeId: reassign.target, teamId: null };
reassign.saving = true;
try {
const { data } = await api.patch<RoutineDto>(`/routines/${r.id}/assignee`, body);
replace(data);
reassign.routine = null;
shell.notifyChanged();
} catch (e) {
reassign.error = apiError(e, 'تغییر مسئول ممکن نشد.');
} finally {
reassign.saving = false;
}
}
/* دسته‌بندی‌ها */
const catsOpen = ref(false);
const catForm = reactive({ id: '', name: '', color: '#14b8a6', saving: false, error: '' });
const CAT_COLORS = ['#14b8a6', '#3b82f6', '#8b5cf6', '#f59e0b', '#f43f5e', '#10b981', '#06b6d4', '#64748b'];
const categories = computed(() => shell.context.value?.categories ?? []);
function editCat(c: RoutineCategoryDto | null) {
Object.assign(catForm, { id: c?.id ?? '', name: c?.name ?? '', color: c?.color ?? '#14b8a6', error: '' });
}
async function refreshCategories() {
const ctx = shell.context.value;
if (!ctx) return;
const { data } = await api.get<RoutineCategoryDto[]>('/routines/categories');
ctx.categories = data;
}
async function saveCat() {
if (!catForm.name.trim()) {
catForm.error = 'نام دسته را وارد کنید.';
return;
}
catForm.saving = true;
catForm.error = '';
try {
const body = { name: catForm.name.trim(), color: catForm.color };
if (catForm.id) await api.patch(`/routines/categories/${catForm.id}`, body);
else await api.post('/routines/categories', body);
await refreshCategories();
editCat(null);
} catch (e) {
catForm.error = apiError(e, 'ذخیره دسته ممکن نشد.');
} finally {
catForm.saving = false;
}
}
async function deleteCat(c: RoutineCategoryDto) {
catForm.error = '';
try {
await api.delete(`/routines/categories/${c.id}`);
await refreshCategories();
if (catForm.id === c.id) editCat(null);
} catch (e) {
catForm.error = apiError(e, 'حذف دسته ممکن نشد.');
}
}
const users = computed(() => shell.context.value?.users ?? []);
const teams = computed(() => shell.context.value?.teams ?? []);
</script>
<template>
<div class="manage">
<div class="toolbar">
<UiSearch v-model="q" placeholder="جستجوی عنوان…" class="search" />
<UiSelect v-model="assignee" size="sm" class="who">
<option value="">همه مسئول‌ها</option>
<optgroup label="کاربران">
<option v-for="u in users" :key="u.id" :value="u.id">{{ u.fullName }}</option>
</optgroup>
<optgroup v-if="teams.length" label="تیم‌ها">
<option v-for="t in teams" :key="t.id" :value="`team:${t.id}`">{{ t.name }}</option>
</optgroup>
</UiSelect>
<UiSegmented v-model="status" :options="statusOptions" />
<span class="spacer" />
<UiButton variant="ghost" size="sm" @click="catsOpen = true; editCat(null)">
<template #icon><UiIcon name="layers" :size="15" /></template>
دسته‌بندی‌ها
</UiButton>
<UiButton @click="router.push('/routines/manage/new')">
<template #icon><UiIcon name="plus" :size="16" /></template>
روتین جدید
</UiButton>
</div>
<p class="summary">
{{ toFaDigits(counts.total) }} روتین · {{ toFaDigits(counts.active) }} فعال
</p>
<p v-if="error" class="error">{{ error }}</p>
<div v-if="loading" class="state">در حال بارگذاری…</div>
<UiEmptyState
v-else-if="!filtered.length"
icon="refresh"
:title="items.length ? 'روتینی با این فیلتر پیدا نشد' : 'هنوز روتینی تعریف نشده'"
description="روتین‌ها کارهای تکرارشونده‌ای هستند که هر روز به‌صورت خودکار برای مسئولشان ساخته می‌شوند."
>
<template v-if="!items.length" #action>
<UiButton @click="router.push('/routines/manage/new')">ساخت اولین روتین</UiButton>
</template>
</UiEmptyState>
<div v-else class="crm-table-container">
<table class="crm-table">
<thead>
<tr>
<th>عنوان</th>
<th>مسئول</th>
<th>تکرار</th>
<th>ساعت</th>
<th>وضعیت</th>
<th>درصد انجام</th>
<th />
</tr>
</thead>
<tbody>
<tr v-for="r in filtered" :key="r.id" :class="{ inactive: !r.isActive }">
<td class="title-cell">
<span class="cat-dot" :style="{ background: r.category?.color ?? 'var(--line-strong)' }" />
<div>
<strong>{{ r.title }}</strong>
<small>
{{ r.category?.name ?? 'بدون دسته' }}
<template v-if="r.isMandatory"> · اجباری</template>
</small>
</div>
</td>
<td>
<span v-if="r.team" class="who-cell">
<span class="team-ico"><UiIcon name="users" :size="13" /></span>
تیم {{ r.team.name }}
</span>
<span v-else-if="r.assignee" class="who-cell">
<UserAvatar :name="r.assignee.fullName" :user-id="r.assignee.id" :size="24" />
{{ r.assignee.fullName }}
</span>
</td>
<td class="nowrap">{{ r.recurrenceLabel }}</td>
<td class="nowrap">{{ toFaDigits(r.timeOfDay) }}</td>
<td>
<button
type="button"
class="switch"
:class="{ on: r.isActive }"
:disabled="busyId === r.id"
:aria-pressed="r.isActive"
:aria-label="r.isActive ? 'غیرفعال کردن' : 'فعال کردن'"
@click="toggle(r)"
>
<i />
</button>
<UiBadge size="sm" :variant="r.isActive ? 'success' : 'neutral'">
{{ r.isActive ? 'فعال' : 'غیرفعال' }}
</UiBadge>
</td>
<td>
<div class="exec" :class="`tone-${percentTone(r.executionRate)}`">
<strong>{{ formatPercent(r.executionRate) }}</strong>
<i class="bar"><b :style="{ width: `${r.executionRate ?? 0}%` }" /></i>
<small v-if="r.executionTotal">
{{ toFaDigits(r.executionDone) }} از {{ toFaDigits(r.executionTotal) }}
</small>
</div>
</td>
<td class="actions-cell">
<div class="crm-table-actions">
<UiButton
variant="icon"
size="sm"
aria-label="ویرایش"
@click="router.push(`/routines/manage/${r.id}/edit`)"
>
<UiIcon name="pencil" :size="15" />
</UiButton>
<UiDropdownMenu
:open="menuFor === r.id"
@update:open="(v: boolean) => (menuFor = v ? r.id : null)"
>
<template #trigger="{ toggle: t }">
<UiButton variant="icon" size="sm" aria-label="عملیات بیشتر" @click="t">
<UiIcon name="dots" :size="16" />
</UiButton>
</template>
<button type="button" @click="router.push(`/routines/manage/${r.id}/edit`)">ویرایش</button>
<button type="button" @click="openReassign(r)">تغییر مسئول</button>
<button type="button" @click="duplicate(r)">کپی روتین</button>
<button type="button" @click="toggle(r)">{{ r.isActive ? 'غیرفعال کردن' : 'فعال کردن' }}</button>
<button type="button" class="danger" @click="removing = r">حذف</button>
</UiDropdownMenu>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- حذف -->
<UiModal :open="!!removing" title="حذف روتین" @close="removing = null">
<p class="confirm">
روتین «{{ removing?.title }}» حذف شود؟ کارهای انجام‌نشده امروزِ آن لغو می‌شوند؛ سوابق قبلی در گزارش‌ها باقی
می‌ماند.
</p>
<template #footer>
<UiButton variant="danger" :loading="removeBusy" @click="confirmRemove">حذف روتین</UiButton>
<UiButton variant="ghost" @click="removing = null">انصراف</UiButton>
</template>
</UiModal>
<!-- تغییر مسئول -->
<UiModal :open="!!reassign.routine" title="تغییر مسئول روتین" @close="reassign.routine = null">
<div class="form">
<p class="confirm">{{ reassign.routine?.title }}</p>
<UiField label="مسئول جدید" hint="کارهای انجام‌نشده امروز به مسئول جدید منتقل می‌شوند.">
<UiSelect v-model="reassign.target">
<option value="" disabled>انتخاب کنید</option>
<optgroup label="کاربران">
<option v-for="u in users" :key="u.id" :value="u.id">{{ u.fullName }}</option>
</optgroup>
<optgroup v-if="teams.length" label="کل اعضای تیم">
<option v-for="t in teams" :key="t.id" :value="`team:${t.id}`">{{ t.name }}</option>
</optgroup>
</UiSelect>
</UiField>
<p v-if="reassign.error" class="error">{{ reassign.error }}</p>
</div>
<template #footer>
<UiButton :loading="reassign.saving" @click="saveReassign">ذخیره</UiButton>
<UiButton variant="ghost" @click="reassign.routine = null">انصراف</UiButton>
</template>
</UiModal>
<!-- دسته‌بندی‌ها -->
<UiModal :open="catsOpen" title="دسته‌بندی کارها" @close="catsOpen = false">
<div class="form">
<ul class="cat-list">
<li v-for="c in categories" :key="c.id">
<span class="cat-dot" :style="{ background: c.color }" />
<span class="cat-name">{{ c.name }}</span>
<UiButton variant="icon" size="sm" aria-label="ویرایش" @click="editCat(c)">
<UiIcon name="pencil" :size="14" />
</UiButton>
<UiButton variant="icon" size="sm" aria-label="حذف" @click="deleteCat(c)">
<UiIcon name="trash" :size="14" />
</UiButton>
</li>
</ul>
<div class="cat-form">
<UiField :label="catForm.id ? 'ویرایش دسته' : 'دسته جدید'">
<UiInput v-model="catForm.name" placeholder="مثلاً: پشتیبانی" :maxlength="60" />
</UiField>
<div class="swatches" role="radiogroup" aria-label="رنگ">
<button
v-for="col in CAT_COLORS"
:key="col"
type="button"
class="swatch"
:class="{ on: catForm.color === col }"
:style="{ background: col }"
:aria-label="col"
@click="catForm.color = col"
/>
</div>
<p v-if="catForm.error" class="error">{{ catForm.error }}</p>
</div>
</div>
<template #footer>
<UiButton :loading="catForm.saving" @click="saveCat">{{ catForm.id ? 'ذخیره تغییرات' : 'افزودن دسته' }}</UiButton>
<UiButton v-if="catForm.id" variant="ghost" @click="editCat(null)">انصراف از ویرایش</UiButton>
<UiButton variant="ghost" @click="catsOpen = false">بستن</UiButton>
</template>
</UiModal>
</div>
</template>
<style scoped>
.manage {
display: grid;
gap: var(--sp-3);
}
.toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--sp-2);
}
.search {
width: 240px;
}
.who {
width: 190px;
}
.spacer {
flex: 1 1 auto;
}
.summary {
margin: 0;
font-size: var(--fs-xs);
color: var(--muted);
}
.state {
padding: var(--sp-8);
text-align: center;
color: var(--muted);
}
tr.inactive td {
color: var(--muted);
}
tr.inactive .title-cell strong {
color: var(--muted);
}
.title-cell {
display: flex;
align-items: center;
gap: var(--sp-2);
min-width: 220px;
}
.title-cell div {
display: grid;
gap: 2px;
}
.title-cell strong {
font-weight: var(--fw-semibold);
}
.title-cell small {
color: var(--muted);
font-size: var(--fs-xs);
}
.cat-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.who-cell {
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.team-ico {
width: 24px;
height: 24px;
border-radius: 50%;
display: grid;
place-items: center;
background: var(--accent-subtle);
color: var(--accent-deep);
}
.nowrap {
white-space: nowrap;
}
td:has(.switch) {
white-space: nowrap;
}
.switch {
width: 34px;
height: 20px;
padding: 2px;
margin-inline-end: 8px;
border: 0;
border-radius: 99px;
background: var(--line-strong);
cursor: pointer;
vertical-align: middle;
transition: background var(--transition-fast);
}
.switch i {
display: block;
width: 16px;
height: 16px;
border-radius: 50%;
background: #fff;
box-shadow: var(--shadow-xs);
transition: transform var(--transition-fast);
}
.switch.on {
background: var(--accent);
}
/* RTL: روشن = دکمه سمت چپ */
.switch.on i {
transform: translateX(-14px);
}
.exec {
display: grid;
grid-template-columns: auto 80px;
align-items: center;
gap: 2px 8px;
min-width: 130px;
}
.exec small {
grid-column: 1 / -1;
color: var(--muted);
font-size: var(--fs-xs);
}
.bar {
height: 6px;
border-radius: 99px;
background: var(--surface-subtle);
overflow: hidden;
}
.bar b {
display: block;
height: 100%;
background: var(--line-strong);
}
.tone-good .bar b {
background: var(--success);
}
.tone-mid .bar b {
background: var(--warning);
}
.tone-low .bar b {
background: var(--danger);
}
:global(.ui-dd-panel button.danger) {
color: var(--danger);
}
.confirm {
margin: 0;
line-height: 1.8;
color: var(--ink-secondary);
}
.form {
display: grid;
gap: var(--sp-4);
}
.cat-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 4px;
}
.cat-list li {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: 6px 10px;
border-radius: var(--radius);
background: var(--surface-subtle);
}
.cat-name {
flex: 1 1 auto;
}
.cat-form {
display: grid;
gap: var(--sp-3);
padding-top: var(--sp-3);
border-top: 1px solid var(--line-light);
}
.swatches {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.swatch {
width: 28px;
height: 28px;
border-radius: 50%;
border: 2px solid var(--surface);
box-shadow: 0 0 0 1px var(--line);
cursor: pointer;
}
.swatch.on {
box-shadow: 0 0 0 2px var(--ink);
}
.error {
margin: 0;
}
@media (max-width: 900px) {
.search,
.who {
width: 100%;
}
.spacer {
display: none;
}
}
</style>
@@ -0,0 +1,272 @@
<script setup lang="ts">
import { computed, inject, ref, watch } from 'vue';
import type { RoutinePerformanceDto } from '@mento/shared';
import KpiCard from '@/components/routines/KpiCard.vue';
import WeeklyReport from '@/components/routines/WeeklyReport.vue';
import MonthlyReport from '@/components/routines/MonthlyReport.vue';
import ActivityHistoryList from '@/components/routines/ActivityHistoryList.vue';
import { UiChart, UiSegmented, UiSelect } from '@/components/ui';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import { categoryBar, statusDonut, trendOption } from '@/lib/routineCharts';
import {
ROUTINES_CTX,
addDaysKey,
apiError,
formatDayNumeric,
formatMinutes,
formatPercent,
todayKey,
} from '@/lib/routines';
import { useAuthStore } from '@/stores/auth';
const shell = inject(ROUTINES_CTX)!;
const auth = useAuthStore();
type Section = 'overview' | 'weekly' | 'monthly' | 'history';
const section = ref<Section>('overview');
const sections = [
{ value: 'overview' as const, label: 'نمای کلی' },
{ value: 'weekly' as const, label: 'گزارش هفتگی' },
{ value: 'monthly' as const, label: 'گزارش ماهانه' },
{ value: 'history' as const, label: 'تاریخچه فعالیت' },
];
const days = ref<'7' | '30' | '90'>('30');
const dayOptions = [
{ value: '7' as const, label: '۷ روز' },
{ value: '30' as const, label: '۳۰ روز' },
{ value: '90' as const, label: '۹۰ روز' },
];
/** مدیر می‌تواند عملکرد یک عضو را هم ببیند */
const userId = ref('');
const users = computed(() => shell.context.value?.users ?? []);
const canPickUser = computed(() => !!shell.context.value?.isManager && users.value.length > 1);
const filters = computed(() => (userId.value ? { userId: userId.value } : {}));
const data = ref<RoutinePerformanceDto | null>(null);
const loading = ref(false);
const error = ref('');
async function load() {
if (section.value !== 'overview') return;
loading.value = true;
error.value = '';
const to = todayKey();
try {
const res = await api.get<RoutinePerformanceDto>('/routines/stats/me', {
params: { ...filters.value, from: addDaysKey(to, -(Number(days.value) - 1)), to },
});
data.value = res.data;
} catch (e) {
error.value = apiError(e, 'بارگذاری عملکرد ممکن نشد.');
} finally {
loading.value = false;
}
}
watch([section, days, userId, shell.refreshTick], load, { immediate: true });
const d = computed(() => data.value);
const trendOpt = computed(() => trendOption(d.value?.trend ?? []));
const donutOpt = computed(() =>
statusDonut(d.value?.status ?? { completed: 0, notDone: 0, overdue: 0, skipped: 0, cancelled: 0 }),
);
const catOpt = computed(() => categoryBar(d.value?.byCategory ?? []));
</script>
<template>
<div class="perf">
<div class="toolbar">
<UiSegmented v-model="section" :options="sections" />
<div class="tools">
<UiSelect v-if="canPickUser" v-model="userId" size="sm" class="who">
<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>
<UiSegmented v-if="section === 'overview'" v-model="days" :options="dayOptions" />
</div>
</div>
<template v-if="section === 'overview'">
<p v-if="error" class="error">{{ error }}</p>
<p v-if="d" class="range">بازه {{ formatDayNumeric(d.from) }} تا {{ formatDayNumeric(d.to) }}</p>
<div class="kpis" :class="{ dim: loading }">
<KpiCard label="درصد انجام" :value="formatPercent(d?.percent)" icon="target" tone="accent" />
<KpiCard
label="کارهای انجام‌شده"
:value="toFaDigits(d?.completed ?? 0)"
:hint="`از ${toFaDigits(d?.total ?? 0)} کار`"
icon="check"
tone="success"
/>
<KpiCard
label="انجام‌نشده"
:value="toFaDigits(d?.notDone ?? 0)"
:hint="`عقب‌افتاده ${toFaDigits(d?.overdue ?? 0)}`"
icon="x-circle"
tone="danger"
/>
<KpiCard
label="میانگین زمان انجام"
:value="formatMinutes(d?.avgCompletionMinutes)"
hint="از زمان برنامه تا انجام"
icon="clock"
tone="info"
/>
<KpiCard
label="روزهای کامل"
:value="toFaDigits(d?.completeDays ?? 0)"
:hint="`ناقص ${toFaDigits(d?.partialDays ?? 0)} · از دست رفته ${toFaDigits(d?.missedDays ?? 0)}`"
icon="calendar"
tone="success"
/>
<KpiCard
label="روزهای پیاپی کامل"
:value="toFaDigits(d?.streak ?? 0)"
hint="انجام همه کارهای اجباری"
icon="flag"
tone="warning"
/>
</div>
<div class="charts">
<section class="card wide">
<h3>روند درصد انجام</h3>
<UiChart :option="trendOpt" height="260px" :loading="loading" />
</section>
<section class="card">
<h3>وضعیت کارها</h3>
<UiChart :option="donutOpt" height="260px" :loading="loading" />
</section>
<section class="card full">
<h3>انجام به تفکیک دسته‌بندی</h3>
<UiChart v-if="d?.byCategory.length" :option="catOpt" height="240px" :loading="loading" />
<p v-else class="muted">داده‌ای برای نمایش نیست.</p>
</section>
</div>
</template>
<WeeklyReport v-else-if="section === 'weekly'" view="me" :filters="filters" :refresh-key="shell.refreshTick.value" />
<MonthlyReport v-else-if="section === 'monthly'" view="me" :filters="filters" :refresh-key="shell.refreshTick.value" />
<ActivityHistoryList
v-else
:user-id="userId || null"
:refresh-key="shell.refreshTick.value"
@open="shell.openTask"
/>
</div>
</template>
<style scoped>
.perf {
display: grid;
gap: var(--sp-4);
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--sp-3);
}
.tools {
display: flex;
align-items: center;
gap: var(--sp-2);
flex-wrap: wrap;
}
.who {
width: 200px;
}
.range {
margin: 0;
font-size: var(--fs-xs);
color: var(--muted);
}
.kpis {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: var(--sp-3);
transition: opacity var(--transition-fast);
}
.dim {
opacity: 0.55;
}
.charts {
display: grid;
grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr);
gap: var(--sp-4);
}
.card {
padding: var(--sp-4);
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
display: grid;
gap: var(--sp-3);
align-content: start;
min-width: 0;
}
.card.full {
grid-column: 1 / -1;
}
.card h3 {
margin: 0;
font-size: var(--fs-sm);
font-weight: var(--fw-semibold);
color: var(--ink-secondary);
}
.muted {
margin: 0;
color: var(--muted);
font-size: var(--fs-sm);
text-align: center;
padding: var(--sp-6);
}
.error {
margin: 0;
}
@media (max-width: 1200px) {
.kpis {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 900px) {
.kpis {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--sp-2);
}
.charts {
grid-template-columns: 1fr;
}
.who {
width: 100%;
}
.toolbar > :first-child {
overflow-x: auto;
max-width: 100%;
}
}
</style>
@@ -0,0 +1,319 @@
<script setup lang="ts">
import { computed, inject, onMounted, ref, watch } from 'vue';
import { RouterLink } from 'vue-router';
import type { RoutineTaskDto } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import UserAvatar from '@/components/UserAvatar.vue';
import RoutineTaskRow, { type RoutineTaskRowAction } from '@/components/routines/RoutineTaskRow.vue';
import RoutineTaskModals from '@/components/routines/RoutineTaskModals.vue';
import { UiBadge, UiButton, UiEmptyState, UiSelect } from '@/components/ui';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import {
ENTITY_TYPE_LABELS,
PRIORITY_LABELS,
PRIORITY_VARIANTS,
ROUTINES_CTX,
apiError,
entityRoute,
formatDayNumeric,
formatDelay,
formatTime,
hasExplicitTime,
} from '@/lib/routines';
import { useRoutineTaskActions } from '@/lib/useRoutineTaskActions';
const shell = inject(ROUTINES_CTX)!;
const items = ref<RoutineTaskDto[]>([]);
const loading = ref(true);
const error = ref('');
const who = ref('');
const isManager = computed(() => !!shell.context.value?.isManager);
const users = computed(() => shell.context.value?.users ?? []);
async function load(silent = false) {
if (!silent) loading.value = true;
error.value = '';
try {
const { data } = await api.get<RoutineTaskDto[]>('/routine-tasks/overdue', {
params: who.value ? { userId: who.value } : {},
});
items.value = data;
} catch (e) {
error.value = apiError(e, 'بارگذاری کارهای عقب‌افتاده ممکن نشد.');
} finally {
loading.value = false;
}
}
onMounted(() => load());
watch(who, () => load());
watch(shell.refreshTick, () => load(true));
const actions = useRoutineTaskActions((updated) => {
if (updated.status === 'overdue') {
const i = items.value.findIndex((t) => t.id === updated.id);
if (i >= 0) items.value[i] = updated;
} else {
items.value = items.value.filter((t) => t.id !== updated.id);
}
shell.notifyChanged();
});
function onAction(kind: RoutineTaskRowAction, task: RoutineTaskDto) {
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 if (kind === 'cancel') actions.openCancel(task);
}
const showAssignee = computed(() => isManager.value && who.value !== '');
</script>
<template>
<div class="overdue">
<div class="toolbar">
<p class="lead">
<UiIcon name="warning" :size="16" />
<template v-if="items.length">{{ toFaDigits(items.length) }} کار از مهلتش گذشته است.</template>
<template v-else>کار عقب‌افتاده‌ای ندارید.</template>
</p>
<UiSelect v-if="isManager" v-model="who" size="sm" class="who">
<option value="">کارهای خودم</option>
<option value="all">همه اعضای تیم</option>
<option v-for="u in users" :key="u.id" :value="u.id">{{ u.fullName }}</option>
</UiSelect>
</div>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="actions.error.value" class="error">{{ actions.error.value }}</p>
<div v-if="loading" class="state">در حال بارگذاری…</div>
<UiEmptyState
v-else-if="!items.length"
icon="check"
title="همه چیز به‌روز است"
description="هیچ کاری از مهلتش عقب نیفتاده."
/>
<template v-else>
<!-- دسکتاپ: جدول -->
<div class="crm-table-container desk">
<table class="crm-table">
<thead>
<tr>
<th>کار</th>
<th>مهلت</th>
<th>تأخیر</th>
<th v-if="showAssignee">مسئول</th>
<th>اولویت</th>
<th>پرونده</th>
<th />
</tr>
</thead>
<tbody>
<tr v-for="t in items" :key="t.id" class="clickable" @click="shell.openTask(t.id)">
<td class="title-cell">
<strong>{{ t.title }}</strong>
<small v-if="t.category" class="cat"><i :style="{ background: t.category.color }" />{{ t.category.name }}</small>
</td>
<td class="nowrap">
{{ formatDayNumeric(t.scheduledFor) }}
<small v-if="hasExplicitTime(t)" class="muted">· {{ formatTime(t.dueAt) }}</small>
</td>
<td class="nowrap late">{{ formatDelay(t.dueAt) }}</td>
<td v-if="showAssignee">
<span v-if="t.assignee" class="who-cell">
<UserAvatar :name="t.assignee.fullName" :user-id="t.assignee.id" :size="24" />
{{ t.assignee.fullName }}
</span>
</td>
<td>
<UiBadge size="sm" :variant="PRIORITY_VARIANTS[t.priority]">{{ PRIORITY_LABELS[t.priority] }}</UiBadge>
</td>
<td>
<RouterLink
v-for="ent in t.entities"
:key="ent.id"
:to="entityRoute(ent)"
class="ent"
@click.stop
>
{{ ENTITY_TYPE_LABELS[ent.type] }}: {{ ent.label }}
</RouterLink>
<span v-if="!t.entities.length" class="muted">—</span>
</td>
<td class="actions-cell" @click.stop>
<div class="crm-table-actions">
<UiButton
size="sm"
variant="success"
:loading="actions.busyId.value === t.id"
@click="actions.toggle(t)"
>
انجام شد
</UiButton>
<UiButton size="sm" variant="ghost" @click="actions.moveToday(t)">به امروز</UiButton>
<UiButton size="sm" variant="ghost" @click="actions.openReschedule(t)">تغییر زمان</UiButton>
<UiButton size="sm" variant="ghost" class="danger-text" @click="actions.openCancel(t)">لغو</UiButton>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- موبایل: کارت -->
<div class="mobile rows">
<RoutineTaskRow
v-for="t in items"
:key="t.id"
:task="t"
:busy="actions.busyId.value === t.id"
show-date
show-delay
:show-assignee="showAssignee"
@toggle="actions.toggle"
@open="shell.openTask(t.id)"
@action="onAction"
/>
</div>
</template>
<RoutineTaskModals :actions="actions" />
</div>
</template>
<style scoped>
.overdue {
display: grid;
gap: var(--sp-4);
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--sp-3);
}
.lead {
margin: 0;
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--ink-secondary);
font-size: var(--fs-sm);
}
.lead :deep(svg) {
color: var(--danger);
}
.who {
width: 220px;
}
.state {
padding: var(--sp-8);
text-align: center;
color: var(--muted);
}
.clickable {
cursor: pointer;
}
.title-cell {
display: grid;
gap: 2px;
min-width: 200px;
}
.title-cell strong {
font-weight: var(--fw-semibold);
}
.cat {
display: inline-flex;
align-items: center;
gap: 5px;
color: var(--muted);
}
.cat i {
width: 8px;
height: 8px;
border-radius: 50%;
}
.nowrap {
white-space: nowrap;
}
.late {
color: var(--danger);
font-weight: var(--fw-semibold);
}
.muted {
color: var(--muted);
}
.who-cell {
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.ent {
display: block;
color: var(--accent-deep);
font-size: var(--fs-xs);
white-space: nowrap;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
}
.ent:hover {
text-decoration: underline;
}
.danger-text {
color: var(--danger) !important;
}
.rows {
display: grid;
gap: var(--sp-2);
}
.mobile {
display: none;
}
.error {
margin: 0;
}
@media (max-width: 900px) {
.desk {
display: none;
}
.mobile {
display: grid;
}
.who {
width: 100%;
}
}
</style>
@@ -0,0 +1,219 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import type { RoutineUserSettingsDto } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import { UiButton, UiCard, UiField, UiSelect } from '@/components/ui';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import { apiError } from '@/lib/routines';
const form = reactive({
remind: '30',
endOfDaySummary: true,
summaryHour: '16',
overdueAlert: true,
});
const loading = ref(true);
const saving = ref(false);
const error = ref('');
const saved = ref(false);
const remindOptions = [
{ value: '', label: 'خاموش' },
{ value: '0', label: 'سر ساعت مهلت' },
{ value: '10', label: '۱۰ دقیقه قبل' },
{ value: '15', label: '۱۵ دقیقه قبل' },
{ value: '30', label: '۳۰ دقیقه قبل' },
{ value: '60', label: '۱ ساعت قبل' },
{ value: '120', label: '۲ ساعت قبل' },
];
const hours = Array.from({ length: 12 }, (_, i) => i + 12);
onMounted(async () => {
try {
const { data } = await api.get<RoutineUserSettingsDto>('/routine-tasks/settings');
Object.assign(form, {
remind: data.remindBeforeMinutes == null ? '' : String(data.remindBeforeMinutes),
endOfDaySummary: data.endOfDaySummary,
summaryHour: String(data.summaryHour),
overdueAlert: data.overdueAlert,
});
} catch (e) {
error.value = apiError(e, 'بارگذاری تنظیمات ممکن نشد.');
} finally {
loading.value = false;
}
});
async function save() {
saving.value = true;
error.value = '';
saved.value = false;
try {
await api.put('/routine-tasks/settings', {
remindBeforeMinutes: form.remind === '' ? null : Number(form.remind),
endOfDaySummary: form.endOfDaySummary,
summaryHour: Number(form.summaryHour),
overdueAlert: form.overdueAlert,
});
saved.value = true;
} catch (e) {
error.value = apiError(e, 'ذخیره تنظیمات ممکن نشد.');
} finally {
saving.value = false;
}
}
</script>
<template>
<div class="settings">
<UiCard>
<header class="head">
<span class="ico"><UiIcon name="bell" :size="18" /></span>
<div>
<h3>یادآوری‌ها و اعلان‌های کارهای روتین</h3>
<p>این تنظیمات فقط برای حساب شما اعمال می‌شود. اعلان‌ها در زنگوله بالای صفحه نمایش داده می‌شوند.</p>
</div>
</header>
<div v-if="loading" class="state">در حال بارگذاری…</div>
<form v-else class="form" @submit.prevent="save">
<UiField label="یادآوری قبل از مهلت" hint="برای کارهایی که ساعت مشخص دارند. روتین‌ها ممکن است یادآوری اختصاصی داشته باشند.">
<UiSelect v-model="form.remind">
<option v-for="o in remindOptions" :key="o.value" :value="o.value">{{ o.label }}</option>
</UiSelect>
</UiField>
<label class="tg">
<input v-model="form.overdueAlert" type="checkbox" />
<span>
<strong>هشدار کار اجباری انجام‌نشده</strong>
<small>وقتی مهلت یک کار اجباری بگذرد و هنوز انجام نشده باشد.</small>
</span>
</label>
<label class="tg">
<input v-model="form.endOfDaySummary" type="checkbox" />
<span>
<strong>خلاصه پایان روز</strong>
<small>اگر کاری باقی مانده باشد، یک اعلان خلاصه دریافت می‌کنید؛ مثل «۳ کار تا پایان امروز باقی مانده است».</small>
</span>
</label>
<UiField v-if="form.endOfDaySummary" label="ساعت ارسال خلاصه" class="nested">
<UiSelect v-model="form.summaryHour">
<option v-for="h in hours" :key="h" :value="String(h)">ساعت {{ toFaDigits(h) }}:۰۰</option>
</UiSelect>
</UiField>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="saved" class="ok">تنظیمات ذخیره شد.</p>
<div class="actions">
<UiButton type="submit" :loading="saving">ذخیره تنظیمات</UiButton>
</div>
</form>
</UiCard>
</div>
</template>
<style scoped>
.settings {
max-width: 680px;
}
.head {
display: flex;
align-items: flex-start;
gap: var(--sp-3);
margin-bottom: var(--sp-5);
}
.ico {
width: 38px;
height: 38px;
border-radius: 10px;
display: grid;
place-items: center;
background: var(--accent-subtle);
color: var(--accent-deep);
flex-shrink: 0;
}
.head h3 {
margin: 0;
font-size: var(--fs-md);
font-weight: var(--fw-semibold);
color: var(--ink);
}
.head p {
margin: 4px 0 0;
font-size: var(--fs-sm);
color: var(--muted);
line-height: 1.7;
}
.state {
padding: var(--sp-6);
text-align: center;
color: var(--muted);
}
.form {
display: grid;
gap: var(--sp-4);
}
.tg {
display: flex;
align-items: flex-start;
gap: var(--sp-2);
cursor: pointer;
}
.tg input {
width: 18px;
height: 18px;
margin-top: 2px;
accent-color: var(--accent);
flex-shrink: 0;
}
.tg span {
display: grid;
gap: 2px;
}
.tg strong {
font-size: var(--fs-sm);
font-weight: var(--fw-medium);
color: var(--ink);
}
.tg small {
font-size: var(--fs-xs);
color: var(--muted);
line-height: 1.6;
}
.nested {
margin-inline-start: 26px;
max-width: 260px;
}
.ok {
margin: 0;
color: var(--success-deep);
font-size: var(--fs-sm);
}
.error {
margin: 0;
}
.actions {
display: flex;
}
</style>
@@ -0,0 +1,231 @@
<script setup lang="ts">
import { computed, onMounted, provide, ref, watch } from 'vue';
import { RouterLink, RouterView, useRoute, useRouter } from 'vue-router';
import type { RoutineContextDto } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import RoutineTaskDrawer from '@/components/routines/RoutineTaskDrawer.vue';
import QuickTaskModal from '@/components/routines/QuickTaskModal.vue';
import { UiButton, UiPageHeader } from '@/components/ui';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import { ROUTINES_CTX, apiError } from '@/lib/routines';
const route = useRoute();
const router = useRouter();
const context = ref<RoutineContextDto | null>(null);
const contextError = ref('');
const refreshTick = ref(0);
const overdueCount = ref(0);
const quickOpen = ref(false);
async function loadContext() {
try {
const { data } = await api.get<RoutineContextDto>('/routines/context');
context.value = data;
contextError.value = '';
} catch (e) {
contextError.value = apiError(e, 'بارگذاری اطلاعات کارهای روتین ممکن نشد.');
}
}
async function loadBadge() {
try {
const { data } = await api.get<{ remaining: number; overdue: number }>('/routine-tasks/badge');
overdueCount.value = data.overdue;
} catch {
overdueCount.value = 0;
}
}
function notifyChanged() {
refreshTick.value += 1;
void loadBadge();
}
function openTask(id: string) {
void router.replace({ query: { ...route.query, task: id } });
}
function closeTask() {
const query = { ...route.query };
delete query.task;
void router.replace({ query });
}
provide(ROUTINES_CTX, { context, refreshTick, notifyChanged, openTask });
onMounted(() => {
void loadContext();
void loadBadge();
});
const taskId = computed(() => (typeof route.query.task === 'string' ? route.query.task : null));
const isManager = computed(() => !!context.value?.isManager);
type Tab = { to: string; name: string; label: string; icon: InstanceType<typeof UiIcon>['$props']['name']; manager?: boolean };
const tabs: Tab[] = [
{ to: '/routines', name: 'routines-today', label: 'کارهای امروز', icon: 'calendar-sun' },
{ to: '/routines/overdue', name: 'routines-overdue', label: 'عقب‌افتاده', icon: 'warning' },
{ to: '/routines/calendar', name: 'routines-calendar', label: 'تقویم', icon: 'calendar' },
{ to: '/routines/list', name: 'routines-list', label: 'روتین‌ها', icon: 'refresh' },
{ to: '/routines/me', name: 'routines-me', label: 'عملکرد من', icon: 'chart' },
{ to: '/routines/team', name: 'routines-team', label: 'عملکرد تیم', icon: 'users', manager: true },
{ to: '/routines/manage', name: 'routines-manage', label: 'مدیریت روتین‌ها', icon: 'sliders', manager: true },
];
const visibleTabs = computed(() => tabs.filter((t) => !t.manager || isManager.value));
const managerOnlyRoutes = new Set(['routines-team', 'routines-manage', 'routine-new', 'routine-edit']);
watch(
[() => route.name, context],
([name, ctx]) => {
if (ctx && !ctx.isManager && managerOnlyRoutes.has(String(name))) {
void router.replace('/routines');
}
},
{ immediate: true },
);
function tabActive(tab: Tab) {
const name = String(route.name ?? '');
if (tab.name === 'routines-manage') return name === 'routines-manage' || name === 'routine-new' || name === 'routine-edit';
return name === tab.name;
}
const hideHeader = computed(() => route.name === 'routine-new' || route.name === 'routine-edit');
</script>
<template>
<section class="crm-page routines-shell">
<template v-if="!hideHeader">
<UiPageHeader title="کارهای روتین" lead="برنامه روزانه، روتین‌های تکرارشونده و پیگیری انجام کارها">
<template #actions>
<RouterLink to="/routines/settings" class="icon-link" title="تنظیمات یادآوری و اعلان‌ها">
<UiIcon name="bell" :size="17" />
</RouterLink>
<UiButton v-if="isManager" variant="outline" @click="router.push('/routines/manage/new')">
<template #icon><UiIcon name="refresh" :size="16" /></template>
روتین جدید
</UiButton>
<UiButton @click="quickOpen = true">
<template #icon><UiIcon name="plus" :size="16" /></template>
کار جدید
</UiButton>
</template>
</UiPageHeader>
<nav class="r-tabs" aria-label="بخش‌های کارهای روتین">
<RouterLink
v-for="tab in visibleTabs"
:key="tab.name"
:to="tab.to"
class="r-tab"
:class="{ on: tabActive(tab) }"
>
<UiIcon :name="tab.icon" :size="15" />
<span>{{ tab.label }}</span>
<em v-if="tab.name === 'routines-overdue' && overdueCount > 0" class="count">
{{ toFaDigits(overdueCount) }}
</em>
</RouterLink>
</nav>
</template>
<p v-if="contextError" class="error">{{ contextError }}</p>
<RouterView v-slot="{ Component }">
<component :is="Component" :key="String(route.name)" />
</RouterView>
<RoutineTaskDrawer :task-id="taskId" @close="closeTask" />
<QuickTaskModal :open="quickOpen" @close="quickOpen = false" />
</section>
</template>
<style scoped>
.routines-shell {
gap: var(--sp-4);
}
.icon-link {
width: var(--control-h);
height: var(--control-h);
display: inline-grid;
place-items: center;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
color: var(--ink-secondary);
flex: 0 0 auto !important;
}
.icon-link:hover,
.icon-link.router-link-active {
border-color: var(--accent);
color: var(--accent-deep);
background: var(--accent-subtle);
}
.r-tabs {
display: flex;
gap: var(--sp-5);
border-bottom: 1px solid var(--line);
overflow-x: auto;
scrollbar-width: none;
}
.r-tabs::-webkit-scrollbar {
display: none;
}
.r-tab {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0.6rem 0.15rem;
margin-bottom: -1px;
border-bottom: 2px solid transparent;
color: var(--muted-light);
font-weight: var(--fw-bold);
font-size: var(--fs-sm);
white-space: nowrap;
transition: color var(--transition-fast), border-color var(--transition-fast);
}
.r-tab:hover {
color: var(--ink-secondary);
}
.r-tab.on {
color: var(--accent-hover);
border-bottom-color: var(--accent);
}
.count {
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 999px;
background: var(--danger);
color: #fff;
font-style: normal;
font-size: 0.68rem;
display: inline-grid;
place-items: center;
}
.error {
margin: 0;
}
@media (max-width: 900px) {
.r-tabs {
gap: var(--sp-4);
margin-inline: calc(-1 * var(--sp-4));
padding-inline: var(--sp-4);
}
}
</style>
@@ -0,0 +1,460 @@
<script setup lang="ts">
import { computed, inject, reactive, ref, watch } from 'vue';
import type {
RoutineExecutionRowDto,
RoutineStatsQuery,
RoutineTaskSource,
RoutineTaskStatus,
RoutineTeamPerformanceDto,
} from '@mento/shared';
import { ROUTINE_TASK_SOURCE_LABELS, ROUTINE_TASK_STATUS_LABELS } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import KpiCard from '@/components/routines/KpiCard.vue';
import TeamPerformanceTable from '@/components/routines/TeamPerformanceTable.vue';
import WeeklyReport from '@/components/routines/WeeklyReport.vue';
import MonthlyReport from '@/components/routines/MonthlyReport.vue';
import { UiButton, UiChart, UiSegmented, UiSelect } from '@/components/ui';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import { categoryBar, membersBar, statusDonut, trendOption } from '@/lib/routineCharts';
import {
ROUTINES_CTX,
addDaysKey,
apiError,
formatPercent,
percentTone,
todayKey,
} from '@/lib/routines';
const shell = inject(ROUTINES_CTX)!;
type Section = 'overview' | 'weekly' | 'monthly' | 'routines';
const section = ref<Section>('overview');
const sections = [
{ value: 'overview' as const, label: 'نمای کلی تیم' },
{ value: 'weekly' as const, label: 'گزارش هفتگی' },
{ value: 'monthly' as const, label: 'گزارش ماهانه' },
{ value: 'routines' as const, label: 'اجرای روتین‌ها' },
];
const f = reactive({
from: addDaysKey(todayKey(), -29),
to: todayKey(),
userId: '',
teamId: '',
categoryId: '',
source: '' as RoutineTaskSource | '',
status: '' as RoutineTaskStatus | '',
});
const users = computed(() => shell.context.value?.users ?? []);
const teams = computed(() => shell.context.value?.teams ?? []);
const categories = computed(() => shell.context.value?.categories ?? []);
const sources = Object.entries(ROUTINE_TASK_SOURCE_LABELS) as Array<[RoutineTaskSource, string]>;
const statuses = Object.entries(ROUTINE_TASK_STATUS_LABELS) as Array<[RoutineTaskStatus, string]>;
const query = computed<RoutineStatsQuery>(() => {
const q: RoutineStatsQuery = {};
if (f.from) q.from = f.from;
if (f.to) q.to = f.to;
if (f.userId) q.userId = f.userId;
if (f.teamId) q.teamId = f.teamId;
if (f.categoryId) q.categoryId = f.categoryId;
if (f.source) q.source = f.source;
if (f.status) q.status = f.status;
return q;
});
const hasFilters = computed(() => !!(f.userId || f.teamId || f.categoryId || f.source || f.status));
function resetFilters() {
Object.assign(f, { userId: '', teamId: '', categoryId: '', source: '', status: '' });
}
const data = ref<RoutineTeamPerformanceDto | null>(null);
const exec = ref<RoutineExecutionRowDto[]>([]);
const loading = ref(false);
const error = ref('');
async function load() {
if (section.value !== 'overview' && section.value !== 'routines') return;
loading.value = true;
error.value = '';
try {
if (section.value === 'overview') {
const res = await api.get<RoutineTeamPerformanceDto>('/routines/stats/team', { params: query.value });
data.value = res.data;
} else {
const res = await api.get<RoutineExecutionRowDto[]>('/routines/stats/routines', { params: query.value });
exec.value = res.data;
}
} catch (e) {
error.value = apiError(e, 'بارگذاری گزارش تیم ممکن نشد.');
} finally {
loading.value = false;
}
}
watch([section, () => JSON.stringify(query.value), shell.refreshTick], load, { immediate: true });
const d = computed(() => data.value);
const activeMembers = computed(() => (d.value?.members ?? []).filter((m) => m.total > 0).length);
const trendOpt = computed(() => trendOption(d.value?.trend ?? []));
const donutOpt = computed(() =>
statusDonut(d.value?.status ?? { completed: 0, notDone: 0, overdue: 0, skipped: 0, cancelled: 0 }),
);
const membersOpt = computed(() => membersBar(d.value?.members ?? []));
const catOpt = computed(() => categoryBar(d.value?.byCategory ?? []));
const membersChartHeight = computed(() => `${Math.max(200, activeMembers.value * 34 + 40)}px`);
const execSort = ref<'percent' | 'occurrences'>('percent');
const execRows = computed(() =>
[...exec.value].sort((a, b) =>
execSort.value === 'percent' ? (a.percent ?? -1) - (b.percent ?? -1) : b.occurrences - a.occurrences,
),
);
</script>
<template>
<div class="team">
<UiSegmented v-model="section" :options="sections" class="sections" />
<!-- فیلترها -->
<section class="filters">
<div v-if="section === 'overview' || section === 'routines'" class="f-item date">
<label>از</label>
<JalaliDateInput v-model="f.from" mode="date" :clearable="false" />
</div>
<div v-if="section === 'overview' || section === 'routines'" class="f-item date">
<label>تا</label>
<JalaliDateInput v-model="f.to" mode="date" :clearable="false" />
</div>
<div class="f-item">
<label>کاربر</label>
<UiSelect v-model="f.userId" size="sm">
<option value="">همه کاربران</option>
<option v-for="u in users" :key="u.id" :value="u.id">{{ u.fullName }}</option>
</UiSelect>
</div>
<div v-if="teams.length" class="f-item">
<label>تیم</label>
<UiSelect v-model="f.teamId" size="sm">
<option value="">همه تیم‌ها</option>
<option v-for="t in teams" :key="t.id" :value="t.id">{{ t.name }}</option>
</UiSelect>
</div>
<div class="f-item">
<label>دسته‌بندی</label>
<UiSelect v-model="f.categoryId" size="sm">
<option value="">همه دسته‌ها</option>
<option v-for="c in categories" :key="c.id" :value="c.id">{{ c.name }}</option>
</UiSelect>
</div>
<div v-if="section !== 'routines'" class="f-item">
<label>نوع کار</label>
<UiSelect v-model="f.source" size="sm">
<option value="">همه</option>
<option v-for="[k, label] in sources" :key="k" :value="k">{{ label }}</option>
</UiSelect>
</div>
<div v-if="section === 'overview'" class="f-item">
<label>وضعیت</label>
<UiSelect v-model="f.status" size="sm">
<option value="">همه</option>
<option v-for="[k, label] in statuses" :key="k" :value="k">{{ label }}</option>
</UiSelect>
</div>
<UiButton v-if="hasFilters" variant="ghost" size="sm" class="reset" @click="resetFilters">
<template #icon><UiIcon name="x" :size="14" /></template>
حذف فیلترها
</UiButton>
</section>
<p v-if="error" class="error">{{ error }}</p>
<!-- نمای کلی -->
<template v-if="section === 'overview'">
<div class="kpis" :class="{ dim: loading }">
<KpiCard label="درصد انجام تیم" :value="formatPercent(d?.percent)" icon="target" tone="accent" />
<KpiCard
label="انجام‌شده"
:value="toFaDigits(d?.completed ?? 0)"
:hint="`از ${toFaDigits(d?.total ?? 0)} کار`"
icon="check"
tone="success"
/>
<KpiCard label="عقب‌افتاده" :value="toFaDigits(d?.overdue ?? 0)" icon="warning" tone="danger" />
<KpiCard
label="اعضای فعال"
:value="toFaDigits(activeMembers)"
:hint="`از ${toFaDigits(d?.members.length ?? 0)} نفر`"
icon="users"
tone="info"
/>
</div>
<div class="charts">
<section class="card wide">
<h3>روند درصد انجام تیم</h3>
<UiChart :option="trendOpt" height="260px" :loading="loading" />
</section>
<section class="card">
<h3>وضعیت کارها</h3>
<UiChart :option="donutOpt" height="260px" :loading="loading" />
</section>
<section class="card">
<h3>مقایسه اعضا</h3>
<UiChart v-if="activeMembers" :option="membersOpt" :height="membersChartHeight" :loading="loading" />
<p v-else class="muted">داده‌ای برای نمایش نیست.</p>
</section>
<section class="card wide">
<h3>انجام به تفکیک دسته‌بندی</h3>
<UiChart v-if="d?.byCategory.length" :option="catOpt" height="240px" :loading="loading" />
<p v-else class="muted">داده‌ای برای نمایش نیست.</p>
</section>
</div>
<section class="card">
<h3>عملکرد اعضا</h3>
<TeamPerformanceTable :members="d?.members ?? []" @select="(id) => (f.userId = id)" />
</section>
</template>
<WeeklyReport
v-else-if="section === 'weekly'"
view="team"
:filters="query"
:refresh-key="shell.refreshTick.value"
/>
<MonthlyReport
v-else-if="section === 'monthly'"
view="team"
:filters="query"
:refresh-key="shell.refreshTick.value"
/>
<!-- گزارش اجرای روتین‌ها -->
<section v-else class="card">
<div class="exec-head">
<h3>گزارش اجرای روتین‌ها</h3>
<UiSelect v-model="execSort" size="sm" class="exec-sort">
<option value="percent">مرتب‌سازی: کمترین اجرا</option>
<option value="occurrences">مرتب‌سازی: بیشترین تکرار</option>
</UiSelect>
</div>
<div class="crm-table-container" :class="{ dim: loading }">
<table class="crm-table">
<thead>
<tr>
<th>روتین</th>
<th>مسئول</th>
<th>تکرار</th>
<th>دفعات</th>
<th>انجام شده</th>
<th>انجام نشده</th>
<th>رد شده</th>
<th>درصد اجرا</th>
</tr>
</thead>
<tbody>
<tr v-for="r in execRows" :key="r.routineId">
<td><strong>{{ r.title }}</strong></td>
<td class="nowrap">{{ r.assigneeLabel }}</td>
<td class="nowrap">{{ r.recurrenceLabel }}</td>
<td>{{ toFaDigits(r.occurrences) }}</td>
<td class="ok">{{ toFaDigits(r.completed) }}</td>
<td :class="{ bad: r.notDone > 0 }">{{ toFaDigits(r.notDone) }}</td>
<td>{{ toFaDigits(r.skipped) }}</td>
<td>
<span class="pct" :class="`tone-${percentTone(r.percent)}`">
<strong>{{ formatPercent(r.percent) }}</strong>
<i><b :style="{ width: `${r.percent ?? 0}%` }" /></i>
</span>
</td>
</tr>
<tr v-if="!execRows.length && !loading">
<td colspan="8" class="empty">در این بازه روتینی اجرا نشده است.</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
</template>
<style scoped>
.team {
display: grid;
gap: var(--sp-4);
}
.sections {
justify-self: start;
max-width: 100%;
overflow-x: auto;
}
.filters {
display: flex;
align-items: flex-end;
flex-wrap: wrap;
gap: var(--sp-3);
padding: var(--sp-3) var(--sp-4);
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
}
.f-item {
display: grid;
gap: 4px;
min-width: 150px;
}
.f-item.date {
min-width: 160px;
}
.f-item label {
font-size: var(--fs-xs);
color: var(--muted);
}
.reset {
margin-inline-start: auto;
}
.kpis {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--sp-3);
transition: opacity var(--transition-fast);
}
.dim {
opacity: 0.55;
}
.charts {
display: grid;
grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr);
gap: var(--sp-4);
}
.card {
padding: var(--sp-4);
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
display: grid;
gap: var(--sp-3);
align-content: start;
min-width: 0;
}
.card h3 {
margin: 0;
font-size: var(--fs-sm);
font-weight: var(--fw-semibold);
color: var(--ink-secondary);
}
.muted {
margin: 0;
color: var(--muted);
font-size: var(--fs-sm);
text-align: center;
padding: var(--sp-6);
}
.exec-head {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--sp-2);
}
.exec-sort {
width: 220px;
}
.nowrap {
white-space: nowrap;
}
.ok {
color: var(--success-deep);
}
.bad {
color: var(--danger);
font-weight: var(--fw-semibold);
}
.pct {
display: grid;
grid-template-columns: 44px 80px;
align-items: center;
gap: 8px;
}
.pct i {
height: 6px;
border-radius: 99px;
background: var(--surface-subtle);
overflow: hidden;
}
.pct b {
display: block;
height: 100%;
background: var(--line-strong);
}
.tone-good b {
background: var(--success);
}
.tone-mid b {
background: var(--warning);
}
.tone-low b {
background: var(--danger);
}
.empty {
text-align: center;
color: var(--muted);
padding: var(--sp-6);
}
.error {
margin: 0;
}
@media (max-width: 1000px) {
.charts {
grid-template-columns: 1fr;
}
.kpis {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--sp-2);
}
}
@media (max-width: 700px) {
.f-item,
.f-item.date {
min-width: calc(50% - var(--sp-3));
flex: 1 1 calc(50% - var(--sp-3));
}
.exec-sort {
width: 100%;
}
}
</style>
@@ -0,0 +1,529 @@
<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>