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';