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:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user