554 lines
13 KiB
Vue
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>
|