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

566 lines
13 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 {
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>