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

566 lines
13 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 {
CompanyDto,
ImportContactsResult,
PaginatedResponse,
PersonDto,
} from '@mento/shared';
type Tab = 'people' | 'companies';
const route = useRoute();
const router = useRouter();
const tab = computed<Tab>(() =>
route.query.tab === 'companies' ? 'companies' : 'people',
);
const q = ref(typeof route.query.q === 'string' ? route.query.q : '');
const loading = ref(false);
const busy = ref(false);
const error = ref('');
const importMsg = ref('');
const people = ref<PaginatedResponse<PersonDto> | null>(null);
const companies = ref<PaginatedResponse<CompanyDto> | null>(null);
const fileInput = ref<HTMLInputElement | null>(null);
async function load() {
loading.value = true;
error.value = '';
try {
if (tab.value === 'people') {
const { data } = await api.get<PaginatedResponse<PersonDto>>('/people', {
params: { q: q.value || undefined, limit: 50 },
});
people.value = data;
} else {
const { data } = await api.get<PaginatedResponse<CompanyDto>>(
'/companies',
{
params: { q: q.value || undefined, limit: 50 },
},
);
companies.value = data;
}
} catch (e) {
if (axios.isAxiosError(e) && !e.response) {
error.value = 'اتصال به API برقرار نشد.';
} else {
error.value = 'بارگذاری مخاطبین ناموفق بود.';
}
} finally {
loading.value = false;
}
}
function setTab(next: Tab) {
router.replace({
name: 'contacts',
query: { ...route.query, tab: next === 'people' ? undefined : next },
});
}
function onSearch() {
router.replace({
name: 'contacts',
query: {
...route.query,
q: q.value.trim() || undefined,
},
});
}
async function exportExcel() {
busy.value = true;
importMsg.value = '';
error.value = '';
try {
const path = tab.value === 'people' ? '/people/export' : '/companies/export';
const { data } = await api.get<Blob>(path, {
params: { q: q.value || undefined },
responseType: 'blob',
});
const url = URL.createObjectURL(data);
const a = document.createElement('a');
a.href = url;
a.download =
tab.value === 'people' ? 'mento-people.xlsx' : 'mento-companies.xlsx';
a.click();
URL.revokeObjectURL(url);
} catch {
error.value = 'خروجی اکسل ناموفق بود.';
} finally {
busy.value = false;
}
}
function pickImportFile() {
fileInput.value?.click();
}
async function onImportFile(ev: Event) {
const input = ev.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) return;
busy.value = true;
importMsg.value = '';
error.value = '';
try {
const form = new FormData();
form.append('file', file);
const path = tab.value === 'people' ? '/people/import' : '/companies/import';
const { data } = await api.post<ImportContactsResult>(path, form, {
headers: { 'Content-Type': undefined },
});
importMsg.value = `وارد شد: ${data.created} · تکراری رد شد: ${data.skipped}${
data.errors.length ? ` · خطا: ${data.errors.length}` : ''
}`;
if (data.errors.length) {
const sample = data.errors
.slice(0, 3)
.map((e) => `ردیف ${e.row}: ${e.message}`)
.join(' | ');
error.value = sample;
}
await load();
} catch (e) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
error.value = Array.isArray(msg)
? msg.join('، ')
: (msg ?? 'ایمپورت ناموفق بود');
} else {
error.value = 'ایمپورت ناموفق بود';
}
} finally {
busy.value = false;
}
}
watch(
() => [route.query.tab, 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="actions">
<button class="btn btn-ghost" type="button" :disabled="busy" @click="exportExcel">
خروجی اکسل
</button>
<button class="btn btn-ghost" type="button" :disabled="busy" @click="pickImportFile">
ورود از اکسل
</button>
<input
ref="fileInput"
type="file"
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
hidden
@change="onImportFile"
/>
<RouterLink
class="btn"
:to="tab === 'companies' ? { name: 'company-new' } : { name: 'person-new' }"
>
{{ tab === 'companies' ? 'شرکت جدید' : 'شخص جدید' }}
</RouterLink>
</div>
</header>
<div class="toolbar">
<div class="tabs">
<button type="button" :class="{ active: tab === 'people' }" @click="setTab('people')">
اشخاص
<span v-if="people">{{ people.total }}</span>
</button>
<button
type="button"
:class="{ active: tab === 'companies' }"
@click="setTab('companies')"
>
شرکت‌ها
<span v-if="companies">{{ companies.total }}</span>
</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="importMsg" class="ok">{{ importMsg }}</p>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
<div v-else-if="tab === 'people'" class="table-wrap">
<div v-if="people?.items.length" class="card-list">
<article v-for="p in people.items" :key="`card-${p.id}`" class="card">
<RouterLink class="card-title" :to="{ name: 'person-detail', params: { id: p.id } }">
{{ p.fullName }}
</RouterLink>
<p class="card-meta">
<span v-if="p.company">{{ p.company.name }}</span>
<span v-if="p.mobile">{{ p.mobile }}</span>
<span v-if="p.position">{{ p.position }}</span>
<span v-if="p.email">{{ p.email }}</span>
</p>
</article>
</div>
<table v-if="people?.items.length" class="data-table">
<thead>
<tr>
<th>نام</th>
<th>شرکت</th>
<th>موبایل</th>
<th>ایمیل</th>
<th>سمت</th>
</tr>
</thead>
<tbody>
<tr v-for="p in people.items" :key="p.id">
<td>
<RouterLink :to="{ name: 'person-detail', params: { id: p.id } }">
{{ p.fullName }}
</RouterLink>
</td>
<td>
<RouterLink
v-if="p.company"
:to="{ name: 'company-detail', params: { id: p.company.id } }"
>
{{ p.company.name }}
</RouterLink>
<span v-else class="muted">—</span>
</td>
<td>{{ p.mobile || '—' }}</td>
<td>{{ p.email || '—' }}</td>
<td>{{ p.position || '—' }}</td>
</tr>
</tbody>
</table>
<p v-else class="empty">شخصی ثبت نشده. اولین نفر را اضافه کنید.</p>
</div>
<div v-else class="table-wrap">
<div v-if="companies?.items.length" class="card-list">
<article v-for="c in companies.items" :key="`card-${c.id}`" class="card">
<RouterLink class="card-title" :to="{ name: 'company-detail', params: { id: c.id } }">
{{ c.name }}
</RouterLink>
<p class="card-meta">
<span v-if="c.phone">{{ c.phone }}</span>
<span v-if="c.email">{{ c.email }}</span>
<span>افراد: {{ c.peopleCount }}</span>
</p>
</article>
</div>
<table v-if="companies?.items.length" class="data-table">
<thead>
<tr>
<th>نام شرکت</th>
<th>تلفن</th>
<th>ایمیل</th>
<th>افراد</th>
</tr>
</thead>
<tbody>
<tr v-for="c in companies.items" :key="c.id">
<td>
<RouterLink :to="{ name: 'company-detail', params: { id: c.id } }">
{{ c.name }}
</RouterLink>
</td>
<td>{{ c.phone || '—' }}</td>
<td>{{ c.email || '—' }}</td>
<td>{{ c.peopleCount }}</td>
</tr>
</tbody>
</table>
<p v-else 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;
}
.actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
align-items: center;
}
.eyebrow {
margin: 0;
color: var(--accent);
font-weight: 700;
}
h1 {
margin: 0.2rem 0;
font-size: 1.85rem;
}
.lead,
.muted {
color: var(--muted);
}
.lead {
margin: 0;
}
.ok {
color: #0f766e;
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;
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.9rem;
border-radius: 10px;
cursor: pointer;
color: var(--muted);
display: inline-flex;
gap: 0.45rem;
align-items: center;
white-space: nowrap;
}
.tabs button.active {
background: var(--accent-soft);
color: var(--accent);
font-weight: 700;
}
.tabs span {
font-size: 0.8rem;
opacity: 0.8;
}
.search {
display: flex;
gap: 0.5rem;
flex: 1 1 220px;
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;
}
.table-wrap {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
overflow: auto;
min-width: 0;
}
.card-list {
display: none;
}
.data-table {
width: 100%;
border-collapse: collapse;
}
th,
td {
text-align: right;
padding: 0.85rem 1rem;
border-bottom: 1px solid var(--line);
white-space: nowrap;
}
th {
font-size: 0.85rem;
color: var(--muted);
font-weight: 600;
background: #f7faf8;
}
tbody tr:last-child td {
border-bottom: 0;
}
td a,
.card-title,
.card-line a {
color: var(--accent);
font-weight: 600;
}
.card {
display: grid;
gap: 0.35rem;
padding: 0.85rem 0.95rem;
border-bottom: 1px solid var(--line);
}
.card:last-child {
border-bottom: 0;
}
.card-title {
font-size: 0.98rem;
overflow-wrap: anywhere;
line-height: 1.35;
}
.card-meta {
margin: 0;
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: baseline;
column-gap: 0.5rem;
row-gap: 0.35rem;
color: var(--muted);
font-size: 0.82rem;
line-height: 1.45;
width: 100%;
}
.card-meta > span {
flex: 0 1 auto;
min-width: 0;
}
.card-line {
margin: 0;
font-size: 0.9rem;
overflow-wrap: anywhere;
}
.empty {
margin: 0;
padding: 1.5rem;
color: var(--muted);
}
@media (max-width: 700px) {
h1 {
font-size: 1.45rem;
}
.page-head {
flex-direction: column;
align-items: stretch;
}
.actions {
width: 100%;
}
.actions .btn {
flex: 1 1 calc(50% - 0.5rem);
text-align: center;
}
.lead {
font-size: 0.92rem;
}
.toolbar {
flex-direction: column;
align-items: stretch;
}
.tabs {
width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.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;
}
.data-table {
display: none;
}
.card-list {
display: grid;
}
.table-wrap {
overflow: hidden;
}
}
</style>