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';
|
||||
|
||||
@@ -38,6 +38,7 @@ const hubStack = ref<NavHubKey[]>([]);
|
||||
const isMobileNav = ref(false);
|
||||
const globalSearch = ref('');
|
||||
const activityBadge = ref(0);
|
||||
const routineBadge = ref(0);
|
||||
|
||||
function pathIn(prefixes: string[]) {
|
||||
return prefixes.some(
|
||||
@@ -67,6 +68,7 @@ const salesActive = computed(() =>
|
||||
);
|
||||
const activitiesActive = computed(() => pathIn(['/activities']));
|
||||
const notesActive = computed(() => route.path === '/notes' || route.path.startsWith('/notes/'));
|
||||
const routinesActive = computed(() => pathIn(['/routines']));
|
||||
const conversationsActive = computed(() => pathIn(['/conversations']));
|
||||
const supportActive = computed(() => pathIn(['/tickets']));
|
||||
const automationsActive = computed(
|
||||
@@ -100,6 +102,7 @@ const settingsActive = computed(() =>
|
||||
);
|
||||
const moreSectionActive = computed(
|
||||
() =>
|
||||
routinesActive.value ||
|
||||
notesActive.value ||
|
||||
conversationsActive.value ||
|
||||
supportActive.value ||
|
||||
@@ -285,6 +288,16 @@ async function loadActivityBadge() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRoutineBadge() {
|
||||
if (!moduleAllowed('routines')) return;
|
||||
try {
|
||||
const { data } = await api.get<{ remaining: number; overdue: number }>('/routine-tasks/badge');
|
||||
routineBadge.value = data.remaining + data.overdue;
|
||||
} catch {
|
||||
routineBadge.value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadChatBadge() {
|
||||
if (!moduleAllowed('conversations')) return;
|
||||
try {
|
||||
@@ -374,9 +387,11 @@ onMounted(() => {
|
||||
}
|
||||
void refreshNotifs();
|
||||
void loadActivityBadge();
|
||||
void loadRoutineBadge();
|
||||
void loadChatBadge();
|
||||
pollTimer = setInterval(() => {
|
||||
void refreshNotifs();
|
||||
void loadRoutineBadge();
|
||||
void refreshUnreadTotal().catch(() => undefined);
|
||||
}, 45000);
|
||||
mobileMq = window.matchMedia('(max-width: 900px)');
|
||||
@@ -510,6 +525,19 @@ onUnmounted(() => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<RouterLink
|
||||
v-if="moduleAllowed('routines')"
|
||||
to="/routines"
|
||||
class="nav-item"
|
||||
title="کارهای روتین"
|
||||
:class="{ 'router-link-active': routinesActive }"
|
||||
@click="closeHub(); closeMenu()"
|
||||
>
|
||||
<UiIcon name="refresh" :size="collapsed ? 20 : 18" />
|
||||
<span>کارهای روتین</span>
|
||||
<span v-if="routineBadge" class="nav-badge">{{ toFaDigits(routineBadge) }}</span>
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink
|
||||
v-if="moduleAllowed('conversations')"
|
||||
to="/conversations"
|
||||
@@ -725,7 +753,8 @@ onUnmounted(() => {
|
||||
:title="`انقضا: ${subscription.expiresAt}`"
|
||||
>
|
||||
<UiIcon name="clock" :size="15" />
|
||||
<span>
|
||||
<span v-if="subscription.planCode === 'lifetime'">اشتراک دائمی</span>
|
||||
<span v-else>
|
||||
{{ toFaDigits(subscription.daysRemaining) }} روز باقیمانده
|
||||
</span>
|
||||
</div>
|
||||
@@ -858,6 +887,17 @@ onUnmounted(() => {
|
||||
<button class="round-btn sm" type="button" aria-label="بستن" @click="closeMore">✕</button>
|
||||
</header>
|
||||
<nav class="more-body">
|
||||
<RouterLink
|
||||
v-if="moduleAllowed('routines')"
|
||||
to="/routines"
|
||||
class="nav-item"
|
||||
:class="{ 'router-link-active': routinesActive }"
|
||||
@click="closeHub(); closeMore()"
|
||||
>
|
||||
<UiIcon name="refresh" :size="18" />
|
||||
<span>کارهای روتین</span>
|
||||
<span v-if="routineBadge" class="nav-badge">{{ toFaDigits(routineBadge) }}</span>
|
||||
</RouterLink>
|
||||
<RouterLink to="/notes" class="nav-item" @click="closeHub(); closeMore()">
|
||||
<UiIcon name="document" :size="18" />
|
||||
<span>یادداشتها</span>
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
import type {
|
||||
RoutineCalendarDayDto,
|
||||
RoutineCategoryStatDto,
|
||||
RoutineMemberStatDto,
|
||||
RoutineStatusBreakdownDto,
|
||||
RoutineTrendPointDto,
|
||||
} from '@mento/shared';
|
||||
import type { UiChartOption } from '@/components/ui';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import { formatDayNumeric } from '@/lib/routines';
|
||||
|
||||
const FONT = 'IRANSans, Tahoma, sans-serif';
|
||||
const AXIS_LABEL = { color: '#64748b', fontFamily: FONT, fontSize: 11 };
|
||||
const SPLIT = { lineStyle: { color: '#f1f5f9', type: 'dashed' as const } };
|
||||
const LEGEND = { bottom: 0, left: 'center', textStyle: { fontFamily: FONT, fontSize: 11, color: '#475569' } };
|
||||
|
||||
export const STATUS_COLORS = {
|
||||
completed: '#10b981',
|
||||
notDone: '#94a3b8',
|
||||
overdue: '#ef4444',
|
||||
skipped: '#f59e0b',
|
||||
cancelled: '#cbd5e1',
|
||||
};
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
/** روند درصد انجام روزانه */
|
||||
export function trendOption(trend: RoutineTrendPointDto[]): UiChartOption {
|
||||
return {
|
||||
textStyle: { fontFamily: FONT },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: (params: any) => {
|
||||
const item = Array.isArray(params) ? params[0] : params;
|
||||
const p = trend[item?.dataIndex ?? -1];
|
||||
if (!p) return '';
|
||||
return `${formatDayNumeric(p.date)}<br/>درصد انجام: <strong>${p.percent == null ? '—' : `${toFaDigits(p.percent)}٪`}</strong><br/>${toFaDigits(p.completed)} از ${toFaDigits(p.total)}`;
|
||||
},
|
||||
},
|
||||
grid: { left: 8, right: 16, top: 16, bottom: 8, containLabel: true },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: trend.map((p) => formatDayNumeric(p.date)),
|
||||
axisLine: { lineStyle: { color: '#cbd5e1' } },
|
||||
axisLabel: { ...AXIS_LABEL, hideOverlap: true },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
max: 100,
|
||||
splitLine: SPLIT,
|
||||
axisLabel: { ...AXIS_LABEL, formatter: (v: number) => `${toFaDigits(v)}٪` },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: 'درصد انجام',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
connectNulls: true,
|
||||
showSymbol: false,
|
||||
itemStyle: { color: '#14b8a6' },
|
||||
lineStyle: { width: 3, color: '#0d9488' },
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: 'rgba(20, 184, 166, 0.3)' },
|
||||
{ offset: 1, color: 'rgba(20, 184, 166, 0.01)' },
|
||||
],
|
||||
},
|
||||
},
|
||||
data: trend.map((p) => p.percent),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** دونات وضعیت کارها */
|
||||
export function statusDonut(s: RoutineStatusBreakdownDto): UiChartOption {
|
||||
const rows = [
|
||||
{ name: 'انجام شده', value: s.completed, color: STATUS_COLORS.completed },
|
||||
{ name: 'انجام نشده', value: s.notDone, color: STATUS_COLORS.notDone },
|
||||
{ name: 'عقبافتاده', value: s.overdue, color: STATUS_COLORS.overdue },
|
||||
{ name: 'رد شده', value: s.skipped, color: STATUS_COLORS.skipped },
|
||||
].filter((r) => r.value > 0);
|
||||
return {
|
||||
textStyle: { fontFamily: FONT },
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: (p: any) => `${p.name}: <strong>${toFaDigits(p.value)}</strong> (${toFaDigits(p.percent)}٪)`,
|
||||
},
|
||||
legend: LEGEND,
|
||||
series: [
|
||||
{
|
||||
name: 'وضعیت',
|
||||
type: 'pie',
|
||||
radius: ['48%', '72%'],
|
||||
center: ['50%', '44%'],
|
||||
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
|
||||
label: { show: false },
|
||||
data: rows.map((r) => ({ name: r.name, value: r.value, itemStyle: { color: r.color } })),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** میلهای انجام به تفکیک دسته */
|
||||
export function categoryBar(rows: RoutineCategoryStatDto[]): UiChartOption {
|
||||
return {
|
||||
textStyle: { fontFamily: FONT },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'shadow' },
|
||||
formatter: (params: any) => {
|
||||
const item = Array.isArray(params) ? params[0] : params;
|
||||
const r = rows[item?.dataIndex ?? -1];
|
||||
if (!r) return '';
|
||||
return `${r.name}<br/>${toFaDigits(r.completed)} از ${toFaDigits(r.total)} · <strong>${r.percent == null ? '—' : `${toFaDigits(r.percent)}٪`}</strong>`;
|
||||
},
|
||||
},
|
||||
grid: { left: 8, right: 16, top: 16, bottom: 8, containLabel: true },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: rows.map((r) => r.name),
|
||||
axisLine: { lineStyle: { color: '#cbd5e1' } },
|
||||
axisLabel: { ...AXIS_LABEL, interval: 0 },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
max: 100,
|
||||
splitLine: SPLIT,
|
||||
axisLabel: { ...AXIS_LABEL, formatter: (v: number) => `${toFaDigits(v)}٪` },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: 'درصد انجام',
|
||||
type: 'bar',
|
||||
barMaxWidth: 36,
|
||||
data: rows.map((r) => ({
|
||||
value: r.percent ?? 0,
|
||||
itemStyle: { color: r.color, borderRadius: [6, 6, 0, 0] },
|
||||
})),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** میلهای انجامشده/کل به تفکیک روز */
|
||||
export function daysBar(days: RoutineCalendarDayDto[], labels?: string[]): UiChartOption {
|
||||
return {
|
||||
textStyle: { fontFamily: FONT },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'shadow' },
|
||||
formatter: (params: any) => {
|
||||
const list = Array.isArray(params) ? params : [params];
|
||||
const d = days[list[0]?.dataIndex ?? -1];
|
||||
if (!d) return '';
|
||||
return `${formatDayNumeric(d.date)}<br/>انجام شده: <strong>${toFaDigits(d.completed)}</strong> از ${toFaDigits(d.total)}`;
|
||||
},
|
||||
},
|
||||
legend: LEGEND,
|
||||
grid: { left: 8, right: 16, top: 16, bottom: 32, containLabel: true },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: labels ?? days.map((d) => formatDayNumeric(d.date)),
|
||||
axisLine: { lineStyle: { color: '#cbd5e1' } },
|
||||
axisLabel: { ...AXIS_LABEL, hideOverlap: true },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
minInterval: 1,
|
||||
splitLine: SPLIT,
|
||||
axisLabel: { ...AXIS_LABEL, formatter: (v: number) => toFaDigits(v) },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: 'انجام شده',
|
||||
type: 'bar',
|
||||
stack: 'all',
|
||||
barMaxWidth: 28,
|
||||
itemStyle: { color: STATUS_COLORS.completed },
|
||||
data: days.map((d) => d.completed),
|
||||
},
|
||||
{
|
||||
name: 'باقیمانده',
|
||||
type: 'bar',
|
||||
stack: 'all',
|
||||
barMaxWidth: 28,
|
||||
itemStyle: { color: '#e2e8f0', borderRadius: [6, 6, 0, 0] },
|
||||
data: days.map((d) => Math.max(0, d.total - d.completed)),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** میلهای افقی مقایسه اعضا */
|
||||
export function membersBar(members: RoutineMemberStatDto[]): UiChartOption {
|
||||
const rows = [...members].filter((m) => m.total > 0).sort((a, b) => (a.percent ?? 0) - (b.percent ?? 0));
|
||||
return {
|
||||
textStyle: { fontFamily: FONT },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'shadow' },
|
||||
formatter: (params: any) => {
|
||||
const item = Array.isArray(params) ? params[0] : params;
|
||||
const m = rows[item?.dataIndex ?? -1];
|
||||
if (!m) return '';
|
||||
return `${m.user.fullName}<br/>${toFaDigits(m.completed)} از ${toFaDigits(m.total)} · <strong>${m.percent == null ? '—' : `${toFaDigits(m.percent)}٪`}</strong>`;
|
||||
},
|
||||
},
|
||||
grid: { left: 8, right: 24, top: 8, bottom: 8, containLabel: true },
|
||||
xAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
max: 100,
|
||||
splitLine: SPLIT,
|
||||
axisLabel: { ...AXIS_LABEL, formatter: (v: number) => `${toFaDigits(v)}٪` },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
data: rows.map((m) => m.user.fullName),
|
||||
axisLabel: AXIS_LABEL,
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: 'درصد انجام',
|
||||
type: 'bar',
|
||||
barMaxWidth: 18,
|
||||
data: rows.map((m) => ({
|
||||
value: m.percent ?? 0,
|
||||
itemStyle: {
|
||||
borderRadius: [0, 6, 6, 0],
|
||||
color: (m.percent ?? 0) >= 85 ? '#10b981' : (m.percent ?? 0) >= 60 ? '#f59e0b' : '#ef4444',
|
||||
},
|
||||
})),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import axios from 'axios';
|
||||
import type { InjectionKey, Ref } from 'vue';
|
||||
import type {
|
||||
ActivityPriority,
|
||||
RoutineContextDto,
|
||||
RoutineDayState,
|
||||
RoutineEntityRefDto,
|
||||
RoutineTaskDto,
|
||||
RoutineTaskStatus,
|
||||
} from '@mento/shared';
|
||||
import {
|
||||
addJalaliMonths,
|
||||
dateToJalali,
|
||||
formatJalaliLong,
|
||||
formatJalaliShort,
|
||||
jalaliMonthLength,
|
||||
jalaliMonthName,
|
||||
jalaliToDate,
|
||||
toFaDigits,
|
||||
} from '@/lib/jalali';
|
||||
|
||||
export {
|
||||
ROUTINE_ENTITY_LINK_LABELS,
|
||||
ROUTINE_RECURRENCE_LABELS,
|
||||
ROUTINE_TASK_SOURCE_LABELS,
|
||||
ROUTINE_TASK_STATUS_LABELS,
|
||||
ROUTINE_WEEKDAY_LABELS,
|
||||
} from '@mento/shared';
|
||||
|
||||
type BadgeVariant = 'neutral' | 'accent' | 'success' | 'warning' | 'danger' | 'info';
|
||||
|
||||
export const PRIORITY_LABELS: Record<ActivityPriority, string> = {
|
||||
very_low: 'خیلی کم',
|
||||
low: 'کم',
|
||||
medium: 'متوسط',
|
||||
high: 'زیاد',
|
||||
very_high: 'خیلی زیاد',
|
||||
};
|
||||
|
||||
export const PRIORITY_VARIANTS: Record<ActivityPriority, BadgeVariant> = {
|
||||
very_low: 'neutral',
|
||||
low: 'neutral',
|
||||
medium: 'info',
|
||||
high: 'warning',
|
||||
very_high: 'danger',
|
||||
};
|
||||
|
||||
export const STATUS_VARIANTS: Record<RoutineTaskStatus, BadgeVariant> = {
|
||||
todo: 'neutral',
|
||||
in_progress: 'info',
|
||||
completed: 'success',
|
||||
overdue: 'danger',
|
||||
skipped: 'warning',
|
||||
cancelled: 'neutral',
|
||||
};
|
||||
|
||||
export const DAY_STATE_LABELS: Record<RoutineDayState, string> = {
|
||||
complete: 'روز کامل',
|
||||
partial: 'روز ناقص',
|
||||
missed: 'روز از دست رفته',
|
||||
empty: 'بدون کار',
|
||||
};
|
||||
|
||||
export const DAY_STATE_SYMBOLS: Record<RoutineDayState, string> = {
|
||||
complete: '✓',
|
||||
partial: '△',
|
||||
missed: '×',
|
||||
empty: '·',
|
||||
};
|
||||
|
||||
/** زمینه مشترک بخش کارهای روتین (کاربران، دستهها، نقش مدیر، رفرش) */
|
||||
export type RoutinesShellContext = {
|
||||
context: Ref<RoutineContextDto | null>;
|
||||
refreshTick: Ref<number>;
|
||||
notifyChanged: () => void;
|
||||
openTask: (id: string) => void;
|
||||
};
|
||||
|
||||
export const ROUTINES_CTX: InjectionKey<RoutinesShellContext> = Symbol('routines-ctx');
|
||||
|
||||
/* ───────── تاریخها (کلید روز YYYY-MM-DD به وقت تهران) ───────── */
|
||||
|
||||
const TEHRAN_DAY = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Tehran',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
});
|
||||
|
||||
const TEHRAN_TIME = new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: 'Asia/Tehran',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
});
|
||||
|
||||
export function todayKey(): string {
|
||||
return TEHRAN_DAY.format(new Date());
|
||||
}
|
||||
|
||||
export function dayKeyOf(iso: string | Date): string {
|
||||
return TEHRAN_DAY.format(typeof iso === 'string' ? new Date(iso) : iso);
|
||||
}
|
||||
|
||||
/** Date محلی (نیمهشب) برای نمایش شمسی */
|
||||
export function keyToDate(key: string): Date {
|
||||
const [y, m, d] = key.split('-').map(Number);
|
||||
return new Date(y!, m! - 1, d!);
|
||||
}
|
||||
|
||||
export function dateToKey(d: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
}
|
||||
|
||||
export function addDaysKey(key: string, n: number): string {
|
||||
const d = keyToDate(key);
|
||||
d.setDate(d.getDate() + n);
|
||||
return dateToKey(d);
|
||||
}
|
||||
|
||||
/** ۰ = شنبه */
|
||||
export function weekdayIndex(key: string): number {
|
||||
return (keyToDate(key).getDay() + 1) % 7;
|
||||
}
|
||||
|
||||
export function weekStart(key: string): string {
|
||||
return addDaysKey(key, -weekdayIndex(key));
|
||||
}
|
||||
|
||||
export function jalaliMonthRange(key: string): { from: string; to: string; label: string } {
|
||||
const { jy, jm } = dateToJalali(keyToDate(key));
|
||||
const from = dateToKey(jalaliToDate(jy, jm, 1));
|
||||
const to = dateToKey(jalaliToDate(jy, jm, jalaliMonthLength(jy, jm)));
|
||||
return { from, to, label: `${jalaliMonthName(jm)} ${toFaDigits(jy)}` };
|
||||
}
|
||||
|
||||
export function shiftJalaliMonth(key: string, delta: number): string {
|
||||
const { jy, jm } = dateToJalali(keyToDate(key));
|
||||
const next = addJalaliMonths(jy, jm, delta);
|
||||
return dateToKey(jalaliToDate(next.jy, next.jm, 1));
|
||||
}
|
||||
|
||||
export function formatDayLong(key: string): string {
|
||||
return formatJalaliLong(keyToDate(key));
|
||||
}
|
||||
|
||||
export function formatDayShort(key: string): string {
|
||||
return formatJalaliShort(keyToDate(key));
|
||||
}
|
||||
|
||||
export function formatDayNumeric(key: string): string {
|
||||
const { jm, jd } = dateToJalali(keyToDate(key));
|
||||
return `${toFaDigits(jd)} ${jalaliMonthName(jm)}`;
|
||||
}
|
||||
|
||||
export function formatTime(iso: string | null | undefined): string {
|
||||
if (!iso) return '—';
|
||||
return toFaDigits(TEHRAN_TIME.format(new Date(iso)));
|
||||
}
|
||||
|
||||
export function timeOf(iso: string | null | undefined): string {
|
||||
if (!iso) return '';
|
||||
return TEHRAN_TIME.format(new Date(iso));
|
||||
}
|
||||
|
||||
/** زمان پایان روز بدون ساعت مشخص (۲۳:۵۹) در UI نمایش داده نمیشود */
|
||||
export function hasExplicitTime(task: Pick<RoutineTaskDto, 'dueAt'>): boolean {
|
||||
return !!task.dueAt && timeOf(task.dueAt) !== '23:59';
|
||||
}
|
||||
|
||||
export function formatDelay(iso: string | null | undefined): string {
|
||||
if (!iso) return '—';
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
if (diff <= 0) return '—';
|
||||
const minutes = Math.floor(diff / 60_000);
|
||||
if (minutes < 60) return `${toFaDigits(minutes)} دقیقه`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${toFaDigits(hours)} ساعت`;
|
||||
return `${toFaDigits(Math.floor(hours / 24))} روز`;
|
||||
}
|
||||
|
||||
export function formatMinutes(min: number | null | undefined): string {
|
||||
if (min == null) return '—';
|
||||
if (min < 60) return `${toFaDigits(min)} دقیقه`;
|
||||
const h = Math.floor(min / 60);
|
||||
const m = min % 60;
|
||||
return m ? `${toFaDigits(h)} ساعت و ${toFaDigits(m)} دقیقه` : `${toFaDigits(h)} ساعت`;
|
||||
}
|
||||
|
||||
export function formatPercent(p: number | null | undefined): string {
|
||||
return p == null ? '—' : `${toFaDigits(p)}٪`;
|
||||
}
|
||||
|
||||
export function percentTone(p: number | null | undefined): 'good' | 'mid' | 'low' | 'none' {
|
||||
if (p == null) return 'none';
|
||||
if (p >= 85) return 'good';
|
||||
if (p >= 60) return 'mid';
|
||||
return 'low';
|
||||
}
|
||||
|
||||
export function isoToLocalInput(iso: string | null | undefined): { date: string; time: string } {
|
||||
if (!iso) return { date: '', time: '' };
|
||||
return { date: dayKeyOf(iso), time: timeOf(iso) };
|
||||
}
|
||||
|
||||
/** ورودی تاریخ/ساعت همیشه به وقت تهران تفسیر میشود */
|
||||
export function localInputToIso(date: string, time: string): string | null {
|
||||
if (!date) return null;
|
||||
const d = new Date(`${date}T${time || '23:59'}:00+03:30`);
|
||||
return Number.isNaN(d.getTime()) ? null : d.toISOString();
|
||||
}
|
||||
|
||||
/* ───────── پیوند پروندهها ───────── */
|
||||
|
||||
export const ENTITY_TYPE_LABELS: Record<RoutineEntityRefDto['type'], string> = {
|
||||
lead: 'لید',
|
||||
person: 'مشتری',
|
||||
company: 'شرکت',
|
||||
deal: 'معامله',
|
||||
invoice: 'فاکتور',
|
||||
activity: 'پیگیری',
|
||||
};
|
||||
|
||||
export function entityRoute(ref: RoutineEntityRefDto): string {
|
||||
switch (ref.type) {
|
||||
case 'lead':
|
||||
return `/leads/${ref.id}`;
|
||||
case 'person':
|
||||
return `/contacts/people/${ref.id}`;
|
||||
case 'company':
|
||||
return `/contacts/companies/${ref.id}`;
|
||||
case 'deal':
|
||||
return `/deals/${ref.id}`;
|
||||
case 'invoice':
|
||||
return `/invoices/${ref.id}`;
|
||||
case 'activity':
|
||||
return `/activities/${ref.id}`;
|
||||
default:
|
||||
return '/';
|
||||
}
|
||||
}
|
||||
|
||||
export function apiError(e: unknown, fallback: string): string {
|
||||
if (axios.isAxiosError(e)) {
|
||||
const msg = (e.response?.data as { message?: string | string[] })?.message;
|
||||
if (Array.isArray(msg)) return msg.join('، ');
|
||||
if (msg) return msg;
|
||||
if (!e.response) return 'اتصال به سرور برقرار نشد.';
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function isOpenStatus(status: RoutineTaskStatus): boolean {
|
||||
return status === 'todo' || status === 'in_progress' || status === 'overdue';
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { reactive, ref } from 'vue';
|
||||
import type { RoutineTaskDto } from '@mento/shared';
|
||||
import { api } from '@/lib/api';
|
||||
import { apiError, isoToLocalInput, localInputToIso, todayKey } from '@/lib/routines';
|
||||
|
||||
export type RoutineTaskModalKind = 'complete' | 'skip' | 'reschedule' | 'cancel';
|
||||
|
||||
/**
|
||||
* اکشنهای مشترک روی یک Task (تیک، رد، جابهجایی، لغو) + وضعیت مودالها.
|
||||
* `onChanged` بعد از هر تغییر موفق با Task بهروزشده صدا زده میشود.
|
||||
*/
|
||||
export function useRoutineTaskActions(onChanged: (task: RoutineTaskDto) => void) {
|
||||
const busyId = ref<string | null>(null);
|
||||
const error = ref('');
|
||||
|
||||
const modal = reactive({
|
||||
kind: null as RoutineTaskModalKind | null,
|
||||
task: null as RoutineTaskDto | null,
|
||||
note: '',
|
||||
date: '',
|
||||
time: '',
|
||||
saving: false,
|
||||
error: '',
|
||||
});
|
||||
|
||||
async function run(task: RoutineTaskDto, path: string, body: object = {}) {
|
||||
busyId.value = task.id;
|
||||
error.value = '';
|
||||
try {
|
||||
const { data } = await api.post<RoutineTaskDto>(`/routine-tasks/${task.id}/${path}`, body);
|
||||
onChanged(data);
|
||||
return data;
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'انجام عملیات ممکن نشد.');
|
||||
return null;
|
||||
} finally {
|
||||
busyId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function open(kind: RoutineTaskModalKind, task: RoutineTaskDto) {
|
||||
const due = isoToLocalInput(task.dueAt);
|
||||
Object.assign(modal, {
|
||||
kind,
|
||||
task,
|
||||
note: kind === 'complete' ? (task.resultNote ?? '') : '',
|
||||
date: kind === 'reschedule' ? due.date || todayKey() : '',
|
||||
time: kind === 'reschedule' ? (due.time === '23:59' ? '' : due.time) : '',
|
||||
saving: false,
|
||||
error: '',
|
||||
});
|
||||
}
|
||||
|
||||
function close() {
|
||||
modal.kind = null;
|
||||
modal.task = null;
|
||||
}
|
||||
|
||||
/** تیک یککلیکی؛ اگر گزارش اجباری باشد مودال نتیجه باز میشود */
|
||||
async function toggle(task: RoutineTaskDto) {
|
||||
if (busyId.value) return;
|
||||
if (task.status === 'completed') return run(task, 'uncomplete');
|
||||
if (task.status === 'cancelled' || task.status === 'skipped') return null;
|
||||
if (task.rules.reportRequired) {
|
||||
open('complete', task);
|
||||
return null;
|
||||
}
|
||||
return run(task, 'complete');
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const task = modal.task;
|
||||
if (!task || !modal.kind) return;
|
||||
modal.error = '';
|
||||
let path = '';
|
||||
let body: object = {};
|
||||
if (modal.kind === 'complete') {
|
||||
if (task.rules.reportRequired && !modal.note.trim()) {
|
||||
modal.error = 'ثبت گزارش برای این کار الزامی است.';
|
||||
return;
|
||||
}
|
||||
path = 'complete';
|
||||
body = { note: modal.note.trim() || null };
|
||||
} else if (modal.kind === 'skip') {
|
||||
if (task.rules.skipReasonRequired && !modal.note.trim()) {
|
||||
modal.error = 'ثبت دلیل رد کردن الزامی است.';
|
||||
return;
|
||||
}
|
||||
path = 'skip';
|
||||
body = { reason: modal.note.trim() || null };
|
||||
} else if (modal.kind === 'cancel') {
|
||||
path = 'cancel';
|
||||
body = { reason: modal.note.trim() || null };
|
||||
} else {
|
||||
const dueAt = localInputToIso(modal.date, modal.time);
|
||||
if (!dueAt) {
|
||||
modal.error = 'تاریخ جدید را انتخاب کنید.';
|
||||
return;
|
||||
}
|
||||
path = 'reschedule';
|
||||
body = { dueAt };
|
||||
}
|
||||
modal.saving = true;
|
||||
try {
|
||||
const { data } = await api.post<RoutineTaskDto>(`/routine-tasks/${task.id}/${path}`, body);
|
||||
onChanged(data);
|
||||
close();
|
||||
} catch (e) {
|
||||
modal.error = apiError(e, 'ذخیره ممکن نشد.');
|
||||
} finally {
|
||||
modal.saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
busyId,
|
||||
error,
|
||||
modal,
|
||||
toggle,
|
||||
start: (task: RoutineTaskDto) => run(task, 'start'),
|
||||
moveToday: (task: RoutineTaskDto) => run(task, 'move-today'),
|
||||
openComplete: (task: RoutineTaskDto) => open('complete', task),
|
||||
openSkip: (task: RoutineTaskDto) => open('skip', task),
|
||||
openReschedule: (task: RoutineTaskDto) => open('reschedule', task),
|
||||
openCancel: (task: RoutineTaskDto) => open('cancel', task),
|
||||
close,
|
||||
submit,
|
||||
};
|
||||
}
|
||||
|
||||
export type RoutineTaskActions = ReturnType<typeof useRoutineTaskActions>;
|
||||
@@ -132,6 +132,62 @@ export const router = createRouter({
|
||||
name: 'activity-detail',
|
||||
component: () => import('@/views/ActivityDetailView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'routines',
|
||||
component: () => import('@/views/routines/RoutinesShellView.vue'),
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'routines-today',
|
||||
component: () => import('@/views/routines/RoutinesTodayView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'overdue',
|
||||
name: 'routines-overdue',
|
||||
component: () => import('@/views/routines/RoutinesOverdueView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'calendar',
|
||||
name: 'routines-calendar',
|
||||
component: () => import('@/views/routines/RoutinesCalendarView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'list',
|
||||
name: 'routines-list',
|
||||
component: () => import('@/views/routines/RoutinesListView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'me',
|
||||
name: 'routines-me',
|
||||
component: () => import('@/views/routines/RoutinesMyPerformanceView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'team',
|
||||
name: 'routines-team',
|
||||
component: () => import('@/views/routines/RoutinesTeamView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'manage',
|
||||
name: 'routines-manage',
|
||||
component: () => import('@/views/routines/RoutinesManageView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'manage/new',
|
||||
name: 'routine-new',
|
||||
component: () => import('@/views/routines/RoutineFormView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'manage/:id/edit',
|
||||
name: 'routine-edit',
|
||||
component: () => import('@/views/routines/RoutineFormView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'routines-settings',
|
||||
component: () => import('@/views/routines/RoutinesSettingsView.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'quotes',
|
||||
name: 'quotes',
|
||||
|
||||
@@ -223,6 +223,29 @@ async function remove() {
|
||||
}
|
||||
}
|
||||
|
||||
const routinesEnabled = computed(() => {
|
||||
const mods = auth.user?.enabledModules;
|
||||
return !mods || mods.includes('routines');
|
||||
});
|
||||
const converting = ref(false);
|
||||
|
||||
/** پیگیری → Task در «کارهای روتین» (تکراری نمیسازد) */
|
||||
async function convertToTask() {
|
||||
if (!id.value || converting.value) return;
|
||||
converting.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const { data } = await api.post<{ id: string }>(`/routine-tasks/from-activity/${id.value}`);
|
||||
moreOpen.value = false;
|
||||
void router.push({ path: '/routines', query: { task: data.id } });
|
||||
} catch (e) {
|
||||
const msg = (e as { response?: { data?: { message?: string | string[] } } }).response?.data?.message;
|
||||
error.value = Array.isArray(msg) ? msg.join('، ') : (msg ?? 'تبدیل به Task ممکن نشد.');
|
||||
} finally {
|
||||
converting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(id, () => {
|
||||
commentOpen.value = false;
|
||||
postponeOpen.value = false;
|
||||
@@ -247,6 +270,9 @@ onMounted(() => {
|
||||
</button>
|
||||
<div v-if="moreOpen && activity" class="menu">
|
||||
<RouterLink :to="{ name: 'activity-edit', params: { id } }">ویرایش</RouterLink>
|
||||
<button v-if="routinesEnabled" type="button" :disabled="converting" @click="convertToTask">
|
||||
تبدیل به Task
|
||||
</button>
|
||||
<button type="button" class="danger" @click="remove">حذف</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { api } from '@/lib/api';
|
||||
import type { CompanyDto, CreateCompanyRequest } from '@mento/shared';
|
||||
import CustomFieldsSection from '@/components/CustomFieldsSection.vue';
|
||||
import EntityTimeline from '@/components/EntityTimeline.vue';
|
||||
import RelatedTasksPanel from '@/components/routines/RelatedTasksPanel.vue';
|
||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||
import { formatLocalDateInput, parseLocalInput } from '@/lib/jalali';
|
||||
import {
|
||||
@@ -347,6 +348,7 @@ onMounted(load);
|
||||
</UiCard>
|
||||
|
||||
<CustomFieldsSection entity-type="company" v-model="customFields" />
|
||||
<RelatedTasksPanel v-if="!isNew && id" :entity="{ type: 'company', id, label: name }" />
|
||||
<EntityTimeline v-if="!isNew && id" entity-type="company" :entity-id="id" />
|
||||
|
||||
<div class="form-bottom-actions">
|
||||
|
||||
@@ -8,6 +8,7 @@ import { toFaDigits } from '@/lib/jalali';
|
||||
import { currencySuffix, formatMoney } from '@/lib/money';
|
||||
import CustomFieldsSection from '@/components/CustomFieldsSection.vue';
|
||||
import EntityTimeline from '@/components/EntityTimeline.vue';
|
||||
import RelatedTasksPanel from '@/components/routines/RelatedTasksPanel.vue';
|
||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||
import MoneyInput from '@/components/MoneyInput.vue';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
@@ -813,6 +814,13 @@ onMounted(load);
|
||||
/>
|
||||
</div>
|
||||
|
||||
<RelatedTasksPanel
|
||||
v-if="!isNew && id"
|
||||
class="timeline"
|
||||
:class="{ hide: tab !== 'main' }"
|
||||
:entity="{ type: 'deal', id, label: title }"
|
||||
/>
|
||||
|
||||
<EntityTimeline
|
||||
v-if="!isNew && id"
|
||||
class="timeline"
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
UiPageHeader,
|
||||
UiSelect,
|
||||
} from '@/components/ui';
|
||||
import RelatedTasksPanel from '@/components/routines/RelatedTasksPanel.vue';
|
||||
import type {
|
||||
ConvertLeadResult,
|
||||
LeadDto,
|
||||
@@ -391,6 +392,8 @@ onMounted(load);
|
||||
</dl>
|
||||
</UiCard>
|
||||
|
||||
<RelatedTasksPanel :entity="{ type: 'lead', id: lead.id, label: lead.title }" />
|
||||
|
||||
<UiCard title="جدول زمانی">
|
||||
<ul v-if="lead.events.length" class="timeline">
|
||||
<li v-for="ev in lead.events" :key="ev.id">
|
||||
|
||||
@@ -24,6 +24,7 @@ import ProfileEmptyState from '@/components/person-profile/ProfileEmptyState.vue
|
||||
import ProfileMoreMenu from '@/components/person-profile/ProfileMoreMenu.vue';
|
||||
import PersonAssetsPanel from '@/components/person-assets/PersonAssetsPanel.vue';
|
||||
import InternalPartyBalance from '@/components/person-assets/InternalPartyBalance.vue';
|
||||
import RelatedTasksPanel from '@/components/routines/RelatedTasksPanel.vue';
|
||||
import type { ProfileMoreItem } from '@/components/person-profile/ProfileMoreMenu.vue';
|
||||
import type {
|
||||
ActivityDto,
|
||||
@@ -44,7 +45,7 @@ import type {
|
||||
} from '@mento/shared';
|
||||
import { PERSON_MOOD_LABELS } from '@/lib/person-mood';
|
||||
|
||||
type TabKey = 'activities' | 'deals' | 'cards' | 'assets' | 'info';
|
||||
type TabKey = 'activities' | 'tasks' | 'deals' | 'cards' | 'assets' | 'info';
|
||||
type TimelineTone = 'call' | 'quote' | 'note' | 'meeting' | 'task';
|
||||
type HistoryFilter = 'all' | TimelineTone;
|
||||
|
||||
@@ -67,6 +68,7 @@ type AttachmentRow = {
|
||||
|
||||
const TABS: Array<{ key: TabKey; label: string }> = [
|
||||
{ key: 'activities', label: 'فعالیتها' },
|
||||
{ key: 'tasks', label: 'کارهای مرتبط' },
|
||||
{ key: 'deals', label: 'معاملات' },
|
||||
{ key: 'cards', label: 'کارتها' },
|
||||
{ key: 'assets', label: 'دارایی' },
|
||||
@@ -389,6 +391,7 @@ function readTab() {
|
||||
const q = route.query.tab;
|
||||
if (
|
||||
q === 'activities' ||
|
||||
q === 'tasks' ||
|
||||
q === 'deals' ||
|
||||
q === 'cards' ||
|
||||
q === 'assets' ||
|
||||
@@ -928,6 +931,10 @@ watch(() => route.query.tab, readTab, { immediate: true });
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="tab === 'tasks'" class="panel">
|
||||
<RelatedTasksPanel :entity="{ type: 'person', id, label: fullName }" />
|
||||
</div>
|
||||
|
||||
<div v-show="tab === 'assets'" class="panel assets-panel">
|
||||
<InternalPartyBalance :person-id="id" />
|
||||
<PersonAssetsPanel :person-id="id" @link-changed="onTahesabLinkChanged" />
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import type { BillingAccountDto, BillingPlanCode } from '@mento/shared';
|
||||
import { BILLING_PLAN_CODES, BILLING_PLAN_LABELS } from '@mento/shared';
|
||||
import {
|
||||
BILLING_PLAN_CODES,
|
||||
BILLING_PLAN_LABELS,
|
||||
isPermanentBillingPlan,
|
||||
} from '@mento/shared';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
|
||||
defineProps<{
|
||||
@@ -20,7 +24,7 @@ const emit = defineEmits<{
|
||||
<p v-if="billing" class="muted">
|
||||
{{ BILLING_PLAN_LABELS[billing.planCode] }}
|
||||
· {{ billing.status }}
|
||||
· {{ toFaDigits(billing.daysRemaining) }} روز
|
||||
· {{ isPermanentBillingPlan(billing.planCode) ? 'بدون انقضا' : `${toFaDigits(billing.daysRemaining)} روز` }}
|
||||
· سقف محیط {{ toFaDigits(billing.maxOrganizations) }}
|
||||
</p>
|
||||
<p v-else class="muted">هنوز پلن ندارد — یکی از پلنها را اعمال کنید.</p>
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
USER_ROLE_LABELS,
|
||||
USER_ROLES,
|
||||
isBusinessField,
|
||||
isPermanentBillingPlan,
|
||||
} from '@mento/shared';
|
||||
import { platformApi } from '@/lib/platform-api';
|
||||
import { BUSINESS_FIELDS, BUSINESS_FIELD_LABELS } from '@/lib/business-fields';
|
||||
@@ -89,6 +90,27 @@ function resetCreateForm() {
|
||||
createForm.ownerMobile = '';
|
||||
}
|
||||
|
||||
function subscriptionBadge(org: PlatformOrganizationDto): {
|
||||
label: string;
|
||||
tone: 'ok' | 'warn' | 'expired' | 'none';
|
||||
} {
|
||||
const sub = org.subscription;
|
||||
if (!sub) return { label: 'بدون اشتراک', tone: 'none' };
|
||||
const plan = BILLING_PLAN_LABELS[sub.planCode] ?? sub.planCode;
|
||||
if (sub.status === 'suspended') return { label: `${plan} · معلق`, tone: 'expired' };
|
||||
if (isPermanentBillingPlan(sub.planCode)) return { label: plan, tone: 'ok' };
|
||||
if (sub.status === 'expired' || sub.daysRemaining <= 0) {
|
||||
return {
|
||||
label: `${plan} · منقضی شده (${formatJalaliNumeric(new Date(sub.expiresAt))})`,
|
||||
tone: 'expired',
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: `${plan} · ${toFaDigits(sub.daysRemaining)} روز مانده`,
|
||||
tone: sub.daysRemaining <= 3 ? 'warn' : 'ok',
|
||||
};
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
@@ -356,6 +378,11 @@ async function applyPlan(userId: string, planCode: BillingPlanCode) {
|
||||
);
|
||||
detail.value = data;
|
||||
}
|
||||
const { data: list } = await platformApi.get<PlatformOrganizationDto[]>(
|
||||
'/platform/organizations',
|
||||
{ params: query.value.trim() ? { q: query.value.trim() } : undefined },
|
||||
);
|
||||
items.value = list;
|
||||
success.value = `پلن «${BILLING_PLAN_LABELS[planCode]}» اعمال شد`;
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'اعمال پلن ناموفق بود');
|
||||
@@ -438,6 +465,9 @@ onMounted(() => {
|
||||
</div>
|
||||
<div class="meta">
|
||||
<em :class="org.isActive ? 'on' : 'off'">{{ org.isActive ? 'فعال' : 'غیرفعال' }}</em>
|
||||
<b class="sub-badge" :class="subscriptionBadge(org).tone">
|
||||
{{ subscriptionBadge(org).label }}
|
||||
</b>
|
||||
<small>
|
||||
{{ toFaDigits(org.memberCount) }} عضو ·
|
||||
{{ toFaDigits(org.personCount) }} شخص ·
|
||||
@@ -779,6 +809,40 @@ label span {
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
.sub-badge {
|
||||
display: inline-block;
|
||||
margin-inline-start: 0.4rem;
|
||||
padding: 0.12rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.sub-badge.ok {
|
||||
color: var(--success-deep);
|
||||
background: #ecfdf5;
|
||||
border-color: #a7f3d0;
|
||||
}
|
||||
|
||||
.sub-badge.warn {
|
||||
color: #b45309;
|
||||
background: #fffbeb;
|
||||
border-color: #fde68a;
|
||||
}
|
||||
|
||||
.sub-badge.expired {
|
||||
color: var(--danger);
|
||||
background: #fef2f2;
|
||||
border-color: #fecaca;
|
||||
}
|
||||
|
||||
.sub-badge.none {
|
||||
color: var(--muted);
|
||||
background: #f1f5f9;
|
||||
border-color: var(--line);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
|
||||
@@ -0,0 +1,530 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, onMounted, reactive, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import type { ActivityPriority, RoutineDto, RoutineEntityLink, SaveRoutineRequest } from '@mento/shared';
|
||||
import { ROUTINE_ENTITY_LINK_LABELS, ROUTINE_WEEKDAY_LABELS } from '@mento/shared';
|
||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||
import RecurrencePicker, { type RecurrenceValue } from '@/components/routines/RecurrencePicker.vue';
|
||||
import {
|
||||
UiButton,
|
||||
UiCard,
|
||||
UiField,
|
||||
UiInput,
|
||||
UiPageHeader,
|
||||
UiSegmented,
|
||||
UiSelect,
|
||||
UiTextarea,
|
||||
} from '@/components/ui';
|
||||
import { api } from '@/lib/api';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import { PRIORITY_LABELS, ROUTINES_CTX, apiError, formatDayNumeric, todayKey } from '@/lib/routines';
|
||||
|
||||
const shell = inject(ROUTINES_CTX)!;
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const id = computed(() => (typeof route.params.id === 'string' ? route.params.id : null));
|
||||
const isEdit = computed(() => !!id.value);
|
||||
|
||||
const form = reactive({
|
||||
title: '',
|
||||
description: '',
|
||||
target: 'user' as 'user' | 'team',
|
||||
assigneeId: '',
|
||||
teamId: '',
|
||||
categoryId: '',
|
||||
priority: 'medium' as ActivityPriority,
|
||||
timeOfDay: '09:00',
|
||||
durationMinutes: '' as string | number,
|
||||
startDate: todayKey(),
|
||||
endDate: '',
|
||||
isMandatory: true,
|
||||
allowSkip: false,
|
||||
skipReasonRequired: true,
|
||||
reportRequired: false,
|
||||
allowAfterDeadline: true,
|
||||
carryOverOverdue: false,
|
||||
entityLink: 'none' as RoutineEntityLink,
|
||||
reminderMinutesBefore: '' as string,
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
const recurrence = ref<RecurrenceValue>({
|
||||
recurrence: 'daily',
|
||||
weekdays: [],
|
||||
intervalDays: null,
|
||||
monthDay: null,
|
||||
specificDates: [],
|
||||
});
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const error = ref('');
|
||||
|
||||
const targetOptions = [
|
||||
{ value: 'user' as const, label: 'یک کاربر' },
|
||||
{ value: 'team' as const, label: 'همه اعضای یک تیم' },
|
||||
];
|
||||
|
||||
const priorities = Object.entries(PRIORITY_LABELS) as Array<[ActivityPriority, string]>;
|
||||
const entityLinks = Object.entries(ROUTINE_ENTITY_LINK_LABELS) as Array<[RoutineEntityLink, string]>;
|
||||
const reminderOptions = [
|
||||
{ value: '', label: 'طبق تنظیمات هر کاربر' },
|
||||
{ value: '0', label: 'سر ساعت' },
|
||||
{ value: '10', label: '۱۰ دقیقه قبل' },
|
||||
{ value: '15', label: '۱۵ دقیقه قبل' },
|
||||
{ value: '30', label: '۳۰ دقیقه قبل' },
|
||||
{ value: '60', label: '۱ ساعت قبل' },
|
||||
{ value: '120', label: '۲ ساعت قبل' },
|
||||
];
|
||||
|
||||
function fill(r: RoutineDto) {
|
||||
Object.assign(form, {
|
||||
title: r.title,
|
||||
description: r.description ?? '',
|
||||
target: r.team ? 'team' : 'user',
|
||||
assigneeId: r.assignee?.id ?? '',
|
||||
teamId: r.team?.id ?? '',
|
||||
categoryId: r.category?.id ?? '',
|
||||
priority: r.priority,
|
||||
timeOfDay: r.timeOfDay,
|
||||
durationMinutes: r.durationMinutes ?? '',
|
||||
startDate: r.startDate,
|
||||
endDate: r.endDate ?? '',
|
||||
isMandatory: r.isMandatory,
|
||||
allowSkip: r.allowSkip,
|
||||
skipReasonRequired: r.skipReasonRequired,
|
||||
reportRequired: r.reportRequired,
|
||||
allowAfterDeadline: r.allowAfterDeadline,
|
||||
carryOverOverdue: r.carryOverOverdue,
|
||||
entityLink: r.entityLink,
|
||||
reminderMinutesBefore: r.reminderMinutesBefore == null ? '' : String(r.reminderMinutesBefore),
|
||||
isActive: r.isActive,
|
||||
});
|
||||
recurrence.value = {
|
||||
recurrence: r.recurrence,
|
||||
weekdays: [...r.weekdays],
|
||||
intervalDays: r.intervalDays,
|
||||
monthDay: r.monthDay,
|
||||
specificDates: [...r.specificDates],
|
||||
};
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!id.value) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await api.get<RoutineDto>(`/routines/${id.value}`);
|
||||
fill(data);
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'روتین پیدا نشد.');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
const users = computed(() => shell.context.value?.users ?? []);
|
||||
const teams = computed(() => shell.context.value?.teams ?? []);
|
||||
const categories = computed(() => shell.context.value?.categories ?? []);
|
||||
|
||||
function joinFa(items: string[]) {
|
||||
if (items.length <= 1) return items.join('');
|
||||
return `${items.slice(0, -1).join('، ')} و ${items[items.length - 1]}`;
|
||||
}
|
||||
|
||||
const summary = computed(() => {
|
||||
const r = recurrence.value;
|
||||
const days = r.weekdays.map((d) => ROUTINE_WEEKDAY_LABELS[d]!);
|
||||
let rec = '';
|
||||
switch (r.recurrence) {
|
||||
case 'daily':
|
||||
rec = 'هر روز';
|
||||
break;
|
||||
case 'weekdays':
|
||||
case 'weekly':
|
||||
rec = days.length ? `هر ${joinFa(days)}` : 'روز هفته انتخاب نشده';
|
||||
break;
|
||||
case 'biweekly':
|
||||
rec = days.length ? `یک هفته در میان، ${joinFa(days)}` : 'روز هفته انتخاب نشده';
|
||||
break;
|
||||
case 'monthly':
|
||||
rec = r.monthDay === 1 ? 'اول هر ماه' : `روز ${toFaDigits(r.monthDay ?? 1)} هر ماه`;
|
||||
break;
|
||||
case 'every_n_days':
|
||||
rec = `هر ${toFaDigits(r.intervalDays ?? 1)} روز یکبار`;
|
||||
break;
|
||||
case 'specific_dates':
|
||||
rec = `${toFaDigits(r.specificDates.length)} تاریخ مشخص`;
|
||||
break;
|
||||
}
|
||||
const who =
|
||||
form.target === 'team'
|
||||
? teams.value.find((t) => t.id === form.teamId)?.name
|
||||
? `برای همه اعضای تیم «${teams.value.find((t) => t.id === form.teamId)?.name}»`
|
||||
: ''
|
||||
: users.value.find((u) => u.id === form.assigneeId)?.fullName
|
||||
? `برای ${users.value.find((u) => u.id === form.assigneeId)?.fullName}`
|
||||
: '';
|
||||
return `${rec}، ساعت ${toFaDigits(form.timeOfDay || '—')}${who ? ` ${who}` : ''} · از ${formatDayNumeric(form.startDate)}${form.endDate ? ` تا ${formatDayNumeric(form.endDate)}` : ''}`;
|
||||
});
|
||||
|
||||
function validate(): string | null {
|
||||
if (form.title.trim().length < 2) return 'عنوان روتین را وارد کنید.';
|
||||
if (form.target === 'user' && !form.assigneeId) return 'مسئول روتین را انتخاب کنید.';
|
||||
if (form.target === 'team' && !form.teamId) return 'تیم را انتخاب کنید.';
|
||||
if (!/^\d{2}:\d{2}$/.test(form.timeOfDay)) return 'ساعت انجام را وارد کنید.';
|
||||
if (!form.startDate) return 'تاریخ شروع را انتخاب کنید.';
|
||||
if (form.endDate && form.endDate < form.startDate) return 'تاریخ پایان نباید قبل از تاریخ شروع باشد.';
|
||||
const r = recurrence.value;
|
||||
if (['weekdays', 'weekly', 'biweekly'].includes(r.recurrence) && !r.weekdays.length) {
|
||||
return 'حداقل یک روز هفته را انتخاب کنید.';
|
||||
}
|
||||
if (r.recurrence === 'every_n_days' && !r.intervalDays) return 'فاصله تکرار را وارد کنید.';
|
||||
if (r.recurrence === 'specific_dates' && !r.specificDates.length) return 'حداقل یک تاریخ اضافه کنید.';
|
||||
return null;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
error.value = validate() ?? '';
|
||||
if (error.value) return;
|
||||
const r = recurrence.value;
|
||||
const duration = Number(form.durationMinutes);
|
||||
const body: SaveRoutineRequest = {
|
||||
title: form.title.trim(),
|
||||
description: form.description.trim() || null,
|
||||
assigneeId: form.target === 'user' ? form.assigneeId : null,
|
||||
teamId: form.target === 'team' ? form.teamId : null,
|
||||
categoryId: form.categoryId || null,
|
||||
priority: form.priority,
|
||||
timeOfDay: form.timeOfDay,
|
||||
durationMinutes: duration > 0 ? Math.floor(duration) : null,
|
||||
startDate: form.startDate,
|
||||
endDate: form.endDate || null,
|
||||
recurrence: r.recurrence,
|
||||
weekdays: ['weekdays', 'weekly', 'biweekly'].includes(r.recurrence) ? r.weekdays : [],
|
||||
intervalDays: r.recurrence === 'every_n_days' ? r.intervalDays : null,
|
||||
monthDay: r.recurrence === 'monthly' ? r.monthDay : null,
|
||||
specificDates: r.recurrence === 'specific_dates' ? r.specificDates : [],
|
||||
isMandatory: form.isMandatory,
|
||||
allowSkip: form.allowSkip,
|
||||
skipReasonRequired: form.allowSkip && form.skipReasonRequired,
|
||||
reportRequired: form.reportRequired,
|
||||
allowAfterDeadline: form.allowAfterDeadline,
|
||||
carryOverOverdue: form.carryOverOverdue,
|
||||
entityLink: form.entityLink,
|
||||
reminderMinutesBefore: form.reminderMinutesBefore === '' ? null : Number(form.reminderMinutesBefore),
|
||||
isActive: form.isActive,
|
||||
};
|
||||
saving.value = true;
|
||||
try {
|
||||
if (isEdit.value) await api.patch(`/routines/${id.value}`, body);
|
||||
else await api.post('/routines', body);
|
||||
shell.notifyChanged();
|
||||
void router.push('/routines/manage');
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'ذخیره روتین ممکن نشد.');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rform">
|
||||
<UiPageHeader
|
||||
:title="isEdit ? 'ویرایش روتین' : 'روتین جدید'"
|
||||
lead="کاری که باید بهصورت منظم انجام شود؛ هر روزِ تکرار، یک کار برای مسئول ساخته میشود."
|
||||
back-to="/routines/manage"
|
||||
/>
|
||||
|
||||
<div v-if="loading" class="state">در حال بارگذاری…</div>
|
||||
|
||||
<form v-else class="grid" @submit.prevent="save">
|
||||
<div class="col">
|
||||
<UiCard>
|
||||
<h3 class="sec-title">اطلاعات پایه</h3>
|
||||
<div class="fields">
|
||||
<UiField label="عنوان" required>
|
||||
<UiInput v-model="form.title" placeholder="مثلاً: تماس با لیدهای جدید" :maxlength="200" />
|
||||
</UiField>
|
||||
<UiField label="توضیحات">
|
||||
<UiTextarea v-model="form.description" :rows="3" placeholder="جزئیات یا دستورالعمل انجام کار" />
|
||||
</UiField>
|
||||
<div class="grid-2">
|
||||
<UiField label="دستهبندی">
|
||||
<UiSelect v-model="form.categoryId">
|
||||
<option value="">بدون دسته</option>
|
||||
<option v-for="c in categories" :key="c.id" :value="c.id">{{ c.name }}</option>
|
||||
</UiSelect>
|
||||
</UiField>
|
||||
<UiField label="اولویت">
|
||||
<UiSelect v-model="form.priority">
|
||||
<option v-for="[k, label] in priorities" :key="k" :value="k">{{ label }}</option>
|
||||
</UiSelect>
|
||||
</UiField>
|
||||
</div>
|
||||
</div>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<h3 class="sec-title">مسئول</h3>
|
||||
<div class="fields">
|
||||
<UiSegmented v-model="form.target" :options="targetOptions" />
|
||||
<UiField v-if="form.target === 'user'" label="کاربر" required>
|
||||
<UiSelect v-model="form.assigneeId">
|
||||
<option value="" disabled>انتخاب کنید</option>
|
||||
<option v-for="u in users" :key="u.id" :value="u.id">{{ u.fullName }}</option>
|
||||
</UiSelect>
|
||||
</UiField>
|
||||
<UiField v-else label="تیم" required hint="برای هر عضو فعال تیم یک کار جداگانه ساخته میشود.">
|
||||
<UiSelect v-model="form.teamId">
|
||||
<option value="" disabled>انتخاب کنید</option>
|
||||
<option v-for="t in teams" :key="t.id" :value="t.id">{{ t.name }}</option>
|
||||
</UiSelect>
|
||||
</UiField>
|
||||
</div>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<h3 class="sec-title">زمانبندی و تکرار</h3>
|
||||
<div class="fields">
|
||||
<div class="grid-2">
|
||||
<UiField label="ساعت انجام" required>
|
||||
<JalaliDateInput v-model="form.timeOfDay" mode="time" :clearable="false" />
|
||||
</UiField>
|
||||
<UiField label="مدت تقریبی (دقیقه)">
|
||||
<UiInput v-model="form.durationMinutes" type="number" placeholder="اختیاری" />
|
||||
</UiField>
|
||||
<UiField label="تاریخ شروع" required>
|
||||
<JalaliDateInput v-model="form.startDate" mode="date" :clearable="false" />
|
||||
</UiField>
|
||||
<UiField label="تاریخ پایان" hint="خالی = بدون پایان">
|
||||
<JalaliDateInput v-model="form.endDate" mode="date" />
|
||||
</UiField>
|
||||
</div>
|
||||
<UiField label="الگوی تکرار" required>
|
||||
<RecurrencePicker v-model="recurrence" />
|
||||
</UiField>
|
||||
</div>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<div class="col side">
|
||||
<UiCard>
|
||||
<h3 class="sec-title">قوانین انجام</h3>
|
||||
<div class="toggles">
|
||||
<label class="tg">
|
||||
<input v-model="form.isMandatory" type="checkbox" />
|
||||
<span>
|
||||
<strong>اجباری</strong>
|
||||
<small>در امتیاز روزانه و روزهای پیاپی کامل حساب میشود.</small>
|
||||
</span>
|
||||
</label>
|
||||
<label class="tg">
|
||||
<input v-model="form.allowSkip" type="checkbox" />
|
||||
<span>
|
||||
<strong>امکان رد کردن</strong>
|
||||
<small>کاربر میتواند این کار را برای یک روز رد کند.</small>
|
||||
</span>
|
||||
</label>
|
||||
<label v-if="form.allowSkip" class="tg nested">
|
||||
<input v-model="form.skipReasonRequired" type="checkbox" />
|
||||
<span><strong>ثبت دلیل رد کردن الزامی باشد</strong></span>
|
||||
</label>
|
||||
<label class="tg">
|
||||
<input v-model="form.reportRequired" type="checkbox" />
|
||||
<span>
|
||||
<strong>گزارش نتیجه الزامی</strong>
|
||||
<small>قبل از انجامشدن، کاربر باید نتیجه را بنویسد.</small>
|
||||
</span>
|
||||
</label>
|
||||
<label class="tg">
|
||||
<input v-model="form.allowAfterDeadline" type="checkbox" />
|
||||
<span>
|
||||
<strong>انجام بعد از مهلت مجاز باشد</strong>
|
||||
<small>اگر خاموش باشد، کار عقبافتاده فقط توسط مدیر بسته میشود.</small>
|
||||
</span>
|
||||
</label>
|
||||
<label class="tg">
|
||||
<input v-model="form.carryOverOverdue" type="checkbox" />
|
||||
<span>
|
||||
<strong>انتقال به روز بعد</strong>
|
||||
<small>کار انجامنشده در فهرست «کارهای امروز» روز بعد هم نمایش داده میشود.</small>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<UiField label="اتصال به پرونده" class="mt">
|
||||
<UiSelect v-model="form.entityLink">
|
||||
<option v-for="[k, label] in entityLinks" :key="k" :value="k">{{ label }}</option>
|
||||
</UiSelect>
|
||||
</UiField>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<h3 class="sec-title">یادآوری و وضعیت</h3>
|
||||
<div class="fields">
|
||||
<UiField label="یادآوری">
|
||||
<UiSelect v-model="form.reminderMinutesBefore">
|
||||
<option v-for="o in reminderOptions" :key="o.value" :value="o.value">{{ o.label }}</option>
|
||||
</UiSelect>
|
||||
</UiField>
|
||||
<label class="tg">
|
||||
<input v-model="form.isActive" type="checkbox" />
|
||||
<span><strong>روتین فعال است</strong></span>
|
||||
</label>
|
||||
</div>
|
||||
</UiCard>
|
||||
|
||||
<div class="summary">
|
||||
<span>خلاصه</span>
|
||||
<p>{{ summary }}</p>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<div class="actions">
|
||||
<UiButton type="submit" :loading="saving" block>{{ isEdit ? 'ذخیره تغییرات' : 'ایجاد روتین' }}</UiButton>
|
||||
<UiButton variant="ghost" block @click="router.push('/routines/manage')">انصراف</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rform {
|
||||
display: grid;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.state {
|
||||
padding: var(--sp-8);
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 360px;
|
||||
gap: var(--sp-4);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.col {
|
||||
display: grid;
|
||||
gap: var(--sp-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.side {
|
||||
position: sticky;
|
||||
top: var(--sp-4);
|
||||
}
|
||||
|
||||
.sec-title {
|
||||
margin: 0 0 var(--sp-4);
|
||||
font-size: var(--fs-md);
|
||||
font-weight: var(--fw-semibold);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.fields {
|
||||
display: grid;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.toggles {
|
||||
display: grid;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.tg {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--sp-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tg.nested {
|
||||
margin-inline-start: 26px;
|
||||
}
|
||||
|
||||
.tg input {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-top: 2px;
|
||||
accent-color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tg span {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.tg strong {
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: var(--fw-medium);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.tg small {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.mt {
|
||||
margin-top: var(--sp-4);
|
||||
}
|
||||
|
||||
.summary {
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border-radius: var(--radius);
|
||||
background: var(--accent-subtle);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 25%, transparent);
|
||||
}
|
||||
|
||||
.summary span {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--accent-deep);
|
||||
font-weight: var(--fw-semibold);
|
||||
}
|
||||
|
||||
.summary p {
|
||||
margin: 4px 0 0;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--ink);
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: grid;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.side {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.grid-2 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,553 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, onMounted, ref, watch } from 'vue';
|
||||
import type { RoutineCalendarDayDto, RoutineCalendarDto, RoutineTaskDto } from '@mento/shared';
|
||||
import { ROUTINE_WEEKDAY_LABELS } from '@mento/shared';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import RoutineMonthGrid from '@/components/routines/RoutineMonthGrid.vue';
|
||||
import RoutineTaskRow, { type RoutineTaskRowAction } from '@/components/routines/RoutineTaskRow.vue';
|
||||
import RoutineTaskModals from '@/components/routines/RoutineTaskModals.vue';
|
||||
import { UiButton, UiEmptyState, UiSegmented, UiSelect } from '@/components/ui';
|
||||
import { api } from '@/lib/api';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import {
|
||||
DAY_STATE_LABELS,
|
||||
ROUTINES_CTX,
|
||||
addDaysKey,
|
||||
apiError,
|
||||
formatDayLong,
|
||||
formatDayNumeric,
|
||||
formatPercent,
|
||||
formatTime,
|
||||
hasExplicitTime,
|
||||
jalaliMonthRange,
|
||||
shiftJalaliMonth,
|
||||
todayKey,
|
||||
weekStart,
|
||||
} from '@/lib/routines';
|
||||
import { useRoutineTaskActions } from '@/lib/useRoutineTaskActions';
|
||||
|
||||
type View = 'day' | 'week' | 'month';
|
||||
|
||||
const shell = inject(ROUTINES_CTX)!;
|
||||
|
||||
const view = ref<View>('month');
|
||||
const anchor = ref(todayKey());
|
||||
const selected = ref(todayKey());
|
||||
const userId = ref('');
|
||||
const cal = ref<RoutineCalendarDto>({ days: [], tasks: [] });
|
||||
const loading = ref(true);
|
||||
const error = ref('');
|
||||
|
||||
const viewOptions = [
|
||||
{ value: 'day' as const, label: 'روز' },
|
||||
{ value: 'week' as const, label: 'هفته' },
|
||||
{ value: 'month' as const, label: 'ماه' },
|
||||
];
|
||||
|
||||
const range = computed(() => {
|
||||
if (view.value === 'day') return { from: anchor.value, to: anchor.value, label: formatDayLong(anchor.value) };
|
||||
if (view.value === 'week') {
|
||||
const from = weekStart(anchor.value);
|
||||
const to = addDaysKey(from, 6);
|
||||
return { from, to, label: `${formatDayNumeric(from)} تا ${formatDayNumeric(to)}` };
|
||||
}
|
||||
return jalaliMonthRange(anchor.value);
|
||||
});
|
||||
|
||||
async function load(silent = false) {
|
||||
if (!silent) loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const { data } = await api.get<RoutineCalendarDto>('/routine-tasks/calendar', {
|
||||
params: {
|
||||
from: range.value.from,
|
||||
to: range.value.to,
|
||||
...(userId.value ? { userId: userId.value } : {}),
|
||||
},
|
||||
});
|
||||
cal.value = data;
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'بارگذاری تقویم ممکن نشد.');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(view, (v) => {
|
||||
if (v !== 'month') anchor.value = selected.value;
|
||||
});
|
||||
|
||||
onMounted(() => load());
|
||||
watch([() => range.value.from, () => range.value.to, userId], () => load());
|
||||
watch(shell.refreshTick, () => load(true));
|
||||
|
||||
function move(delta: number) {
|
||||
if (view.value === 'day') anchor.value = addDaysKey(anchor.value, delta);
|
||||
else if (view.value === 'week') anchor.value = addDaysKey(anchor.value, delta * 7);
|
||||
else anchor.value = shiftJalaliMonth(anchor.value, delta);
|
||||
if (view.value === 'day') selected.value = anchor.value;
|
||||
}
|
||||
|
||||
function goToday() {
|
||||
anchor.value = todayKey();
|
||||
selected.value = todayKey();
|
||||
}
|
||||
|
||||
function selectDay(day: string) {
|
||||
selected.value = day;
|
||||
}
|
||||
|
||||
function openDay(day: string) {
|
||||
anchor.value = day;
|
||||
selected.value = day;
|
||||
view.value = 'day';
|
||||
}
|
||||
|
||||
const tasksByDay = computed(() => {
|
||||
const map = new Map<string, RoutineTaskDto[]>();
|
||||
for (const t of cal.value.tasks) {
|
||||
const list = map.get(t.scheduledFor) ?? [];
|
||||
list.push(t);
|
||||
map.set(t.scheduledFor, list);
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
const dayMap = computed(() => new Map(cal.value.days.map((d) => [d.date, d])));
|
||||
|
||||
const weekDays = computed(() => {
|
||||
const out: Array<{ key: string; day?: RoutineCalendarDayDto; tasks: RoutineTaskDto[] }> = [];
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const key = addDaysKey(range.value.from, i);
|
||||
out.push({ key, day: dayMap.value.get(key), tasks: tasksByDay.value.get(key) ?? [] });
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
const focusDay = computed(() => (view.value === 'day' ? anchor.value : selected.value));
|
||||
const focusTasks = computed(() => tasksByDay.value.get(focusDay.value) ?? []);
|
||||
const focusStat = computed(() => dayMap.value.get(focusDay.value));
|
||||
|
||||
const actions = useRoutineTaskActions((updated) => {
|
||||
const i = cal.value.tasks.findIndex((t) => t.id === updated.id);
|
||||
if (i >= 0) cal.value.tasks[i] = updated;
|
||||
shell.notifyChanged();
|
||||
});
|
||||
|
||||
function onAction(kind: RoutineTaskRowAction, task: RoutineTaskDto) {
|
||||
if (kind === 'start') void actions.start(task);
|
||||
else if (kind === 'complete-note') actions.openComplete(task);
|
||||
else if (kind === 'reschedule') actions.openReschedule(task);
|
||||
else if (kind === 'move-today') void actions.moveToday(task);
|
||||
else if (kind === 'skip') actions.openSkip(task);
|
||||
else actions.openCancel(task);
|
||||
}
|
||||
|
||||
const users = computed(() => shell.context.value?.users ?? []);
|
||||
const isManager = computed(() => !!shell.context.value?.isManager);
|
||||
const today = todayKey();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="calendar">
|
||||
<div class="cal-toolbar">
|
||||
<div class="nav">
|
||||
<UiButton variant="icon" aria-label="قبلی" @click="move(-1)">
|
||||
<UiIcon name="chevron-right" :size="18" />
|
||||
</UiButton>
|
||||
<strong class="range-label">{{ range.label }}</strong>
|
||||
<UiButton variant="icon" aria-label="بعدی" @click="move(1)">
|
||||
<UiIcon name="chevron-left" :size="18" />
|
||||
</UiButton>
|
||||
<UiButton variant="ghost" size="sm" @click="goToday">امروز</UiButton>
|
||||
</div>
|
||||
<div class="tools">
|
||||
<UiSelect v-if="isManager && users.length > 1" v-model="userId" size="sm" class="who">
|
||||
<option value="">تقویم خودم</option>
|
||||
<option v-for="u in users" :key="u.id" :value="u.id">{{ u.fullName }}</option>
|
||||
</UiSelect>
|
||||
<UiSegmented v-model="view" :options="viewOptions" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="legend">
|
||||
<span><i class="lg complete" /> روز کامل</span>
|
||||
<span><i class="lg partial" /> بخشی انجام شده</span>
|
||||
<span><i class="lg missed" /> انجام نشده</span>
|
||||
<span><i class="lg empty" /> بدون کار</span>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-if="actions.error.value" class="error">{{ actions.error.value }}</p>
|
||||
|
||||
<div class="cal-body" :class="{ dim: loading }">
|
||||
<!-- ماه -->
|
||||
<div v-if="view === 'month'" class="month-layout">
|
||||
<RoutineMonthGrid
|
||||
:from="range.from"
|
||||
:to="range.to"
|
||||
:days="cal.days"
|
||||
:selected="selected"
|
||||
@select="selectDay"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- هفته -->
|
||||
<div v-else-if="view === 'week'" class="week">
|
||||
<div
|
||||
v-for="(d, i) in weekDays"
|
||||
:key="d.key"
|
||||
class="week-col"
|
||||
:class="[`s-${d.day?.state ?? 'empty'}`, { today: d.key === today, sel: d.key === selected }]"
|
||||
>
|
||||
<button type="button" class="week-head" @click="openDay(d.key)">
|
||||
<span>{{ ROUTINE_WEEKDAY_LABELS[i] }}</span>
|
||||
<strong>{{ formatDayNumeric(d.key) }}</strong>
|
||||
<em v-if="d.day?.total">{{ toFaDigits(d.day.completed) }}/{{ toFaDigits(d.day.total) }}</em>
|
||||
</button>
|
||||
<ul class="week-tasks">
|
||||
<li
|
||||
v-for="t in d.tasks"
|
||||
:key="t.id"
|
||||
:class="`st-${t.status}`"
|
||||
@click="shell.openTask(t.id)"
|
||||
>
|
||||
<span class="wt-dot" />
|
||||
<span class="wt-title">{{ t.title }}</span>
|
||||
<small v-if="hasExplicitTime(t)">{{ formatTime(t.dueAt) }}</small>
|
||||
</li>
|
||||
<li v-if="!d.tasks.length" class="none">—</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- جزئیات روز انتخابشده / نمای روز -->
|
||||
<section v-if="view !== 'week'" class="day-panel">
|
||||
<header>
|
||||
<div>
|
||||
<h3>{{ formatDayLong(focusDay) }}</h3>
|
||||
<p v-if="focusStat?.total" class="day-sub">
|
||||
<span :class="`chip s-${focusStat.state}`">{{ DAY_STATE_LABELS[focusStat.state] }}</span>
|
||||
{{ toFaDigits(focusStat.completed) }} از {{ toFaDigits(focusStat.total) }} انجام شده
|
||||
· {{ formatPercent(focusStat.percent) }}
|
||||
</p>
|
||||
</div>
|
||||
<UiButton v-if="view === 'month'" variant="ghost" size="sm" @click="openDay(focusDay)">نمای روز</UiButton>
|
||||
</header>
|
||||
<div v-if="focusTasks.length" class="rows">
|
||||
<RoutineTaskRow
|
||||
v-for="t in focusTasks"
|
||||
:key="t.id"
|
||||
:task="t"
|
||||
:busy="actions.busyId.value === t.id"
|
||||
:show-assignee="!!userId"
|
||||
@toggle="actions.toggle"
|
||||
@open="shell.openTask(t.id)"
|
||||
@action="onAction"
|
||||
/>
|
||||
</div>
|
||||
<UiEmptyState
|
||||
v-else
|
||||
icon="calendar"
|
||||
title="کاری برای این روز نیست"
|
||||
:description="focusDay > today ? 'کارهای روتین روزهای آینده صبح همان روز ساخته میشوند.' : undefined"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<RoutineTaskModals :actions="actions" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.calendar {
|
||||
display: grid;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.cal-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.range-label {
|
||||
min-width: 150px;
|
||||
text-align: center;
|
||||
font-size: var(--fs-md);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.who {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-4);
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.legend span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.lg {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.lg.complete {
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.lg.partial {
|
||||
background: var(--warning);
|
||||
}
|
||||
|
||||
.lg.missed {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.lg.empty {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line-strong);
|
||||
}
|
||||
|
||||
.cal-body {
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.cal-body.dim {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.week {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.week-col {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
min-height: 260px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.week-col.today {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.week-head {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
padding: 8px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--line-light);
|
||||
background: var(--surface-subtle);
|
||||
text-align: right;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.s-complete .week-head {
|
||||
background: var(--success-soft);
|
||||
}
|
||||
|
||||
.s-partial .week-head {
|
||||
background: var(--warning-soft);
|
||||
}
|
||||
|
||||
.s-missed .week-head {
|
||||
background: var(--danger-soft);
|
||||
}
|
||||
|
||||
.week-head span {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.week-head strong {
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.week-head em {
|
||||
font-style: normal;
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--ink-secondary);
|
||||
}
|
||||
|
||||
.week-tasks {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 6px;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.week-tasks li {
|
||||
display: grid;
|
||||
grid-template-columns: 8px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 2px 6px;
|
||||
padding: 6px;
|
||||
border-radius: 6px;
|
||||
font-size: var(--fs-xs);
|
||||
cursor: pointer;
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
|
||||
.week-tasks li:hover {
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
|
||||
.week-tasks li small {
|
||||
grid-column: 2;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.week-tasks li.none {
|
||||
background: transparent;
|
||||
color: var(--muted-light);
|
||||
cursor: default;
|
||||
text-align: center;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.wt-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--line-strong);
|
||||
}
|
||||
|
||||
.st-completed .wt-dot {
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.st-completed .wt-title {
|
||||
text-decoration: line-through;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.st-overdue .wt-dot {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.st-skipped .wt-dot,
|
||||
.st-cancelled .wt-dot {
|
||||
background: var(--warning);
|
||||
}
|
||||
|
||||
.wt-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.day-panel {
|
||||
display: grid;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-4);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.day-panel header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.day-panel h3 {
|
||||
margin: 0;
|
||||
font-size: var(--fs-md);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.day-sub {
|
||||
margin: 4px 0 0;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
padding: 1px 8px;
|
||||
border-radius: 99px;
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: var(--fw-semibold);
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
|
||||
.chip.s-complete {
|
||||
background: var(--success-soft);
|
||||
color: var(--success-deep);
|
||||
}
|
||||
|
||||
.chip.s-partial {
|
||||
background: var(--warning-soft);
|
||||
color: var(--warning-deep);
|
||||
}
|
||||
|
||||
.chip.s-missed {
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.rows {
|
||||
display: grid;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.week {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.week-col {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.tools,
|
||||
.who {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,255 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, onMounted, ref, watch } from 'vue';
|
||||
import type { RoutineDto } from '@mento/shared';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import { UiBadge, UiEmptyState, UiSearch } from '@/components/ui';
|
||||
import { api } from '@/lib/api';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import {
|
||||
PRIORITY_LABELS,
|
||||
PRIORITY_VARIANTS,
|
||||
ROUTINES_CTX,
|
||||
apiError,
|
||||
formatDayNumeric,
|
||||
formatPercent,
|
||||
percentTone,
|
||||
} from '@/lib/routines';
|
||||
|
||||
const shell = inject(ROUTINES_CTX)!;
|
||||
|
||||
const items = ref<RoutineDto[]>([]);
|
||||
const loading = ref(true);
|
||||
const error = ref('');
|
||||
const q = ref('');
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const { data } = await api.get<RoutineDto[]>('/routines', { params: { scope: 'mine' } });
|
||||
items.value = data;
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'بارگذاری روتینها ممکن نشد.');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
watch(shell.refreshTick, load);
|
||||
|
||||
const filtered = computed(() => {
|
||||
const term = q.value.trim();
|
||||
if (!term) return items.value;
|
||||
return items.value.filter((r) => r.title.includes(term) || (r.description ?? '').includes(term));
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rlist">
|
||||
<div class="toolbar">
|
||||
<p class="lead">روتینهایی که به شما یا تیم شما سپرده شدهاند. کارهای هر روز از روی اینها ساخته میشوند.</p>
|
||||
<UiSearch v-model="q" placeholder="جستجوی روتین…" class="search" />
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<div v-if="loading" class="state">در حال بارگذاری…</div>
|
||||
|
||||
<UiEmptyState
|
||||
v-else-if="!filtered.length"
|
||||
icon="refresh"
|
||||
:title="q ? 'روتینی با این عنوان پیدا نشد' : 'هنوز روتینی برای شما تعریف نشده'"
|
||||
description="روتینها توسط مدیر تیم تعریف میشوند و هر روز بهصورت خودکار در «کارهای امروز» ظاهر میشوند."
|
||||
/>
|
||||
|
||||
<div v-else class="cards">
|
||||
<article v-for="r in filtered" :key="r.id" class="rcard">
|
||||
<header>
|
||||
<span class="ico" :style="{ background: r.category?.color ?? 'var(--accent)' }">
|
||||
<UiIcon name="refresh" :size="16" />
|
||||
</span>
|
||||
<div class="titles">
|
||||
<h3>{{ r.title }}</h3>
|
||||
<p>{{ r.recurrenceLabel }} · ساعت {{ toFaDigits(r.timeOfDay) }}</p>
|
||||
</div>
|
||||
</header>
|
||||
<p v-if="r.description" class="desc">{{ r.description }}</p>
|
||||
<div class="tags">
|
||||
<UiBadge v-if="r.category" size="sm">{{ r.category.name }}</UiBadge>
|
||||
<UiBadge size="sm" :variant="PRIORITY_VARIANTS[r.priority]">{{ PRIORITY_LABELS[r.priority] }}</UiBadge>
|
||||
<UiBadge v-if="r.isMandatory" size="sm" variant="warning">اجباری</UiBadge>
|
||||
<UiBadge v-if="r.reportRequired" size="sm" variant="info">گزارش الزامی</UiBadge>
|
||||
<UiBadge v-if="r.team" size="sm" variant="accent">تیم {{ r.team.name }}</UiBadge>
|
||||
</div>
|
||||
<footer>
|
||||
<div class="exec" :class="`tone-${percentTone(r.executionRate)}`">
|
||||
<span>اجرا در ۳۰ روز اخیر</span>
|
||||
<strong>{{ formatPercent(r.executionRate) }}</strong>
|
||||
<i class="bar"><b :style="{ width: `${r.executionRate ?? 0}%` }" /></i>
|
||||
</div>
|
||||
<small class="since">
|
||||
از {{ formatDayNumeric(r.startDate) }}{{ r.endDate ? ` تا ${formatDayNumeric(r.endDate)}` : '' }}
|
||||
</small>
|
||||
</footer>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rlist {
|
||||
display: grid;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.lead {
|
||||
margin: 0;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.search {
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
.state {
|
||||
padding: var(--sp-8);
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.rcard {
|
||||
display: grid;
|
||||
gap: var(--sp-3);
|
||||
align-content: start;
|
||||
padding: var(--sp-4);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
|
||||
.rcard header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.ico {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.titles {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.titles h3 {
|
||||
margin: 0;
|
||||
font-size: var(--fs-base);
|
||||
font-weight: var(--fw-semibold);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.titles p {
|
||||
margin: 2px 0 0;
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.desc {
|
||||
margin: 0;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--ink-secondary);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.rcard footer {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding-top: var(--sp-3);
|
||||
border-top: 1px solid var(--line-light);
|
||||
}
|
||||
|
||||
.exec {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 4px;
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.exec strong {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.bar {
|
||||
grid-column: 1 / -1;
|
||||
height: 5px;
|
||||
border-radius: 99px;
|
||||
background: var(--surface-subtle);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bar b {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--line-strong);
|
||||
}
|
||||
|
||||
.tone-good .bar b {
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.tone-mid .bar b {
|
||||
background: var(--warning);
|
||||
}
|
||||
|
||||
.tone-low .bar b {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.since {
|
||||
color: var(--muted-light);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.search {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,673 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import type { RoutineCategoryDto, RoutineDto } from '@mento/shared';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import UserAvatar from '@/components/UserAvatar.vue';
|
||||
import {
|
||||
UiBadge,
|
||||
UiButton,
|
||||
UiDropdownMenu,
|
||||
UiEmptyState,
|
||||
UiField,
|
||||
UiInput,
|
||||
UiModal,
|
||||
UiSearch,
|
||||
UiSegmented,
|
||||
UiSelect,
|
||||
} from '@/components/ui';
|
||||
import { api } from '@/lib/api';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import { ROUTINES_CTX, apiError, formatPercent, percentTone } from '@/lib/routines';
|
||||
|
||||
const shell = inject(ROUTINES_CTX)!;
|
||||
const router = useRouter();
|
||||
|
||||
const items = ref<RoutineDto[]>([]);
|
||||
const loading = ref(true);
|
||||
const error = ref('');
|
||||
const q = ref('');
|
||||
const status = ref<'all' | 'active' | 'inactive'>('all');
|
||||
const assignee = ref('');
|
||||
const busyId = ref<string | null>(null);
|
||||
const menuFor = ref<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const { data } = await api.get<RoutineDto[]>('/routines', { params: { scope: 'all' } });
|
||||
items.value = data;
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'بارگذاری روتینها ممکن نشد.');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
watch(shell.refreshTick, load);
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'all' as const, label: 'همه' },
|
||||
{ value: 'active' as const, label: 'فعال' },
|
||||
{ value: 'inactive' as const, label: 'غیرفعال' },
|
||||
];
|
||||
|
||||
const filtered = computed(() => {
|
||||
const term = q.value.trim();
|
||||
return items.value.filter((r) => {
|
||||
if (status.value === 'active' && !r.isActive) return false;
|
||||
if (status.value === 'inactive' && r.isActive) return false;
|
||||
if (assignee.value && r.assignee?.id !== assignee.value && `team:${r.team?.id}` !== assignee.value) return false;
|
||||
if (term && !r.title.includes(term)) return false;
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
const counts = computed(() => ({
|
||||
total: items.value.length,
|
||||
active: items.value.filter((r) => r.isActive).length,
|
||||
}));
|
||||
|
||||
function replace(updated: RoutineDto) {
|
||||
const i = items.value.findIndex((r) => r.id === updated.id);
|
||||
if (i >= 0) items.value[i] = updated;
|
||||
else items.value.unshift(updated);
|
||||
}
|
||||
|
||||
async function toggle(r: RoutineDto) {
|
||||
busyId.value = r.id;
|
||||
error.value = '';
|
||||
try {
|
||||
const { data } = await api.patch<RoutineDto>(`/routines/${r.id}/toggle`, { isActive: !r.isActive });
|
||||
replace(data);
|
||||
shell.notifyChanged();
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'تغییر وضعیت ممکن نشد.');
|
||||
} finally {
|
||||
busyId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function duplicate(r: RoutineDto) {
|
||||
busyId.value = r.id;
|
||||
error.value = '';
|
||||
try {
|
||||
const { data } = await api.post<RoutineDto>(`/routines/${r.id}/duplicate`);
|
||||
replace(data);
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'کپی روتین ممکن نشد.');
|
||||
} finally {
|
||||
busyId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
/* حذف */
|
||||
const removing = ref<RoutineDto | null>(null);
|
||||
const removeBusy = ref(false);
|
||||
|
||||
async function confirmRemove() {
|
||||
const r = removing.value;
|
||||
if (!r) return;
|
||||
removeBusy.value = true;
|
||||
try {
|
||||
await api.delete(`/routines/${r.id}`);
|
||||
items.value = items.value.filter((x) => x.id !== r.id);
|
||||
removing.value = null;
|
||||
shell.notifyChanged();
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'حذف روتین ممکن نشد.');
|
||||
removing.value = null;
|
||||
} finally {
|
||||
removeBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* تغییر مسئول */
|
||||
const reassign = reactive({ routine: null as RoutineDto | null, target: '', saving: false, error: '' });
|
||||
|
||||
function openReassign(r: RoutineDto) {
|
||||
Object.assign(reassign, {
|
||||
routine: r,
|
||||
target: r.team ? `team:${r.team.id}` : (r.assignee?.id ?? ''),
|
||||
saving: false,
|
||||
error: '',
|
||||
});
|
||||
}
|
||||
|
||||
async function saveReassign() {
|
||||
const r = reassign.routine;
|
||||
if (!r || !reassign.target) {
|
||||
reassign.error = 'مسئول را انتخاب کنید.';
|
||||
return;
|
||||
}
|
||||
const body = reassign.target.startsWith('team:')
|
||||
? { teamId: reassign.target.slice(5), assigneeId: null }
|
||||
: { assigneeId: reassign.target, teamId: null };
|
||||
reassign.saving = true;
|
||||
try {
|
||||
const { data } = await api.patch<RoutineDto>(`/routines/${r.id}/assignee`, body);
|
||||
replace(data);
|
||||
reassign.routine = null;
|
||||
shell.notifyChanged();
|
||||
} catch (e) {
|
||||
reassign.error = apiError(e, 'تغییر مسئول ممکن نشد.');
|
||||
} finally {
|
||||
reassign.saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* دستهبندیها */
|
||||
const catsOpen = ref(false);
|
||||
const catForm = reactive({ id: '', name: '', color: '#14b8a6', saving: false, error: '' });
|
||||
const CAT_COLORS = ['#14b8a6', '#3b82f6', '#8b5cf6', '#f59e0b', '#f43f5e', '#10b981', '#06b6d4', '#64748b'];
|
||||
const categories = computed(() => shell.context.value?.categories ?? []);
|
||||
|
||||
function editCat(c: RoutineCategoryDto | null) {
|
||||
Object.assign(catForm, { id: c?.id ?? '', name: c?.name ?? '', color: c?.color ?? '#14b8a6', error: '' });
|
||||
}
|
||||
|
||||
async function refreshCategories() {
|
||||
const ctx = shell.context.value;
|
||||
if (!ctx) return;
|
||||
const { data } = await api.get<RoutineCategoryDto[]>('/routines/categories');
|
||||
ctx.categories = data;
|
||||
}
|
||||
|
||||
async function saveCat() {
|
||||
if (!catForm.name.trim()) {
|
||||
catForm.error = 'نام دسته را وارد کنید.';
|
||||
return;
|
||||
}
|
||||
catForm.saving = true;
|
||||
catForm.error = '';
|
||||
try {
|
||||
const body = { name: catForm.name.trim(), color: catForm.color };
|
||||
if (catForm.id) await api.patch(`/routines/categories/${catForm.id}`, body);
|
||||
else await api.post('/routines/categories', body);
|
||||
await refreshCategories();
|
||||
editCat(null);
|
||||
} catch (e) {
|
||||
catForm.error = apiError(e, 'ذخیره دسته ممکن نشد.');
|
||||
} finally {
|
||||
catForm.saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteCat(c: RoutineCategoryDto) {
|
||||
catForm.error = '';
|
||||
try {
|
||||
await api.delete(`/routines/categories/${c.id}`);
|
||||
await refreshCategories();
|
||||
if (catForm.id === c.id) editCat(null);
|
||||
} catch (e) {
|
||||
catForm.error = apiError(e, 'حذف دسته ممکن نشد.');
|
||||
}
|
||||
}
|
||||
|
||||
const users = computed(() => shell.context.value?.users ?? []);
|
||||
const teams = computed(() => shell.context.value?.teams ?? []);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="manage">
|
||||
<div class="toolbar">
|
||||
<UiSearch v-model="q" placeholder="جستجوی عنوان…" class="search" />
|
||||
<UiSelect v-model="assignee" size="sm" class="who">
|
||||
<option value="">همه مسئولها</option>
|
||||
<optgroup label="کاربران">
|
||||
<option v-for="u in users" :key="u.id" :value="u.id">{{ u.fullName }}</option>
|
||||
</optgroup>
|
||||
<optgroup v-if="teams.length" label="تیمها">
|
||||
<option v-for="t in teams" :key="t.id" :value="`team:${t.id}`">{{ t.name }}</option>
|
||||
</optgroup>
|
||||
</UiSelect>
|
||||
<UiSegmented v-model="status" :options="statusOptions" />
|
||||
<span class="spacer" />
|
||||
<UiButton variant="ghost" size="sm" @click="catsOpen = true; editCat(null)">
|
||||
<template #icon><UiIcon name="layers" :size="15" /></template>
|
||||
دستهبندیها
|
||||
</UiButton>
|
||||
<UiButton @click="router.push('/routines/manage/new')">
|
||||
<template #icon><UiIcon name="plus" :size="16" /></template>
|
||||
روتین جدید
|
||||
</UiButton>
|
||||
</div>
|
||||
|
||||
<p class="summary">
|
||||
{{ toFaDigits(counts.total) }} روتین · {{ toFaDigits(counts.active) }} فعال
|
||||
</p>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<div v-if="loading" class="state">در حال بارگذاری…</div>
|
||||
|
||||
<UiEmptyState
|
||||
v-else-if="!filtered.length"
|
||||
icon="refresh"
|
||||
:title="items.length ? 'روتینی با این فیلتر پیدا نشد' : 'هنوز روتینی تعریف نشده'"
|
||||
description="روتینها کارهای تکرارشوندهای هستند که هر روز بهصورت خودکار برای مسئولشان ساخته میشوند."
|
||||
>
|
||||
<template v-if="!items.length" #action>
|
||||
<UiButton @click="router.push('/routines/manage/new')">ساخت اولین روتین</UiButton>
|
||||
</template>
|
||||
</UiEmptyState>
|
||||
|
||||
<div v-else class="crm-table-container">
|
||||
<table class="crm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>عنوان</th>
|
||||
<th>مسئول</th>
|
||||
<th>تکرار</th>
|
||||
<th>ساعت</th>
|
||||
<th>وضعیت</th>
|
||||
<th>درصد انجام</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in filtered" :key="r.id" :class="{ inactive: !r.isActive }">
|
||||
<td class="title-cell">
|
||||
<span class="cat-dot" :style="{ background: r.category?.color ?? 'var(--line-strong)' }" />
|
||||
<div>
|
||||
<strong>{{ r.title }}</strong>
|
||||
<small>
|
||||
{{ r.category?.name ?? 'بدون دسته' }}
|
||||
<template v-if="r.isMandatory"> · اجباری</template>
|
||||
</small>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="r.team" class="who-cell">
|
||||
<span class="team-ico"><UiIcon name="users" :size="13" /></span>
|
||||
تیم {{ r.team.name }}
|
||||
</span>
|
||||
<span v-else-if="r.assignee" class="who-cell">
|
||||
<UserAvatar :name="r.assignee.fullName" :user-id="r.assignee.id" :size="24" />
|
||||
{{ r.assignee.fullName }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="nowrap">{{ r.recurrenceLabel }}</td>
|
||||
<td class="nowrap">{{ toFaDigits(r.timeOfDay) }}</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
class="switch"
|
||||
:class="{ on: r.isActive }"
|
||||
:disabled="busyId === r.id"
|
||||
:aria-pressed="r.isActive"
|
||||
:aria-label="r.isActive ? 'غیرفعال کردن' : 'فعال کردن'"
|
||||
@click="toggle(r)"
|
||||
>
|
||||
<i />
|
||||
</button>
|
||||
<UiBadge size="sm" :variant="r.isActive ? 'success' : 'neutral'">
|
||||
{{ r.isActive ? 'فعال' : 'غیرفعال' }}
|
||||
</UiBadge>
|
||||
</td>
|
||||
<td>
|
||||
<div class="exec" :class="`tone-${percentTone(r.executionRate)}`">
|
||||
<strong>{{ formatPercent(r.executionRate) }}</strong>
|
||||
<i class="bar"><b :style="{ width: `${r.executionRate ?? 0}%` }" /></i>
|
||||
<small v-if="r.executionTotal">
|
||||
{{ toFaDigits(r.executionDone) }} از {{ toFaDigits(r.executionTotal) }}
|
||||
</small>
|
||||
</div>
|
||||
</td>
|
||||
<td class="actions-cell">
|
||||
<div class="crm-table-actions">
|
||||
<UiButton
|
||||
variant="icon"
|
||||
size="sm"
|
||||
aria-label="ویرایش"
|
||||
@click="router.push(`/routines/manage/${r.id}/edit`)"
|
||||
>
|
||||
<UiIcon name="pencil" :size="15" />
|
||||
</UiButton>
|
||||
<UiDropdownMenu
|
||||
:open="menuFor === r.id"
|
||||
@update:open="(v: boolean) => (menuFor = v ? r.id : null)"
|
||||
>
|
||||
<template #trigger="{ toggle: t }">
|
||||
<UiButton variant="icon" size="sm" aria-label="عملیات بیشتر" @click="t">
|
||||
<UiIcon name="dots" :size="16" />
|
||||
</UiButton>
|
||||
</template>
|
||||
<button type="button" @click="router.push(`/routines/manage/${r.id}/edit`)">ویرایش</button>
|
||||
<button type="button" @click="openReassign(r)">تغییر مسئول</button>
|
||||
<button type="button" @click="duplicate(r)">کپی روتین</button>
|
||||
<button type="button" @click="toggle(r)">{{ r.isActive ? 'غیرفعال کردن' : 'فعال کردن' }}</button>
|
||||
<button type="button" class="danger" @click="removing = r">حذف</button>
|
||||
</UiDropdownMenu>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- حذف -->
|
||||
<UiModal :open="!!removing" title="حذف روتین" @close="removing = null">
|
||||
<p class="confirm">
|
||||
روتین «{{ removing?.title }}» حذف شود؟ کارهای انجامنشده امروزِ آن لغو میشوند؛ سوابق قبلی در گزارشها باقی
|
||||
میماند.
|
||||
</p>
|
||||
<template #footer>
|
||||
<UiButton variant="danger" :loading="removeBusy" @click="confirmRemove">حذف روتین</UiButton>
|
||||
<UiButton variant="ghost" @click="removing = null">انصراف</UiButton>
|
||||
</template>
|
||||
</UiModal>
|
||||
|
||||
<!-- تغییر مسئول -->
|
||||
<UiModal :open="!!reassign.routine" title="تغییر مسئول روتین" @close="reassign.routine = null">
|
||||
<div class="form">
|
||||
<p class="confirm">{{ reassign.routine?.title }}</p>
|
||||
<UiField label="مسئول جدید" hint="کارهای انجامنشده امروز به مسئول جدید منتقل میشوند.">
|
||||
<UiSelect v-model="reassign.target">
|
||||
<option value="" disabled>انتخاب کنید</option>
|
||||
<optgroup label="کاربران">
|
||||
<option v-for="u in users" :key="u.id" :value="u.id">{{ u.fullName }}</option>
|
||||
</optgroup>
|
||||
<optgroup v-if="teams.length" label="کل اعضای تیم">
|
||||
<option v-for="t in teams" :key="t.id" :value="`team:${t.id}`">{{ t.name }}</option>
|
||||
</optgroup>
|
||||
</UiSelect>
|
||||
</UiField>
|
||||
<p v-if="reassign.error" class="error">{{ reassign.error }}</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton :loading="reassign.saving" @click="saveReassign">ذخیره</UiButton>
|
||||
<UiButton variant="ghost" @click="reassign.routine = null">انصراف</UiButton>
|
||||
</template>
|
||||
</UiModal>
|
||||
|
||||
<!-- دستهبندیها -->
|
||||
<UiModal :open="catsOpen" title="دستهبندی کارها" @close="catsOpen = false">
|
||||
<div class="form">
|
||||
<ul class="cat-list">
|
||||
<li v-for="c in categories" :key="c.id">
|
||||
<span class="cat-dot" :style="{ background: c.color }" />
|
||||
<span class="cat-name">{{ c.name }}</span>
|
||||
<UiButton variant="icon" size="sm" aria-label="ویرایش" @click="editCat(c)">
|
||||
<UiIcon name="pencil" :size="14" />
|
||||
</UiButton>
|
||||
<UiButton variant="icon" size="sm" aria-label="حذف" @click="deleteCat(c)">
|
||||
<UiIcon name="trash" :size="14" />
|
||||
</UiButton>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="cat-form">
|
||||
<UiField :label="catForm.id ? 'ویرایش دسته' : 'دسته جدید'">
|
||||
<UiInput v-model="catForm.name" placeholder="مثلاً: پشتیبانی" :maxlength="60" />
|
||||
</UiField>
|
||||
<div class="swatches" role="radiogroup" aria-label="رنگ">
|
||||
<button
|
||||
v-for="col in CAT_COLORS"
|
||||
:key="col"
|
||||
type="button"
|
||||
class="swatch"
|
||||
:class="{ on: catForm.color === col }"
|
||||
:style="{ background: col }"
|
||||
:aria-label="col"
|
||||
@click="catForm.color = col"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="catForm.error" class="error">{{ catForm.error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton :loading="catForm.saving" @click="saveCat">{{ catForm.id ? 'ذخیره تغییرات' : 'افزودن دسته' }}</UiButton>
|
||||
<UiButton v-if="catForm.id" variant="ghost" @click="editCat(null)">انصراف از ویرایش</UiButton>
|
||||
<UiButton variant="ghost" @click="catsOpen = false">بستن</UiButton>
|
||||
</template>
|
||||
</UiModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.manage {
|
||||
display: grid;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.search {
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
.who {
|
||||
width: 190px;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.summary {
|
||||
margin: 0;
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.state {
|
||||
padding: var(--sp-8);
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
tr.inactive td {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
tr.inactive .title-cell strong {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.title-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.title-cell div {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.title-cell strong {
|
||||
font-weight: var(--fw-semibold);
|
||||
}
|
||||
|
||||
.title-cell small {
|
||||
color: var(--muted);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.cat-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.who-cell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.team-ico {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent-deep);
|
||||
}
|
||||
|
||||
.nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
td:has(.switch) {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.switch {
|
||||
width: 34px;
|
||||
height: 20px;
|
||||
padding: 2px;
|
||||
margin-inline-end: 8px;
|
||||
border: 0;
|
||||
border-radius: 99px;
|
||||
background: var(--line-strong);
|
||||
cursor: pointer;
|
||||
vertical-align: middle;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.switch i {
|
||||
display: block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
box-shadow: var(--shadow-xs);
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.switch.on {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
/* RTL: روشن = دکمه سمت چپ */
|
||||
.switch.on i {
|
||||
transform: translateX(-14px);
|
||||
}
|
||||
|
||||
.exec {
|
||||
display: grid;
|
||||
grid-template-columns: auto 80px;
|
||||
align-items: center;
|
||||
gap: 2px 8px;
|
||||
min-width: 130px;
|
||||
}
|
||||
|
||||
.exec small {
|
||||
grid-column: 1 / -1;
|
||||
color: var(--muted);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.bar {
|
||||
height: 6px;
|
||||
border-radius: 99px;
|
||||
background: var(--surface-subtle);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bar b {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--line-strong);
|
||||
}
|
||||
|
||||
.tone-good .bar b {
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.tone-mid .bar b {
|
||||
background: var(--warning);
|
||||
}
|
||||
|
||||
.tone-low .bar b {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
:global(.ui-dd-panel button.danger) {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.confirm {
|
||||
margin: 0;
|
||||
line-height: 1.8;
|
||||
color: var(--ink-secondary);
|
||||
}
|
||||
|
||||
.form {
|
||||
display: grid;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.cat-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.cat-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
|
||||
.cat-name {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.cat-form {
|
||||
display: grid;
|
||||
gap: var(--sp-3);
|
||||
padding-top: var(--sp-3);
|
||||
border-top: 1px solid var(--line-light);
|
||||
}
|
||||
|
||||
.swatches {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.swatch {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--surface);
|
||||
box-shadow: 0 0 0 1px var(--line);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.swatch.on {
|
||||
box-shadow: 0 0 0 2px var(--ink);
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.search,
|
||||
.who {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,272 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, ref, watch } from 'vue';
|
||||
import type { RoutinePerformanceDto } from '@mento/shared';
|
||||
import KpiCard from '@/components/routines/KpiCard.vue';
|
||||
import WeeklyReport from '@/components/routines/WeeklyReport.vue';
|
||||
import MonthlyReport from '@/components/routines/MonthlyReport.vue';
|
||||
import ActivityHistoryList from '@/components/routines/ActivityHistoryList.vue';
|
||||
import { UiChart, UiSegmented, UiSelect } from '@/components/ui';
|
||||
import { api } from '@/lib/api';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import { categoryBar, statusDonut, trendOption } from '@/lib/routineCharts';
|
||||
import {
|
||||
ROUTINES_CTX,
|
||||
addDaysKey,
|
||||
apiError,
|
||||
formatDayNumeric,
|
||||
formatMinutes,
|
||||
formatPercent,
|
||||
todayKey,
|
||||
} from '@/lib/routines';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
|
||||
const shell = inject(ROUTINES_CTX)!;
|
||||
const auth = useAuthStore();
|
||||
|
||||
type Section = 'overview' | 'weekly' | 'monthly' | 'history';
|
||||
const section = ref<Section>('overview');
|
||||
const sections = [
|
||||
{ value: 'overview' as const, label: 'نمای کلی' },
|
||||
{ value: 'weekly' as const, label: 'گزارش هفتگی' },
|
||||
{ value: 'monthly' as const, label: 'گزارش ماهانه' },
|
||||
{ value: 'history' as const, label: 'تاریخچه فعالیت' },
|
||||
];
|
||||
|
||||
const days = ref<'7' | '30' | '90'>('30');
|
||||
const dayOptions = [
|
||||
{ value: '7' as const, label: '۷ روز' },
|
||||
{ value: '30' as const, label: '۳۰ روز' },
|
||||
{ value: '90' as const, label: '۹۰ روز' },
|
||||
];
|
||||
|
||||
/** مدیر میتواند عملکرد یک عضو را هم ببیند */
|
||||
const userId = ref('');
|
||||
const users = computed(() => shell.context.value?.users ?? []);
|
||||
const canPickUser = computed(() => !!shell.context.value?.isManager && users.value.length > 1);
|
||||
const filters = computed(() => (userId.value ? { userId: userId.value } : {}));
|
||||
|
||||
const data = ref<RoutinePerformanceDto | null>(null);
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
|
||||
async function load() {
|
||||
if (section.value !== 'overview') return;
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
const to = todayKey();
|
||||
try {
|
||||
const res = await api.get<RoutinePerformanceDto>('/routines/stats/me', {
|
||||
params: { ...filters.value, from: addDaysKey(to, -(Number(days.value) - 1)), to },
|
||||
});
|
||||
data.value = res.data;
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'بارگذاری عملکرد ممکن نشد.');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch([section, days, userId, shell.refreshTick], load, { immediate: true });
|
||||
|
||||
const d = computed(() => data.value);
|
||||
const trendOpt = computed(() => trendOption(d.value?.trend ?? []));
|
||||
const donutOpt = computed(() =>
|
||||
statusDonut(d.value?.status ?? { completed: 0, notDone: 0, overdue: 0, skipped: 0, cancelled: 0 }),
|
||||
);
|
||||
const catOpt = computed(() => categoryBar(d.value?.byCategory ?? []));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="perf">
|
||||
<div class="toolbar">
|
||||
<UiSegmented v-model="section" :options="sections" />
|
||||
<div class="tools">
|
||||
<UiSelect v-if="canPickUser" v-model="userId" size="sm" class="who">
|
||||
<option value="">عملکرد خودم</option>
|
||||
<option v-for="u in users.filter((x) => x.id !== auth.user?.id)" :key="u.id" :value="u.id">
|
||||
{{ u.fullName }}
|
||||
</option>
|
||||
</UiSelect>
|
||||
<UiSegmented v-if="section === 'overview'" v-model="days" :options="dayOptions" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="section === 'overview'">
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-if="d" class="range">بازه {{ formatDayNumeric(d.from) }} تا {{ formatDayNumeric(d.to) }}</p>
|
||||
|
||||
<div class="kpis" :class="{ dim: loading }">
|
||||
<KpiCard label="درصد انجام" :value="formatPercent(d?.percent)" icon="target" tone="accent" />
|
||||
<KpiCard
|
||||
label="کارهای انجامشده"
|
||||
:value="toFaDigits(d?.completed ?? 0)"
|
||||
:hint="`از ${toFaDigits(d?.total ?? 0)} کار`"
|
||||
icon="check"
|
||||
tone="success"
|
||||
/>
|
||||
<KpiCard
|
||||
label="انجامنشده"
|
||||
:value="toFaDigits(d?.notDone ?? 0)"
|
||||
:hint="`عقبافتاده ${toFaDigits(d?.overdue ?? 0)}`"
|
||||
icon="x-circle"
|
||||
tone="danger"
|
||||
/>
|
||||
<KpiCard
|
||||
label="میانگین زمان انجام"
|
||||
:value="formatMinutes(d?.avgCompletionMinutes)"
|
||||
hint="از زمان برنامه تا انجام"
|
||||
icon="clock"
|
||||
tone="info"
|
||||
/>
|
||||
<KpiCard
|
||||
label="روزهای کامل"
|
||||
:value="toFaDigits(d?.completeDays ?? 0)"
|
||||
:hint="`ناقص ${toFaDigits(d?.partialDays ?? 0)} · از دست رفته ${toFaDigits(d?.missedDays ?? 0)}`"
|
||||
icon="calendar"
|
||||
tone="success"
|
||||
/>
|
||||
<KpiCard
|
||||
label="روزهای پیاپی کامل"
|
||||
:value="toFaDigits(d?.streak ?? 0)"
|
||||
hint="انجام همه کارهای اجباری"
|
||||
icon="flag"
|
||||
tone="warning"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="charts">
|
||||
<section class="card wide">
|
||||
<h3>روند درصد انجام</h3>
|
||||
<UiChart :option="trendOpt" height="260px" :loading="loading" />
|
||||
</section>
|
||||
<section class="card">
|
||||
<h3>وضعیت کارها</h3>
|
||||
<UiChart :option="donutOpt" height="260px" :loading="loading" />
|
||||
</section>
|
||||
<section class="card full">
|
||||
<h3>انجام به تفکیک دستهبندی</h3>
|
||||
<UiChart v-if="d?.byCategory.length" :option="catOpt" height="240px" :loading="loading" />
|
||||
<p v-else class="muted">دادهای برای نمایش نیست.</p>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<WeeklyReport v-else-if="section === 'weekly'" view="me" :filters="filters" :refresh-key="shell.refreshTick.value" />
|
||||
<MonthlyReport v-else-if="section === 'monthly'" view="me" :filters="filters" :refresh-key="shell.refreshTick.value" />
|
||||
<ActivityHistoryList
|
||||
v-else
|
||||
:user-id="userId || null"
|
||||
:refresh-key="shell.refreshTick.value"
|
||||
@open="shell.openTask"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.perf {
|
||||
display: grid;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.who {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.range {
|
||||
margin: 0;
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: var(--sp-3);
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.dim {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.charts {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr);
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: var(--sp-4);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
display: grid;
|
||||
gap: var(--sp-3);
|
||||
align-content: start;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card.full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
margin: 0;
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: var(--fw-semibold);
|
||||
color: var(--ink-secondary);
|
||||
}
|
||||
|
||||
.muted {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: var(--fs-sm);
|
||||
text-align: center;
|
||||
padding: var(--sp-6);
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.kpis {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.kpis {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.charts {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.who {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.toolbar > :first-child {
|
||||
overflow-x: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,319 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, onMounted, ref, watch } from 'vue';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import type { RoutineTaskDto } from '@mento/shared';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import UserAvatar from '@/components/UserAvatar.vue';
|
||||
import RoutineTaskRow, { type RoutineTaskRowAction } from '@/components/routines/RoutineTaskRow.vue';
|
||||
import RoutineTaskModals from '@/components/routines/RoutineTaskModals.vue';
|
||||
import { UiBadge, UiButton, UiEmptyState, UiSelect } from '@/components/ui';
|
||||
import { api } from '@/lib/api';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import {
|
||||
ENTITY_TYPE_LABELS,
|
||||
PRIORITY_LABELS,
|
||||
PRIORITY_VARIANTS,
|
||||
ROUTINES_CTX,
|
||||
apiError,
|
||||
entityRoute,
|
||||
formatDayNumeric,
|
||||
formatDelay,
|
||||
formatTime,
|
||||
hasExplicitTime,
|
||||
} from '@/lib/routines';
|
||||
import { useRoutineTaskActions } from '@/lib/useRoutineTaskActions';
|
||||
|
||||
const shell = inject(ROUTINES_CTX)!;
|
||||
|
||||
const items = ref<RoutineTaskDto[]>([]);
|
||||
const loading = ref(true);
|
||||
const error = ref('');
|
||||
const who = ref('');
|
||||
|
||||
const isManager = computed(() => !!shell.context.value?.isManager);
|
||||
const users = computed(() => shell.context.value?.users ?? []);
|
||||
|
||||
async function load(silent = false) {
|
||||
if (!silent) loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const { data } = await api.get<RoutineTaskDto[]>('/routine-tasks/overdue', {
|
||||
params: who.value ? { userId: who.value } : {},
|
||||
});
|
||||
items.value = data;
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'بارگذاری کارهای عقبافتاده ممکن نشد.');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => load());
|
||||
watch(who, () => load());
|
||||
watch(shell.refreshTick, () => load(true));
|
||||
|
||||
const actions = useRoutineTaskActions((updated) => {
|
||||
if (updated.status === 'overdue') {
|
||||
const i = items.value.findIndex((t) => t.id === updated.id);
|
||||
if (i >= 0) items.value[i] = updated;
|
||||
} else {
|
||||
items.value = items.value.filter((t) => t.id !== updated.id);
|
||||
}
|
||||
shell.notifyChanged();
|
||||
});
|
||||
|
||||
function onAction(kind: RoutineTaskRowAction, task: RoutineTaskDto) {
|
||||
if (kind === 'complete-note') actions.openComplete(task);
|
||||
else if (kind === 'reschedule') actions.openReschedule(task);
|
||||
else if (kind === 'move-today') void actions.moveToday(task);
|
||||
else if (kind === 'skip') actions.openSkip(task);
|
||||
else if (kind === 'cancel') actions.openCancel(task);
|
||||
}
|
||||
|
||||
const showAssignee = computed(() => isManager.value && who.value !== '');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="overdue">
|
||||
<div class="toolbar">
|
||||
<p class="lead">
|
||||
<UiIcon name="warning" :size="16" />
|
||||
<template v-if="items.length">{{ toFaDigits(items.length) }} کار از مهلتش گذشته است.</template>
|
||||
<template v-else>کار عقبافتادهای ندارید.</template>
|
||||
</p>
|
||||
<UiSelect v-if="isManager" v-model="who" size="sm" class="who">
|
||||
<option value="">کارهای خودم</option>
|
||||
<option value="all">همه اعضای تیم</option>
|
||||
<option v-for="u in users" :key="u.id" :value="u.id">{{ u.fullName }}</option>
|
||||
</UiSelect>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-if="actions.error.value" class="error">{{ actions.error.value }}</p>
|
||||
|
||||
<div v-if="loading" class="state">در حال بارگذاری…</div>
|
||||
|
||||
<UiEmptyState
|
||||
v-else-if="!items.length"
|
||||
icon="check"
|
||||
title="همه چیز بهروز است"
|
||||
description="هیچ کاری از مهلتش عقب نیفتاده."
|
||||
/>
|
||||
|
||||
<template v-else>
|
||||
<!-- دسکتاپ: جدول -->
|
||||
<div class="crm-table-container desk">
|
||||
<table class="crm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>کار</th>
|
||||
<th>مهلت</th>
|
||||
<th>تأخیر</th>
|
||||
<th v-if="showAssignee">مسئول</th>
|
||||
<th>اولویت</th>
|
||||
<th>پرونده</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="t in items" :key="t.id" class="clickable" @click="shell.openTask(t.id)">
|
||||
<td class="title-cell">
|
||||
<strong>{{ t.title }}</strong>
|
||||
<small v-if="t.category" class="cat"><i :style="{ background: t.category.color }" />{{ t.category.name }}</small>
|
||||
</td>
|
||||
<td class="nowrap">
|
||||
{{ formatDayNumeric(t.scheduledFor) }}
|
||||
<small v-if="hasExplicitTime(t)" class="muted">· {{ formatTime(t.dueAt) }}</small>
|
||||
</td>
|
||||
<td class="nowrap late">{{ formatDelay(t.dueAt) }}</td>
|
||||
<td v-if="showAssignee">
|
||||
<span v-if="t.assignee" class="who-cell">
|
||||
<UserAvatar :name="t.assignee.fullName" :user-id="t.assignee.id" :size="24" />
|
||||
{{ t.assignee.fullName }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<UiBadge size="sm" :variant="PRIORITY_VARIANTS[t.priority]">{{ PRIORITY_LABELS[t.priority] }}</UiBadge>
|
||||
</td>
|
||||
<td>
|
||||
<RouterLink
|
||||
v-for="ent in t.entities"
|
||||
:key="ent.id"
|
||||
:to="entityRoute(ent)"
|
||||
class="ent"
|
||||
@click.stop
|
||||
>
|
||||
{{ ENTITY_TYPE_LABELS[ent.type] }}: {{ ent.label }}
|
||||
</RouterLink>
|
||||
<span v-if="!t.entities.length" class="muted">—</span>
|
||||
</td>
|
||||
<td class="actions-cell" @click.stop>
|
||||
<div class="crm-table-actions">
|
||||
<UiButton
|
||||
size="sm"
|
||||
variant="success"
|
||||
:loading="actions.busyId.value === t.id"
|
||||
@click="actions.toggle(t)"
|
||||
>
|
||||
انجام شد
|
||||
</UiButton>
|
||||
<UiButton size="sm" variant="ghost" @click="actions.moveToday(t)">به امروز</UiButton>
|
||||
<UiButton size="sm" variant="ghost" @click="actions.openReschedule(t)">تغییر زمان</UiButton>
|
||||
<UiButton size="sm" variant="ghost" class="danger-text" @click="actions.openCancel(t)">لغو</UiButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- موبایل: کارت -->
|
||||
<div class="mobile rows">
|
||||
<RoutineTaskRow
|
||||
v-for="t in items"
|
||||
:key="t.id"
|
||||
:task="t"
|
||||
:busy="actions.busyId.value === t.id"
|
||||
show-date
|
||||
show-delay
|
||||
:show-assignee="showAssignee"
|
||||
@toggle="actions.toggle"
|
||||
@open="shell.openTask(t.id)"
|
||||
@action="onAction"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<RoutineTaskModals :actions="actions" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.overdue {
|
||||
display: grid;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.lead {
|
||||
margin: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--ink-secondary);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.lead :deep(svg) {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.who {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.state {
|
||||
padding: var(--sp-8);
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.title-cell {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.title-cell strong {
|
||||
font-weight: var(--fw-semibold);
|
||||
}
|
||||
|
||||
.cat {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.cat i {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.late {
|
||||
color: var(--danger);
|
||||
font-weight: var(--fw-semibold);
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.who-cell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ent {
|
||||
display: block;
|
||||
color: var(--accent-deep);
|
||||
font-size: var(--fs-xs);
|
||||
white-space: nowrap;
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.ent:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.danger-text {
|
||||
color: var(--danger) !important;
|
||||
}
|
||||
|
||||
.rows {
|
||||
display: grid;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.mobile {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.desk {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.who {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,219 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import type { RoutineUserSettingsDto } from '@mento/shared';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import { UiButton, UiCard, UiField, UiSelect } from '@/components/ui';
|
||||
import { api } from '@/lib/api';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import { apiError } from '@/lib/routines';
|
||||
|
||||
const form = reactive({
|
||||
remind: '30',
|
||||
endOfDaySummary: true,
|
||||
summaryHour: '16',
|
||||
overdueAlert: true,
|
||||
});
|
||||
const loading = ref(true);
|
||||
const saving = ref(false);
|
||||
const error = ref('');
|
||||
const saved = ref(false);
|
||||
|
||||
const remindOptions = [
|
||||
{ value: '', label: 'خاموش' },
|
||||
{ value: '0', label: 'سر ساعت مهلت' },
|
||||
{ value: '10', label: '۱۰ دقیقه قبل' },
|
||||
{ value: '15', label: '۱۵ دقیقه قبل' },
|
||||
{ value: '30', label: '۳۰ دقیقه قبل' },
|
||||
{ value: '60', label: '۱ ساعت قبل' },
|
||||
{ value: '120', label: '۲ ساعت قبل' },
|
||||
];
|
||||
|
||||
const hours = Array.from({ length: 12 }, (_, i) => i + 12);
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const { data } = await api.get<RoutineUserSettingsDto>('/routine-tasks/settings');
|
||||
Object.assign(form, {
|
||||
remind: data.remindBeforeMinutes == null ? '' : String(data.remindBeforeMinutes),
|
||||
endOfDaySummary: data.endOfDaySummary,
|
||||
summaryHour: String(data.summaryHour),
|
||||
overdueAlert: data.overdueAlert,
|
||||
});
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'بارگذاری تنظیمات ممکن نشد.');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function save() {
|
||||
saving.value = true;
|
||||
error.value = '';
|
||||
saved.value = false;
|
||||
try {
|
||||
await api.put('/routine-tasks/settings', {
|
||||
remindBeforeMinutes: form.remind === '' ? null : Number(form.remind),
|
||||
endOfDaySummary: form.endOfDaySummary,
|
||||
summaryHour: Number(form.summaryHour),
|
||||
overdueAlert: form.overdueAlert,
|
||||
});
|
||||
saved.value = true;
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'ذخیره تنظیمات ممکن نشد.');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="settings">
|
||||
<UiCard>
|
||||
<header class="head">
|
||||
<span class="ico"><UiIcon name="bell" :size="18" /></span>
|
||||
<div>
|
||||
<h3>یادآوریها و اعلانهای کارهای روتین</h3>
|
||||
<p>این تنظیمات فقط برای حساب شما اعمال میشود. اعلانها در زنگوله بالای صفحه نمایش داده میشوند.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="loading" class="state">در حال بارگذاری…</div>
|
||||
<form v-else class="form" @submit.prevent="save">
|
||||
<UiField label="یادآوری قبل از مهلت" hint="برای کارهایی که ساعت مشخص دارند. روتینها ممکن است یادآوری اختصاصی داشته باشند.">
|
||||
<UiSelect v-model="form.remind">
|
||||
<option v-for="o in remindOptions" :key="o.value" :value="o.value">{{ o.label }}</option>
|
||||
</UiSelect>
|
||||
</UiField>
|
||||
|
||||
<label class="tg">
|
||||
<input v-model="form.overdueAlert" type="checkbox" />
|
||||
<span>
|
||||
<strong>هشدار کار اجباری انجامنشده</strong>
|
||||
<small>وقتی مهلت یک کار اجباری بگذرد و هنوز انجام نشده باشد.</small>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="tg">
|
||||
<input v-model="form.endOfDaySummary" type="checkbox" />
|
||||
<span>
|
||||
<strong>خلاصه پایان روز</strong>
|
||||
<small>اگر کاری باقی مانده باشد، یک اعلان خلاصه دریافت میکنید؛ مثل «۳ کار تا پایان امروز باقی مانده است».</small>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<UiField v-if="form.endOfDaySummary" label="ساعت ارسال خلاصه" class="nested">
|
||||
<UiSelect v-model="form.summaryHour">
|
||||
<option v-for="h in hours" :key="h" :value="String(h)">ساعت {{ toFaDigits(h) }}:۰۰</option>
|
||||
</UiSelect>
|
||||
</UiField>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-if="saved" class="ok">تنظیمات ذخیره شد.</p>
|
||||
|
||||
<div class="actions">
|
||||
<UiButton type="submit" :loading="saving">ذخیره تنظیمات</UiButton>
|
||||
</div>
|
||||
</form>
|
||||
</UiCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.settings {
|
||||
max-width: 680px;
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--sp-3);
|
||||
margin-bottom: var(--sp-5);
|
||||
}
|
||||
|
||||
.ico {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 10px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent-deep);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.head h3 {
|
||||
margin: 0;
|
||||
font-size: var(--fs-md);
|
||||
font-weight: var(--fw-semibold);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.head p {
|
||||
margin: 4px 0 0;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--muted);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.state {
|
||||
padding: var(--sp-6);
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.form {
|
||||
display: grid;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.tg {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--sp-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tg input {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-top: 2px;
|
||||
accent-color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tg span {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.tg strong {
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: var(--fw-medium);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.tg small {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.nested {
|
||||
margin-inline-start: 26px;
|
||||
max-width: 260px;
|
||||
}
|
||||
|
||||
.ok {
|
||||
margin: 0;
|
||||
color: var(--success-deep);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,231 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, provide, ref, watch } from 'vue';
|
||||
import { RouterLink, RouterView, useRoute, useRouter } from 'vue-router';
|
||||
import type { RoutineContextDto } from '@mento/shared';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import RoutineTaskDrawer from '@/components/routines/RoutineTaskDrawer.vue';
|
||||
import QuickTaskModal from '@/components/routines/QuickTaskModal.vue';
|
||||
import { UiButton, UiPageHeader } from '@/components/ui';
|
||||
import { api } from '@/lib/api';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import { ROUTINES_CTX, apiError } from '@/lib/routines';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const context = ref<RoutineContextDto | null>(null);
|
||||
const contextError = ref('');
|
||||
const refreshTick = ref(0);
|
||||
const overdueCount = ref(0);
|
||||
const quickOpen = ref(false);
|
||||
|
||||
async function loadContext() {
|
||||
try {
|
||||
const { data } = await api.get<RoutineContextDto>('/routines/context');
|
||||
context.value = data;
|
||||
contextError.value = '';
|
||||
} catch (e) {
|
||||
contextError.value = apiError(e, 'بارگذاری اطلاعات کارهای روتین ممکن نشد.');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBadge() {
|
||||
try {
|
||||
const { data } = await api.get<{ remaining: number; overdue: number }>('/routine-tasks/badge');
|
||||
overdueCount.value = data.overdue;
|
||||
} catch {
|
||||
overdueCount.value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function notifyChanged() {
|
||||
refreshTick.value += 1;
|
||||
void loadBadge();
|
||||
}
|
||||
|
||||
function openTask(id: string) {
|
||||
void router.replace({ query: { ...route.query, task: id } });
|
||||
}
|
||||
|
||||
function closeTask() {
|
||||
const query = { ...route.query };
|
||||
delete query.task;
|
||||
void router.replace({ query });
|
||||
}
|
||||
|
||||
provide(ROUTINES_CTX, { context, refreshTick, notifyChanged, openTask });
|
||||
|
||||
onMounted(() => {
|
||||
void loadContext();
|
||||
void loadBadge();
|
||||
});
|
||||
|
||||
const taskId = computed(() => (typeof route.query.task === 'string' ? route.query.task : null));
|
||||
const isManager = computed(() => !!context.value?.isManager);
|
||||
|
||||
type Tab = { to: string; name: string; label: string; icon: InstanceType<typeof UiIcon>['$props']['name']; manager?: boolean };
|
||||
|
||||
const tabs: Tab[] = [
|
||||
{ to: '/routines', name: 'routines-today', label: 'کارهای امروز', icon: 'calendar-sun' },
|
||||
{ to: '/routines/overdue', name: 'routines-overdue', label: 'عقبافتاده', icon: 'warning' },
|
||||
{ to: '/routines/calendar', name: 'routines-calendar', label: 'تقویم', icon: 'calendar' },
|
||||
{ to: '/routines/list', name: 'routines-list', label: 'روتینها', icon: 'refresh' },
|
||||
{ to: '/routines/me', name: 'routines-me', label: 'عملکرد من', icon: 'chart' },
|
||||
{ to: '/routines/team', name: 'routines-team', label: 'عملکرد تیم', icon: 'users', manager: true },
|
||||
{ to: '/routines/manage', name: 'routines-manage', label: 'مدیریت روتینها', icon: 'sliders', manager: true },
|
||||
];
|
||||
|
||||
const visibleTabs = computed(() => tabs.filter((t) => !t.manager || isManager.value));
|
||||
|
||||
const managerOnlyRoutes = new Set(['routines-team', 'routines-manage', 'routine-new', 'routine-edit']);
|
||||
|
||||
watch(
|
||||
[() => route.name, context],
|
||||
([name, ctx]) => {
|
||||
if (ctx && !ctx.isManager && managerOnlyRoutes.has(String(name))) {
|
||||
void router.replace('/routines');
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function tabActive(tab: Tab) {
|
||||
const name = String(route.name ?? '');
|
||||
if (tab.name === 'routines-manage') return name === 'routines-manage' || name === 'routine-new' || name === 'routine-edit';
|
||||
return name === tab.name;
|
||||
}
|
||||
|
||||
const hideHeader = computed(() => route.name === 'routine-new' || route.name === 'routine-edit');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="crm-page routines-shell">
|
||||
<template v-if="!hideHeader">
|
||||
<UiPageHeader title="کارهای روتین" lead="برنامه روزانه، روتینهای تکرارشونده و پیگیری انجام کارها">
|
||||
<template #actions>
|
||||
<RouterLink to="/routines/settings" class="icon-link" title="تنظیمات یادآوری و اعلانها">
|
||||
<UiIcon name="bell" :size="17" />
|
||||
</RouterLink>
|
||||
<UiButton v-if="isManager" variant="outline" @click="router.push('/routines/manage/new')">
|
||||
<template #icon><UiIcon name="refresh" :size="16" /></template>
|
||||
روتین جدید
|
||||
</UiButton>
|
||||
<UiButton @click="quickOpen = true">
|
||||
<template #icon><UiIcon name="plus" :size="16" /></template>
|
||||
کار جدید
|
||||
</UiButton>
|
||||
</template>
|
||||
</UiPageHeader>
|
||||
|
||||
<nav class="r-tabs" aria-label="بخشهای کارهای روتین">
|
||||
<RouterLink
|
||||
v-for="tab in visibleTabs"
|
||||
:key="tab.name"
|
||||
:to="tab.to"
|
||||
class="r-tab"
|
||||
:class="{ on: tabActive(tab) }"
|
||||
>
|
||||
<UiIcon :name="tab.icon" :size="15" />
|
||||
<span>{{ tab.label }}</span>
|
||||
<em v-if="tab.name === 'routines-overdue' && overdueCount > 0" class="count">
|
||||
{{ toFaDigits(overdueCount) }}
|
||||
</em>
|
||||
</RouterLink>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<p v-if="contextError" class="error">{{ contextError }}</p>
|
||||
|
||||
<RouterView v-slot="{ Component }">
|
||||
<component :is="Component" :key="String(route.name)" />
|
||||
</RouterView>
|
||||
|
||||
<RoutineTaskDrawer :task-id="taskId" @close="closeTask" />
|
||||
<QuickTaskModal :open="quickOpen" @close="quickOpen = false" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.routines-shell {
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.icon-link {
|
||||
width: var(--control-h);
|
||||
height: var(--control-h);
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
color: var(--ink-secondary);
|
||||
flex: 0 0 auto !important;
|
||||
}
|
||||
|
||||
.icon-link:hover,
|
||||
.icon-link.router-link-active {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-deep);
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
|
||||
.r-tabs {
|
||||
display: flex;
|
||||
gap: var(--sp-5);
|
||||
border-bottom: 1px solid var(--line);
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.r-tabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.r-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0.6rem 0.15rem;
|
||||
margin-bottom: -1px;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--muted-light);
|
||||
font-weight: var(--fw-bold);
|
||||
font-size: var(--fs-sm);
|
||||
white-space: nowrap;
|
||||
transition: color var(--transition-fast), border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.r-tab:hover {
|
||||
color: var(--ink-secondary);
|
||||
}
|
||||
|
||||
.r-tab.on {
|
||||
color: var(--accent-hover);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
.count {
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: 999px;
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
font-style: normal;
|
||||
font-size: 0.68rem;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.r-tabs {
|
||||
gap: var(--sp-4);
|
||||
margin-inline: calc(-1 * var(--sp-4));
|
||||
padding-inline: var(--sp-4);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,460 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, reactive, ref, watch } from 'vue';
|
||||
import type {
|
||||
RoutineExecutionRowDto,
|
||||
RoutineStatsQuery,
|
||||
RoutineTaskSource,
|
||||
RoutineTaskStatus,
|
||||
RoutineTeamPerformanceDto,
|
||||
} from '@mento/shared';
|
||||
import { ROUTINE_TASK_SOURCE_LABELS, ROUTINE_TASK_STATUS_LABELS } from '@mento/shared';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||
import KpiCard from '@/components/routines/KpiCard.vue';
|
||||
import TeamPerformanceTable from '@/components/routines/TeamPerformanceTable.vue';
|
||||
import WeeklyReport from '@/components/routines/WeeklyReport.vue';
|
||||
import MonthlyReport from '@/components/routines/MonthlyReport.vue';
|
||||
import { UiButton, UiChart, UiSegmented, UiSelect } from '@/components/ui';
|
||||
import { api } from '@/lib/api';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import { categoryBar, membersBar, statusDonut, trendOption } from '@/lib/routineCharts';
|
||||
import {
|
||||
ROUTINES_CTX,
|
||||
addDaysKey,
|
||||
apiError,
|
||||
formatPercent,
|
||||
percentTone,
|
||||
todayKey,
|
||||
} from '@/lib/routines';
|
||||
|
||||
const shell = inject(ROUTINES_CTX)!;
|
||||
|
||||
type Section = 'overview' | 'weekly' | 'monthly' | 'routines';
|
||||
const section = ref<Section>('overview');
|
||||
const sections = [
|
||||
{ value: 'overview' as const, label: 'نمای کلی تیم' },
|
||||
{ value: 'weekly' as const, label: 'گزارش هفتگی' },
|
||||
{ value: 'monthly' as const, label: 'گزارش ماهانه' },
|
||||
{ value: 'routines' as const, label: 'اجرای روتینها' },
|
||||
];
|
||||
|
||||
const f = reactive({
|
||||
from: addDaysKey(todayKey(), -29),
|
||||
to: todayKey(),
|
||||
userId: '',
|
||||
teamId: '',
|
||||
categoryId: '',
|
||||
source: '' as RoutineTaskSource | '',
|
||||
status: '' as RoutineTaskStatus | '',
|
||||
});
|
||||
|
||||
const users = computed(() => shell.context.value?.users ?? []);
|
||||
const teams = computed(() => shell.context.value?.teams ?? []);
|
||||
const categories = computed(() => shell.context.value?.categories ?? []);
|
||||
const sources = Object.entries(ROUTINE_TASK_SOURCE_LABELS) as Array<[RoutineTaskSource, string]>;
|
||||
const statuses = Object.entries(ROUTINE_TASK_STATUS_LABELS) as Array<[RoutineTaskStatus, string]>;
|
||||
|
||||
const query = computed<RoutineStatsQuery>(() => {
|
||||
const q: RoutineStatsQuery = {};
|
||||
if (f.from) q.from = f.from;
|
||||
if (f.to) q.to = f.to;
|
||||
if (f.userId) q.userId = f.userId;
|
||||
if (f.teamId) q.teamId = f.teamId;
|
||||
if (f.categoryId) q.categoryId = f.categoryId;
|
||||
if (f.source) q.source = f.source;
|
||||
if (f.status) q.status = f.status;
|
||||
return q;
|
||||
});
|
||||
|
||||
const hasFilters = computed(() => !!(f.userId || f.teamId || f.categoryId || f.source || f.status));
|
||||
|
||||
function resetFilters() {
|
||||
Object.assign(f, { userId: '', teamId: '', categoryId: '', source: '', status: '' });
|
||||
}
|
||||
|
||||
const data = ref<RoutineTeamPerformanceDto | null>(null);
|
||||
const exec = ref<RoutineExecutionRowDto[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
|
||||
async function load() {
|
||||
if (section.value !== 'overview' && section.value !== 'routines') return;
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
if (section.value === 'overview') {
|
||||
const res = await api.get<RoutineTeamPerformanceDto>('/routines/stats/team', { params: query.value });
|
||||
data.value = res.data;
|
||||
} else {
|
||||
const res = await api.get<RoutineExecutionRowDto[]>('/routines/stats/routines', { params: query.value });
|
||||
exec.value = res.data;
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'بارگذاری گزارش تیم ممکن نشد.');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch([section, () => JSON.stringify(query.value), shell.refreshTick], load, { immediate: true });
|
||||
|
||||
const d = computed(() => data.value);
|
||||
const activeMembers = computed(() => (d.value?.members ?? []).filter((m) => m.total > 0).length);
|
||||
const trendOpt = computed(() => trendOption(d.value?.trend ?? []));
|
||||
const donutOpt = computed(() =>
|
||||
statusDonut(d.value?.status ?? { completed: 0, notDone: 0, overdue: 0, skipped: 0, cancelled: 0 }),
|
||||
);
|
||||
const membersOpt = computed(() => membersBar(d.value?.members ?? []));
|
||||
const catOpt = computed(() => categoryBar(d.value?.byCategory ?? []));
|
||||
const membersChartHeight = computed(() => `${Math.max(200, activeMembers.value * 34 + 40)}px`);
|
||||
|
||||
const execSort = ref<'percent' | 'occurrences'>('percent');
|
||||
const execRows = computed(() =>
|
||||
[...exec.value].sort((a, b) =>
|
||||
execSort.value === 'percent' ? (a.percent ?? -1) - (b.percent ?? -1) : b.occurrences - a.occurrences,
|
||||
),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="team">
|
||||
<UiSegmented v-model="section" :options="sections" class="sections" />
|
||||
|
||||
<!-- فیلترها -->
|
||||
<section class="filters">
|
||||
<div v-if="section === 'overview' || section === 'routines'" class="f-item date">
|
||||
<label>از</label>
|
||||
<JalaliDateInput v-model="f.from" mode="date" :clearable="false" />
|
||||
</div>
|
||||
<div v-if="section === 'overview' || section === 'routines'" class="f-item date">
|
||||
<label>تا</label>
|
||||
<JalaliDateInput v-model="f.to" mode="date" :clearable="false" />
|
||||
</div>
|
||||
<div class="f-item">
|
||||
<label>کاربر</label>
|
||||
<UiSelect v-model="f.userId" size="sm">
|
||||
<option value="">همه کاربران</option>
|
||||
<option v-for="u in users" :key="u.id" :value="u.id">{{ u.fullName }}</option>
|
||||
</UiSelect>
|
||||
</div>
|
||||
<div v-if="teams.length" class="f-item">
|
||||
<label>تیم</label>
|
||||
<UiSelect v-model="f.teamId" size="sm">
|
||||
<option value="">همه تیمها</option>
|
||||
<option v-for="t in teams" :key="t.id" :value="t.id">{{ t.name }}</option>
|
||||
</UiSelect>
|
||||
</div>
|
||||
<div class="f-item">
|
||||
<label>دستهبندی</label>
|
||||
<UiSelect v-model="f.categoryId" size="sm">
|
||||
<option value="">همه دستهها</option>
|
||||
<option v-for="c in categories" :key="c.id" :value="c.id">{{ c.name }}</option>
|
||||
</UiSelect>
|
||||
</div>
|
||||
<div v-if="section !== 'routines'" class="f-item">
|
||||
<label>نوع کار</label>
|
||||
<UiSelect v-model="f.source" size="sm">
|
||||
<option value="">همه</option>
|
||||
<option v-for="[k, label] in sources" :key="k" :value="k">{{ label }}</option>
|
||||
</UiSelect>
|
||||
</div>
|
||||
<div v-if="section === 'overview'" class="f-item">
|
||||
<label>وضعیت</label>
|
||||
<UiSelect v-model="f.status" size="sm">
|
||||
<option value="">همه</option>
|
||||
<option v-for="[k, label] in statuses" :key="k" :value="k">{{ label }}</option>
|
||||
</UiSelect>
|
||||
</div>
|
||||
<UiButton v-if="hasFilters" variant="ghost" size="sm" class="reset" @click="resetFilters">
|
||||
<template #icon><UiIcon name="x" :size="14" /></template>
|
||||
حذف فیلترها
|
||||
</UiButton>
|
||||
</section>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
|
||||
<!-- نمای کلی -->
|
||||
<template v-if="section === 'overview'">
|
||||
<div class="kpis" :class="{ dim: loading }">
|
||||
<KpiCard label="درصد انجام تیم" :value="formatPercent(d?.percent)" icon="target" tone="accent" />
|
||||
<KpiCard
|
||||
label="انجامشده"
|
||||
:value="toFaDigits(d?.completed ?? 0)"
|
||||
:hint="`از ${toFaDigits(d?.total ?? 0)} کار`"
|
||||
icon="check"
|
||||
tone="success"
|
||||
/>
|
||||
<KpiCard label="عقبافتاده" :value="toFaDigits(d?.overdue ?? 0)" icon="warning" tone="danger" />
|
||||
<KpiCard
|
||||
label="اعضای فعال"
|
||||
:value="toFaDigits(activeMembers)"
|
||||
:hint="`از ${toFaDigits(d?.members.length ?? 0)} نفر`"
|
||||
icon="users"
|
||||
tone="info"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="charts">
|
||||
<section class="card wide">
|
||||
<h3>روند درصد انجام تیم</h3>
|
||||
<UiChart :option="trendOpt" height="260px" :loading="loading" />
|
||||
</section>
|
||||
<section class="card">
|
||||
<h3>وضعیت کارها</h3>
|
||||
<UiChart :option="donutOpt" height="260px" :loading="loading" />
|
||||
</section>
|
||||
<section class="card">
|
||||
<h3>مقایسه اعضا</h3>
|
||||
<UiChart v-if="activeMembers" :option="membersOpt" :height="membersChartHeight" :loading="loading" />
|
||||
<p v-else class="muted">دادهای برای نمایش نیست.</p>
|
||||
</section>
|
||||
<section class="card wide">
|
||||
<h3>انجام به تفکیک دستهبندی</h3>
|
||||
<UiChart v-if="d?.byCategory.length" :option="catOpt" height="240px" :loading="loading" />
|
||||
<p v-else class="muted">دادهای برای نمایش نیست.</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="card">
|
||||
<h3>عملکرد اعضا</h3>
|
||||
<TeamPerformanceTable :members="d?.members ?? []" @select="(id) => (f.userId = id)" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<WeeklyReport
|
||||
v-else-if="section === 'weekly'"
|
||||
view="team"
|
||||
:filters="query"
|
||||
:refresh-key="shell.refreshTick.value"
|
||||
/>
|
||||
<MonthlyReport
|
||||
v-else-if="section === 'monthly'"
|
||||
view="team"
|
||||
:filters="query"
|
||||
:refresh-key="shell.refreshTick.value"
|
||||
/>
|
||||
|
||||
<!-- گزارش اجرای روتینها -->
|
||||
<section v-else class="card">
|
||||
<div class="exec-head">
|
||||
<h3>گزارش اجرای روتینها</h3>
|
||||
<UiSelect v-model="execSort" size="sm" class="exec-sort">
|
||||
<option value="percent">مرتبسازی: کمترین اجرا</option>
|
||||
<option value="occurrences">مرتبسازی: بیشترین تکرار</option>
|
||||
</UiSelect>
|
||||
</div>
|
||||
<div class="crm-table-container" :class="{ dim: loading }">
|
||||
<table class="crm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>روتین</th>
|
||||
<th>مسئول</th>
|
||||
<th>تکرار</th>
|
||||
<th>دفعات</th>
|
||||
<th>انجام شده</th>
|
||||
<th>انجام نشده</th>
|
||||
<th>رد شده</th>
|
||||
<th>درصد اجرا</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in execRows" :key="r.routineId">
|
||||
<td><strong>{{ r.title }}</strong></td>
|
||||
<td class="nowrap">{{ r.assigneeLabel }}</td>
|
||||
<td class="nowrap">{{ r.recurrenceLabel }}</td>
|
||||
<td>{{ toFaDigits(r.occurrences) }}</td>
|
||||
<td class="ok">{{ toFaDigits(r.completed) }}</td>
|
||||
<td :class="{ bad: r.notDone > 0 }">{{ toFaDigits(r.notDone) }}</td>
|
||||
<td>{{ toFaDigits(r.skipped) }}</td>
|
||||
<td>
|
||||
<span class="pct" :class="`tone-${percentTone(r.percent)}`">
|
||||
<strong>{{ formatPercent(r.percent) }}</strong>
|
||||
<i><b :style="{ width: `${r.percent ?? 0}%` }" /></i>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!execRows.length && !loading">
|
||||
<td colspan="8" class="empty">در این بازه روتینی اجرا نشده است.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.team {
|
||||
display: grid;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.sections {
|
||||
justify-self: start;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.f-item {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.f-item.date {
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.f-item label {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.reset {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: var(--sp-3);
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.dim {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.charts {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr);
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: var(--sp-4);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
display: grid;
|
||||
gap: var(--sp-3);
|
||||
align-content: start;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
margin: 0;
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: var(--fw-semibold);
|
||||
color: var(--ink-secondary);
|
||||
}
|
||||
|
||||
.muted {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: var(--fs-sm);
|
||||
text-align: center;
|
||||
padding: var(--sp-6);
|
||||
}
|
||||
|
||||
.exec-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.exec-sort {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ok {
|
||||
color: var(--success-deep);
|
||||
}
|
||||
|
||||
.bad {
|
||||
color: var(--danger);
|
||||
font-weight: var(--fw-semibold);
|
||||
}
|
||||
|
||||
.pct {
|
||||
display: grid;
|
||||
grid-template-columns: 44px 80px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pct i {
|
||||
height: 6px;
|
||||
border-radius: 99px;
|
||||
background: var(--surface-subtle);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pct b {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--line-strong);
|
||||
}
|
||||
|
||||
.tone-good b {
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.tone-mid b {
|
||||
background: var(--warning);
|
||||
}
|
||||
|
||||
.tone-low b {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
padding: var(--sp-6);
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.charts {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.kpis {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.f-item,
|
||||
.f-item.date {
|
||||
min-width: calc(50% - var(--sp-3));
|
||||
flex: 1 1 calc(50% - var(--sp-3));
|
||||
}
|
||||
|
||||
.exec-sort {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,529 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, onMounted, ref, watch } from 'vue';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import type { RoutineCalendarDto, RoutineTaskDto, RoutineTodayDto } from '@mento/shared';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import KpiCard from '@/components/routines/KpiCard.vue';
|
||||
import DailyScoreCard from '@/components/routines/DailyScoreCard.vue';
|
||||
import RoutineTaskRow, { type RoutineTaskRowAction } from '@/components/routines/RoutineTaskRow.vue';
|
||||
import RoutineTaskModals from '@/components/routines/RoutineTaskModals.vue';
|
||||
import { UiEmptyState, UiSegmented, UiSelect } from '@/components/ui';
|
||||
import { api } from '@/lib/api';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import {
|
||||
ROUTINES_CTX,
|
||||
addDaysKey,
|
||||
apiError,
|
||||
formatDayLong,
|
||||
formatPercent,
|
||||
isOpenStatus,
|
||||
todayKey,
|
||||
} from '@/lib/routines';
|
||||
import { useRoutineTaskActions } from '@/lib/useRoutineTaskActions';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
|
||||
const shell = inject(ROUTINES_CTX)!;
|
||||
const auth = useAuthStore();
|
||||
|
||||
const data = ref<RoutineTodayDto | null>(null);
|
||||
const recent = ref<RoutineCalendarDto['days']>([]);
|
||||
const loading = ref(true);
|
||||
const error = ref('');
|
||||
const userId = ref('');
|
||||
const filter = ref<'all' | 'open' | 'done'>('all');
|
||||
|
||||
const viewingSelf = computed(() => !userId.value || userId.value === auth.user?.id);
|
||||
|
||||
async function load(silent = false) {
|
||||
if (!silent) loading.value = true;
|
||||
error.value = '';
|
||||
const params = userId.value ? { userId: userId.value } : {};
|
||||
try {
|
||||
const today = todayKey();
|
||||
const [todayRes, calRes] = await Promise.all([
|
||||
api.get<RoutineTodayDto>('/routine-tasks/today', { params }),
|
||||
api.get<RoutineCalendarDto>('/routine-tasks/calendar', {
|
||||
params: { ...params, from: addDaysKey(today, -13), to: today },
|
||||
}),
|
||||
]);
|
||||
data.value = todayRes.data;
|
||||
recent.value = calRes.data.days;
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'بارگذاری کارهای امروز ممکن نشد.');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => load());
|
||||
watch(userId, () => load());
|
||||
watch(shell.refreshTick, () => load(true));
|
||||
|
||||
function applyLocal(updated: RoutineTaskDto) {
|
||||
const d = data.value;
|
||||
if (!d) return;
|
||||
for (const list of [d.tasks, d.overdue]) {
|
||||
const i = list.findIndex((t) => t.id === updated.id);
|
||||
if (i >= 0) list[i] = updated;
|
||||
}
|
||||
}
|
||||
|
||||
const actions = useRoutineTaskActions((updated) => {
|
||||
applyLocal(updated);
|
||||
shell.notifyChanged();
|
||||
});
|
||||
|
||||
function onAction(kind: RoutineTaskRowAction, task: RoutineTaskDto) {
|
||||
if (kind === 'start') void actions.start(task);
|
||||
else if (kind === 'complete-note') actions.openComplete(task);
|
||||
else if (kind === 'reschedule') actions.openReschedule(task);
|
||||
else if (kind === 'move-today') void actions.moveToday(task);
|
||||
else if (kind === 'skip') actions.openSkip(task);
|
||||
else actions.openCancel(task);
|
||||
}
|
||||
|
||||
const stats = computed(() => data.value?.stats ?? { total: 0, completed: 0, remaining: 0, overdue: 0, percent: null });
|
||||
|
||||
const tasks = computed(() => {
|
||||
const list = data.value?.tasks ?? [];
|
||||
if (filter.value === 'open') return list.filter((t) => isOpenStatus(t.status));
|
||||
if (filter.value === 'done') return list.filter((t) => !isOpenStatus(t.status));
|
||||
return list;
|
||||
});
|
||||
|
||||
const openTasks = computed(() => tasks.value.filter((t) => isOpenStatus(t.status)));
|
||||
const closedTasks = computed(() => tasks.value.filter((t) => !isOpenStatus(t.status)));
|
||||
|
||||
const progressPct = computed(() => stats.value.percent ?? 0);
|
||||
|
||||
const filterOptions = computed(() => [
|
||||
{ value: 'all' as const, label: `همه (${toFaDigits(data.value?.tasks.length ?? 0)})` },
|
||||
{ value: 'open' as const, label: 'باقیمانده' },
|
||||
{ value: 'done' as const, label: 'انجامشده' },
|
||||
]);
|
||||
|
||||
const users = computed(() => shell.context.value?.users ?? []);
|
||||
const showUserPicker = computed(() => !!shell.context.value?.isManager && users.value.length > 1);
|
||||
|
||||
const greeting = computed(() => {
|
||||
if (!viewingSelf.value) {
|
||||
const u = users.value.find((x) => x.id === userId.value);
|
||||
return u ? `کارهای امروز ${u.fullName}` : 'کارهای امروز';
|
||||
}
|
||||
return 'امروز چه کارهایی داری؟';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="today">
|
||||
<!-- سربرگ روز -->
|
||||
<section class="day-hero">
|
||||
<div class="hero-copy">
|
||||
<h2>کارهای امروز</h2>
|
||||
<p class="sub">{{ greeting }}</p>
|
||||
<p class="date">
|
||||
<UiIcon name="calendar" :size="14" />
|
||||
{{ formatDayLong(data?.date ?? todayKey()) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="hero-progress" :aria-label="`${stats.completed} از ${stats.total} کار انجام شده`">
|
||||
<div class="ring" :style="{ '--p': progressPct }">
|
||||
<span>{{ formatPercent(stats.percent) }}</span>
|
||||
</div>
|
||||
<div class="progress-copy">
|
||||
<strong>{{ toFaDigits(stats.completed) }} از {{ toFaDigits(stats.total) }}</strong>
|
||||
<span>کار امروز انجام شده</span>
|
||||
<i class="pbar"><b :style="{ width: `${progressPct}%` }" /></i>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showUserPicker" class="hero-user">
|
||||
<UiSelect v-model="userId" size="sm">
|
||||
<option value="">کارهای خودم</option>
|
||||
<option v-for="u in users.filter((x) => x.id !== auth.user?.id)" :key="u.id" :value="u.id">
|
||||
{{ u.fullName }}
|
||||
</option>
|
||||
</UiSelect>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- کارتهای آماری -->
|
||||
<section class="stat-grid">
|
||||
<KpiCard label="انجام شده" :value="toFaDigits(stats.completed)" icon="check" tone="success" />
|
||||
<KpiCard label="باقیمانده" :value="toFaDigits(stats.remaining)" icon="clock" tone="info" />
|
||||
<KpiCard
|
||||
label="عقبافتاده"
|
||||
:value="toFaDigits(stats.overdue)"
|
||||
icon="warning"
|
||||
:tone="stats.overdue ? 'danger' : 'neutral'"
|
||||
/>
|
||||
<KpiCard label="درصد تکمیل" :value="formatPercent(stats.percent)" icon="target" tone="accent" />
|
||||
</section>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-if="actions.error.value" class="error">{{ actions.error.value }}</p>
|
||||
|
||||
<div class="layout">
|
||||
<section class="main">
|
||||
<div class="list-head">
|
||||
<UiSegmented v-model="filter" :options="filterOptions" />
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="skeleton">
|
||||
<span v-for="i in 4" :key="i" />
|
||||
</div>
|
||||
|
||||
<UiEmptyState
|
||||
v-else-if="!data?.tasks.length"
|
||||
icon="calendar-sun"
|
||||
title="برای امروز کاری ثبت نشده"
|
||||
description="روتینهای فعال بهصورت خودکار هر روز کار میسازند. میتوانید یک کار دستی هم اضافه کنید."
|
||||
/>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="openTasks.length" class="group">
|
||||
<h3 class="group-title">باقیمانده <em>{{ toFaDigits(openTasks.length) }}</em></h3>
|
||||
<TransitionGroup name="rt-list" tag="div" class="rows">
|
||||
<RoutineTaskRow
|
||||
v-for="t in openTasks"
|
||||
:key="t.id"
|
||||
:task="t"
|
||||
:busy="actions.busyId.value === t.id"
|
||||
show-delay
|
||||
@toggle="actions.toggle"
|
||||
@open="shell.openTask(t.id)"
|
||||
@action="onAction"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
<div v-else-if="filter !== 'done'" class="all-done">
|
||||
<UiIcon name="check" :size="20" />
|
||||
همه کارهای امروز انجام شد.
|
||||
</div>
|
||||
|
||||
<div v-if="closedTasks.length" class="group">
|
||||
<h3 class="group-title">انجامشده / بسته <em>{{ toFaDigits(closedTasks.length) }}</em></h3>
|
||||
<TransitionGroup name="rt-list" tag="div" class="rows">
|
||||
<RoutineTaskRow
|
||||
v-for="t in closedTasks"
|
||||
:key="t.id"
|
||||
:task="t"
|
||||
:busy="actions.busyId.value === t.id"
|
||||
@toggle="actions.toggle"
|
||||
@open="shell.openTask(t.id)"
|
||||
@action="onAction"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<aside class="side">
|
||||
<DailyScoreCard :score="data?.score ?? null" :days="recent" />
|
||||
|
||||
<section v-if="data?.overdue.length" class="overdue-card">
|
||||
<header>
|
||||
<h3>
|
||||
<UiIcon name="warning" :size="15" />
|
||||
عقبافتاده از روزهای قبل
|
||||
</h3>
|
||||
<RouterLink to="/routines/overdue">همه ({{ toFaDigits(data.overdue.length) }})</RouterLink>
|
||||
</header>
|
||||
<div class="rows">
|
||||
<RoutineTaskRow
|
||||
v-for="t in data.overdue.slice(0, 4)"
|
||||
:key="t.id"
|
||||
:task="t"
|
||||
:busy="actions.busyId.value === t.id"
|
||||
show-date
|
||||
@toggle="actions.toggle"
|
||||
@open="shell.openTask(t.id)"
|
||||
@action="onAction"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<RoutineTaskModals :actions="actions" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.today {
|
||||
display: grid;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.day-hero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-4);
|
||||
padding: var(--sp-5);
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 22%, var(--line));
|
||||
background: linear-gradient(135deg, var(--accent-subtle), var(--surface) 70%);
|
||||
}
|
||||
|
||||
.hero-copy h2 {
|
||||
margin: 0;
|
||||
font-size: var(--fs-xl);
|
||||
font-weight: var(--fw-bold);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.sub {
|
||||
margin: 4px 0 0;
|
||||
color: var(--ink-secondary);
|
||||
font-size: var(--fs-base);
|
||||
}
|
||||
|
||||
.date {
|
||||
margin: 8px 0 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--accent-deep);
|
||||
font-weight: var(--fw-semibold);
|
||||
}
|
||||
|
||||
.hero-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.ring {
|
||||
--p: 0;
|
||||
width: 76px;
|
||||
height: 76px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: conic-gradient(var(--accent) calc(var(--p) * 1%), var(--line-light) 0);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ring span {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
background: var(--surface);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-weight: var(--fw-bold);
|
||||
font-size: var(--fs-md);
|
||||
color: var(--accent-deep);
|
||||
}
|
||||
|
||||
.progress-copy {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.progress-copy strong {
|
||||
font-size: var(--fs-xl);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.progress-copy span {
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.pbar {
|
||||
height: 6px;
|
||||
border-radius: 99px;
|
||||
background: var(--line-light);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pbar b {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: inherit;
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
.hero-user {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 320px;
|
||||
gap: var(--sp-4);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.main {
|
||||
display: grid;
|
||||
gap: var(--sp-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.list-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.group {
|
||||
display: grid;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.group-title {
|
||||
margin: 0;
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: var(--fw-semibold);
|
||||
color: var(--ink-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.group-title em {
|
||||
font-style: normal;
|
||||
color: var(--muted);
|
||||
font-weight: var(--fw-normal);
|
||||
}
|
||||
|
||||
.rows {
|
||||
position: relative;
|
||||
display: grid;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.all-done {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-4);
|
||||
border-radius: var(--radius);
|
||||
background: var(--success-soft);
|
||||
color: var(--success-deep);
|
||||
font-weight: var(--fw-semibold);
|
||||
}
|
||||
|
||||
.side {
|
||||
display: grid;
|
||||
gap: var(--sp-4);
|
||||
position: sticky;
|
||||
top: var(--sp-4);
|
||||
}
|
||||
|
||||
.overdue-card {
|
||||
display: grid;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-4);
|
||||
border: 1px solid color-mix(in srgb, var(--danger) 20%, var(--line));
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.overdue-card header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.overdue-card h3 {
|
||||
margin: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.overdue-card a {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--accent-deep);
|
||||
font-weight: var(--fw-semibold);
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
display: grid;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.skeleton span {
|
||||
height: 72px;
|
||||
border-radius: var(--radius);
|
||||
background: linear-gradient(90deg, var(--surface-subtle), var(--line-light), var(--surface-subtle));
|
||||
background-size: 200% 100%;
|
||||
animation: sk 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes sk {
|
||||
to {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.rt-list-move,
|
||||
.rt-list-enter-active,
|
||||
.rt-list-leave-active {
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
.rt-list-enter-from,
|
||||
.rt-list-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
|
||||
.rt-list-leave-active {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.side {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.day-hero {
|
||||
padding: var(--sp-4);
|
||||
}
|
||||
|
||||
.hero-progress {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.progress-copy {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.hero-user {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.stat-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user