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

554 lines
13 KiB
Vue

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