Files
CRM_Montzar/apps/web/src/views/ActivitiesView.vue
T

408 lines
8.3 KiB
Vue
Raw Normal View History

<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 type { ActivityDto, ActivityFilter, PaginatedResponse } from '@mento/shared';
const route = useRoute();
const router = useRouter();
const filter = computed<ActivityFilter>(() => {
const value = typeof route.query.filter === 'string' ? route.query.filter : 'today';
if (['today', 'overdue', 'upcoming', 'done', 'all'].includes(value)) {
return value as ActivityFilter;
}
return 'today';
});
const loading = ref(false);
const error = ref('');
const data = ref<PaginatedResponse<ActivityDto> | null>(null);
const q = ref(typeof route.query.q === 'string' ? route.query.q : '');
const typeLabel: Record<ActivityDto['type'], string> = {
call: 'تماس',
meeting: 'جلسه',
task: 'وظیفه',
note: 'یادداشت',
};
function formatDue(value: string | null) {
if (!value) return 'بدون موعد';
try {
return new Intl.DateTimeFormat('fa-IR', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(value));
} catch {
return value;
}
}
async function load() {
loading.value = true;
error.value = '';
try {
const { data: res } = await api.get<PaginatedResponse<ActivityDto>>('/activities', {
params: { filter: filter.value, q: q.value || undefined, limit: 50 },
});
data.value = res;
} catch (e) {
if (axios.isAxiosError(e) && !e.response) {
error.value = 'اتصال به API برقرار نشد.';
} else {
error.value = 'بارگذاری فعالیت‌ها ناموفق بود.';
}
} finally {
loading.value = false;
}
}
function setFilter(next: ActivityFilter) {
router.replace({
name: 'activities',
query: {
...route.query,
filter: next === 'today' ? undefined : next,
},
});
}
function onSearch() {
router.replace({
name: 'activities',
query: {
...route.query,
q: q.value.trim() || undefined,
},
});
}
async function complete(id: string) {
try {
await api.patch(`/activities/${id}/complete`);
await load();
} catch {
error.value = 'ثبت انجام‌شدن ناموفق بود.';
}
}
watch(
() => [route.query.filter, route.query.q],
() => {
q.value = typeof route.query.q === 'string' ? route.query.q : '';
void load();
},
);
onMounted(load);
</script>
<template>
<section class="page">
<header class="page-head">
<div>
<p class="eyebrow">پیگیری</p>
<h1>فعالیت‌ها</h1>
<p class="lead">تماس، جلسه و وظیفه — امروز و عقب‌افتاده‌ها یکجا.</p>
</div>
<div class="head-actions">
<RouterLink class="btn btn-ghost" :to="{ name: 'activities-calendar' }">تقویم</RouterLink>
<RouterLink class="btn" :to="{ name: 'activity-new' }">فعالیت جدید</RouterLink>
</div>
</header>
<div class="toolbar">
<div class="tabs">
<button type="button" :class="{ active: filter === 'today' }" @click="setFilter('today')">
امروز
</button>
<button
type="button"
:class="{ active: filter === 'overdue' }"
@click="setFilter('overdue')"
>
عقب‌افتاده
</button>
<button
type="button"
:class="{ active: filter === 'upcoming' }"
@click="setFilter('upcoming')"
>
بعداً
</button>
<button type="button" :class="{ active: filter === 'done' }" @click="setFilter('done')">
انجام‌شده
</button>
<button type="button" :class="{ active: filter === 'all' }" @click="setFilter('all')">
همه
</button>
</div>
<form class="search" @submit.prevent="onSearch">
<input v-model="q" type="search" placeholder="جستجو در عنوان…" />
<button class="btn btn-ghost" type="submit">جستجو</button>
</form>
</div>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
<div v-else class="list">
<article v-for="item in data?.items || []" :key="item.id" class="item">
<div class="main">
<p class="type">{{ typeLabel[item.type] }}</p>
<RouterLink :to="{ name: 'activity-detail', params: { id: item.id } }">
<h2>{{ item.title }}</h2>
</RouterLink>
<p class="meta">
<span>{{ formatDue(item.dueAt) }}</span>
<span v-if="item.person"> · {{ item.person.fullName }}</span>
<span v-if="item.deal"> · {{ item.deal.title }}</span>
</p>
</div>
<div class="actions">
<button
v-if="item.status === 'open'"
class="btn btn-ghost"
type="button"
@click="complete(item.id)"
>
انجام شد
</button>
<span v-else class="done">انجام‌شده</span>
</div>
</article>
<p v-if="!data?.items.length" class="empty">فعالیتی در این فهرست نیست.</p>
</div>
</section>
</template>
<style scoped>
.page {
display: grid;
gap: 1.1rem;
min-width: 0;
}
.page-head {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
flex-wrap: wrap;
}
.head-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.eyebrow {
margin: 0;
color: var(--accent);
font-weight: 700;
}
h1 {
margin: 0.2rem 0;
font-size: 1.85rem;
}
.lead,
.muted,
.meta,
.empty,
.done {
color: var(--muted);
}
.lead {
margin: 0;
}
.toolbar {
display: flex;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
align-items: center;
min-width: 0;
}
.tabs {
display: flex;
gap: 0.35rem;
flex-wrap: wrap;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.25rem;
max-width: 100%;
}
.tabs button {
border: 0;
background: transparent;
padding: 0.55rem 0.85rem;
border-radius: 10px;
cursor: pointer;
color: var(--muted);
white-space: nowrap;
flex: 0 0 auto;
}
.tabs button.active {
background: var(--accent-soft);
color: var(--accent);
font-weight: 700;
}
.search {
display: flex;
gap: 0.5rem;
flex: 1 1 200px;
min-width: 0;
}
.search input {
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.7rem 0.9rem;
min-width: 0;
flex: 1;
background: #fff;
}
.list {
display: grid;
gap: 0.7rem;
min-width: 0;
}
.item {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: center;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 1rem 1.1rem;
box-shadow: var(--shadow);
min-width: 0;
}
.main {
min-width: 0;
flex: 1;
}
.type {
margin: 0;
font-size: 0.8rem;
color: var(--accent);
font-weight: 700;
}
h2 {
margin: 0.2rem 0;
font-size: 1.05rem;
overflow-wrap: anywhere;
}
.meta {
margin: 0.2rem 0 0;
font-size: 0.9rem;
overflow-wrap: anywhere;
}
.main a {
color: inherit;
}
.actions {
flex-shrink: 0;
}
.empty {
margin: 0;
padding: 1.2rem;
background: var(--surface);
border: 1px dashed var(--line);
border-radius: var(--radius);
}
@media (max-width: 700px) {
h1 {
font-size: 1.45rem;
}
.page-head {
flex-direction: column;
align-items: stretch;
}
.head-actions {
width: 100%;
}
.head-actions .btn {
flex: 1 1 auto;
text-align: center;
}
.lead {
font-size: 0.92rem;
}
.toolbar {
flex-direction: column;
align-items: stretch;
}
.tabs {
width: 100%;
overflow-x: auto;
flex-wrap: nowrap;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
}
.tabs button {
padding: 0.5rem 0.7rem;
font-size: 0.9rem;
}
.search {
width: 100%;
flex: 0 0 auto;
flex-direction: row;
align-items: center;
}
.search input {
flex: 1 1 auto;
height: 2.75rem;
min-height: 2.75rem;
max-height: 2.75rem;
padding: 0 0.9rem;
}
.search .btn {
flex: 0 0 auto;
white-space: nowrap;
}
.item {
flex-direction: column;
align-items: stretch;
}
.item .actions .btn {
width: 100%;
}
}
</style>