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

408 lines
8.3 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.
<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>