Files
CRM_Montzar/apps/web/src/views/ActivitiesCalendarView.vue
T
MahdiandCursor 092767b6f2 Align older CRM pages with the Mento visual language.
Restyle list, form, settings, and secondary modules to match contacts/activities patterns with teal chrome, soft panels, and mobile layouts — without changing already-redesigned screens.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-05 02:29:41 +03:30

706 lines
15 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import UiIcon from '@/components/UiIcon.vue';
import type { ActivityDto, PaginatedResponse } from '@mento/shared';
import {
addJalaliMonths,
dateToJalali,
formatJalaliLong,
gregorianDayKey,
jalaliMonthLength,
jalaliMonthName,
jalaliToDate,
toFaDigits,
} from '@/lib/jalali';
const route = useRoute();
const router = useRouter();
const loading = ref(false);
const error = ref('');
const items = ref<ActivityDto[]>([]);
const selectedKey = ref<string | null>(null);
const typeLabel: Record<ActivityDto['type'], string> = {
call: 'تماس',
meeting: 'جلسه',
task: 'وظیفه',
note: 'یادداشت',
};
const WEEKDAYS = ['ش', 'ی', 'د', 'س', 'چ', 'پ', 'ج'] as const;
type CalCell = {
key: string;
date: Date;
jy: number;
jm: number;
jd: number;
inMonth: boolean;
isToday: boolean;
};
type JalaliCursor = { jy: number; jm: number };
function parseMonthParam(value: unknown): JalaliCursor {
if (typeof value === 'string' && /^\d{4}-\d{2}$/.test(value)) {
const [y, m] = value.split('-').map(Number);
if (m >= 1 && m <= 12) {
// queryهای قدیمی با سال میلادی (مثلاً 2026-08) را به شمسی برمی‌گردانیم
if (y >= 1700) {
const j = dateToJalali(new Date(y, m - 1, 1));
return { jy: j.jy, jm: j.jm };
}
if (y >= 1200 && y <= 1600) {
return { jy: y, jm: m };
}
}
}
const today = dateToJalali(new Date());
return { jy: today.jy, jm: today.jm };
}
const cursor = computed(() => parseMonthParam(route.query.month));
const monthLabel = computed(
() => `${jalaliMonthName(cursor.value.jm)} ${toFaDigits(cursor.value.jy)}`,
);
function monthParam(jy: number, jm: number) {
return `${jy}-${String(jm).padStart(2, '0')}`;
}
/** شبکه ماه شمسی؛ هفته از شنبه */
const cells = computed<CalCell[]>(() => {
const { jy, jm } = cursor.value;
const daysInMonth = jalaliMonthLength(jy, jm);
const first = jalaliToDate(jy, jm, 1);
const firstDow = (first.getDay() + 1) % 7; // شنبه = 0
const todayKey = gregorianDayKey(new Date());
const out: CalCell[] = [];
const prev = addJalaliMonths(jy, jm, -1);
const prevLen = jalaliMonthLength(prev.jy, prev.jm);
for (let i = 0; i < firstDow; i += 1) {
const jd = prevLen - firstDow + i + 1;
const date = jalaliToDate(prev.jy, prev.jm, jd);
out.push({
key: gregorianDayKey(date),
date,
jy: prev.jy,
jm: prev.jm,
jd,
inMonth: false,
isToday: gregorianDayKey(date) === todayKey,
});
}
for (let jd = 1; jd <= daysInMonth; jd += 1) {
const date = jalaliToDate(jy, jm, jd);
out.push({
key: gregorianDayKey(date),
date,
jy,
jm,
jd,
inMonth: true,
isToday: gregorianDayKey(date) === todayKey,
});
}
const next = addJalaliMonths(jy, jm, 1);
let n = 1;
while (out.length % 7 !== 0) {
const date = jalaliToDate(next.jy, next.jm, n);
out.push({
key: gregorianDayKey(date),
date,
jy: next.jy,
jm: next.jm,
jd: n,
inMonth: false,
isToday: gregorianDayKey(date) === todayKey,
});
n += 1;
}
return out;
});
const byDay = computed(() => {
const map = new Map<string, ActivityDto[]>();
for (const item of items.value) {
if (!item.dueAt) continue;
const key = gregorianDayKey(new Date(item.dueAt));
const list = map.get(key) ?? [];
list.push(item);
map.set(key, list);
}
return map;
});
const selectedItems = computed(() => {
if (!selectedKey.value) return [];
return byDay.value.get(selectedKey.value) ?? [];
});
const selectedLabel = computed(() => {
if (!selectedKey.value) return '';
const cell = cells.value.find((c) => c.key === selectedKey.value);
if (cell) return formatJalaliLong(cell.date);
const [y, m, d] = selectedKey.value.split('-').map(Number);
return formatJalaliLong(new Date(y, m - 1, d));
});
function rangeForJalaliMonth(jy: number, jm: number) {
const from = jalaliToDate(jy, jm, 1);
from.setHours(0, 0, 0, 0);
const next = addJalaliMonths(jy, jm, 1);
const to = jalaliToDate(next.jy, next.jm, 1);
to.setHours(0, 0, 0, 0);
return { from: from.toISOString(), to: to.toISOString() };
}
async function load() {
loading.value = true;
error.value = '';
try {
const { from, to } = rangeForJalaliMonth(cursor.value.jy, cursor.value.jm);
const { data } = await api.get<PaginatedResponse<ActivityDto>>('/activities', {
params: { filter: 'all', from, to, limit: 200 },
});
items.value = data.items;
if (!selectedKey.value) {
selectedKey.value = gregorianDayKey(new Date());
}
} catch (e) {
if (axios.isAxiosError(e) && !e.response) {
error.value = 'اتصال به API برقرار نشد.';
} else {
error.value = 'بارگذاری تقویم ناموفق بود.';
}
items.value = [];
} finally {
loading.value = false;
}
}
function goMonth(delta: number) {
const next = addJalaliMonths(cursor.value.jy, cursor.value.jm, delta);
router.replace({
name: 'activities-calendar',
query: { month: monthParam(next.jy, next.jm) },
});
}
function goToday() {
const now = new Date();
const j = dateToJalali(now);
selectedKey.value = gregorianDayKey(now);
router.replace({
name: 'activities-calendar',
query: { month: monthParam(j.jy, j.jm) },
});
}
function selectDay(cell: CalCell) {
selectedKey.value = cell.key;
if (!cell.inMonth) {
router.replace({
name: 'activities-calendar',
query: { month: monthParam(cell.jy, cell.jm) },
});
}
}
function newForSelected() {
if (!selectedKey.value) return { name: 'activity-new' as const };
const dueAt = `${selectedKey.value}T09:00:00`;
return {
name: 'activity-new' as const,
query: { dueAt },
};
}
function formatTime(iso: string | null) {
if (!iso) return '';
try {
return new Intl.DateTimeFormat('fa-IR', { timeStyle: 'short' }).format(new Date(iso));
} catch {
return '';
}
}
watch(
() => route.query.month,
() => {
void load();
},
);
onMounted(() => {
selectedKey.value = gregorianDayKey(new Date());
const parsed = parseMonthParam(route.query.month);
const normalized = monthParam(parsed.jy, parsed.jm);
if (route.query.month !== normalized) {
void router.replace({
name: 'activities-calendar',
query: { month: normalized },
});
} else {
void load();
}
});
</script>
<template>
<section class="page">
<nav class="crumbs" aria-label="مسیر">
<RouterLink :to="{ name: 'home' }">خانه</RouterLink>
<span>‹</span>
<RouterLink :to="{ name: 'activities' }">فعالیت‌ها</RouterLink>
<span>‹</span>
<span>تقویم</span>
</nav>
<header class="page-head">
<div class="head-copy">
<h1>تقویم فعالیت‌ها</h1>
<p class="lead">نمای ماهانه شمسی موعد تماس، جلسه و وظیفه.</p>
</div>
<div class="head-actions">
<RouterLink class="btn ghost" :to="{ name: 'activities' }">
<UiIcon name="list" :size="18" />
لیست
</RouterLink>
<RouterLink class="btn primary" :to="newForSelected()">
<span>+</span>
فعالیت جدید
</RouterLink>
</div>
</header>
<div class="nav">
<button class="btn ghost nav-btn" type="button" @click="goMonth(-1)">ماه قبل</button>
<div class="nav-center">
<strong>{{ monthLabel }}</strong>
<button class="linkish" type="button" @click="goToday">برو به امروز</button>
</div>
<button class="btn ghost nav-btn" type="button" @click="goMonth(1)">ماه بعد</button>
</div>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
<div class="layout">
<div class="calendar" :aria-busy="loading">
<div class="weekdays">
<span v-for="w in WEEKDAYS" :key="w">{{ w }}</span>
</div>
<div class="grid">
<button
v-for="cell in cells"
:key="cell.key + '-' + cell.jd"
type="button"
class="day"
:class="{
outside: !cell.inMonth,
today: cell.isToday,
selected: selectedKey === cell.key,
has: (byDay.get(cell.key)?.length ?? 0) > 0,
}"
@click="selectDay(cell)"
>
<span class="num">{{ toFaDigits(cell.jd) }}</span>
<span v-if="byDay.get(cell.key)?.length" class="count">
{{ toFaDigits(byDay.get(cell.key)!.length) }}
</span>
<ul v-if="cell.inMonth" class="previews">
<li
v-for="act in (byDay.get(cell.key) ?? []).slice(0, 2)"
:key="act.id"
:class="{ done: act.status === 'done' }"
>
{{ act.title }}
</li>
</ul>
</button>
</div>
</div>
<aside class="day-panel">
<header>
<h2>{{ selectedLabel || 'یک روز را انتخاب کنید' }}</h2>
<RouterLink v-if="selectedKey" class="btn ghost sm" :to="newForSelected()">
افزودن در این روز
</RouterLink>
</header>
<ul v-if="selectedItems.length" class="day-list">
<li v-for="act in selectedItems" :key="act.id" :class="{ done: act.status === 'done' }">
<RouterLink :to="{ name: 'activity-detail', params: { id: act.id } }">
<span class="type">{{ typeLabel[act.type] }}</span>
<strong>{{ act.title }}</strong>
<span class="meta">
{{ formatTime(act.dueAt) }}
<template v-if="act.person">
· {{ act.person.fullName }}
</template>
<template v-if="act.status === 'done'"> · انجام‌شده</template>
</span>
</RouterLink>
</li>
</ul>
<p v-else class="muted">برای این روز فعالیتی با موعد ثبت نشده.</p>
</aside>
</div>
</section>
</template>
<style scoped>
.page {
display: grid;
gap: 1rem;
min-width: 0;
overflow-x: clip;
}
.crumbs {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
align-items: center;
color: #94a3b8;
font-size: 0.78rem;
}
.crumbs a {
color: inherit;
text-decoration: none;
}
.crumbs a:hover {
color: #0f766e;
}
.page-head {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
flex-wrap: wrap;
}
.head-copy {
min-width: 0;
}
.head-actions {
display: flex;
gap: 0.55rem;
flex-wrap: wrap;
}
h1 {
margin: 0 0 0.3rem;
font-size: clamp(1.45rem, 2.5vw, 1.85rem);
font-weight: 800;
}
h2 {
margin: 0;
font-size: 1.05rem;
color: #0f172a;
}
.lead,
.muted,
.meta {
margin: 0;
color: #64748b;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
min-height: 42px;
padding: 0.55rem 1rem;
border-radius: 12px;
font-weight: 700;
border: 1px solid transparent;
cursor: pointer;
font: inherit;
text-decoration: none;
}
.btn.primary {
background: #14b8a6;
color: #fff;
box-shadow: 0 8px 18px rgba(20, 184, 166, 0.24);
}
.btn.ghost {
background: #fff;
color: #0f766e;
border-color: #99f6e4;
}
.btn.sm {
min-height: 36px;
padding: 0.4rem 0.75rem;
font-size: 0.88rem;
}
.nav {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
background: #fff;
border-radius: 18px;
padding: 0.75rem 0.95rem;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
}
.nav-center {
display: grid;
justify-items: center;
gap: 0.15rem;
color: #0f766e;
}
.linkish {
border: 0;
background: transparent;
color: #14b8a6;
cursor: pointer;
font: inherit;
font-size: 0.85rem;
font-weight: 700;
padding: 0;
}
.layout {
display: grid;
grid-template-columns: minmax(0, 1.6fr) minmax(240px, 0.9fr);
gap: 1rem;
align-items: start;
}
.calendar {
background: #fff;
border-radius: 18px;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
padding: 0.85rem;
min-width: 0;
}
.weekdays {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 0.35rem;
margin-bottom: 0.35rem;
color: #94a3b8;
font-size: 0.85rem;
text-align: center;
font-weight: 700;
}
.grid {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 0.35rem;
}
.day {
min-height: 92px;
border: 1px solid #e2e8f0;
border-radius: 14px;
background: #f8fafc;
padding: 0.45rem;
text-align: right;
cursor: pointer;
display: grid;
grid-template-rows: auto auto 1fr;
gap: 0.2rem;
font: inherit;
color: inherit;
min-width: 0;
transition: border-color 0.15s ease, background 0.15s ease;
}
.day:hover {
border-color: #99f6e4;
}
.day.outside {
opacity: 0.45;
}
.day.today {
border-color: #14b8a6;
background: #f0fdfa;
}
.day.selected {
outline: 2px solid #14b8a6;
outline-offset: 1px;
background: #fff;
}
.day.has .num {
font-weight: 800;
color: #0f766e;
}
.num {
font-size: 0.95rem;
}
.count {
justify-self: start;
font-size: 0.72rem;
background: #0f766e;
color: #fff;
border-radius: 999px;
padding: 0.05rem 0.4rem;
font-weight: 700;
}
.previews {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.15rem;
align-content: start;
}
.previews li {
font-size: 0.72rem;
color: #64748b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.previews li.done {
text-decoration: line-through;
}
.day-panel {
background: #fff;
border-radius: 18px;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
padding: 1rem;
display: grid;
gap: 0.85rem;
min-width: 0;
}
.day-panel header {
display: flex;
justify-content: space-between;
gap: 0.75rem;
align-items: flex-start;
flex-wrap: wrap;
}
.day-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.55rem;
}
.day-list a {
display: grid;
gap: 0.15rem;
padding: 0.75rem 0.85rem;
border: 1px solid #f1f5f9;
border-radius: 14px;
background: #f8fafc;
text-decoration: none;
color: inherit;
transition: border-color 0.15s ease;
}
.day-list a:hover {
border-color: #99f6e4;
}
.day-list .type {
font-size: 0.78rem;
color: #0f766e;
font-weight: 700;
}
.day-list .done a {
opacity: 0.7;
}
.error {
margin: 0;
color: #b91c1c;
}
@media (max-width: 900px) {
.crumbs {
display: none;
}
.layout {
grid-template-columns: 1fr;
}
.page-head {
flex-direction: column;
}
.head-actions {
width: 100%;
}
.head-actions .btn {
flex: 1;
}
.day {
min-height: 68px;
}
.previews {
display: none;
}
}
@media (max-width: 700px) {
h1 {
font-size: 1.55rem;
}
.nav {
justify-content: center;
}
.nav-btn {
flex: 1;
min-width: 0;
}
.day {
min-height: 54px;
padding: 0.3rem;
}
.count {
display: none;
}
}
</style>