Initial Mento CRM: portal OTP, reports, SMS, and deploy for crm.montzar.ir.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-29 21:06:30 +03:30
co-authored by Cursor
commit 29a040c21d
195 changed files with 39430 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
<script setup lang="ts">
import { RouterView } from 'vue-router';
</script>
<template>
<RouterView />
</template>
@@ -0,0 +1,178 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue';
import { api } from '@/lib/api';
import type {
CustomFieldDefinitionDto,
CustomFieldEntity,
} from '@mento/shared';
const props = defineProps<{
entityType: CustomFieldEntity;
modelValue: Record<string, unknown>;
}>();
const emit = defineEmits<{
'update:modelValue': [value: Record<string, unknown>];
}>();
const fields = ref<CustomFieldDefinitionDto[]>([]);
const loading = ref(false);
async function load() {
loading.value = true;
try {
fields.value = (
await api.get<CustomFieldDefinitionDto[]>('/custom-fields', {
params: { entityType: props.entityType, activeOnly: true },
})
).data;
} catch {
fields.value = [];
} finally {
loading.value = false;
}
}
function setValue(key: string, value: unknown) {
emit('update:modelValue', { ...props.modelValue, [key]: value });
}
function displayValue(key: string) {
const v = props.modelValue[key];
if (v == null) return '';
if (typeof v === 'boolean') return v;
return String(v);
}
watch(
() => props.entityType,
() => {
void load();
},
);
onMounted(load);
</script>
<template>
<div v-if="loading" class="muted">بارگذاری فیلدهای سفارشی…</div>
<div v-else-if="fields.length" class="custom-block">
<h3>فیلدهای سفارشی</h3>
<div class="grid">
<div v-for="field in fields" :key="field.id" class="field">
<label :for="`cf-${field.key}`">
{{ field.label }}
<span v-if="field.required" class="req">*</span>
</label>
<select
v-if="field.fieldType === 'select'"
:id="`cf-${field.key}`"
:value="String(displayValue(field.key))"
@change="setValue(field.key, ($event.target as HTMLSelectElement).value)"
>
<option value="">انتخاب کنید</option>
<option v-for="opt in field.options" :key="opt" :value="opt">
{{ opt }}
</option>
</select>
<label v-else-if="field.fieldType === 'boolean'" class="check">
<input
:id="`cf-${field.key}`"
type="checkbox"
:checked="Boolean(props.modelValue[field.key])"
@change="
setValue(field.key, ($event.target as HTMLInputElement).checked)
"
/>
فعال
</label>
<input
v-else-if="field.fieldType === 'number'"
:id="`cf-${field.key}`"
type="number"
:value="String(displayValue(field.key))"
@input="
setValue(
field.key,
($event.target as HTMLInputElement).value === ''
? null
: Number(($event.target as HTMLInputElement).value),
)
"
/>
<input
v-else-if="field.fieldType === 'date'"
:id="`cf-${field.key}`"
type="date"
:value="String(displayValue(field.key))"
@input="setValue(field.key, ($event.target as HTMLInputElement).value)"
/>
<input
v-else
:id="`cf-${field.key}`"
type="text"
:value="String(displayValue(field.key))"
@input="setValue(field.key, ($event.target as HTMLInputElement).value)"
/>
</div>
</div>
</div>
</template>
<style scoped>
.custom-block {
display: grid;
gap: 0.75rem;
padding-top: 0.4rem;
border-top: 1px dashed var(--line);
}
h3 {
margin: 0;
font-size: 1rem;
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.9rem;
}
.muted {
color: var(--muted);
}
.req {
color: #b42318;
}
.check {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 500;
}
select,
input[type='text'],
input[type='number'],
input[type='date'] {
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.8rem 0.95rem;
background: #fff;
font: inherit;
width: 100%;
}
@media (max-width: 700px) {
.grid {
grid-template-columns: 1fr;
}
}
</style>
+387
View File
@@ -0,0 +1,387 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { RouterLink } from 'vue-router';
import { api } from '@/lib/api';
import type {
ActivityDto,
NoteDto,
PaginatedResponse,
} from '@mento/shared';
const props = defineProps<{
entityType: 'person' | 'company' | 'deal';
entityId: string;
}>();
type TimelineKind = 'note' | 'activity';
type TimelineItem = {
key: string;
kind: TimelineKind;
id: string;
title: string;
body?: string | null;
meta: string;
at: string;
href: string;
done?: boolean;
};
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const items = ref<TimelineItem[]>([]);
const noteDraft = ref('');
const activityDraft = ref('');
const entityParams = computed(() => {
if (props.entityType === 'person') return { personId: props.entityId };
if (props.entityType === 'company') return { companyId: props.entityId };
return { dealId: props.entityId };
});
const typeLabel: Record<ActivityDto['type'], string> = {
call: 'تماس',
meeting: 'جلسه',
task: 'وظیفه',
note: 'یادداشت',
};
function noteTypeLabel(value?: string | null) {
const map: Record<string, string> = {
general: 'یادداشت',
call_result: 'نتیجه تماس',
meeting: 'جلسه',
important: 'نکته مهم',
warning: 'هشدار',
next_action: 'کار بعدی',
};
return map[value || 'general'] ?? 'یادداشت';
}
function formatTime(iso: string) {
try {
return new Intl.DateTimeFormat('fa-IR', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(iso));
} catch {
return iso;
}
}
async function load() {
if (!props.entityId) {
items.value = [];
return;
}
loading.value = true;
error.value = '';
try {
const [notesRes, activitiesRes] = await Promise.all([
api.get<PaginatedResponse<NoteDto>>('/notes', {
params: { ...entityParams.value, limit: 40 },
}),
api.get<PaginatedResponse<ActivityDto>>('/activities', {
params: {
filter: 'all',
...entityParams.value,
limit: 40,
},
}),
]);
const notes: TimelineItem[] = notesRes.data.items.map((n) => ({
key: `note-${n.id}`,
kind: 'note',
id: n.id,
title: n.title?.trim() || 'یادداشت',
body: n.body,
meta:
noteTypeLabel(n.noteType) +
(n.pinned ? ' · پین' : '') +
(n.attachments?.length ? ` · ${n.attachments.length} پیوست` : ''),
at: n.updatedAt || n.createdAt,
href: '/notes',
}));
const activities: TimelineItem[] = activitiesRes.data.items.map((a) => ({
key: `activity-${a.id}`,
kind: 'activity',
id: a.id,
title: a.title,
body: a.description,
meta: `${typeLabel[a.type] ?? a.type}${a.status === 'done' ? ' · انجام‌شده' : ''}`,
at: a.dueAt || a.createdAt,
href: `/activities/${a.id}`,
done: a.status === 'done',
}));
items.value = [...notes, ...activities].sort(
(a, b) => new Date(b.at).getTime() - new Date(a.at).getTime(),
);
} catch {
error.value = 'بارگذاری تایم‌لاین ناموفق بود.';
items.value = [];
} finally {
loading.value = false;
}
}
async function addNote() {
const body = noteDraft.value.trim();
if (!body || saving.value) return;
saving.value = true;
error.value = '';
try {
await api.post('/notes', {
body,
...entityParams.value,
});
noteDraft.value = '';
await load();
} catch {
error.value = 'ثبت یادداشت ناموفق بود.';
} finally {
saving.value = false;
}
}
async function addActivity() {
const title = activityDraft.value.trim();
if (!title || saving.value) return;
saving.value = true;
error.value = '';
try {
await api.post('/activities', {
title,
type: 'task',
...entityParams.value,
});
activityDraft.value = '';
await load();
} catch {
error.value = 'ثبت فعالیت ناموفق بود.';
} finally {
saving.value = false;
}
}
async function completeActivity(item: TimelineItem) {
if (item.kind !== 'activity' || item.done) return;
try {
await api.patch(`/activities/${item.id}/complete`);
await load();
} catch {
error.value = 'اتمام فعالیت ناموفق بود.';
}
}
watch(
() => [props.entityType, props.entityId],
() => {
void load();
},
);
onMounted(load);
</script>
<template>
<section class="timeline">
<header class="head">
<h3>تایم‌لاین</h3>
<p class="muted">یادداشت‌ها و فعالیت‌های مرتبط با این پرونده</p>
</header>
<div class="composers">
<div class="composer">
<textarea
v-model="noteDraft"
rows="2"
placeholder="یادداشت سریع…"
@keydown.ctrl.enter.prevent="addNote"
/>
<button class="btn" type="button" :disabled="saving || !noteDraft.trim()" @click="addNote">
ثبت یادداشت
</button>
</div>
<div class="composer">
<input
v-model="activityDraft"
type="text"
placeholder="عنوان فعالیت / پیگیری…"
@keydown.enter.prevent="addActivity"
/>
<button
class="btn btn-ghost"
type="button"
:disabled="saving || !activityDraft.trim()"
@click="addActivity"
>
افزودن فعالیت
</button>
</div>
</div>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="loading" class="muted">در حال بارگذاری…</p>
<ul v-else-if="items.length" class="list">
<li v-for="item in items" :key="item.key" :class="['item', item.kind, { done: item.done }]">
<div class="item-main">
<span class="badge">{{ item.meta }}</span>
<strong>{{ item.title }}</strong>
<p v-if="item.body" class="body">{{ item.body }}</p>
<time>{{ formatTime(item.at) }}</time>
</div>
<div class="item-actions">
<RouterLink :to="item.href">باز کردن</RouterLink>
<button
v-if="item.kind === 'activity' && !item.done"
class="linkish"
type="button"
@click="completeActivity(item)"
>
انجام شد
</button>
</div>
</li>
</ul>
<p v-else class="muted">هنوز چیزی در تایم‌لاین نیست. یک یادداشت یا فعالیت ثبت کنید.</p>
</section>
</template>
<style scoped>
.timeline {
margin-top: 1.25rem;
padding-top: 1.1rem;
border-top: 1px solid var(--line);
display: grid;
gap: 0.9rem;
}
.head h3 {
margin: 0 0 0.25rem;
font-size: 1.05rem;
}
.head .muted {
margin: 0;
}
.composers {
display: grid;
gap: 0.75rem;
}
.composer {
display: grid;
gap: 0.5rem;
}
.composer textarea,
.composer input {
width: 100%;
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.7rem 0.85rem;
background: #fff;
}
.list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.55rem;
}
.item {
display: flex;
justify-content: space-between;
gap: 0.75rem;
align-items: flex-start;
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.75rem 0.85rem;
background: #fbfcfb;
}
@media (max-width: 640px) {
.item {
flex-direction: column;
}
.item-actions {
justify-items: start;
flex-direction: row;
display: flex;
gap: 0.75rem;
}
}
.item.note {
border-right: 3px solid var(--accent);
}
.item.activity {
border-right: 3px solid #b7791f;
}
.item.done {
opacity: 0.72;
}
.item-main {
display: grid;
gap: 0.2rem;
min-width: 0;
}
.badge {
font-size: 0.75rem;
color: var(--muted);
}
.body {
margin: 0;
color: var(--muted);
font-size: 0.9rem;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
time {
font-size: 0.78rem;
color: var(--muted);
}
.item-actions {
display: grid;
gap: 0.35rem;
justify-items: end;
flex-shrink: 0;
}
.item-actions a,
.linkish {
border: 0;
background: transparent;
color: var(--accent);
cursor: pointer;
font: inherit;
font-size: 0.85rem;
padding: 0;
}
.error {
color: var(--danger);
margin: 0;
}
.muted {
color: var(--muted);
margin: 0;
}
</style>
+652
View File
@@ -0,0 +1,652 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { RouterLink, RouterView, useRoute, useRouter } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
import { api } from '@/lib/api';
import type { NotificationDto, NotificationsListDto } from '@mento/shared';
import { USER_ROLE_LABELS, canManageSettings, canViewAllRecords } from '@/lib/roles';
const auth = useAuthStore();
const router = useRouter();
const route = useRoute();
const canSettings = computed(() => canManageSettings(auth.user?.role));
const showOwnRecordsBanner = computed(
() => Boolean(auth.user?.role) && !canViewAllRecords(auth.user?.role),
);
const roleLabel = computed(() =>
auth.user?.role ? USER_ROLE_LABELS[auth.user.role] : '',
);
const menuOpen = ref(false);
const notifOpen = ref(false);
const loading = ref(false);
const unreadCount = ref(0);
const items = ref<NotificationDto[]>([]);
let pollTimer: ReturnType<typeof setInterval> | null = null;
const hasUnread = computed(() => unreadCount.value > 0);
function closeMenu() {
menuOpen.value = false;
}
function closeNotif() {
notifOpen.value = false;
}
function onLogout() {
closeMenu();
closeNotif();
auth.logout();
router.push({ name: 'login' });
}
async function refresh() {
if (!auth.isAuthenticated) return;
loading.value = true;
try {
const { data } = await api.get<NotificationsListDto>('/notifications', {
params: { limit: 20 },
});
items.value = data.items;
unreadCount.value = data.unreadCount;
} catch {
// keep previous
} finally {
loading.value = false;
}
}
async function markRead(item: NotificationDto) {
if (!item.readAt) {
await api.patch(`/notifications/${item.id}/read`);
item.readAt = new Date().toISOString();
unreadCount.value = Math.max(0, unreadCount.value - 1);
}
closeNotif();
closeMenu();
if (item.link) {
await router.push(item.link);
}
}
async function markAll() {
await api.patch('/notifications/read-all');
items.value = items.value.map((n) => ({
...n,
readAt: n.readAt ?? new Date().toISOString(),
}));
unreadCount.value = 0;
}
function toggleMenu() {
notifOpen.value = false;
menuOpen.value = !menuOpen.value;
}
function toggleNotif() {
menuOpen.value = false;
notifOpen.value = !notifOpen.value;
if (notifOpen.value) void refresh();
}
function formatTime(iso: string) {
try {
return new Intl.DateTimeFormat('fa-IR', {
dateStyle: 'short',
timeStyle: 'short',
}).format(new Date(iso));
} catch {
return iso;
}
}
watch(menuOpen, (open) => {
document.body.style.overflow = open || notifOpen.value ? 'hidden' : '';
});
watch(notifOpen, (open) => {
document.body.style.overflow = open || menuOpen.value ? 'hidden' : '';
});
watch(
() => route.fullPath,
() => {
closeMenu();
closeNotif();
},
);
onMounted(() => {
void refresh();
pollTimer = setInterval(() => {
void refresh();
}, 45000);
});
onUnmounted(() => {
if (pollTimer) clearInterval(pollTimer);
document.body.style.overflow = '';
});
</script>
<template>
<div class="shell" :class="{ 'menu-open': menuOpen }">
<header class="topbar">
<button
class="icon-btn"
type="button"
:aria-expanded="menuOpen"
aria-controls="app-sidebar"
aria-label="منو"
@click="toggleMenu"
>
<span class="burger" :class="{ open: menuOpen }" />
</button>
<div class="top-brand">
<span class="mark">M</span>
<strong>منتو</strong>
</div>
<button class="icon-btn notif-top" type="button" aria-label="اعلان‌ها" @click="toggleNotif">
اعلان
<span v-if="hasUnread" class="badge">{{ unreadCount }}</span>
</button>
</header>
<div
v-if="menuOpen"
class="backdrop"
aria-hidden="true"
@click="closeMenu"
/>
<aside id="app-sidebar" class="sidebar" :class="{ open: menuOpen }">
<div class="brand desktop-only">
<span class="mark">M</span>
<div>
<strong>منتو</strong>
<small>Mento CRM</small>
</div>
</div>
<div class="drawer-head mobile-only">
<div class="brand">
<span class="mark">M</span>
<div>
<strong>منتو</strong>
<small>Mento CRM</small>
</div>
</div>
<button class="icon-btn ghost" type="button" aria-label="بستن منو" @click="closeMenu">
✕
</button>
</div>
<nav @click="closeMenu">
<RouterLink to="/">خانه امروز</RouterLink>
<RouterLink to="/activities">فعالیت‌ها</RouterLink>
<RouterLink to="/activities/calendar">تقویم فعالیت‌ها</RouterLink>
<RouterLink to="/pipeline">کاریز فروش</RouterLink>
<RouterLink to="/sales/forecast">هدف و پیش‌بینی</RouterLink>
<RouterLink to="/reports">گزارشات</RouterLink>
<RouterLink to="/tickets">تیکت‌ها</RouterLink>
<RouterLink to="/quotes">پیش‌فاکتورها</RouterLink>
<RouterLink to="/invoices">فاکتورها</RouterLink>
<RouterLink to="/contacts">اشخاص و شرکت‌ها</RouterLink>
<RouterLink to="/notes">یادداشت‌ها</RouterLink>
<template v-if="canSettings">
<RouterLink to="/settings/team">تیم و نقش‌ها</RouterLink>
<RouterLink to="/settings/lead-forms">فرم‌ساز سرنخ</RouterLink>
<RouterLink to="/settings/custom-fields">فیلدهای سفارشی</RouterLink>
<RouterLink to="/settings/products">محصولات</RouterLink>
<RouterLink to="/settings/currencies">ارزها</RouterLink>
<RouterLink to="/settings/faq">FAQ</RouterLink>
</template>
</nav>
<p v-if="showOwnRecordsBanner" class="scope-banner">
فقط پرونده‌های خودتان را می‌بینید.
</p>
<div class="sidebar-foot">
<div class="notif-wrap desktop-only">
<button class="notif-btn" type="button" @click="toggleNotif">
اعلان‌ها
<span v-if="hasUnread" class="badge">{{ unreadCount }}</span>
</button>
</div>
<p>{{ auth.user?.fullName }}</p>
<p v-if="roleLabel" class="role-line">{{ roleLabel }}</p>
<button class="btn btn-ghost logout" type="button" @click="onLogout">خروج</button>
</div>
</aside>
<div v-if="notifOpen" class="notif-layer">
<div class="notif-backdrop" @click="closeNotif" />
<div class="notif-panel" role="dialog" aria-label="اعلان‌ها">
<header class="notif-head">
<strong>اعلان‌ها</strong>
<div class="notif-head-actions">
<button
v-if="hasUnread"
class="linkish"
type="button"
@click="markAll"
>
همه خوانده شد
</button>
<button class="icon-btn ghost" type="button" aria-label="بستن" @click="closeNotif">
✕
</button>
</div>
</header>
<p v-if="loading && !items.length" class="muted">در حال بارگذاری…</p>
<ul v-else-if="items.length" class="notif-list">
<li
v-for="item in items"
:key="item.id"
:class="{ unread: !item.readAt }"
>
<button type="button" @click="markRead(item)">
<span class="title">{{ item.title }}</span>
<span v-if="item.body" class="body">{{ item.body }}</span>
<span class="time">{{ formatTime(item.createdAt) }}</span>
</button>
</li>
</ul>
<p v-else class="muted">اعلانی نیست.</p>
</div>
</div>
<main class="content">
<RouterView />
</main>
</div>
</template>
<style scoped>
.shell {
display: grid;
grid-template-columns: 260px minmax(0, 1fr);
min-height: 100vh;
min-height: 100dvh;
}
.topbar {
display: none;
}
.backdrop {
display: none;
}
.mobile-only {
display: none;
}
.sidebar {
background: #13201b;
color: #edf5f1;
padding: 1.4rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
min-height: 100vh;
min-height: 100dvh;
}
.brand {
display: flex;
align-items: center;
gap: 0.8rem;
}
.mark {
width: 42px;
height: 42px;
border-radius: 12px;
display: grid;
place-items: center;
background: var(--accent);
font-weight: 700;
flex-shrink: 0;
}
.brand small {
display: block;
color: #9fb3aa;
}
nav {
display: grid;
gap: 0.35rem;
}
nav a {
padding: 0.7rem 0.85rem;
border-radius: 10px;
color: #d7e4de;
}
nav a.router-link-active {
background: rgba(255, 255, 255, 0.08);
color: #fff;
}
.scope-banner {
margin: 0;
padding: 0.55rem 0.75rem;
border-radius: 8px;
background: rgba(255, 255, 255, 0.06);
color: #9fb3aa;
font-size: 0.78rem;
line-height: 1.45;
}
.sidebar-foot {
margin-top: auto;
display: grid;
gap: 0.75rem;
}
.sidebar-foot p {
margin: 0;
color: #b7c8c0;
}
.role-line {
font-size: 0.8rem;
color: #8fa399 !important;
}
.logout {
color: #edf5f1;
border-color: rgba(255, 255, 255, 0.18);
}
.notif-btn {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.06);
color: #edf5f1;
border-radius: 10px;
padding: 0.65rem 0.85rem;
cursor: pointer;
font: inherit;
}
.badge {
min-width: 1.4rem;
height: 1.4rem;
border-radius: 999px;
background: #e11d48;
color: #fff;
font-size: 0.75rem;
display: grid;
place-items: center;
padding: 0 0.35rem;
}
.notif-layer {
position: fixed;
inset: 0;
z-index: 40;
display: grid;
place-items: start end;
padding: 4.5rem 0.75rem 1rem;
pointer-events: none;
}
.notif-backdrop {
position: absolute;
inset: 0;
background: rgba(10, 18, 15, 0.35);
pointer-events: auto;
}
.notif-panel {
position: relative;
width: min(360px, calc(100vw - 1.5rem));
max-height: min(70vh, 480px);
overflow: auto;
background: #1b2a24;
color: #edf5f1;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 14px;
padding: 0.85rem;
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.35);
pointer-events: auto;
z-index: 1;
}
.notif-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.55rem;
}
.notif-head-actions {
display: flex;
align-items: center;
gap: 0.35rem;
}
.linkish {
border: 0;
background: transparent;
color: #9fd4c0;
cursor: pointer;
font: inherit;
font-size: 0.82rem;
}
.notif-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.35rem;
}
.notif-list button {
width: 100%;
text-align: right;
border: 0;
background: rgba(255, 255, 255, 0.04);
color: inherit;
border-radius: 10px;
padding: 0.65rem 0.7rem;
cursor: pointer;
display: grid;
gap: 0.2rem;
font: inherit;
}
.notif-list li.unread button {
background: rgba(15, 92, 99, 0.35);
}
.title {
font-weight: 700;
font-size: 0.9rem;
}
.body,
.time,
.muted {
color: #9fb3aa;
font-size: 0.8rem;
}
.content {
padding: 1.5rem;
min-width: 0;
overflow-x: clip;
}
.icon-btn {
border: 1px solid transparent;
background: rgba(255, 255, 255, 0.08);
color: inherit;
border-radius: 10px;
min-width: 42px;
height: 42px;
padding: 0 0.65rem;
cursor: pointer;
font: inherit;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
}
.icon-btn.ghost {
background: transparent;
border-color: rgba(255, 255, 255, 0.14);
}
.burger {
width: 18px;
height: 2px;
background: currentColor;
position: relative;
display: block;
transition: background 0.15s ease;
}
.burger::before,
.burger::after {
content: '';
position: absolute;
inset-inline: 0;
height: 2px;
background: currentColor;
transition: transform 0.15s ease;
}
.burger::before {
top: -6px;
}
.burger::after {
top: 6px;
}
.burger.open {
background: transparent;
}
.burger.open::before {
transform: translateY(6px) rotate(45deg);
}
.burger.open::after {
transform: translateY(-6px) rotate(-45deg);
}
@media (max-width: 900px) {
.shell {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto 1fr;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
position: sticky;
top: 0;
z-index: 30;
padding: 0.7rem 0.85rem;
padding-top: max(0.7rem, env(safe-area-inset-top));
background: #13201b;
color: #edf5f1;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.top-brand {
display: flex;
align-items: center;
gap: 0.55rem;
min-width: 0;
}
.top-brand .mark {
width: 34px;
height: 34px;
border-radius: 10px;
font-size: 0.9rem;
}
.notif-top {
font-size: 0.85rem;
}
.desktop-only {
display: none !important;
}
.mobile-only {
display: flex;
}
.drawer-head {
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.backdrop {
display: block;
position: fixed;
inset: 0;
background: rgba(10, 18, 15, 0.45);
z-index: 34;
}
.sidebar {
position: fixed;
top: 0;
bottom: 0;
inset-inline-start: 0;
width: min(300px, 86vw);
z-index: 35;
/* پنل RTL است؛ منو از سمت راست می‌آید */
transform: translateX(110%);
transition: transform 0.2s ease;
box-shadow: none;
padding-top: max(1rem, env(safe-area-inset-top));
overflow-y: auto;
}
.sidebar.open {
transform: translateX(0);
box-shadow: 0 0 40px rgba(0, 0, 0, 0.35);
}
.content {
padding: 1rem 0.85rem 1.4rem;
padding-bottom: max(1.4rem, env(safe-area-inset-bottom));
}
.notif-layer {
padding: 4.25rem 0.65rem 1rem;
place-items: start stretch;
}
.notif-panel {
width: 100%;
max-height: min(75vh, 560px);
margin-inline: auto;
}
}
</style>
+43
View File
@@ -0,0 +1,43 @@
import axios from 'axios';
export const api = axios.create({
baseURL: '/api',
headers: {
'Content-Type': 'application/json',
},
});
api.interceptors.request.use((config) => {
const token =
localStorage.getItem('mento_token') ?? localStorage.getItem('ment_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
if (typeof FormData !== 'undefined' && config.data instanceof FormData) {
if (config.headers && 'Content-Type' in config.headers) {
delete config.headers['Content-Type'];
}
}
return config;
});
api.interceptors.response.use(
(response) => response,
(error) => {
if (axios.isAxiosError(error) && error.response?.status === 401) {
const path = window.location.pathname;
if (path.startsWith('/p/')) {
return Promise.reject(error);
}
localStorage.removeItem('mento_token');
localStorage.removeItem('mento_user');
localStorage.removeItem('ment_token');
localStorage.removeItem('ment_user');
if (!path.startsWith('/login')) {
const redirect = encodeURIComponent(path + window.location.search);
window.location.assign(`/login?redirect=${redirect}`);
}
}
return Promise.reject(error);
},
);
+182
View File
@@ -0,0 +1,182 @@
/** تبدیل و ابزار تقویم شمسی (الگوریتم استاندارد جلالی) */
export type JalaliDate = { jy: number; jm: number; jd: number };
const MONTH_NAMES = [
'فروردین',
'اردیبهشت',
'خرداد',
'تیر',
'مرداد',
'شهریور',
'مهر',
'آبان',
'آذر',
'دی',
'بهمن',
'اسفند',
] as const;
const WEEKDAY_NAMES = [
'یکشنبه',
'دوشنبه',
'سه‌شنبه',
'چهارشنبه',
'پنجشنبه',
'جمعه',
'شنبه',
] as const;
function div(a: number, b: number) {
return Math.trunc(a / b);
}
export function isLeapJalaliYear(jy: number): boolean {
const breaks = [
-61, 9, 38, 199, 426, 686, 756, 818, 1111, 1181, 1210, 1635, 2060, 2097, 2192,
2262, 2324, 2394, 2456, 3178,
];
const bl = breaks.length;
let jp = breaks[0];
let jump = 0;
for (let i = 1; i < bl; i += 1) {
const jm = breaks[i];
jump = jm - jp;
if (jy < jm) break;
jp = jm;
}
let n = jy - jp;
if (jump - n < 6) n = n - jump + div(jump + 4, 33) * 33;
let leap = mod(n + 1, 33) - 1;
if (leap === -1) leap = 32;
return leap === 0 || leap % 4 === 0;
}
function mod(a: number, b: number) {
return a - Math.trunc(a / b) * b;
}
export function jalaliMonthLength(jy: number, jm: number): number {
if (jm <= 6) return 31;
if (jm <= 11) return 30;
return isLeapJalaliYear(jy) ? 30 : 29;
}
export function toJalali(gy: number, gm: number, gd: number): JalaliDate {
const gdm = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
let gy2 = gm > 2 ? gy + 1 : gy;
let days =
355666 +
365 * gy +
div(gy2 + 3, 4) -
div(gy2 + 99, 100) +
div(gy2 + 399, 400) +
gd +
gdm[gm - 1];
let jy = -1595 + 33 * div(days, 12053);
days %= 12053;
jy += 4 * div(days, 1461);
days %= 1461;
if (days > 365) {
jy += div(days - 1, 365);
days = (days - 1) % 365;
}
const jm = days < 186 ? 1 + div(days, 31) : 7 + div(days - 186, 30);
const jd = 1 + (days < 186 ? days % 31 : (days - 186) % 30);
return { jy, jm, jd };
}
export function toGregorian(jy: number, jm: number, jd: number): {
gy: number;
gm: number;
gd: number;
} {
const jy2 = jy + 1595;
let days =
-355668 +
365 * jy2 +
div(jy2, 33) * 8 +
div(mod(jy2, 33) + 3, 4) +
jd +
(jm < 7 ? (jm - 1) * 31 : (jm - 7) * 30 + 186);
let gy = 400 * div(days, 146097);
days %= 146097;
if (days > 36524) {
gy += 100 * div(--days, 36524);
days %= 36524;
if (days >= 365) days += 1;
}
gy += 4 * div(days, 1461);
days %= 1461;
if (days > 365) {
gy += div(days - 1, 365);
days = (days - 1) % 365;
}
const gd = days + 1;
const salA = [
0,
31,
(gy % 4 === 0 && gy % 100 !== 0) || gy % 400 === 0 ? 29 : 28,
31,
30,
31,
30,
31,
31,
30,
31,
30,
31,
];
let gm = 0;
let dayAcc = gd;
for (gm = 1; gm <= 12 && dayAcc > salA[gm]; gm += 1) {
dayAcc -= salA[gm];
}
return { gy, gm, gd: dayAcc };
}
export function dateToJalali(d: Date): JalaliDate {
return toJalali(d.getFullYear(), d.getMonth() + 1, d.getDate());
}
export function jalaliToDate(jy: number, jm: number, jd: number): Date {
const { gy, gm, gd } = toGregorian(jy, jm, jd);
return new Date(gy, gm - 1, gd);
}
export function addJalaliMonths(jy: number, jm: number, delta: number): {
jy: number;
jm: number;
} {
const idx = jy * 12 + (jm - 1) + delta;
return {
jy: Math.floor(idx / 12),
jm: (idx % 12) + 1,
};
}
export function jalaliMonthName(jm: number): string {
return MONTH_NAMES[jm - 1] ?? '';
}
export function weekdayName(d: Date): string {
return WEEKDAY_NAMES[d.getDay()] ?? '';
}
/** مثل: پنجشنبه ۵ شهریور ۱۴۰۵ */
export function formatJalaliLong(d: Date): string {
const { jy, jm, jd } = dateToJalali(d);
return `${weekdayName(d)} ${toFaDigits(jd)} ${jalaliMonthName(jm)} ${toFaDigits(jy)}`;
}
export function toFaDigits(value: number | string): string {
return String(value).replace(/\d/g, (d) => '۰۱۲۳۴۵۶۷۸۹'[Number(d)]!);
}
export function gregorianDayKey(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
+24
View File
@@ -0,0 +1,24 @@
export const CURRENCY_OPTIONS = [
{ code: 'IRR', labelFa: 'ریال ایران', suffix: 'ریال' },
{ code: 'USD', labelFa: 'دلار آمریکا', suffix: 'دلار' },
{ code: 'EUR', labelFa: 'یورو', suffix: 'یورو' },
{ code: 'AED', labelFa: 'درهم امارات', suffix: 'درهم' },
{ code: 'TRY', labelFa: 'لیر ترکیه', suffix: 'لیر' },
{ code: 'GBP', labelFa: 'پوند انگلیس', suffix: 'پوند' },
] as const;
export function currencySuffix(code: string): string {
return CURRENCY_OPTIONS.find((c) => c.code === code)?.suffix ?? code;
}
export function formatMoney(amount: number, currency = 'IRR'): string {
try {
return (
new Intl.NumberFormat('fa-IR').format(amount) +
' ' +
currencySuffix(currency)
);
} catch {
return `${amount} ${currency}`;
}
}
+38
View File
@@ -0,0 +1,38 @@
import axios from 'axios';
const PORTAL_TOKEN_KEY = 'mento_portal_token';
export function getPortalToken() {
return localStorage.getItem(PORTAL_TOKEN_KEY);
}
export function setPortalToken(token: string | null) {
if (token) localStorage.setItem(PORTAL_TOKEN_KEY, token);
else localStorage.removeItem(PORTAL_TOKEN_KEY);
}
export const portalApi = axios.create({
baseURL: '/api',
headers: { 'Content-Type': 'application/json' },
});
portalApi.interceptors.request.use((config) => {
const token = getPortalToken();
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
portalApi.interceptors.response.use(
(r) => r,
(error) => {
if (axios.isAxiosError(error) && error.response?.status === 401) {
setPortalToken(null);
const path = window.location.pathname;
const m = path.match(/^\/p\/([^/]+)/);
if (m && !path.endsWith('/login')) {
window.location.assign(`/p/${m[1]}`);
}
}
return Promise.reject(error);
},
);
+24
View File
@@ -0,0 +1,24 @@
import type { UserRole } from '@mento/shared';
export const USER_ROLE_LABELS: Record<UserRole, string> = {
owner: 'مالک',
admin: 'مدیر',
sales: 'فروش',
support: 'پشتیبانی',
};
export function canManageSettings(role: UserRole | string | null | undefined): boolean {
return role === 'owner' || role === 'admin';
}
export function canManageUsers(role: UserRole | string | null | undefined): boolean {
return role === 'owner' || role === 'admin';
}
export function canViewAllRecords(role: UserRole | string | null | undefined): boolean {
return role === 'owner' || role === 'admin';
}
export function canReassignRecords(role: UserRole | string | null | undefined): boolean {
return role === 'owner' || role === 'admin';
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import { router } from './router';
import './styles/main.css';
const app = createApp(App);
app.use(createPinia());
app.use(router);
app.mount('#app');
+222
View File
@@ -0,0 +1,222 @@
import { createRouter, createWebHistory } from 'vue-router';
import type { UserRole } from '@mento/shared';
import { useAuthStore } from '@/stores/auth';
export const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/login',
name: 'login',
component: () => import('@/views/LoginView.vue'),
meta: { public: true },
},
{
path: '/register',
name: 'register',
component: () => import('@/views/RegisterView.vue'),
meta: { public: true },
},
{
path: '/f/:token',
name: 'public-lead-form',
component: () => import('@/views/LeadFormPublicView.vue'),
meta: { public: true },
},
{
path: '/p/:slug',
name: 'portal',
component: () => import('@/views/PortalView.vue'),
meta: { public: true },
},
{
path: '/',
component: () => import('@/layouts/AppLayout.vue'),
children: [
{
path: '',
name: 'home',
component: () => import('@/views/HomeView.vue'),
},
{
path: 'reports',
name: 'reports',
component: () => import('@/views/ReportsView.vue'),
},
{
path: 'sales/forecast',
name: 'sales-forecast',
component: () => import('@/views/SalesForecastView.vue'),
},
{
path: 'tickets',
name: 'tickets',
component: () => import('@/views/TicketsView.vue'),
},
{
path: 'tickets/new',
name: 'ticket-new',
component: () => import('@/views/TicketFormView.vue'),
},
{
path: 'tickets/:id',
name: 'ticket-detail',
component: () => import('@/views/TicketFormView.vue'),
},
{
path: 'activities',
name: 'activities',
component: () => import('@/views/ActivitiesView.vue'),
},
{
path: 'activities/calendar',
name: 'activities-calendar',
component: () => import('@/views/ActivitiesCalendarView.vue'),
},
{
path: 'activities/new',
name: 'activity-new',
component: () => import('@/views/ActivityFormView.vue'),
},
{
path: 'activities/:id',
name: 'activity-detail',
component: () => import('@/views/ActivityFormView.vue'),
},
{
path: 'quotes',
name: 'quotes',
component: () => import('@/views/QuotesView.vue'),
},
{
path: 'quotes/new',
name: 'quote-new',
component: () => import('@/views/QuoteFormView.vue'),
},
{
path: 'quotes/:id',
name: 'quote-detail',
component: () => import('@/views/QuoteFormView.vue'),
},
{
path: 'invoices',
name: 'invoices',
component: () => import('@/views/InvoicesView.vue'),
},
{
path: 'invoices/new',
name: 'invoice-new',
component: () => import('@/views/InvoiceFormView.vue'),
},
{
path: 'invoices/:id',
name: 'invoice-detail',
component: () => import('@/views/InvoiceFormView.vue'),
},
{
path: 'pipeline',
name: 'pipeline',
component: () => import('@/views/PipelineBoardView.vue'),
},
{
path: 'deals/new',
name: 'deal-new',
component: () => import('@/views/DealFormView.vue'),
},
{
path: 'deals/:id',
name: 'deal-detail',
component: () => import('@/views/DealFormView.vue'),
},
{
path: 'contacts',
name: 'contacts',
component: () => import('@/views/ContactsView.vue'),
},
{
path: 'notes',
name: 'notes',
component: () => import('@/views/NotesView.vue'),
},
{
path: 'settings/lead-forms',
name: 'lead-forms',
component: () => import('@/views/LeadFormsSettingsView.vue'),
meta: { roles: ['owner', 'admin'] as UserRole[] },
},
{
path: 'settings/team',
name: 'team-settings',
component: () => import('@/views/TeamSettingsView.vue'),
meta: { roles: ['owner', 'admin'] as UserRole[] },
},
{
path: 'settings/custom-fields',
name: 'custom-fields',
component: () => import('@/views/CustomFieldsSettingsView.vue'),
meta: { roles: ['owner', 'admin'] as UserRole[] },
},
{
path: 'settings/products',
name: 'products',
component: () => import('@/views/ProductsSettingsView.vue'),
meta: { roles: ['owner', 'admin'] as UserRole[] },
},
{
path: 'settings/currencies',
name: 'currencies',
component: () => import('@/views/CurrencySettingsView.vue'),
meta: { roles: ['owner', 'admin'] as UserRole[] },
},
{
path: 'settings/faq',
name: 'faq-settings',
component: () => import('@/views/FaqSettingsView.vue'),
meta: { roles: ['owner', 'admin'] as UserRole[] },
},
{
path: 'contacts/people/new',
name: 'person-new',
component: () => import('@/views/PersonFormView.vue'),
},
{
path: 'contacts/people/:id',
name: 'person-detail',
component: () => import('@/views/PersonFormView.vue'),
},
{
path: 'contacts/companies/new',
name: 'company-new',
component: () => import('@/views/CompanyFormView.vue'),
},
{
path: 'contacts/companies/:id',
name: 'company-detail',
component: () => import('@/views/CompanyFormView.vue'),
},
],
},
],
});
router.beforeEach((to) => {
const auth = useAuthStore();
if (!to.meta.public && !auth.isAuthenticated) {
return { name: 'login', query: { redirect: to.fullPath } };
}
if (
to.meta.public &&
auth.isAuthenticated &&
(to.name === 'login' || to.name === 'register')
) {
return { name: 'home' };
}
const roles = to.meta.roles as UserRole[] | undefined;
if (roles?.length) {
const role = auth.user?.role as UserRole | undefined;
if (!role || !roles.includes(role)) {
return { name: 'home' };
}
}
return true;
});
+73
View File
@@ -0,0 +1,73 @@
import { defineStore } from 'pinia';
import { computed, ref } from 'vue';
import type { AuthUserDto, LoginResponse } from '@mento/shared';
import { api } from '@/lib/api';
const TOKEN_KEY = 'mento_token';
const USER_KEY = 'mento_user';
const LEGACY_KEYS = ['ment_token', 'ment_user'] as const;
function readStoredToken(): string | null {
return localStorage.getItem(TOKEN_KEY) ?? localStorage.getItem('ment_token');
}
function readStoredUser(): AuthUserDto | null {
const raw = localStorage.getItem(USER_KEY) ?? localStorage.getItem('ment_user');
if (!raw) return null;
try {
return JSON.parse(raw) as AuthUserDto;
} catch {
return null;
}
}
function clearLegacy() {
for (const key of LEGACY_KEYS) localStorage.removeItem(key);
}
export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(readStoredToken());
const user = ref<AuthUserDto | null>(readStoredUser());
const isAuthenticated = computed(() => Boolean(token.value));
function persist(payload: LoginResponse) {
token.value = payload.accessToken;
user.value = payload.user;
localStorage.setItem(TOKEN_KEY, payload.accessToken);
localStorage.setItem(USER_KEY, JSON.stringify(payload.user));
clearLegacy();
}
async function login(email: string, password: string) {
const { data } = await api.post<LoginResponse>('/auth/login', { email, password });
persist(data);
}
async function register(payload: {
organizationName: string;
fullName: string;
email: string;
password: string;
}) {
const { data } = await api.post<LoginResponse>('/auth/register', payload);
persist(data);
}
function logout() {
token.value = null;
user.value = null;
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
clearLegacy();
}
return {
token,
user,
isAuthenticated,
login,
register,
logout,
};
});
+129
View File
@@ -0,0 +1,129 @@
:root {
--bg: #eef2f0;
--surface: #ffffff;
--ink: #17211c;
--muted: #5b6b63;
--line: #d5ddd8;
--accent: #0f6b5c;
--accent-soft: #d8efe9;
--danger: #b42318;
--shadow: 0 10px 30px rgba(23, 33, 28, 0.06);
--radius: 14px;
--font: "Vazirmatn", Tahoma, sans-serif;
}
* {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
}
html,
body,
#app {
min-height: 100%;
max-width: 100%;
}
body {
margin: 0;
font-family: var(--font);
color: var(--ink);
background:
radial-gradient(circle at top left, rgba(15, 107, 92, 0.08), transparent 28%),
linear-gradient(180deg, #f7faf8 0%, var(--bg) 100%);
overflow-x: clip;
}
img,
svg,
video,
canvas {
max-width: 100%;
height: auto;
}
a {
color: inherit;
text-decoration: none;
}
button,
input,
select,
textarea {
font: inherit;
max-width: 100%;
}
.btn {
border: 0;
border-radius: 12px;
padding: 0.75rem 1.1rem;
cursor: pointer;
background: var(--accent);
color: white;
font-weight: 600;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-ghost {
background: transparent;
color: var(--accent);
border: 1px solid var(--line);
}
.btn-danger {
background: transparent;
color: var(--danger);
border: 1px solid color-mix(in srgb, var(--danger) 35%, white);
}
.field {
display: grid;
gap: 0.35rem;
min-width: 0;
}
.field label {
font-size: 0.9rem;
color: var(--muted);
}
.field input,
.field select,
.field textarea {
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.8rem 0.95rem;
background: #fff;
width: 100%;
}
.field input:focus,
.field select:focus,
.field textarea:focus {
outline: 2px solid var(--accent-soft);
border-color: var(--accent);
}
.error {
color: var(--danger);
font-size: 0.9rem;
}
@media (max-width: 900px) {
h1 {
font-size: clamp(1.35rem, 5vw, 1.75rem);
}
.btn {
padding: 0.7rem 0.95rem;
}
}
@@ -0,0 +1,593 @@
<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, 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">
<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' }">لیست</RouterLink>
<RouterLink class="btn" :to="newForSelected()">فعالیت جدید</RouterLink>
</div>
</header>
<div class="nav">
<button class="btn btn-ghost" 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 btn-ghost" 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 btn-ghost" :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.firstName }} {{ act.person.lastName }}
</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;
}
.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 {
color: var(--muted);
}
.nav {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0.65rem 0.85rem;
}
.nav-center {
display: grid;
justify-items: center;
gap: 0.15rem;
}
.linkish {
border: 0;
background: transparent;
color: var(--accent);
cursor: pointer;
font: inherit;
font-size: 0.85rem;
padding: 0;
}
.layout {
display: grid;
grid-template-columns: minmax(0, 1.6fr) minmax(240px, 0.9fr);
gap: 1rem;
align-items: start;
}
.calendar {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 0.75rem;
min-width: 0;
}
.weekdays {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 0.35rem;
margin-bottom: 0.35rem;
color: var(--muted);
font-size: 0.85rem;
text-align: center;
font-weight: 600;
}
.grid {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 0.35rem;
}
.day {
min-height: 92px;
border: 1px solid var(--line);
border-radius: 12px;
background: #fbfcfb;
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;
}
.day.outside {
opacity: 0.45;
}
.day.today {
border-color: var(--accent);
background: var(--accent-soft);
}
.day.selected {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.day.has .num {
font-weight: 700;
}
.num {
font-size: 0.95rem;
}
.count {
justify-self: start;
font-size: 0.72rem;
background: #13201b;
color: #edf5f1;
border-radius: 999px;
padding: 0.05rem 0.4rem;
}
.previews {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.15rem;
align-content: start;
}
.previews li {
font-size: 0.72rem;
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.previews li.done {
text-decoration: line-through;
}
.day-panel {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
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-panel h2 {
margin: 0;
font-size: 1.05rem;
}
.day-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.55rem;
}
.day-list a {
display: grid;
gap: 0.15rem;
padding: 0.7rem 0.8rem;
border: 1px solid var(--line);
border-radius: 12px;
background: #fbfcfb;
}
.day-list .type {
font-size: 0.78rem;
color: var(--accent);
font-weight: 700;
}
.day-list .done a {
opacity: 0.7;
}
@media (max-width: 900px) {
.layout {
grid-template-columns: 1fr;
}
.day {
min-height: 68px;
}
.previews {
display: none;
}
}
@media (max-width: 520px) {
.nav {
justify-content: center;
}
.day {
min-height: 54px;
padding: 0.3rem;
}
.count {
display: none;
}
}
</style>
+407
View File
@@ -0,0 +1,407 @@
<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>
+279
View File
@@ -0,0 +1,279 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import type {
ActivityDto,
ActivityType,
CreateActivityRequest,
DealDto,
PaginatedResponse,
PersonDto,
PipelineBoardDto,
} from '@mento/shared';
const route = useRoute();
const router = useRouter();
const id = computed(() => (typeof route.params.id === 'string' ? route.params.id : ''));
const isNew = computed(() => route.name === 'activity-new');
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const people = ref<PersonDto[]>([]);
const deals = ref<DealDto[]>([]);
const title = ref('');
const type = ref<ActivityType>('task');
const description = ref('');
const dueAt = ref('');
const personId = ref('');
const dealId = ref('');
const typeOptions: Array<{ value: ActivityType; label: string }> = [
{ value: 'call', label: 'تماس' },
{ value: 'meeting', label: 'جلسه' },
{ value: 'task', label: 'وظیفه' },
{ value: 'note', label: 'یادداشت' },
];
function toLocalInput(iso: string | null) {
if (!iso) return '';
const d = new Date(iso);
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
async function load() {
loading.value = true;
error.value = '';
try {
const [peopleRes, boardRes] = await Promise.all([
api.get<PaginatedResponse<PersonDto>>('/people', { params: { limit: 100 } }),
api.get<PipelineBoardDto>('/pipelines/board'),
]);
people.value = peopleRes.data.items;
deals.value = boardRes.data.stages.flatMap((s) => s.deals);
if (!isNew.value && id.value) {
const { data } = await api.get<ActivityDto>(`/activities/${id.value}`);
title.value = data.title;
type.value = data.type;
description.value = data.description ?? '';
dueAt.value = toLocalInput(data.dueAt);
personId.value = data.person?.id ?? '';
dealId.value = data.deal?.id ?? '';
} else if (typeof route.query.dueAt === 'string' && route.query.dueAt) {
dueAt.value = toLocalInput(new Date(route.query.dueAt).toISOString());
} else {
const tomorrow = new Date();
tomorrow.setHours(10, 0, 0, 0);
dueAt.value = toLocalInput(tomorrow.toISOString());
}
} catch {
error.value = 'بارگذاری فرم فعالیت ناموفق بود.';
} finally {
loading.value = false;
}
}
function payload(): CreateActivityRequest {
return {
title: title.value,
type: type.value,
description: description.value || undefined,
dueAt: dueAt.value ? new Date(dueAt.value).toISOString() : null,
personId: personId.value || null,
dealId: dealId.value || null,
};
}
async function submit() {
saving.value = true;
error.value = '';
try {
if (isNew.value) {
const { data } = await api.post<ActivityDto>('/activities', payload());
await router.replace({ name: 'activity-detail', params: { id: data.id } });
} else {
await api.patch(`/activities/${id.value}`, payload());
await router.push({ name: 'activities' });
}
} catch (e) {
if (axios.isAxiosError(e)) {
if (!e.response) {
error.value = 'اتصال به سرور برقرار نشد.';
} else {
const msg = (e.response.data as { message?: string | string[] })?.message;
error.value = Array.isArray(msg) ? msg.join('، ') : (msg ?? 'ذخیره ناموفق بود');
}
} else {
error.value = 'ذخیره ناموفق بود';
}
} finally {
saving.value = false;
}
}
async function complete() {
if (!id.value) return;
try {
await api.patch(`/activities/${id.value}/complete`);
await router.push({ name: 'activities' });
} catch {
error.value = 'ثبت انجام‌شدن ناموفق بود';
}
}
async function remove() {
if (!id.value || !confirm('این فعالیت حذف شود؟')) return;
try {
await api.delete(`/activities/${id.value}`);
await router.push({ name: 'activities' });
} catch {
error.value = 'حذف ناموفق بود';
}
}
onMounted(load);
</script>
<template>
<section class="page">
<header class="page-head">
<div>
<p class="eyebrow">فعالیت</p>
<h1>{{ isNew ? 'فعالیت جدید' : 'ویرایش فعالیت' }}</h1>
</div>
<RouterLink class="btn btn-ghost" :to="{ name: 'activities' }">بازگشت</RouterLink>
</header>
<p v-if="loading" class="muted">در حال بارگذاری…</p>
<form v-else class="form-card" @submit.prevent="submit">
<div class="grid">
<div class="field wide">
<label for="title">عنوان</label>
<input id="title" v-model="title" required minlength="2" placeholder="مثلاً تماس پیگیری پیشنهاد" />
</div>
<div class="field">
<label for="type">نوع</label>
<select id="type" v-model="type">
<option v-for="opt in typeOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
</div>
<div class="field">
<label for="dueAt">موعد</label>
<input id="dueAt" v-model="dueAt" type="datetime-local" />
</div>
<div class="field">
<label for="personId">شخص مرتبط</label>
<select id="personId" v-model="personId">
<option value="">بدون شخص</option>
<option v-for="p in people" :key="p.id" :value="p.id">{{ p.fullName }}</option>
</select>
</div>
<div class="field">
<label for="dealId">معامله مرتبط</label>
<select id="dealId" v-model="dealId">
<option value="">بدون معامله</option>
<option v-for="d in deals" :key="d.id" :value="d.id">{{ d.title }}</option>
</select>
</div>
<div class="field wide">
<label for="description">توضیح</label>
<textarea id="description" v-model="description" rows="4" />
</div>
</div>
<p v-if="error" class="error">{{ error }}</p>
<div class="form-actions">
<button class="btn" type="submit" :disabled="saving">
{{ saving ? 'در حال ذخیره…' : 'ذخیره' }}
</button>
<button
v-if="!isNew"
class="btn btn-ghost"
type="button"
:disabled="saving"
@click="complete"
>
انجام شد
</button>
<button
v-if="!isNew"
class="btn btn-danger"
type="button"
:disabled="saving"
@click="remove"
>
حذف
</button>
</div>
</form>
</section>
</template>
<style scoped>
.page {
display: grid;
gap: 1.1rem;
max-width: 820px;
}
.page-head {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
}
.eyebrow {
margin: 0;
color: var(--accent);
font-weight: 700;
}
h1 {
margin: 0.2rem 0 0;
font-size: 1.7rem;
}
.muted {
color: var(--muted);
}
.form-card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 1.2rem;
display: grid;
gap: 1rem;
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.9rem;
}
.wide {
grid-column: 1 / -1;
}
.form-actions {
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
}
@media (max-width: 700px) {
.grid {
grid-template-columns: 1fr;
}
}
</style>
+486
View File
@@ -0,0 +1,486 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import type { CompanyDto, CreateCompanyRequest } from '@mento/shared';
import CustomFieldsSection from '@/components/CustomFieldsSection.vue';
import EntityTimeline from '@/components/EntityTimeline.vue';
const route = useRoute();
const router = useRouter();
const id = computed(() => (typeof route.params.id === 'string' ? route.params.id : ''));
const isNew = computed(() => route.name === 'company-new');
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const name = ref('');
const nationalId = ref('');
const phone = ref('');
const email = ref('');
const website = ref('');
const address = ref('');
const notes = ref('');
const customFields = ref<Record<string, unknown>>({});
function fill(c: CompanyDto) {
name.value = c.name;
nationalId.value = c.nationalId ?? '';
phone.value = c.phone ?? '';
email.value = c.email ?? '';
website.value = c.website ?? '';
address.value = c.address ?? '';
notes.value = c.notes ?? '';
customFields.value = { ...(c.customFields ?? {}) };
}
async function load() {
if (isNew.value) return;
loading.value = true;
error.value = '';
try {
const { data } = await api.get<CompanyDto>(`/companies/${id.value}`);
fill(data);
} catch {
error.value = 'بارگذاری شرکت ناموفق بود.';
} finally {
loading.value = false;
}
}
function payload(): CreateCompanyRequest {
return {
name: name.value,
nationalId: nationalId.value || undefined,
phone: phone.value || undefined,
email: email.value || undefined,
website: website.value || undefined,
address: address.value || undefined,
notes: notes.value || undefined,
customFields: customFields.value,
};
}
async function submit() {
saving.value = true;
error.value = '';
try {
if (isNew.value) {
const { data } = await api.post<CompanyDto>('/companies', payload());
await router.replace({ name: 'company-detail', params: { id: data.id } });
} else {
await api.patch(`/companies/${id.value}`, payload());
await router.push({ name: 'contacts', query: { tab: 'companies' } });
}
} catch (e) {
if (axios.isAxiosError(e)) {
if (!e.response) {
error.value = 'اتصال به سرور برقرار نشد.';
} else {
const msg = (e.response.data as { message?: string | string[] })?.message;
error.value = Array.isArray(msg) ? msg.join('، ') : (msg ?? 'ذخیره ناموفق بود');
}
} else {
error.value = 'ذخیره ناموفق بود';
}
} finally {
saving.value = false;
}
}
async function remove() {
if (!id.value || !confirm('این شرکت حذف شود؟ افراد متصل بدون شرکت می‌مانند.')) return;
try {
await api.delete(`/companies/${id.value}`);
await router.push({ name: 'contacts', query: { tab: 'companies' } });
} catch {
error.value = 'حذف ناموفق بود';
}
}
onMounted(load);
</script>
<template>
<section class="page">
<header class="page-head">
<div>
<p class="eyebrow">شرکت</p>
<h1>{{ isNew ? 'شرکت جدید' : 'ویرایش شرکت' }}</h1>
</div>
<RouterLink class="btn btn-ghost" :to="{ name: 'contacts', query: { tab: 'companies' } }">
بازگشت
</RouterLink>
</header>
<p v-if="loading" class="muted">در حال بارگذاری…</p>
<form v-else class="form-card" @submit.prevent="submit">
<div class="grid">
<div class="field wide">
<label for="name">نام شرکت</label>
<input id="name" v-model="name" required minlength="2" />
</div>
<div class="field">
<label for="nationalId">شناسه ملی</label>
<input id="nationalId" v-model="nationalId" />
</div>
<div class="field">
<label for="phone">تلفن</label>
<input id="phone" v-model="phone" inputmode="tel" />
</div>
<div class="field">
<label for="email">ایمیل</label>
<input id="email" v-model="email" type="email" />
</div>
<div class="field">
<label for="website">وب‌سایت</label>
<input id="website" v-model="website" />
</div>
<div class="field wide">
<label for="address">آدرس</label>
<input id="address" v-model="address" />
</div>
<div class="field wide">
<label for="notes">یادداشت</label>
<textarea id="notes" v-model="notes" rows="4" />
</div>
</div>
<CustomFieldsSection entity-type="company" v-model="customFields" />
<EntityTimeline
v-if="!isNew && id"
entity-type="company"
:entity-id="id"
/>
<p v-if="error" class="error">{{ error }}</p>
<div class="form-actions">
<button class="btn" type="submit" :disabled="saving">
{{ saving ? 'در حال ذخیره…' : 'ذخیره' }}
</button>
<button
v-if="!isNew"
class="btn btn-danger"
type="button"
:disabled="saving"
@click="remove"
>
حذف
</button>
</div>
</form>
</section>
</template>
<style scoped>
.page {
display: grid;
gap: 1.1rem;
max-width: 820px;
}
.page-head {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
}
.eyebrow {
margin: 0;
color: var(--accent);
font-weight: 700;
}
h1 {
margin: 0.2rem 0 0;
font-size: 1.7rem;
}
.muted {
color: var(--muted);
}
.form-card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 1.2rem;
display: grid;
gap: 1rem;
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.9rem;
}
.wide {
grid-column: 1 / -1;
}
textarea {
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.8rem 0.95rem;
background: #fff;
font: inherit;
width: 100%;
}
.form-actions {
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
}
@media (max-width: 700px) {
.grid {
grid-template-columns: 1fr;
}
}
</style>
+565
View File
@@ -0,0 +1,565 @@
<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>
+280
View File
@@ -0,0 +1,280 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import axios from 'axios';
import { api } from '@/lib/api';
import { CURRENCY_OPTIONS, formatMoney } from '@/lib/money';
import type { OrganizationDto, UpdateOrgCurrenciesRequest } from '@mento/shared';
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const ok = ref('');
const org = ref<OrganizationDto | null>(null);
const baseCurrency = ref('IRR');
const enabled = ref<string[]>(['IRR', 'USD', 'EUR']);
const rates = ref<Record<string, string>>({});
const rateCurrencies = computed(() =>
enabled.value.filter((c) => c !== baseCurrency.value),
);
function toggleCurrency(code: string) {
if (code === baseCurrency.value) return;
if (enabled.value.includes(code)) {
enabled.value = enabled.value.filter((c) => c !== code);
} else {
enabled.value = [...enabled.value, code];
if (!rates.value[code]) rates.value[code] = '';
}
}
function onBaseChange() {
if (!enabled.value.includes(baseCurrency.value)) {
enabled.value = [baseCurrency.value, ...enabled.value];
}
}
async function load() {
loading.value = true;
error.value = '';
try {
const { data } = await api.get<OrganizationDto>('/organizations/current');
org.value = data;
baseCurrency.value = data.baseCurrency || 'IRR';
enabled.value = data.enabledCurrencies?.length
? [...data.enabledCurrencies]
: ['IRR'];
const next: Record<string, string> = {};
for (const r of data.exchangeRates ?? []) {
next[r.currency] = String(r.rateToBase);
}
rates.value = next;
} catch (e) {
if (axios.isAxiosError(e) && !e.response) {
error.value = 'اتصال به API برقرار نشد.';
} else {
error.value = 'بارگذاری تنظیمات ارز ناموفق بود.';
}
} finally {
loading.value = false;
}
}
async function save() {
saving.value = true;
error.value = '';
ok.value = '';
try {
const body: UpdateOrgCurrenciesRequest = {
baseCurrency: baseCurrency.value,
enabledCurrencies: enabled.value,
rates: rateCurrencies.value
.filter((c) => rates.value[c] && Number(rates.value[c]) > 0)
.map((c) => ({
currency: c,
rateToBase: Number(rates.value[c]),
})),
};
const { data } = await api.patch<OrganizationDto>(
'/organizations/current/currencies',
body,
);
org.value = data;
ok.value = 'تنظیمات ارز ذخیره شد.';
} 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 {
saving.value = false;
}
}
onMounted(load);
</script>
<template>
<section class="page">
<header>
<p class="eyebrow">تنظیمات</p>
<h1>ارزها و نرخ تبدیل</h1>
<p class="lead">
ارز پایه برای جمع‌ها و پیش‌بینی استفاده می‌شود. نرخ یعنی چند واحد ارز پایه
برابر با ۱ واحد ارز خارجی است.
</p>
</header>
<p v-if="loading" class="muted">در حال بارگذاری…</p>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="ok" class="ok">{{ ok }}</p>
<form v-if="!loading" class="card" @submit.prevent="save">
<div class="field">
<label>ارز پایه</label>
<select v-model="baseCurrency" @change="onBaseChange">
<option
v-for="c in CURRENCY_OPTIONS"
:key="c.code"
:value="c.code"
>
{{ c.labelFa }} ({{ c.code }})
</option>
</select>
</div>
<div class="field">
<label>ارزهای فعال</label>
<div class="chips">
<label
v-for="c in CURRENCY_OPTIONS"
:key="c.code"
class="chip"
:class="{ on: enabled.includes(c.code), locked: c.code === baseCurrency }"
>
<input
type="checkbox"
:checked="enabled.includes(c.code)"
:disabled="c.code === baseCurrency"
@change="toggleCurrency(c.code)"
/>
{{ c.labelFa }}
</label>
</div>
</div>
<div v-if="rateCurrencies.length" class="rates">
<h2>نرخ به {{ baseCurrency }}</h2>
<div v-for="code in rateCurrencies" :key="code" class="rate-row">
<span>۱ {{ code }} =</span>
<input
v-model="rates[code]"
type="number"
min="0.000001"
step="any"
:placeholder="`نرخ به ${baseCurrency}`"
/>
<span>{{ baseCurrency }}</span>
</div>
<p v-if="rates.USD" class="muted example">
مثال: ۱ دلار ≈
{{ formatMoney(Number(rates.USD) || 0, baseCurrency) }}
</p>
</div>
<button class="btn" type="submit" :disabled="saving">
{{ saving ? 'در حال ذخیره…' : 'ذخیره' }}
</button>
</form>
</section>
</template>
<style scoped>
.page {
display: grid;
gap: 1rem;
max-width: 640px;
}
.eyebrow {
margin: 0;
color: var(--accent);
font-weight: 700;
}
h1 {
margin: 0.2rem 0;
}
.lead,
.muted {
color: var(--muted);
}
.card {
display: grid;
gap: 1rem;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 1rem;
}
.field {
display: grid;
gap: 0.4rem;
}
label {
color: var(--muted);
font-size: 0.9rem;
}
select,
input[type='number'] {
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.7rem 0.85rem;
font: inherit;
background: #fff;
width: 100%;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
border: 1px solid var(--line);
border-radius: 999px;
padding: 0.4rem 0.75rem;
cursor: pointer;
color: inherit;
font-size: 0.9rem;
}
.chip.on {
border-color: color-mix(in srgb, var(--accent) 50%, var(--line));
background: color-mix(in srgb, var(--accent) 8%, #fff);
}
.chip.locked {
opacity: 0.85;
}
.rates {
display: grid;
gap: 0.55rem;
}
.rates h2 {
margin: 0;
font-size: 1rem;
}
.rate-row {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 0.5rem;
align-items: center;
}
.error {
color: #b42318;
}
.ok {
color: var(--accent);
font-weight: 600;
}
</style>
@@ -0,0 +1,326 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import axios from 'axios';
import { api } from '@/lib/api';
import type {
CreateCustomFieldRequest,
CustomFieldDefinitionDto,
CustomFieldEntity,
CustomFieldType,
} from '@mento/shared';
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const fields = ref<CustomFieldDefinitionDto[]>([]);
const filter = ref<'all' | CustomFieldEntity>('all');
const label = ref('');
const entityType = ref<CustomFieldEntity>('person');
const fieldType = ref<CustomFieldType>('text');
const optionsRaw = ref('');
const required = ref(false);
const entityLabels: Record<CustomFieldEntity, string> = {
person: 'شخص',
company: 'شرکت',
deal: 'معامله',
};
const typeLabels: Record<CustomFieldType, string> = {
text: 'متن',
number: 'عدد',
date: 'تاریخ',
boolean: 'بله/خیر',
select: 'لیست انتخابی',
};
const visible = computed(() =>
filter.value === 'all'
? fields.value
: fields.value.filter((f) => f.entityType === filter.value),
);
async function load() {
loading.value = true;
error.value = '';
try {
fields.value = (await api.get<CustomFieldDefinitionDto[]>('/custom-fields')).data;
} catch (e) {
if (axios.isAxiosError(e) && !e.response) {
error.value = 'اتصال به API برقرار نشد.';
} else {
error.value = 'بارگذاری فیلدها ناموفق بود.';
}
} finally {
loading.value = false;
}
}
async function createField() {
if (!label.value.trim()) {
error.value = 'عنوان فیلد را وارد کنید.';
return;
}
saving.value = true;
error.value = '';
try {
const payload: CreateCustomFieldRequest = {
entityType: entityType.value,
label: label.value.trim(),
fieldType: fieldType.value,
required: required.value,
options:
fieldType.value === 'select'
? optionsRaw.value
.split(/[,،\n]/)
.map((o) => o.trim())
.filter(Boolean)
: undefined,
};
await api.post('/custom-fields', payload);
label.value = '';
optionsRaw.value = '';
required.value = false;
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 {
saving.value = false;
}
}
async function toggleActive(field: CustomFieldDefinitionDto) {
await api.patch(`/custom-fields/${field.id}`, { isActive: !field.isActive });
await load();
}
async function removeField(field: CustomFieldDefinitionDto) {
if (!confirm(`فیلد «${field.label}» حذف شود؟`)) return;
await api.delete(`/custom-fields/${field.id}`);
await load();
}
onMounted(load);
</script>
<template>
<section class="page">
<header class="page-head">
<div>
<p class="eyebrow">تنظیمات</p>
<h1>فیلدهای سفارشی</h1>
<p class="muted">
فیلدهای اختصاصی سازمان را برای شخص، شرکت یا معامله تعریف کنید.
</p>
</div>
</header>
<form class="form-card" @submit.prevent="createField">
<h2>فیلد جدید</h2>
<div class="grid">
<div class="field">
<label for="cf-label">عنوان</label>
<input id="cf-label" v-model="label" required placeholder="مثلاً صنعت" />
</div>
<div class="field">
<label for="cf-entity">روی چه چیزی؟</label>
<select id="cf-entity" v-model="entityType">
<option value="person">شخص</option>
<option value="company">شرکت</option>
<option value="deal">معامله</option>
</select>
</div>
<div class="field">
<label for="cf-type">نوع</label>
<select id="cf-type" v-model="fieldType">
<option value="text">متن</option>
<option value="number">عدد</option>
<option value="date">تاریخ</option>
<option value="boolean">بله/خیر</option>
<option value="select">لیست انتخابی</option>
</select>
</div>
<div class="field check-field">
<label class="check">
<input v-model="required" type="checkbox" />
اجباری
</label>
</div>
<div v-if="fieldType === 'select'" class="field wide">
<label for="cf-options">گزینه‌ها (با کاما جدا کنید)</label>
<input
id="cf-options"
v-model="optionsRaw"
placeholder="مثلاً فناوری، تولید، خدمات"
/>
</div>
</div>
<button class="btn" type="submit" :disabled="saving">
{{ saving ? 'در حال ثبت…' : 'افزودن فیلد' }}
</button>
</form>
<div class="toolbar">
<select v-model="filter">
<option value="all">همه</option>
<option value="person">شخص</option>
<option value="company">شرکت</option>
<option value="deal">معامله</option>
</select>
<button class="btn btn-ghost" type="button" @click="load">بروزرسانی</button>
</div>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="loading" class="muted">در حال بارگذاری…</p>
<div v-else class="list">
<article v-for="field in visible" :key="field.id" class="row">
<div>
<strong>{{ field.label }}</strong>
<p class="meta">
{{ entityLabels[field.entityType] }} · {{ typeLabels[field.fieldType] }}
· کلید: <code>{{ field.key }}</code>
<span v-if="!field.isActive"> · غیرفعال</span>
</p>
</div>
<div class="actions">
<button class="btn btn-ghost" type="button" @click="toggleActive(field)">
{{ field.isActive ? 'غیرفعال' : 'فعال' }}
</button>
<button class="btn btn-danger" type="button" @click="removeField(field)">
حذف
</button>
</div>
</article>
<p v-if="!visible.length" class="muted">هنوز فیلدی تعریف نشده است.</p>
</div>
</section>
</template>
<style scoped>
.page {
display: grid;
gap: 1.1rem;
max-width: 920px;
}
.page-head h1 {
margin: 0.2rem 0 0.35rem;
font-size: 1.7rem;
}
.eyebrow {
margin: 0;
color: var(--accent);
font-weight: 700;
}
.muted {
color: var(--muted);
}
.form-card,
.row {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 1.1rem;
}
.form-card {
display: grid;
gap: 0.9rem;
}
.form-card h2 {
margin: 0;
font-size: 1.05rem;
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.85rem;
}
.wide {
grid-column: 1 / -1;
}
select,
input[type='text'] {
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.8rem 0.95rem;
background: #fff;
font: inherit;
width: 100%;
}
.check-field {
display: flex;
align-items: end;
}
.check {
display: flex;
align-items: center;
gap: 0.45rem;
}
.toolbar {
display: flex;
gap: 0.6rem;
align-items: center;
}
.list {
display: grid;
gap: 0.7rem;
}
.row {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: center;
}
.meta {
margin: 0.25rem 0 0;
color: var(--muted);
font-size: 0.92rem;
}
.actions {
display: flex;
gap: 0.45rem;
flex-wrap: wrap;
}
code {
font-family: Consolas, monospace;
font-size: 0.85em;
}
@media (max-width: 700px) {
.grid,
.row {
grid-template-columns: 1fr;
}
.row {
flex-direction: column;
align-items: stretch;
}
}
</style>
+528
View File
@@ -0,0 +1,528 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import CustomFieldsSection from '@/components/CustomFieldsSection.vue';
import EntityTimeline from '@/components/EntityTimeline.vue';
import { formatMoney } from '@/lib/money';
import type {
CompanyDto,
CreateDealRequest,
DealDto,
DealLineItemInput,
OrganizationDto,
PersonDto,
PipelineBoardDto,
ProductDto,
} from '@mento/shared';
type LineDraft = {
key: string;
productId: string;
name: string;
quantity: string;
unitPrice: string;
};
const route = useRoute();
const router = useRouter();
const id = computed(() => (typeof route.params.id === 'string' ? route.params.id : ''));
const isNew = computed(() => route.name === 'deal-new');
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const people = ref<PersonDto[]>([]);
const companies = ref<CompanyDto[]>([]);
const products = ref<ProductDto[]>([]);
const stages = ref<PipelineBoardDto['stages']>([]);
const title = ref('');
const amount = ref('');
const currency = ref('IRR');
const enabledCurrencies = ref<string[]>(['IRR']);
const source = ref('');
const notes = ref('');
const tagsRaw = ref('');
const isStale = ref(false);
const isRotten = ref(false);
const expectedCloseAt = ref('');
const personId = ref('');
const companyId = ref('');
const stageId = ref('');
const pipelineId = ref('');
const lostReason = ref('');
const status = ref('');
const customFields = ref<Record<string, unknown>>({});
const lines = ref<LineDraft[]>([]);
const linesTotal = computed(() =>
lines.value.reduce((sum, line) => {
const q = Number(line.quantity);
const p = Number(line.unitPrice);
if (Number.isNaN(q) || Number.isNaN(p)) return sum;
return sum + q * p;
}, 0),
);
function parseTags(raw: string) {
return raw
.split(/[,،#\n]+/)
.map((t) => t.trim())
.filter(Boolean);
}
function newLine(): LineDraft {
return {
key: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
productId: '',
name: '',
quantity: '1',
unitPrice: '',
};
}
function addLine() {
lines.value.push(newLine());
}
function removeLine(key: string) {
lines.value = lines.value.filter((l) => l.key !== key);
}
function onPickProduct(line: LineDraft) {
const product = products.value.find((p) => p.id === line.productId);
if (!product) return;
line.name = product.name;
if (product.unitPrice != null) line.unitPrice = String(product.unitPrice);
}
function toLineInputs(): DealLineItemInput[] {
return lines.value
.filter((l) => l.name.trim() && l.quantity !== '' && l.unitPrice !== '')
.map((l) => ({
productId: l.productId || null,
name: l.name.trim(),
quantity: Number(l.quantity),
unitPrice: Number(l.unitPrice),
currency: currency.value || 'IRR',
}));
}
async function load() {
loading.value = true;
error.value = '';
try {
const queryPipelineId =
typeof route.query.pipelineId === 'string' ? route.query.pipelineId : undefined;
const [peopleRes, companiesRes, boardRes, productsRes, orgRes] =
await Promise.all([
api.get<{ items: PersonDto[] }>('/people', { params: { limit: 100 } }),
api.get<{ items: CompanyDto[] }>('/companies', { params: { limit: 100 } }),
api.get<PipelineBoardDto>('/pipelines/board', {
params: queryPipelineId ? { pipelineId: queryPipelineId } : undefined,
}),
api.get<ProductDto[]>('/products', { params: { activeOnly: true } }),
api.get<OrganizationDto>('/organizations/current'),
]);
people.value = peopleRes.data.items;
companies.value = companiesRes.data.items;
stages.value = boardRes.data.stages;
pipelineId.value = boardRes.data.pipeline.id;
products.value = productsRes.data;
enabledCurrencies.value = orgRes.data.enabledCurrencies?.length
? orgRes.data.enabledCurrencies
: [orgRes.data.baseCurrency || 'IRR'];
if (!currency.value || !enabledCurrencies.value.includes(currency.value)) {
currency.value = orgRes.data.baseCurrency || 'IRR';
}
if (!isNew.value && id.value) {
const { data } = await api.get<DealDto>(`/deals/${id.value}`);
title.value = data.title;
amount.value = data.amount == null ? '' : String(data.amount);
currency.value = data.currency || currency.value;
source.value = data.source ?? '';
notes.value = data.notes ?? '';
tagsRaw.value = (data.tags ?? []).join('، ');
isStale.value = data.isStale;
isRotten.value = data.isRotten;
expectedCloseAt.value = data.expectedCloseAt
? data.expectedCloseAt.slice(0, 10)
: '';
personId.value = data.person?.id ?? '';
companyId.value = data.company?.id ?? '';
stageId.value = data.stageId;
pipelineId.value = data.pipelineId;
lostReason.value = data.lostReason ?? '';
status.value = data.status;
customFields.value = { ...(data.customFields ?? {}) };
lines.value = (data.lineItems ?? []).map((item) => ({
key: item.id,
productId: item.productId ?? '',
name: item.name,
quantity: String(item.quantity),
unitPrice: String(item.unitPrice),
}));
} else {
const firstOpen = stages.value.find((s) => !s.isWon && !s.isLost);
stageId.value = firstOpen?.id ?? stages.value[0]?.id ?? '';
}
} catch {
error.value = 'بارگذاری فرم معامله ناموفق بود.';
} finally {
loading.value = false;
}
}
function payload(): CreateDealRequest {
const lineItems = toLineInputs();
return {
title: title.value,
amount:
lineItems.length > 0
? linesTotal.value
: amount.value === ''
? null
: Number(amount.value),
source: source.value || undefined,
notes: notes.value || undefined,
tags: parseTags(tagsRaw.value),
isStale: isStale.value,
isRotten: isRotten.value,
expectedCloseAt: expectedCloseAt.value || null,
personId: personId.value || null,
companyId: companyId.value || null,
pipelineId: pipelineId.value || undefined,
stageId: stageId.value || undefined,
customFields: customFields.value,
currency: currency.value || 'IRR',
lineItems,
};
}
async function submit() {
saving.value = true;
error.value = '';
try {
if (isNew.value) {
const { data } = await api.post<DealDto>('/deals', payload());
await router.replace({ name: 'deal-detail', params: { id: data.id } });
} else {
await api.patch(`/deals/${id.value}`, {
...payload(),
lostReason: lostReason.value || null,
});
if (stageId.value) {
await api.patch(`/deals/${id.value}/move`, { stageId: stageId.value });
}
await router.push({ name: 'pipeline' });
}
} catch (e) {
if (axios.isAxiosError(e)) {
if (!e.response) {
error.value = 'اتصال به سرور برقرار نشد.';
} else {
const msg = (e.response.data as { message?: string | string[] })?.message;
error.value = Array.isArray(msg) ? msg.join('، ') : (msg ?? 'ذخیره ناموفق بود');
}
} else {
error.value = 'ذخیره ناموفق بود';
}
} finally {
saving.value = false;
}
}
async function remove() {
if (!id.value || !confirm('این معامله حذف شود؟')) return;
try {
await api.delete(`/deals/${id.value}`);
await router.push({ name: 'pipeline' });
} catch {
error.value = 'حذف ناموفق بود';
}
}
onMounted(load);
</script>
<template>
<section class="page">
<header class="page-head">
<div>
<p class="eyebrow">معامله</p>
<h1>{{ isNew ? 'معامله جدید' : 'ویرایش معامله' }}</h1>
<p v-if="status" class="lead">وضعیت: {{ status === 'won' ? 'برنده' : status === 'lost' ? 'از دست‌رفته' : 'باز' }}</p>
</div>
<div class="head-actions">
<RouterLink
v-if="!isNew && id"
class="btn btn-ghost"
:to="{ name: 'quote-new', query: { fromDealId: id } }"
>
صدور پیش‌فاکتور
</RouterLink>
<RouterLink
v-if="!isNew && id"
class="btn btn-ghost"
:to="{ name: 'invoice-new', query: { fromDealId: id } }"
>
صدور فاکتور
</RouterLink>
<RouterLink class="btn btn-ghost" :to="{ name: 'pipeline' }">بازگشت به کاریز</RouterLink>
</div>
</header>
<p v-if="loading" class="muted">در حال بارگذاری…</p>
<form v-else class="form-card" @submit.prevent="submit">
<div class="grid">
<div class="field wide">
<label for="title">عنوان معامله</label>
<input id="title" v-model="title" required minlength="2" placeholder="مثلاً فروش بسته سازمانی" />
</div>
<div class="field">
<label for="amount">مبلغ</label>
<input
id="amount"
v-model="amount"
type="number"
min="0"
step="1"
:readonly="lines.length > 0"
:placeholder="lines.length ? 'جمع ردیف‌ها' : undefined"
/>
<small v-if="lines.length" class="hint">
جمع ردیف‌ها: {{ formatMoney(linesTotal, currency) }}
</small>
</div>
<div class="field">
<label for="currency">ارز</label>
<select id="currency" v-model="currency">
<option v-for="code in enabledCurrencies" :key="code" :value="code">
{{ code }}
</option>
</select>
</div>
<div class="field">
<label for="source">منبع آشنایی</label>
<input id="source" v-model="source" placeholder="وب‌سایت، معرفی، نمایشگاه…" />
</div>
<div class="field">
<label for="expectedCloseAt">تاریخ بسته‌شدن مورد انتظار</label>
<input id="expectedCloseAt" v-model="expectedCloseAt" type="date" />
</div>
<div class="field wide">
<label for="tags">برچسب‌ها (با کاما جدا کنید)</label>
<input id="tags" v-model="tagsRaw" placeholder="مثلاً سازمانی، فوری، نمایشگاه" />
</div>
<div class="field">
<label class="check">
<input v-model="isStale" type="checkbox" />
راکد (مدتی پیگیری نشده)
</label>
</div>
<div class="field">
<label class="check">
<input v-model="isRotten" type="checkbox" />
فاسد (احتمال موفقیت خیلی کم)
</label>
</div>
<div class="field">
<label for="personId">شخص</label>
<select id="personId" v-model="personId">
<option value="">بدون شخص</option>
<option v-for="p in people" :key="p.id" :value="p.id">{{ p.fullName }}</option>
</select>
</div>
<div class="field">
<label for="companyId">شرکت</label>
<select id="companyId" v-model="companyId">
<option value="">بدون شرکت</option>
<option v-for="c in companies" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>
</div>
<div class="field wide">
<label for="stageId">مرحله کاریز</label>
<select id="stageId" v-model="stageId" required>
<option v-for="s in stages" :key="s.id" :value="s.id">{{ s.name }}</option>
</select>
</div>
<div v-if="!isNew" class="field wide">
<label for="lostReason">دلیل شکست (اگر از دست رفت)</label>
<input id="lostReason" v-model="lostReason" />
</div>
<div class="field wide">
<label for="notes">یادداشت</label>
<textarea id="notes" v-model="notes" rows="4" />
</div>
</div>
<div class="lines-block">
<div class="lines-head">
<h3>محصولات / خدمات روی معامله</h3>
<button class="btn btn-ghost" type="button" @click="addLine">افزودن ردیف</button>
</div>
<div v-for="line in lines" :key="line.key" class="line-row">
<select v-model="line.productId" @change="onPickProduct(line)">
<option value="">محصول از کاتالوگ…</option>
<option v-for="p in products" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
<input v-model="line.name" placeholder="نام ردیف" required />
<input v-model="line.quantity" type="number" min="0.01" step="0.01" placeholder="تعداد" />
<input v-model="line.unitPrice" type="number" min="0" step="1" placeholder="قیمت واحد" />
<button class="btn btn-ghost" type="button" @click="removeLine(line.key)">حذف</button>
</div>
<p v-if="!lines.length" class="muted">
اختیاری — می‌توانید فقط مبلغ کل را وارد کنید یا از کاتالوگ محصول ردیف اضافه کنید.
</p>
</div>
<CustomFieldsSection entity-type="deal" v-model="customFields" />
<EntityTimeline
v-if="!isNew && id"
entity-type="deal"
:entity-id="id"
/>
<p v-if="error" class="error">{{ error }}</p>
<div class="form-actions">
<button class="btn" type="submit" :disabled="saving">
{{ saving ? 'در حال ذخیره…' : 'ذخیره' }}
</button>
<button
v-if="!isNew"
class="btn btn-danger"
type="button"
:disabled="saving"
@click="remove"
>
حذف
</button>
</div>
</form>
</section>
</template>
<style scoped>
.page {
display: grid;
gap: 1.1rem;
max-width: 820px;
}
.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 0;
font-size: 1.7rem;
}
.lead,
.muted {
color: var(--muted);
}
.form-card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 1.2rem;
display: grid;
gap: 1rem;
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.9rem;
}
.wide {
grid-column: 1 / -1;
}
.check {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 1.7rem;
font-weight: 500;
}
.hint {
display: block;
margin-top: 0.35rem;
color: var(--muted);
font-size: 0.85rem;
}
.lines-block {
display: grid;
gap: 0.7rem;
padding-top: 0.5rem;
border-top: 1px dashed var(--line);
}
.lines-head {
display: flex;
justify-content: space-between;
gap: 0.75rem;
align-items: center;
}
.lines-head h3 {
margin: 0;
font-size: 1rem;
}
.line-row {
display: grid;
grid-template-columns: 1.2fr 1.4fr 0.7fr 0.9fr auto;
gap: 0.45rem;
align-items: center;
}
.line-row input,
.line-row select {
border: 1px solid var(--line);
border-radius: 10px;
padding: 0.65rem 0.75rem;
background: #fff;
font: inherit;
width: 100%;
}
.form-actions {
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
}
@media (max-width: 700px) {
.grid,
.line-row {
grid-template-columns: 1fr;
}
}
</style>
+325
View File
@@ -0,0 +1,325 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import axios from 'axios';
import { api } from '@/lib/api';
import type {
CreateFaqArticleRequest,
FaqArticleDto,
UpdateFaqArticleRequest,
} from '@mento/shared';
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const ok = ref('');
const items = ref<FaqArticleDto[]>([]);
const editingId = ref<string | null>(null);
const question = ref('');
const answer = ref('');
const category = ref('');
const isPublished = ref(true);
const position = ref(0);
function resetForm() {
editingId.value = null;
question.value = '';
answer.value = '';
category.value = '';
isPublished.value = true;
position.value = 0;
}
function startEdit(row: FaqArticleDto) {
editingId.value = row.id;
question.value = row.question;
answer.value = row.answer;
category.value = row.category ?? '';
isPublished.value = row.isPublished;
position.value = row.position;
ok.value = '';
}
async function load() {
loading.value = true;
error.value = '';
try {
const { data } = await api.get<FaqArticleDto[]>('/faq', {
params: { publishedOnly: false },
});
items.value = data;
} catch (e) {
if (axios.isAxiosError(e) && !e.response) {
error.value = 'اتصال به API برقرار نشد.';
} else {
error.value = 'بارگذاری FAQ ناموفق بود.';
}
} finally {
loading.value = false;
}
}
async function save() {
if (!question.value.trim() || !answer.value.trim()) {
error.value = 'سؤال و پاسخ را وارد کنید.';
return;
}
saving.value = true;
error.value = '';
ok.value = '';
try {
if (editingId.value) {
const body: UpdateFaqArticleRequest = {
question: question.value.trim(),
answer: answer.value.trim(),
category: category.value.trim() || null,
isPublished: isPublished.value,
position: position.value,
};
await api.patch(`/faq/${editingId.value}`, body);
ok.value = 'مقاله به‌روز شد.';
} else {
const body: CreateFaqArticleRequest = {
question: question.value.trim(),
answer: answer.value.trim(),
category: category.value.trim() || undefined,
isPublished: isPublished.value,
position: position.value,
};
await api.post('/faq', body);
ok.value = 'مقاله اضافه شد.';
}
resetForm();
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 {
saving.value = false;
}
}
async function remove(row: FaqArticleDto) {
if (!confirm(`حذف «${row.question}»؟`)) return;
await api.delete(`/faq/${row.id}`);
if (editingId.value === row.id) resetForm();
await load();
}
onMounted(load);
</script>
<template>
<section class="page">
<header class="page-head">
<div>
<p class="eyebrow">تنظیمات</p>
<h1>پایگاه دانش (FAQ)</h1>
<p class="lead">سؤالات پرتکرار برای کاهش تیکت تکراری.</p>
</div>
</header>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="ok" class="ok">{{ ok }}</p>
<p v-if="loading" class="muted">در حال بارگذاری…</p>
<form class="form" @submit.prevent="save">
<h2>{{ editingId ? 'ویرایش مقاله' : 'مقاله جدید' }}</h2>
<div class="field">
<label>سؤال</label>
<input v-model="question" required maxlength="500" />
</div>
<div class="field">
<label>پاسخ</label>
<textarea v-model="answer" rows="4" required />
</div>
<div class="row-fields">
<div class="field">
<label>دسته</label>
<input v-model="category" maxlength="100" placeholder="مثلاً نصب" />
</div>
<div class="field">
<label>ترتیب</label>
<input v-model.number="position" type="number" min="0" />
</div>
<label class="check">
<input v-model="isPublished" type="checkbox" />
منتشرشده
</label>
</div>
<div class="form-actions">
<button class="btn" type="submit" :disabled="saving">
{{ saving ? '…' : editingId ? 'ذخیره تغییرات' : 'افزودن' }}
</button>
<button
v-if="editingId"
class="btn btn-ghost"
type="button"
@click="resetForm"
>
انصراف
</button>
</div>
</form>
<div class="list">
<article v-for="row in items" :key="row.id" class="card">
<header>
<strong>{{ row.question }}</strong>
<span v-if="!row.isPublished" class="tag muted-tag">پیش‌نویس</span>
<span v-if="row.category" class="tag">{{ row.category }}</span>
</header>
<p>{{ row.answer }}</p>
<div class="card-actions">
<button class="btn btn-ghost" type="button" @click="startEdit(row)">
ویرایش
</button>
<button class="btn btn-danger" type="button" @click="remove(row)">
حذف
</button>
</div>
</article>
<p v-if="!loading && !items.length" class="empty">هنوز مقاله‌ای نیست.</p>
</div>
</section>
</template>
<style scoped>
.page {
display: grid;
gap: 1.1rem;
max-width: 720px;
}
.eyebrow {
margin: 0;
color: var(--accent);
font-weight: 700;
}
h1 {
margin: 0.2rem 0;
}
h2 {
margin: 0;
font-size: 1.05rem;
}
.lead,
.muted {
color: var(--muted);
}
.form,
.list {
display: grid;
gap: 0.75rem;
}
.form {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 1rem;
}
.field {
display: grid;
gap: 0.35rem;
}
.row-fields {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: flex-end;
}
.row-fields .field {
flex: 1;
min-width: 120px;
}
label {
color: var(--muted);
font-size: 0.9rem;
}
input,
textarea {
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.7rem 0.85rem;
font: inherit;
background: #fff;
width: 100%;
}
.check {
display: flex;
align-items: center;
gap: 0.4rem;
color: inherit;
padding-bottom: 0.5rem;
}
.form-actions,
.card-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.card {
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0.9rem 1rem;
background: var(--surface);
}
.card header {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
align-items: center;
margin-bottom: 0.4rem;
}
.card p {
margin: 0 0 0.65rem;
white-space: pre-wrap;
color: var(--muted);
}
.tag {
font-size: 0.75rem;
color: var(--accent);
font-weight: 700;
}
.muted-tag {
color: var(--muted);
}
.error {
color: #b42318;
}
.ok {
color: #027a48;
}
.empty {
padding: 1rem;
border: 1px dashed var(--line);
border-radius: var(--radius);
color: var(--muted);
}
</style>
+337
View File
@@ -0,0 +1,337 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { RouterLink, useRouter } from 'vue-router';
import axios from 'axios';
import { useAuthStore } from '@/stores/auth';
import { api } from '@/lib/api';
import { formatMoney as formatMoneyLib } from '@/lib/money';
import type {
ActivityDto,
ActivitySummaryDto,
HealthResponse,
OrganizationDto,
SalesForecastDto,
} from '@mento/shared';
const auth = useAuthStore();
const router = useRouter();
const org = ref<OrganizationDto | null>(null);
const health = ref<HealthResponse | null>(null);
const summary = ref<ActivitySummaryDto | null>(null);
const forecast = ref<SalesForecastDto | null>(null);
const orgError = ref('');
const apiError = ref('');
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', {
hour: '2-digit',
minute: '2-digit',
}).format(new Date(value));
} catch {
return value;
}
}
function formatMoney(amount: number) {
return formatMoneyLib(amount, forecast.value?.currency || 'IRR');
}
async function complete(id: string) {
try {
await api.patch(`/activities/${id}/complete`);
const { data } = await api.get<ActivitySummaryDto>('/activities/summary');
summary.value = data;
} catch {
// ignore brief failure on home
}
}
onMounted(async () => {
try {
const { data } = await api.get<HealthResponse>('/health');
health.value = data;
} catch {
apiError.value = 'ارتباط با API برقرار نشد. سرویس Nest را چک کنید.';
}
try {
const { data } = await api.get<OrganizationDto>('/organizations/current');
org.value = data;
} catch (e) {
if (axios.isAxiosError(e) && e.response?.status === 401) {
auth.logout();
await router.replace({ name: 'login', query: { redirect: '/' } });
return;
}
orgError.value = apiError.value
? 'به‌خاطر قطع بودن API بارگذاری نشد.'
: 'بارگذاری سازمان ناموفق بود.';
}
try {
const { data } = await api.get<ActivitySummaryDto>('/activities/summary');
summary.value = data;
} catch {
// home still works without activities
}
try {
const { data } = await api.get<SalesForecastDto>('/sales/forecast');
forecast.value = data;
} catch {
// optional card
}
});
</script>
<template>
<section class="home">
<header>
<div>
<p class="eyebrow">خانه امروز</p>
<h1>سلام {{ auth.user?.fullName }}</h1>
<p class="lead">
کارهای امروز را ببینید و یادداشت‌های سریع را در Smart Notes ثبت کنید.
</p>
</div>
<div class="actions">
<RouterLink class="btn btn-ghost" :to="{ name: 'sales-forecast' }"
>هدف و پیش‌بینی</RouterLink
>
<RouterLink class="btn btn-ghost" :to="{ name: 'activities-calendar' }"
>تقویم</RouterLink
>
<RouterLink class="btn" :to="{ name: 'activity-new' }">فعالیت جدید</RouterLink>
</div>
</header>
<div class="grid">
<article>
<h2>سازمان</h2>
<p v-if="org">{{ org.name }} <span>({{ org.slug }})</span></p>
<p v-else-if="orgError" class="error">{{ orgError }}</p>
<p v-else class="muted">در حال بارگذاری…</p>
</article>
<article>
<h2>وضعیت API</h2>
<p v-if="health" class="ok">{{ health.status }} — {{ health.service }}</p>
<p v-else-if="apiError" class="error">{{ apiError }}</p>
<p v-else class="muted">در حال بررسی…</p>
</article>
<article v-if="forecast" class="wide forecast-card">
<div class="section-head">
<h2>فروش این ماه</h2>
<RouterLink :to="{ name: 'sales-forecast' }">جزئیات ←</RouterLink>
</div>
<div class="forecast-row">
<div>
<p class="muted">هدف</p>
<strong>{{ formatMoney(forecast.targetAmount) }}</strong>
</div>
<div>
<p class="muted">تحقق</p>
<strong>{{ formatMoney(forecast.wonAmount) }}</strong>
</div>
<div>
<p class="muted">پیش‌بینی</p>
<strong>{{ formatMoney(forecast.forecastAmount) }}</strong>
</div>
<div>
<p class="muted">پیشرفت</p>
<strong>
{{
forecast.progressPercent == null
? '—'
: `${new Intl.NumberFormat('fa-IR').format(forecast.progressPercent)}٪`
}}
</strong>
</div>
</div>
</article>
<article class="wide">
<div class="section-head">
<h2>عقب‌افتاده ({{ summary?.overdueCount ?? 0 }})</h2>
<RouterLink :to="{ name: 'activities', query: { filter: 'overdue' } }">همه ←</RouterLink>
</div>
<div v-if="summary?.overdue.length" class="activity-list">
<div v-for="item in summary.overdue.slice(0, 5)" :key="item.id" class="activity-row">
<div>
<strong>{{ item.title }}</strong>
<p class="muted">{{ typeLabel[item.type] }} · {{ item.person?.fullName || '—' }}</p>
</div>
<button class="btn btn-ghost" type="button" @click="complete(item.id)">انجام شد</button>
</div>
</div>
<p v-else class="muted">عقب‌افتاده‌ای نیست.</p>
</article>
<article class="wide">
<div class="section-head">
<h2>امروز ({{ summary?.todayCount ?? 0 }})</h2>
<RouterLink :to="{ name: 'activities' }">همه ←</RouterLink>
</div>
<div v-if="summary?.today.length" class="activity-list">
<div v-for="item in summary.today.slice(0, 6)" :key="item.id" class="activity-row">
<div>
<strong>{{ item.title }}</strong>
<p class="muted">
{{ typeLabel[item.type] }} · {{ formatDue(item.dueAt) }}
<span v-if="item.deal"> · {{ item.deal.title }}</span>
</p>
</div>
<button class="btn btn-ghost" type="button" @click="complete(item.id)">انجام شد</button>
</div>
</div>
<p v-else class="muted">برای امروز فعالیتی ثبت نشده.</p>
</article>
<article class="wide">
<h2>میانبر</h2>
<p>
<RouterLink to="/activities">فعالیت‌ها ←</RouterLink>
&nbsp;·&nbsp;
<RouterLink to="/notes">یادداشت‌ها ←</RouterLink>
&nbsp;·&nbsp;
<RouterLink to="/pipeline">کاریز فروش ←</RouterLink>
&nbsp;·&nbsp;
<RouterLink to="/contacts">اشخاص و شرکت‌ها ←</RouterLink>
</p>
</article>
</div>
</section>
</template>
<style scoped>
.home {
display: grid;
gap: 1.25rem;
}
header {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
flex-wrap: wrap;
}
.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: 2rem;
}
.lead,
.muted,
span {
color: var(--muted);
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
article {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 1.1rem 1.2rem;
box-shadow: var(--shadow);
}
.wide {
grid-column: 1 / -1;
}
h2 {
margin: 0 0 0.6rem;
font-size: 1.05rem;
}
.section-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.6rem;
}
.section-head h2 {
margin: 0;
}
.forecast-row {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.75rem;
}
.forecast-row p {
margin: 0 0 0.2rem;
font-size: 0.85rem;
}
.ok {
color: var(--accent);
font-weight: 600;
}
article a {
color: var(--accent);
font-weight: 600;
}
.activity-list {
display: grid;
gap: 0.65rem;
}
.activity-row {
display: flex;
justify-content: space-between;
gap: 0.8rem;
align-items: center;
padding: 0.7rem 0;
border-top: 1px solid var(--line);
}
.activity-row:first-child {
border-top: 0;
padding-top: 0;
}
.activity-row p {
margin: 0.2rem 0 0;
font-size: 0.9rem;
}
@media (max-width: 800px) {
.grid {
grid-template-columns: 1fr;
}
}
</style>
+797
View File
@@ -0,0 +1,797 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import { formatMoney as formatMoneyLib } from '@/lib/money';
import type {
CompanyDto,
CreateInvoiceRequest,
CreatePaymentRequest,
DealLineItemInput,
InvoiceDto,
InvoiceInstallmentInput,
InvoiceStatus,
OrganizationDto,
PaginatedResponse,
PaymentMethod,
PersonDto,
ProductDto,
} from '@mento/shared';
type LineDraft = {
key: string;
productId: string;
name: string;
quantity: string;
unitPrice: string;
};
type InstallmentDraft = {
key: string;
dueDate: string;
amount: string;
};
const route = useRoute();
const router = useRouter();
const id = computed(() =>
typeof route.params.id === 'string' ? route.params.id : '',
);
const isNew = computed(() => route.name === 'invoice-new');
const loading = ref(false);
const saving = ref(false);
const paying = ref(false);
const error = ref('');
const number = ref('');
const title = ref('فاکتور');
const status = ref<InvoiceStatus>('draft');
const notes = ref('');
const dueDate = ref('');
const currency = ref('IRR');
const enabledCurrencies = ref<string[]>(['IRR']);
const personId = ref('');
const companyId = ref('');
const dealId = ref('');
const quoteId = ref('');
const people = ref<PersonDto[]>([]);
const companies = ref<CompanyDto[]>([]);
const products = ref<ProductDto[]>([]);
const lines = ref<LineDraft[]>([]);
const installments = ref<InstallmentDraft[]>([]);
const invoice = ref<InvoiceDto | null>(null);
const payAmount = ref('');
const payMethod = ref<PaymentMethod>('transfer');
const payNotes = ref('');
const payInstallmentId = ref('');
const payDate = ref(new Date().toISOString().slice(0, 10));
const statusLabel: Record<InvoiceStatus, string> = {
draft: 'پیش‌نویس',
issued: 'صادرشده',
partially_paid: 'پرداخت جزئی',
paid: 'تسویه‌شده',
canceled: 'لغوشده',
};
const methodLabel: Record<PaymentMethod, string> = {
cash: 'نقد',
transfer: 'کارت‌به‌کارت / حواله',
card: 'کارتخوان',
check: 'چک',
other: 'سایر',
};
const totalAmount = computed(() =>
toLineInputs().reduce((sum, l) => sum + l.quantity * l.unitPrice, 0),
);
function formatMoney(amount: number) {
return formatMoneyLib(amount, currency.value);
}
function newLine(): LineDraft {
return {
key: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
productId: '',
name: '',
quantity: '1',
unitPrice: '',
};
}
function newInstallment(): InstallmentDraft {
return {
key: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
dueDate: '',
amount: '',
};
}
function addLine() {
lines.value.push(newLine());
}
function removeLine(key: string) {
lines.value = lines.value.filter((l) => l.key !== key);
}
function addInstallment() {
installments.value.push(newInstallment());
}
function removeInstallment(key: string) {
installments.value = installments.value.filter((l) => l.key !== key);
}
function splitInstallments(count: number) {
const total = totalAmount.value;
if (!total || count < 1) return;
const base = Math.floor(total / count);
const remainder = total - base * count;
const today = new Date();
installments.value = Array.from({ length: count }, (_, i) => {
const d = new Date(today);
d.setMonth(d.getMonth() + i + 1);
return {
key: `split-${i}-${Date.now()}`,
dueDate: d.toISOString().slice(0, 10),
amount: String(base + (i === count - 1 ? remainder : 0)),
};
});
}
function onPickProduct(line: LineDraft) {
const product = products.value.find((p) => p.id === line.productId);
if (!product) return;
line.name = product.name;
if (product.unitPrice != null) line.unitPrice = String(product.unitPrice);
}
function toLineInputs(): DealLineItemInput[] {
return lines.value
.filter((l) => l.name.trim() && l.quantity !== '' && l.unitPrice !== '')
.map((l) => ({
productId: l.productId || null,
name: l.name.trim(),
quantity: Number(l.quantity),
unitPrice: Number(l.unitPrice),
currency: currency.value || 'IRR',
}));
}
function toInstallmentInputs(): InvoiceInstallmentInput[] {
return installments.value
.filter((i) => i.dueDate && i.amount !== '')
.map((i) => ({
dueDate: new Date(i.dueDate).toISOString(),
amount: Number(i.amount),
currency: currency.value || 'IRR',
}));
}
async function load() {
loading.value = true;
error.value = '';
try {
const fromDealId =
typeof route.query.fromDealId === 'string'
? route.query.fromDealId
: typeof route.query.dealId === 'string'
? route.query.dealId
: '';
const fromQuoteId =
typeof route.query.fromQuoteId === 'string'
? route.query.fromQuoteId
: '';
const [peopleRes, companiesRes, productsRes, orgRes] = await Promise.all([
api.get<PaginatedResponse<PersonDto>>('/people', {
params: { limit: 100 },
}),
api.get<PaginatedResponse<CompanyDto>>('/companies', {
params: { limit: 100 },
}),
api.get<ProductDto[]>('/products', { params: { activeOnly: true } }),
api.get<OrganizationDto>('/organizations/current'),
]);
people.value = peopleRes.data.items;
companies.value = companiesRes.data.items;
products.value = productsRes.data;
enabledCurrencies.value = orgRes.data.enabledCurrencies?.length
? orgRes.data.enabledCurrencies
: [orgRes.data.baseCurrency || 'IRR'];
currency.value = orgRes.data.baseCurrency || 'IRR';
if (!isNew.value && id.value) {
const { data } = await api.get<InvoiceDto>(`/invoices/${id.value}`);
invoice.value = data;
number.value = data.number;
title.value = data.title;
status.value = data.status as InvoiceStatus;
notes.value = data.notes ?? '';
currency.value = data.currency || currency.value;
dueDate.value = data.dueDate ? data.dueDate.slice(0, 10) : '';
personId.value = data.person?.id ?? '';
companyId.value = data.company?.id ?? '';
dealId.value = data.deal?.id ?? '';
quoteId.value = data.quote?.id ?? '';
lines.value = (data.lineItems ?? []).map((item) => ({
key: item.id,
productId: item.productId ?? '',
name: item.name,
quantity: String(item.quantity),
unitPrice: String(item.unitPrice),
}));
installments.value = (data.installments ?? []).map((item) => ({
key: item.id,
dueDate: item.dueDate.slice(0, 10),
amount: String(item.amount),
}));
if (data.remainingAmount > 0) {
payAmount.value = String(data.remainingAmount);
}
} else if (fromQuoteId) {
quoteId.value = fromQuoteId;
title.value = 'فاکتور';
} else if (fromDealId) {
dealId.value = fromDealId;
title.value = 'فاکتور';
} else if (!lines.value.length) {
lines.value = [newLine()];
}
} catch {
error.value = 'بارگذاری فرم فاکتور ناموفق بود.';
} finally {
loading.value = false;
}
}
function payload(): CreateInvoiceRequest {
const fromDealId =
isNew.value && typeof route.query.fromDealId === 'string'
? route.query.fromDealId
: undefined;
const fromQuoteId =
isNew.value && typeof route.query.fromQuoteId === 'string'
? route.query.fromQuoteId
: undefined;
return {
title: title.value.trim(),
status: status.value,
notes: notes.value || undefined,
dueDate: dueDate.value ? new Date(dueDate.value).toISOString() : null,
personId: personId.value || null,
companyId: companyId.value || null,
dealId: dealId.value || null,
quoteId: quoteId.value || null,
fromDealId: fromDealId || null,
fromQuoteId: fromQuoteId || null,
lineItems: toLineInputs(),
installments: toInstallmentInputs(),
currency: currency.value || 'IRR',
};
}
async function save() {
if (!title.value.trim()) {
error.value = 'عنوان را وارد کنید.';
return;
}
saving.value = true;
error.value = '';
try {
if (isNew.value) {
const { data } = await api.post<InvoiceDto>('/invoices', payload());
await router.replace({ name: 'invoice-detail', params: { id: data.id } });
} else {
await api.patch(`/invoices/${id.value}`, payload());
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 {
saving.value = false;
}
}
async function remove() {
if (!id.value || !confirm('این فاکتور حذف شود؟')) return;
await api.delete(`/invoices/${id.value}`);
await router.push({ name: 'invoices' });
}
async function submitPayment() {
if (!id.value || !payAmount.value) {
error.value = 'مبلغ پرداخت را وارد کنید.';
return;
}
paying.value = true;
error.value = '';
try {
const body: CreatePaymentRequest = {
amount: Number(payAmount.value),
method: payMethod.value,
notes: payNotes.value || undefined,
paidAt: payDate.value
? new Date(payDate.value).toISOString()
: undefined,
installmentId: payInstallmentId.value || null,
};
const { data } = await api.post<InvoiceDto>(
`/invoices/${id.value}/payments`,
body,
);
invoice.value = data;
payNotes.value = '';
payInstallmentId.value = '';
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 {
paying.value = false;
}
}
async function removePayment(paymentId: string) {
if (!id.value || !confirm('این پرداخت حذف شود؟')) return;
await api.delete(`/invoices/${id.value}/payments/${paymentId}`);
await load();
}
function printInvoice() {
window.print();
}
const pendingInstallments = computed(
() =>
invoice.value?.installments.filter((i) => i.status === 'pending') ?? [],
);
onMounted(load);
</script>
<template>
<section class="page">
<header class="page-head no-print">
<div>
<RouterLink class="back" :to="{ name: 'invoices' }"
>← بازگشت به لیست</RouterLink
>
<h1>{{ isNew ? 'فاکتور جدید' : number || 'فاکتور' }}</h1>
</div>
<button v-if="!isNew" class="btn btn-ghost" type="button" @click="printInvoice">
چاپ
</button>
</header>
<p v-if="loading" class="muted">در حال بارگذاری…</p>
<form v-else class="form" @submit.prevent="save">
<div class="print-banner">
<strong>منتو</strong>
<span v-if="number">{{ number }}</span>
</div>
<div v-if="invoice && !isNew" class="summary no-print">
<span>جمع: {{ formatMoney(invoice.totalAmount) }}</span>
<span>وصول: {{ formatMoney(invoice.paidAmount) }}</span>
<span>مانده: {{ formatMoney(invoice.remainingAmount) }}</span>
</div>
<div class="grid">
<div class="field wide">
<label>عنوان</label>
<input v-model="title" required maxlength="200" />
</div>
<div class="field">
<label>وضعیت</label>
<select v-model="status">
<option
v-for="(label, key) in statusLabel"
:key="key"
:value="key"
>
{{ label }}
</option>
</select>
</div>
<div class="field">
<label>سررسید</label>
<input v-model="dueDate" type="date" />
</div>
<div class="field">
<label>ارز</label>
<select v-model="currency">
<option v-for="code in enabledCurrencies" :key="code" :value="code">
{{ code }}
</option>
</select>
</div>
<div class="field">
<label>شخص</label>
<select v-model="personId">
<option value="">بدون شخص</option>
<option v-for="p in people" :key="p.id" :value="p.id">
{{ p.fullName }}
</option>
</select>
</div>
<div class="field">
<label>شرکت</label>
<select v-model="companyId">
<option value="">بدون شرکت</option>
<option v-for="c in companies" :key="c.id" :value="c.id">
{{ c.name }}
</option>
</select>
</div>
<div class="field wide">
<label>یادداشت</label>
<textarea v-model="notes" rows="3" />
</div>
</div>
<div class="lines-block">
<div class="lines-head">
<h3>ردیف‌های فاکتور</h3>
<button class="btn btn-ghost no-print" type="button" @click="addLine">
افزودن ردیف
</button>
</div>
<div v-for="line in lines" :key="line.key" class="line-row">
<select
v-model="line.productId"
class="no-print"
@change="onPickProduct(line)"
>
<option value="">محصول از کاتالوگ…</option>
<option v-for="p in products" :key="p.id" :value="p.id">
{{ p.name }}
</option>
</select>
<input v-model="line.name" placeholder="نام ردیف" required />
<input
v-model="line.quantity"
type="number"
min="0.01"
step="0.01"
placeholder="تعداد"
/>
<input
v-model="line.unitPrice"
type="number"
min="0"
step="1"
placeholder="قیمت واحد"
/>
<button
class="btn btn-ghost no-print"
type="button"
@click="removeLine(line.key)"
>
حذف
</button>
</div>
<p class="total"><strong>جمع:</strong> {{ formatMoney(totalAmount) }}</p>
</div>
<div class="lines-block">
<div class="lines-head">
<h3>اقساط</h3>
<div class="split no-print">
<button class="btn btn-ghost" type="button" @click="splitInstallments(2)">
۲ قسط
</button>
<button class="btn btn-ghost" type="button" @click="splitInstallments(3)">
۳ قسط
</button>
<button class="btn btn-ghost" type="button" @click="addInstallment">
افزودن قسط
</button>
</div>
</div>
<div
v-for="item in installments"
:key="item.key"
class="installment-row"
>
<input v-model="item.dueDate" type="date" />
<input
v-model="item.amount"
type="number"
min="0.01"
step="1"
placeholder="مبلغ قسط"
/>
<button
class="btn btn-ghost no-print"
type="button"
@click="removeInstallment(item.key)"
>
حذف
</button>
</div>
<p v-if="!installments.length" class="muted">بدون برنامه اقساط.</p>
</div>
<p v-if="error" class="error">{{ error }}</p>
<div class="form-actions no-print">
<button class="btn" type="submit" :disabled="saving">
{{ saving ? 'در حال ذخیره…' : 'ذخیره' }}
</button>
<button
v-if="!isNew"
class="btn btn-danger"
type="button"
:disabled="saving"
@click="remove"
>
حذف
</button>
</div>
</form>
<section v-if="!isNew && invoice && !loading" class="pay-block no-print">
<h3>ثبت پرداخت</h3>
<div class="pay-form">
<input v-model="payAmount" type="number" min="0.01" placeholder="مبلغ" />
<select v-model="payMethod">
<option
v-for="(label, key) in methodLabel"
:key="key"
:value="key"
>
{{ label }}
</option>
</select>
<input v-model="payDate" type="date" />
<select v-model="payInstallmentId">
<option value="">بدون اتصال به قسط</option>
<option
v-for="inst in pendingInstallments"
:key="inst.id"
:value="inst.id"
>
قسط {{ inst.position + 1 }} — {{ formatMoney(inst.amount) }}
</option>
</select>
<input v-model="payNotes" placeholder="یادداشت پرداخت" />
<button
class="btn"
type="button"
:disabled="paying"
@click="submitPayment"
>
{{ paying ? '…' : 'ثبت پرداخت' }}
</button>
</div>
<div v-if="invoice.payments.length" class="pay-list">
<div
v-for="p in invoice.payments"
:key="p.id"
class="pay-row"
>
<div>
<strong>{{ formatMoney(p.amount) }}</strong>
<span class="muted">
· {{ methodLabel[p.method as PaymentMethod] || p.method }}
· {{ p.paidAt.slice(0, 10) }}
</span>
</div>
<button
class="btn btn-ghost"
type="button"
@click="removePayment(p.id)"
>
حذف
</button>
</div>
</div>
<p v-else class="muted">هنوز پرداختی ثبت نشده.</p>
</section>
</section>
</template>
<style scoped>
.page {
display: grid;
gap: 1.1rem;
max-width: 860px;
}
.page-head {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
}
.back {
color: var(--muted);
text-decoration: none;
font-size: 0.9rem;
}
h1 {
margin: 0.25rem 0 0;
}
.form,
.lines-block,
.pay-block {
display: grid;
gap: 0.85rem;
}
.summary {
display: flex;
flex-wrap: wrap;
gap: 1rem;
padding: 0.75rem 1rem;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
font-weight: 600;
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
.field {
display: grid;
gap: 0.35rem;
}
.field.wide {
grid-column: 1 / -1;
}
label {
color: var(--muted);
font-size: 0.9rem;
}
input,
select,
textarea {
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.7rem 0.85rem;
font: inherit;
background: #fff;
width: 100%;
}
.lines-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.lines-head h3,
.pay-block h3 {
margin: 0;
font-size: 1rem;
}
.split {
display: flex;
gap: 0.35rem;
flex-wrap: wrap;
}
.line-row {
display: grid;
grid-template-columns: 1.2fr 1.4fr 0.7fr 0.9fr auto;
gap: 0.45rem;
}
.installment-row {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: 0.45rem;
}
.total {
margin: 0;
text-align: start;
font-size: 1.05rem;
}
.form-actions {
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
}
.error {
color: #b42318;
}
.muted {
color: var(--muted);
}
.pay-form {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.5rem;
}
.pay-list {
display: grid;
gap: 0.45rem;
}
.pay-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
padding: 0.65rem 0.8rem;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--surface);
}
.print-banner {
display: none;
}
@media (max-width: 700px) {
.grid,
.line-row,
.installment-row,
.pay-form {
grid-template-columns: 1fr;
}
}
@media print {
.no-print {
display: none !important;
}
.print-banner {
display: flex;
justify-content: space-between;
border-bottom: 1px solid #ccc;
padding-bottom: 0.5rem;
margin-bottom: 0.5rem;
}
.page {
max-width: none;
}
input,
select,
textarea {
border: 0;
padding: 0.2rem 0;
}
}
</style>
+245
View File
@@ -0,0 +1,245 @@
<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 { formatMoney } from '@/lib/money';
import type { InvoiceDto, InvoiceStatus, PaginatedResponse } from '@mento/shared';
const route = useRoute();
const router = useRouter();
const loading = ref(false);
const error = ref('');
const data = ref<PaginatedResponse<InvoiceDto> | null>(null);
const q = ref(typeof route.query.q === 'string' ? route.query.q : '');
const status = ref<InvoiceStatus | 'all'>(
typeof route.query.status === 'string' &&
['draft', 'issued', 'partially_paid', 'paid', 'canceled'].includes(
route.query.status,
)
? (route.query.status as InvoiceStatus)
: 'all',
);
const statusLabel: Record<InvoiceStatus, string> = {
draft: 'پیش‌نویس',
issued: 'صادرشده',
partially_paid: 'پرداخت جزئی',
paid: 'تسویه‌شده',
canceled: 'لغوشده',
};
const items = computed(() => data.value?.items ?? []);
async function load() {
loading.value = true;
error.value = '';
try {
const { data: res } = await api.get<PaginatedResponse<InvoiceDto>>(
'/invoices',
{
params: {
q: q.value || undefined,
status: status.value === 'all' ? undefined : status.value,
dealId:
typeof route.query.dealId === 'string'
? route.query.dealId
: 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 onSearch() {
router.replace({
name: 'invoices',
query: {
...route.query,
q: q.value.trim() || undefined,
status: status.value === 'all' ? undefined : status.value,
},
});
}
watch(
() => [route.query.q, route.query.status, route.query.dealId],
() => {
q.value = typeof route.query.q === 'string' ? route.query.q : '';
status.value =
typeof route.query.status === 'string' &&
['draft', 'issued', 'partially_paid', 'paid', 'canceled'].includes(
route.query.status,
)
? (route.query.status as InvoiceStatus)
: 'all';
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>
<RouterLink class="btn" :to="{ name: 'invoice-new', query: route.query }"
>فاکتور جدید</RouterLink
>
</header>
<form class="toolbar" @submit.prevent="onSearch">
<input v-model="q" type="search" placeholder="جستجو در عنوان یا شماره…" />
<select v-model="status" @change="onSearch">
<option value="all">همه وضعیت‌ها</option>
<option v-for="(label, key) in statusLabel" :key="key" :value="key">
{{ label }}
</option>
</select>
<button class="btn btn-ghost" type="submit">جستجو</button>
</form>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
<div v-else class="list">
<RouterLink
v-for="invoice in items"
:key="invoice.id"
class="row"
:to="{ name: 'invoice-detail', params: { id: invoice.id } }"
>
<div>
<strong>{{ invoice.title }}</strong>
<p class="muted">
{{ invoice.number }}
· {{ statusLabel[invoice.status as InvoiceStatus] || invoice.status }}
<span v-if="invoice.person"> · {{ invoice.person.fullName }}</span>
<span v-if="invoice.deal"> · {{ invoice.deal.title }}</span>
</p>
</div>
<div class="amounts">
<strong>{{ formatMoney(invoice.totalAmount, invoice.currency) }}</strong>
<span v-if="invoice.paidAmount > 0" class="muted paid">
وصول: {{ formatMoney(invoice.paidAmount, invoice.currency) }}
</span>
</div>
</RouterLink>
<p v-if="!items.length" class="empty">هنوز فاکتوری نیست.</p>
</div>
</section>
</template>
<style scoped>
.page {
display: grid;
gap: 1.1rem;
}
.page-head {
display: flex;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
align-items: flex-start;
}
.eyebrow {
margin: 0;
color: var(--accent);
font-weight: 700;
}
h1 {
margin: 0.2rem 0;
}
.lead,
.muted {
color: var(--muted);
}
.toolbar {
display: flex;
gap: 0.55rem;
flex-wrap: wrap;
}
.toolbar input,
.toolbar select {
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.7rem 0.85rem;
font: inherit;
background: #fff;
min-width: 0;
flex: 1;
}
.list {
display: grid;
gap: 0.55rem;
}
.row {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: center;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0.9rem 1rem;
text-decoration: none;
color: inherit;
}
.row p {
margin: 0.25rem 0 0;
}
.amounts {
display: grid;
gap: 0.2rem;
text-align: end;
white-space: nowrap;
}
.paid {
font-size: 0.85rem;
}
.empty,
.error {
margin: 0;
}
.error {
color: #b42318;
}
.empty {
padding: 1rem;
border: 1px dashed var(--line);
border-radius: var(--radius);
color: var(--muted);
}
</style>
+197
View File
@@ -0,0 +1,197 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { useRoute } from 'vue-router';
import axios from 'axios';
import type { PublicLeadFormDto, SubmitLeadFormResponse } from '@mento/shared';
const route = useRoute();
const loading = ref(true);
const saving = ref(false);
const error = ref('');
const done = ref(false);
const thankYou = ref('');
const form = ref<PublicLeadFormDto | null>(null);
const values = reactive<Record<string, string>>({});
const token = String(route.params.token || '');
async function load() {
loading.value = true;
error.value = '';
try {
const { data } = await axios.get<PublicLeadFormDto>(`/api/public/forms/${token}`);
form.value = data;
for (const field of data.fields) {
values[field.key] = values[field.key] ?? '';
}
} catch {
error.value = 'این فرم پیدا نشد یا غیرفعال است.';
form.value = null;
} finally {
loading.value = false;
}
}
async function submit() {
if (!form.value) return;
saving.value = true;
error.value = '';
try {
const { data } = await axios.post<SubmitLeadFormResponse>(
`/api/public/forms/${token}/submit`,
{ values: { ...values } },
);
thankYou.value = data.thankYouMessage || 'ثبت شد. متشکریم.';
done.value = true;
if (data.redirectUrl) {
window.location.assign(data.redirectUrl);
}
} 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 {
saving.value = false;
}
}
onMounted(load);
</script>
<template>
<main class="shell">
<div class="panel">
<header>
<p class="brand">منتو</p>
<h1 v-if="form">{{ form.name }}</h1>
<h1 v-else>فرم سرنخ</h1>
</header>
<p v-if="loading" class="muted">در حال بارگذاری…</p>
<p v-else-if="error && !form" class="error">{{ error }}</p>
<div v-else-if="done" class="success">
<strong>ثبت شد</strong>
<p>{{ thankYou }}</p>
</div>
<form v-else-if="form" class="fields" @submit.prevent="submit">
<label v-for="field in form.fields" :key="field.key">
{{ field.label }}
<span v-if="field.required" class="req">*</span>
<textarea
v-if="field.fieldType === 'textarea'"
v-model="values[field.key]"
rows="4"
:required="field.required"
/>
<input
v-else
v-model="values[field.key]"
:type="field.fieldType === 'email' ? 'email' : field.fieldType === 'phone' ? 'tel' : 'text'"
:required="field.required"
/>
</label>
<p v-if="error" class="error">{{ error }}</p>
<button class="btn" type="submit" :disabled="saving">
{{ saving ? 'در حال ارسال…' : 'ارسال' }}
</button>
</form>
</div>
</main>
</template>
<style scoped>
.shell {
min-height: 100vh;
display: grid;
place-items: center;
padding: 1.5rem;
background:
radial-gradient(circle at top left, rgba(15, 107, 92, 0.12), transparent 30%),
linear-gradient(180deg, #f7faf8 0%, #eef2f0 100%);
}
.panel {
width: min(520px, 100%);
background: #fff;
border: 1px solid #d5ddd8;
border-radius: 18px;
box-shadow: 0 16px 40px rgba(23, 33, 28, 0.08);
padding: 1.35rem;
display: grid;
gap: 1rem;
}
.brand {
margin: 0;
color: #0f6b5c;
font-weight: 700;
}
h1 {
margin: 0.25rem 0 0;
font-size: 1.45rem;
}
.fields {
display: grid;
gap: 0.85rem;
}
label {
display: grid;
gap: 0.35rem;
color: #5b6b63;
font-size: 0.92rem;
}
.req {
color: #b42318;
}
input,
textarea {
border: 1px solid #d5ddd8;
border-radius: 12px;
padding: 0.75rem 0.9rem;
font: inherit;
width: 100%;
}
.btn {
border: 0;
background: #0f6b5c;
color: #fff;
border-radius: 12px;
padding: 0.85rem 1rem;
font: inherit;
font-weight: 700;
cursor: pointer;
}
.muted {
color: #5b6b63;
}
.error {
color: #b42318;
margin: 0;
}
.success {
display: grid;
gap: 0.4rem;
background: #e7f6ef;
border-radius: 12px;
padding: 1rem;
}
.success p {
margin: 0;
color: #5b6b63;
}
</style>
@@ -0,0 +1,384 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import axios from 'axios';
import { api } from '@/lib/api';
import type {
CreateLeadFormRequest,
LeadFormDto,
OrgUserDto,
PipelineDto,
} from '@mento/shared';
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const forms = ref<LeadFormDto[]>([]);
const users = ref<OrgUserDto[]>([]);
const pipelines = ref<PipelineDto[]>([]);
const name = ref('فرم تماس سایت');
const source = ref('وب‌سایت');
const pipelineId = ref('');
const thankYouMessage = ref('پیام شما ثبت شد. به‌زودی با شما تماس می‌گیریم.');
const assigneeIds = ref<string[]>([]);
const origin = computed(() => window.location.origin);
async function load() {
loading.value = true;
error.value = '';
try {
const [formsRes, usersRes, pipesRes] = await Promise.all([
api.get<LeadFormDto[]>('/lead-forms'),
api.get<OrgUserDto[]>('/users'),
api.get<PipelineDto[]>('/pipelines'),
]);
forms.value = formsRes.data;
users.value = usersRes.data;
pipelines.value = pipesRes.data;
if (!pipelineId.value && pipesRes.data[0]) {
pipelineId.value = pipesRes.data.find((p) => p.isDefault)?.id ?? pipesRes.data[0].id;
}
} catch (e) {
if (axios.isAxiosError(e) && !e.response) {
error.value = 'اتصال به API برقرار نشد.';
} else {
error.value = 'بارگذاری فرم‌ها ناموفق بود.';
}
} finally {
loading.value = false;
}
}
async function createForm() {
if (!name.value.trim()) {
error.value = 'نام فرم را وارد کنید.';
return;
}
saving.value = true;
error.value = '';
try {
const payload: CreateLeadFormRequest = {
name: name.value.trim(),
source: source.value.trim() || 'فرم وب',
pipelineId: pipelineId.value || null,
thankYouMessage: thankYouMessage.value.trim() || null,
assigneeIds: assigneeIds.value,
createPerson: true,
createDeal: true,
};
await api.post('/lead-forms', payload);
name.value = 'فرم تماس سایت';
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 {
saving.value = false;
}
}
async function toggleActive(form: LeadFormDto) {
await api.patch(`/lead-forms/${form.id}`, { isActive: !form.isActive });
await load();
}
async function removeForm(form: LeadFormDto) {
if (!confirm(`فرم «${form.name}» حذف شود؟`)) return;
await api.delete(`/lead-forms/${form.id}`);
await load();
}
function publicUrl(form: LeadFormDto) {
return `${origin.value}${form.publicPath}`;
}
function iframeSnippet(form: LeadFormDto) {
return `<iframe src="${publicUrl(form)}" width="100%" height="640" style="border:0;border-radius:12px" title="${form.name}"></iframe>`;
}
function qrUrl(form: LeadFormDto) {
return `https://api.qrserver.com/v1/create-qr-code/?size=160x160&data=${encodeURIComponent(publicUrl(form))}`;
}
async function copyText(text: string) {
await navigator.clipboard.writeText(text);
}
function toggleAssignee(id: string) {
if (assigneeIds.value.includes(id)) {
assigneeIds.value = assigneeIds.value.filter((x) => x !== id);
} else {
assigneeIds.value = [...assigneeIds.value, id];
}
}
onMounted(load);
</script>
<template>
<section class="page">
<header>
<p class="eyebrow">تنظیمات</p>
<h1>فرم‌ساز سرنخ</h1>
<p class="lead">
فرم وب بسازید، لینک یا iframe بگیرید؛ ارسال‌ها به‌صورت شخص و معامله در کاریز ثبت می‌شوند.
</p>
</header>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
<form class="create" @submit.prevent="createForm">
<h2>فرم جدید</h2>
<div class="grid">
<label>
نام فرم
<input v-model="name" required maxlength="120" />
</label>
<label>
منبع بازاریابی
<input v-model="source" maxlength="80" placeholder="وب‌سایت / لندینگ" />
</label>
<label>
کاریز مقصد
<select v-model="pipelineId">
<option v-for="p in pipelines" :key="p.id" :value="p.id">
{{ p.name }}{{ p.isDefault ? ' (پیش‌فرض)' : '' }}
</option>
</select>
</label>
<label class="full">
پیام تشکر
<input v-model="thankYouMessage" maxlength="500" />
</label>
</div>
<div class="assignees">
<p class="muted">توزیع نوبتی بین مسئولان (اختیاری)</p>
<div class="chips">
<button
v-for="u in users"
:key="u.id"
type="button"
class="chip"
:class="{ on: assigneeIds.includes(u.id) }"
@click="toggleAssignee(u.id)"
>
{{ u.fullName }}
</button>
</div>
</div>
<button class="btn" type="submit" :disabled="saving">
{{ saving ? 'در حال ساخت…' : 'ساخت فرم با فیلدهای پیش‌فرض' }}
</button>
</form>
<div class="list">
<article v-for="form in forms" :key="form.id" class="card">
<header class="card-head">
<div>
<strong>{{ form.name }}</strong>
<p class="muted">
{{ form.isActive ? 'فعال' : 'غیرفعال' }}
· منبع: {{ form.source || '—' }}
· {{ form.fields.length }} فیلد
</p>
</div>
<div class="actions">
<button type="button" @click="toggleActive(form)">
{{ form.isActive ? 'غیرفعال' : 'فعال' }}
</button>
<button type="button" class="danger" @click="removeForm(form)">حذف</button>
</div>
</header>
<div class="share">
<label>
لینک عمومی
<div class="row">
<input :value="publicUrl(form)" readonly />
<button type="button" class="btn btn-ghost" @click="copyText(publicUrl(form))">کپی</button>
<a class="btn btn-ghost" :href="form.publicPath" target="_blank" rel="noopener">بازکردن</a>
</div>
</label>
<label>
کد iframe
<div class="row">
<input :value="iframeSnippet(form)" readonly />
<button type="button" class="btn btn-ghost" @click="copyText(iframeSnippet(form))">کپی</button>
</div>
</label>
<div class="qr">
<img :src="qrUrl(form)" :alt="`QR ${form.name}`" width="120" height="120" />
<p class="muted">QR برای اشتراک‌گذاری سریع</p>
</div>
</div>
</article>
<p v-if="!loading && !forms.length" class="muted empty">هنوز فرمی نساخته‌اید.</p>
</div>
</section>
</template>
<style scoped>
.page {
display: grid;
gap: 1.2rem;
max-width: 900px;
}
.eyebrow {
margin: 0;
color: var(--accent);
font-weight: 700;
}
h1 {
margin: 0.2rem 0;
}
.lead,
.muted {
color: var(--muted);
}
.error {
color: #b42318;
}
.create,
.card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 1rem;
display: grid;
gap: 0.85rem;
}
.create h2 {
margin: 0;
font-size: 1rem;
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
.full {
grid-column: 1 / -1;
}
label {
display: grid;
gap: 0.35rem;
font-size: 0.9rem;
color: var(--muted);
}
input,
select {
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.7rem 0.85rem;
font: inherit;
background: #fff;
color: var(--text, var(--ink));
width: 100%;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.chip {
border: 1px solid var(--line);
background: #fff;
border-radius: 999px;
padding: 0.35rem 0.75rem;
font: inherit;
cursor: pointer;
}
.chip.on {
background: var(--accent-soft);
border-color: transparent;
color: var(--accent);
font-weight: 700;
}
.list {
display: grid;
gap: 0.85rem;
}
.card-head,
.actions,
.row {
display: flex;
gap: 0.55rem;
align-items: center;
flex-wrap: wrap;
}
.card-head {
justify-content: space-between;
}
.card-head p {
margin: 0.25rem 0 0;
}
.actions button {
border: 0;
background: transparent;
color: var(--muted);
cursor: pointer;
font: inherit;
}
.actions .danger {
color: #b42318;
}
.share {
display: grid;
gap: 0.75rem;
}
.row input {
flex: 1;
min-width: 0;
}
.qr {
display: flex;
gap: 0.85rem;
align-items: center;
}
.qr img {
border-radius: 12px;
border: 1px solid var(--line);
background: #fff;
}
.empty {
padding: 1rem;
border: 1px dashed var(--line);
border-radius: var(--radius);
}
@media (max-width: 700px) {
.grid {
grid-template-columns: 1fr;
}
}
</style>
+117
View File
@@ -0,0 +1,117 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter, useRoute, RouterLink } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
import axios from 'axios';
const auth = useAuthStore();
const router = useRouter();
const route = useRoute();
const email = ref('owner@demo.local');
const password = ref('demo1234');
const error = ref('');
const loading = ref(false);
async function submit() {
error.value = '';
loading.value = true;
try {
await auth.login(email.value, password.value);
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/';
await router.push(redirect);
} catch (e) {
if (axios.isAxiosError(e)) {
if (!e.response) {
error.value = 'اتصال به سرور برقرار نشد. مطمئن شوید API روی پورت ۳۰۰۰ در حال اجراست.';
} else {
const msg = (e.response.data as { message?: string | string[] })?.message;
error.value = Array.isArray(msg) ? msg.join('، ') : (msg ?? 'ورود ناموفق بود');
}
} else {
error.value = 'ورود ناموفق بود';
}
} finally {
loading.value = false;
}
}
</script>
<template>
<div class="auth">
<section class="panel">
<p class="eyebrow">منتو CRM</p>
<h1>ورود به پنل</h1>
<p class="lead">هم‌ترازی قابلیت با دیدار، با تجربه و هویت مستقل منتو (Mento).</p>
<form class="form" @submit.prevent="submit">
<div class="field">
<label for="email">ایمیل</label>
<input id="email" v-model="email" type="email" required autocomplete="username" />
</div>
<div class="field">
<label for="password">رمز عبور</label>
<input
id="password"
v-model="password"
type="password"
required
autocomplete="current-password"
/>
</div>
<p v-if="error" class="error">{{ error }}</p>
<button class="btn" type="submit" :disabled="loading">
{{ loading ? 'در حال ورود…' : 'ورود' }}
</button>
</form>
<p class="switch">
سازمان ندارید؟
<RouterLink to="/register">ثبت‌نام سازمان</RouterLink>
</p>
</section>
</div>
</template>
<style scoped>
.auth {
min-height: 100vh;
min-height: 100dvh;
display: grid;
place-items: center;
padding: 1rem;
padding-bottom: max(1rem, env(safe-area-inset-bottom));
}
.panel {
width: min(440px, 100%);
background: var(--surface);
border: 1px solid var(--line);
border-radius: calc(var(--radius) + 4px);
box-shadow: var(--shadow);
padding: clamp(1.1rem, 4vw, 1.6rem);
}
.eyebrow {
margin: 0;
color: var(--accent);
font-weight: 700;
letter-spacing: 0.04em;
}
h1 {
margin: 0.35rem 0 0.4rem;
font-size: 1.8rem;
}
.lead,
.switch {
color: var(--muted);
}
.form {
display: grid;
gap: 0.9rem;
margin: 1.4rem 0 1rem;
}
</style>
+889
View File
@@ -0,0 +1,889 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import type {
DealDto,
NoteAttachmentDto,
NoteDto,
NoteType,
PaginatedResponse,
PersonDto,
PipelineBoardDto,
} from '@mento/shared';
const router = useRouter();
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const q = ref('');
const typeFilter = ref<NoteType | 'all'>('all');
const notes = ref<NoteDto[]>([]);
const people = ref<PersonDto[]>([]);
const deals = ref<DealDto[]>([]);
const draftBody = ref('');
const draftTitle = ref('');
const draftType = ref<NoteType>('general');
const draftTags = ref('');
const draftPersonId = ref('');
const draftDealId = ref('');
const draftReminder = ref('');
const draftFiles = ref<File[]>([]);
const showExtras = ref(false);
const editingId = ref<string | null>(null);
const fileInput = ref<HTMLInputElement | null>(null);
const noteTypeOptions: Array<{ value: NoteType; label: string }> = [
{ value: 'general', label: 'معمولی' },
{ value: 'call_result', label: 'نتیجه تماس' },
{ value: 'meeting', label: 'جلسه' },
{ value: 'important', label: 'نکته مهم' },
{ value: 'warning', label: 'هشدار' },
{ value: 'next_action', label: 'کار بعدی' },
];
const typeLabel = Object.fromEntries(
noteTypeOptions.map((o) => [o.value, o.label]),
) as Record<string, string>;
const pinnedNotes = computed(() => notes.value.filter((n) => n.pinned));
const otherNotes = computed(() => notes.value.filter((n) => !n.pinned));
const colors = ['default', 'mint', 'sand', 'sky', 'rose'] as const;
async function load() {
loading.value = true;
error.value = '';
try {
const [notesRes, peopleRes, boardRes] = await Promise.all([
api.get<PaginatedResponse<NoteDto>>('/notes', {
params: {
q: q.value || undefined,
noteType: typeFilter.value === 'all' ? undefined : typeFilter.value,
limit: 100,
},
}),
api.get<PaginatedResponse<PersonDto>>('/people', { params: { limit: 100 } }),
api.get<PipelineBoardDto>('/pipelines/board'),
]);
notes.value = notesRes.data.items;
people.value = peopleRes.data.items;
deals.value = boardRes.data.stages.flatMap((s) => s.deals);
} catch (e) {
if (axios.isAxiosError(e) && !e.response) {
error.value = 'اتصال به API برقرار نشد.';
} else {
error.value = 'بارگذاری یادداشت‌ها ناموفق بود.';
}
} finally {
loading.value = false;
}
}
function parseTags(raw: string) {
return raw
.split(/[,\s#]+/)
.map((t) => t.trim())
.filter(Boolean);
}
function resetDraft() {
draftBody.value = '';
draftTitle.value = '';
draftType.value = 'general';
draftTags.value = '';
draftPersonId.value = '';
draftDealId.value = '';
draftReminder.value = '';
draftFiles.value = [];
showExtras.value = false;
editingId.value = null;
if (fileInput.value) fileInput.value.value = '';
}
function onDraftFilesSelected(event: Event) {
const input = event.target as HTMLInputElement;
const picked = Array.from(input.files ?? []);
if (!picked.length) return;
draftFiles.value = [...draftFiles.value, ...picked].slice(0, 8);
input.value = '';
}
function removeDraftFile(index: number) {
draftFiles.value = draftFiles.value.filter((_, i) => i !== index);
}
function formatSize(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
async function uploadFiles(noteId: string, files: File[]) {
for (const file of files) {
const form = new FormData();
form.append('file', file);
await api.post(`/notes/${noteId}/attachments`, form);
}
}
async function downloadAttachment(noteId: string, att: NoteAttachmentDto) {
const { data } = await api.get<Blob>(
`/notes/${noteId}/attachments/${att.id}/download`,
{ responseType: 'blob' },
);
const url = URL.createObjectURL(data);
const a = document.createElement('a');
a.href = url;
a.download = att.fileName;
a.click();
URL.revokeObjectURL(url);
}
async function removeAttachment(noteId: string, attachmentId: string) {
if (!confirm('این پیوست حذف شود؟')) return;
await api.delete(`/notes/${noteId}/attachments/${attachmentId}`);
await load();
}
async function onCardFilesSelected(noteId: string, event: Event) {
const input = event.target as HTMLInputElement;
const files = Array.from(input.files ?? []);
input.value = '';
if (!files.length) return;
try {
await uploadFiles(noteId, files);
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 = 'آپلود پیوست ناموفق بود';
}
}
}
function startEdit(note: NoteDto) {
editingId.value = note.id;
draftTitle.value = note.title ?? '';
draftBody.value = note.body;
draftType.value = (note.noteType as NoteType) || 'general';
draftTags.value = note.tags.join(' ');
draftPersonId.value = note.person?.id ?? '';
draftDealId.value = note.deal?.id ?? '';
draftReminder.value = note.reminderAt
? note.reminderAt.slice(0, 16)
: '';
showExtras.value = true;
window.scrollTo({ top: 0, behavior: 'smooth' });
}
async function saveNote() {
if (!draftBody.value.trim()) return;
saving.value = true;
error.value = '';
try {
const payload = {
title: draftTitle.value || undefined,
body: draftBody.value,
noteType: draftType.value,
tags: parseTags(draftTags.value),
personId: draftPersonId.value || null,
dealId: draftDealId.value || null,
reminderAt: draftReminder.value
? new Date(draftReminder.value).toISOString()
: null,
};
const pending = [...draftFiles.value];
let noteId = editingId.value;
if (editingId.value) {
await api.patch(`/notes/${editingId.value}`, payload);
} else {
const { data } = await api.post<NoteDto>('/notes', payload);
noteId = data.id;
}
if (noteId && pending.length) {
await uploadFiles(noteId, pending);
}
resetDraft();
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 {
saving.value = false;
}
}
async function togglePin(id: string) {
await api.patch(`/notes/${id}/pin`);
await load();
}
async function removeNote(id: string) {
if (!confirm('این یادداشت حذف شود؟')) return;
await api.delete(`/notes/${id}`);
if (editingId.value === id) resetDraft();
await load();
}
async function convertToTask(id: string) {
const { data } = await api.post<{ activityId: string }>(`/notes/${id}/convert-to-task`);
await router.push({ name: 'activity-detail', params: { id: data.activityId } });
}
async function setColor(id: string, color: string) {
await api.patch(`/notes/${id}`, { color });
await load();
}
function formatWhen(value: string) {
try {
return new Intl.DateTimeFormat('fa-IR', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(value));
} catch {
return value;
}
}
onMounted(load);
</script>
<template>
<section class="page">
<header class="page-head">
<div>
<p class="eyebrow">Smart Notes</p>
<h1>یادداشت‌ها</h1>
<p class="lead">مثل Keep بنویسید؛ به شخص یا معامله وصل کنید. AI و حافظه مشتری در گام‌های بعد می‌آید.</p>
</div>
<form class="search" @submit.prevent="load">
<input v-model="q" type="search" placeholder="جستجو در متن و برچسب…" />
<button class="btn btn-ghost" type="submit">جستجو</button>
</form>
</header>
<div class="type-tabs">
<button
type="button"
:class="{ active: typeFilter === 'all' }"
@click="typeFilter = 'all'; load()"
>
همه
</button>
<button
v-for="opt in noteTypeOptions"
:key="opt.value"
type="button"
:class="{ active: typeFilter === opt.value }"
@click="typeFilter = opt.value; load()"
>
{{ opt.label }}
</button>
</div>
<form class="composer" @submit.prevent="saveNote">
<div class="type-row">
<label for="note-type">نوع</label>
<select id="note-type" v-model="draftType">
<option v-for="opt in noteTypeOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
</div>
<input
v-model="draftTitle"
class="title-input"
type="text"
placeholder="عنوان (اختیاری)"
/>
<textarea
v-model="draftBody"
rows="3"
required
placeholder="هر چیزی که الان به ذهنتان رسید بنویسید…"
/>
<div class="composer-actions">
<div class="left-actions">
<button class="linkish" type="button" @click="showExtras = !showExtras">
{{ showExtras ? 'بستن جزئیات' : 'شخص / معامله / یادآوری / برچسب' }}
</button>
<button class="linkish" type="button" @click="fileInput?.click()">
پیوست فایل
</button>
<input
ref="fileInput"
type="file"
class="sr-only"
multiple
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt,.csv,.zip"
@change="onDraftFilesSelected"
/>
</div>
<div class="right">
<button v-if="editingId" class="btn btn-ghost" type="button" @click="resetDraft">انصراف</button>
<button class="btn" type="submit" :disabled="saving || !draftBody.trim()">
{{ saving ? 'در حال ثبت…' : editingId ? 'ذخیره تغییرات' : 'ثبت یادداشت' }}
</button>
</div>
</div>
<ul v-if="draftFiles.length" class="pending-files">
<li v-for="(file, idx) in draftFiles" :key="`${file.name}-${idx}`">
<span>{{ file.name }} · {{ formatSize(file.size) }}</span>
<button type="button" @click="removeDraftFile(idx)">حذف</button>
</li>
</ul>
<div v-if="showExtras" class="extras">
<div class="field">
<label>برچسب‌ها</label>
<input v-model="draftTags" placeholder="مثلاً مهم پیگیری قیمت" />
</div>
<div class="field">
<label>شخص</label>
<select v-model="draftPersonId">
<option value="">بدون شخص</option>
<option v-for="p in people" :key="p.id" :value="p.id">{{ p.fullName }}</option>
</select>
</div>
<div class="field">
<label>معامله</label>
<select v-model="draftDealId">
<option value="">بدون معامله</option>
<option v-for="d in deals" :key="d.id" :value="d.id">{{ d.title }}</option>
</select>
</div>
<div class="field">
<label>یادآوری</label>
<input v-model="draftReminder" type="datetime-local" />
</div>
</div>
</form>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
<template v-else>
<section v-if="pinnedNotes.length" class="block">
<h2>سنجاق‌شده</h2>
<div class="cards">
<article
v-for="note in pinnedNotes"
:key="note.id"
class="card"
:class="`tone-${note.color || 'default'}`"
>
<header>
<div class="card-head">
<span class="type-badge" :class="`type-${note.noteType || 'general'}`">
{{ typeLabel[note.noteType || 'general'] || 'معمولی' }}
</span>
<strong v-if="note.title">{{ note.title }}</strong>
</div>
<button type="button" title="برداشتن سنجاق" @click="togglePin(note.id)">📌</button>
</header>
<p class="body">{{ note.body }}</p>
<ul v-if="note.attachments?.length" class="attachments">
<li v-for="att in note.attachments" :key="att.id">
<button type="button" class="att-name" @click="downloadAttachment(note.id, att)">
{{ att.fileName }}
</button>
<span class="att-size">{{ formatSize(att.sizeBytes) }}</span>
<button type="button" class="att-remove" @click="removeAttachment(note.id, att.id)">حذف</button>
</li>
</ul>
<p v-if="note.tags.length" class="tags">
<span v-for="tag in note.tags" :key="tag">#{{ tag }}</span>
</p>
<p class="meta">
<span v-if="note.person">{{ note.person.fullName }}</span>
<span v-if="note.deal"> · {{ note.deal.title }}</span>
<span v-if="note.reminderAt"> · یادآوری {{ formatWhen(note.reminderAt) }}</span>
</p>
<footer>
<button type="button" @click="startEdit(note)">ویرایش</button>
<label class="attach-label">
پیوست
<input
type="file"
class="sr-only"
multiple
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt,.csv,.zip"
@change="onCardFilesSelected(note.id, $event)"
/>
</label>
<button type="button" @click="convertToTask(note.id)">تبدیل به وظیفه</button>
<button type="button" @click="removeNote(note.id)">حذف</button>
<span class="swatches">
<button
v-for="c in colors"
:key="c"
type="button"
class="swatch"
:class="`tone-${c}`"
@click="setColor(note.id, c)"
/>
</span>
</footer>
</article>
</div>
</section>
<section class="block">
<h2>همه یادداشت‌ها</h2>
<div v-if="otherNotes.length" class="cards">
<article
v-for="note in otherNotes"
:key="note.id"
class="card"
:class="`tone-${note.color || 'default'}`"
>
<header>
<div class="card-head">
<span class="type-badge" :class="`type-${note.noteType || 'general'}`">
{{ typeLabel[note.noteType || 'general'] || 'معمولی' }}
</span>
<strong v-if="note.title">{{ note.title }}</strong>
<strong v-else class="muted-title">بدون عنوان</strong>
</div>
<button type="button" title="سنجاق" @click="togglePin(note.id)">📍</button>
</header>
<p class="body">{{ note.body }}</p>
<ul v-if="note.attachments?.length" class="attachments">
<li v-for="att in note.attachments" :key="att.id">
<button type="button" class="att-name" @click="downloadAttachment(note.id, att)">
{{ att.fileName }}
</button>
<span class="att-size">{{ formatSize(att.sizeBytes) }}</span>
<button type="button" class="att-remove" @click="removeAttachment(note.id, att.id)">حذف</button>
</li>
</ul>
<p v-if="note.tags.length" class="tags">
<span v-for="tag in note.tags" :key="tag">#{{ tag }}</span>
</p>
<p class="meta">
<span v-if="note.person">{{ note.person.fullName }}</span>
<span v-if="note.deal"> · {{ note.deal.title }}</span>
<span v-if="note.reminderAt"> · یادآوری {{ formatWhen(note.reminderAt) }}</span>
</p>
<footer>
<button type="button" @click="startEdit(note)">ویرایش</button>
<label class="attach-label">
پیوست
<input
type="file"
class="sr-only"
multiple
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt,.csv,.zip"
@change="onCardFilesSelected(note.id, $event)"
/>
</label>
<button type="button" @click="convertToTask(note.id)">تبدیل به وظیفه</button>
<button type="button" @click="removeNote(note.id)">حذف</button>
<span class="swatches">
<button
v-for="c in colors"
:key="c"
type="button"
class="swatch"
:class="`tone-${c}`"
@click="setColor(note.id, c)"
/>
</span>
</footer>
</article>
</div>
<p v-else class="empty">هنوز یادداشتی نیست. از کادر بالا شروع کنید.</p>
</section>
</template>
</section>
</template>
<style scoped>
.page {
display: grid;
gap: 1.2rem;
}
.page-head {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
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 {
color: var(--muted);
}
.search {
display: flex;
gap: 0.5rem;
flex: 1 1 220px;
min-width: 0;
}
.search input {
min-width: 0;
flex: 1;
}
.type-tabs {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
max-width: 100%;
}
.type-tabs button {
border: 1px solid var(--line);
background: var(--surface);
color: var(--muted);
border-radius: 999px;
padding: 0.4rem 0.75rem;
cursor: pointer;
font: inherit;
font-size: 0.85rem;
white-space: nowrap;
}
.type-tabs button.active {
background: var(--accent-soft);
border-color: transparent;
color: var(--accent);
font-weight: 700;
}
.type-row {
display: flex;
align-items: center;
gap: 0.65rem;
min-width: 0;
}
.type-row label {
color: var(--muted);
font-size: 0.9rem;
flex: 0 0 auto;
}
.type-row select {
flex: 1;
min-width: 0;
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.65rem 0.85rem;
background: #fff;
font: inherit;
}
.card-head {
display: grid;
gap: 0.35rem;
min-width: 0;
}
.type-badge {
justify-self: start;
font-size: 0.72rem;
font-weight: 700;
border-radius: 999px;
padding: 0.12rem 0.5rem;
}
.type-general { background: #eef2f0; color: #4b5c54; }
.type-call_result { background: #e8f2fb; color: #1d4f91; }
.type-meeting { background: #efe9fb; color: #5b3d9a; }
.type-important { background: #e7f6ef; color: #0f6b5c; }
.type-warning { background: #fff4e5; color: #9a6700; }
.type-next_action { background: #fde8e8; color: #b42318; }
.search input,
.composer input,
.composer textarea,
.extras input,
.extras select {
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.75rem 0.9rem;
background: #fff;
font: inherit;
width: 100%;
}
.composer {
background: var(--surface);
border: 1px solid var(--line);
border-radius: calc(var(--radius) + 2px);
box-shadow: var(--shadow);
padding: 1rem;
display: grid;
gap: 0.7rem;
}
.title-input {
font-weight: 700;
}
.composer-actions,
.right,
.left-actions {
display: flex;
gap: 0.6rem;
align-items: center;
flex-wrap: wrap;
}
.composer-actions {
justify-content: space-between;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
.pending-files,
.attachments {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.35rem;
}
.pending-files li,
.attachments li {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
align-items: center;
font-size: 0.85rem;
background: rgba(15, 107, 92, 0.06);
border-radius: 10px;
padding: 0.35rem 0.55rem;
}
.pending-files button,
.att-remove {
border: 0;
background: transparent;
color: #b42318;
cursor: pointer;
font: inherit;
padding: 0;
}
.att-name {
border: 0;
background: transparent;
color: var(--accent);
cursor: pointer;
font: inherit;
padding: 0;
text-align: start;
text-decoration: underline;
text-underline-offset: 2px;
}
.att-size {
color: var(--muted);
margin-inline-start: auto;
}
.attach-label {
cursor: pointer;
color: var(--muted);
font: inherit;
}
.linkish {
border: 0;
background: transparent;
color: var(--accent);
cursor: pointer;
font: inherit;
}
.extras {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
.block h2 {
margin: 0 0 0.75rem;
font-size: 1rem;
color: var(--muted);
}
.cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 0.85rem;
}
.card {
border: 1px solid var(--line);
border-radius: 14px;
padding: 0.9rem;
display: grid;
gap: 0.55rem;
background: #fff;
}
.card header {
display: flex;
justify-content: space-between;
gap: 0.5rem;
align-items: start;
}
.card header button,
.card footer button {
border: 0;
background: transparent;
cursor: pointer;
color: var(--muted);
font: inherit;
padding: 0;
}
.card footer {
display: flex;
flex-wrap: wrap;
gap: 0.65rem;
align-items: center;
border-top: 1px solid color-mix(in srgb, var(--line) 70%, transparent);
padding-top: 0.55rem;
}
.body {
margin: 0;
white-space: pre-wrap;
line-height: 1.6;
}
.tags {
margin: 0;
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.tags span {
background: rgba(15, 107, 92, 0.08);
color: var(--accent);
border-radius: 999px;
padding: 0.15rem 0.5rem;
font-size: 0.8rem;
}
.meta {
margin: 0;
font-size: 0.85rem;
}
.muted-title {
color: var(--muted);
font-weight: 600;
}
.swatches {
margin-inline-start: auto;
display: inline-flex;
gap: 0.3rem;
}
.swatch {
width: 14px;
height: 14px;
border-radius: 50%;
border: 1px solid var(--line) !important;
padding: 0 !important;
}
.tone-default { background: #fff; }
.tone-mint { background: #e7f6ef; }
.tone-sand { background: #f7f1e4; }
.tone-sky { background: #e8f2fb; }
.tone-rose { background: #f9ecec; }
.empty {
margin: 0;
padding: 1.2rem;
border: 1px dashed var(--line);
border-radius: var(--radius);
background: var(--surface);
}
@media (max-width: 700px) {
h1 {
font-size: 1.45rem;
}
.page-head {
flex-direction: column;
align-items: stretch;
}
.search {
width: 100%;
flex: 0 0 auto;
}
.search input {
height: 2.75rem;
max-height: 2.75rem;
padding: 0 0.9rem;
}
.type-tabs {
flex-wrap: nowrap;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
padding-bottom: 0.15rem;
}
.extras {
grid-template-columns: 1fr;
}
.composer {
padding: 0.9rem;
}
.cards {
grid-template-columns: 1fr;
}
}
</style>
+515
View File
@@ -0,0 +1,515 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import type { CompanyDto, CreatePersonRequest, PersonDto } from '@mento/shared';
import CustomFieldsSection from '@/components/CustomFieldsSection.vue';
import EntityTimeline from '@/components/EntityTimeline.vue';
const route = useRoute();
const router = useRouter();
const id = computed(() => (typeof route.params.id === 'string' ? route.params.id : ''));
const isNew = computed(() => route.name === 'person-new');
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const companies = ref<CompanyDto[]>([]);
const firstName = ref('');
const lastName = ref('');
const email = ref('');
const phone = ref('');
const mobile = ref('');
const position = ref('');
const notes = ref('');
const companyId = ref('');
const customFields = ref<Record<string, unknown>>({});
function fill(p: PersonDto) {
firstName.value = p.firstName;
lastName.value = p.lastName;
email.value = p.email ?? '';
phone.value = p.phone ?? '';
mobile.value = p.mobile ?? '';
position.value = p.position ?? '';
notes.value = p.notes ?? '';
companyId.value = p.company?.id ?? '';
customFields.value = { ...(p.customFields ?? {}) };
}
async function load() {
loading.value = true;
error.value = '';
try {
const companiesRes = await api.get<{ items: CompanyDto[] }>('/companies', {
params: { limit: 100 },
});
companies.value = companiesRes.data.items;
if (!isNew.value && id.value) {
const { data } = await api.get<PersonDto>(`/people/${id.value}`);
fill(data);
}
} catch {
error.value = 'بارگذاری فرم ناموفق بود.';
} finally {
loading.value = false;
}
}
function payload(): CreatePersonRequest {
return {
firstName: firstName.value,
lastName: lastName.value,
email: email.value || undefined,
phone: phone.value || undefined,
mobile: mobile.value || undefined,
position: position.value || undefined,
notes: notes.value || undefined,
companyId: companyId.value || null,
customFields: customFields.value,
};
}
async function submit() {
saving.value = true;
error.value = '';
try {
if (isNew.value) {
const { data } = await api.post<PersonDto>('/people', payload());
await router.replace({ name: 'person-detail', params: { id: data.id } });
} else {
await api.patch(`/people/${id.value}`, payload());
await router.push({ name: 'contacts', query: { tab: undefined } });
}
} catch (e) {
if (axios.isAxiosError(e)) {
if (!e.response) {
error.value = 'اتصال به سرور برقرار نشد.';
} else {
const msg = (e.response.data as { message?: string | string[] })?.message;
error.value = Array.isArray(msg) ? msg.join('، ') : (msg ?? 'ذخیره ناموفق بود');
}
} else {
error.value = 'ذخیره ناموفق بود';
}
} finally {
saving.value = false;
}
}
async function remove() {
if (!id.value || !confirm('این شخص حذف شود؟')) return;
try {
await api.delete(`/people/${id.value}`);
await router.push({ name: 'contacts' });
} catch {
error.value = 'حذف ناموفق بود';
}
}
onMounted(load);
</script>
<template>
<section class="page">
<header class="page-head">
<div>
<p class="eyebrow">شخص</p>
<h1>{{ isNew ? 'شخص جدید' : 'ویرایش شخص' }}</h1>
</div>
<RouterLink class="btn btn-ghost" :to="{ name: 'contacts' }">بازگشت</RouterLink>
</header>
<p v-if="loading" class="muted">در حال بارگذاری…</p>
<form v-else class="form-card" @submit.prevent="submit">
<div class="grid">
<div class="field">
<label for="firstName">نام</label>
<input id="firstName" v-model="firstName" required />
</div>
<div class="field">
<label for="lastName">نام خانوادگی</label>
<input id="lastName" v-model="lastName" required />
</div>
<div class="field">
<label for="mobile">موبایل</label>
<input id="mobile" v-model="mobile" inputmode="tel" />
</div>
<div class="field">
<label for="phone">تلفن</label>
<input id="phone" v-model="phone" inputmode="tel" />
</div>
<div class="field">
<label for="email">ایمیل</label>
<input id="email" v-model="email" type="email" />
</div>
<div class="field">
<label for="position">سمت</label>
<input id="position" v-model="position" />
</div>
<div class="field wide">
<label for="companyId">شرکت</label>
<select id="companyId" v-model="companyId">
<option value="">بدون شرکت</option>
<option v-for="c in companies" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>
</div>
<div class="field wide">
<label for="notes">یادداشت</label>
<textarea id="notes" v-model="notes" rows="4" />
</div>
</div>
<CustomFieldsSection entity-type="person" v-model="customFields" />
<EntityTimeline
v-if="!isNew && id"
entity-type="person"
:entity-id="id"
/>
<p v-if="error" class="error">{{ error }}</p>
<div class="form-actions">
<button class="btn" type="submit" :disabled="saving">
{{ saving ? 'در حال ذخیره…' : 'ذخیره' }}
</button>
<button
v-if="!isNew"
class="btn btn-danger"
type="button"
:disabled="saving"
@click="remove"
>
حذف
</button>
</div>
</form>
</section>
</template>
<style scoped>
.page {
display: grid;
gap: 1.1rem;
max-width: 820px;
}
.page-head {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
}
.eyebrow {
margin: 0;
color: var(--accent);
font-weight: 700;
}
h1 {
margin: 0.2rem 0 0;
font-size: 1.7rem;
}
.muted {
color: var(--muted);
}
.form-card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 1.2rem;
display: grid;
gap: 1rem;
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.9rem;
}
.wide {
grid-column: 1 / -1;
}
select,
textarea {
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.8rem 0.95rem;
background: #fff;
font: inherit;
width: 100%;
}
.form-actions {
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
}
@media (max-width: 700px) {
.grid {
grid-template-columns: 1fr;
}
}
</style>
+539
View File
@@ -0,0 +1,539 @@
<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 { useAuthStore } from '@/stores/auth';
import type { DealDto, PipelineBoardDto, PipelineDto } from '@mento/shared';
import { canManageSettings } from '@/lib/roles';
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const canCreatePipeline = computed(() => canManageSettings(auth.user?.role));
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const board = ref<PipelineBoardDto | null>(null);
const pipelines = ref<PipelineDto[]>([]);
const draggingId = ref<string | null>(null);
const dropStageId = ref<string | null>(null);
const showCreate = ref(false);
const newName = ref('');
const selectedPipelineId = computed(() => {
const q = route.query.pipelineId;
if (typeof q === 'string' && q) return q;
return board.value?.pipeline.id ?? '';
});
const openCount = computed(
() =>
board.value?.stages
.filter((s) => !s.isWon && !s.isLost)
.reduce((n, s) => n + s.deals.length, 0) ?? 0,
);
function formatAmount(amount: number | null, currency: string) {
if (amount == null) return '—';
try {
return new Intl.NumberFormat('fa-IR').format(amount) + (currency === 'IRR' ? ' ریال' : ` ${currency}`);
} catch {
return `${amount}`;
}
}
async function loadPipelines() {
const { data } = await api.get<PipelineDto[]>('/pipelines');
pipelines.value = data;
}
async function load() {
loading.value = true;
error.value = '';
try {
await loadPipelines();
const pipelineId =
typeof route.query.pipelineId === 'string' ? route.query.pipelineId : undefined;
const { data } = await api.get<PipelineBoardDto>('/pipelines/board', {
params: pipelineId ? { pipelineId } : undefined,
});
board.value = data;
if (!pipelineId || pipelineId !== data.pipeline.id) {
await router.replace({
name: 'pipeline',
query: { pipelineId: data.pipeline.id },
});
}
} catch (e) {
if (axios.isAxiosError(e) && !e.response) {
error.value = 'اتصال به API برقرار نشد.';
} else {
error.value = 'بارگذاری کاریز ناموفق بود.';
}
} finally {
loading.value = false;
}
}
function onSelectPipeline(event: Event) {
const id = (event.target as HTMLSelectElement).value;
void router.replace({ name: 'pipeline', query: { pipelineId: id } });
}
async function createPipeline() {
const name = newName.value.trim();
if (name.length < 2 || saving.value) return;
saving.value = true;
error.value = '';
try {
const { data } = await api.post<PipelineDto>('/pipelines', { name });
newName.value = '';
showCreate.value = false;
await router.replace({ name: 'pipeline', query: { pipelineId: data.id } });
} catch {
error.value = 'ساخت کاریز ناموفق بود.';
} finally {
saving.value = false;
}
}
function onDragStart(deal: DealDto, event: DragEvent) {
draggingId.value = deal.id;
event.dataTransfer?.setData('text/plain', deal.id);
event.dataTransfer!.effectAllowed = 'move';
}
function onDragEnd() {
draggingId.value = null;
dropStageId.value = null;
}
function onDragOver(stageId: string, event: DragEvent) {
event.preventDefault();
dropStageId.value = stageId;
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
}
async function onDrop(stageId: string, event: DragEvent) {
event.preventDefault();
const dealId = event.dataTransfer?.getData('text/plain') || draggingId.value;
dropStageId.value = null;
draggingId.value = null;
if (!dealId || !board.value) return;
const current = board.value.stages.find((s) => s.deals.some((d) => d.id === dealId));
if (current?.id === stageId) return;
try {
await api.patch(`/deals/${dealId}/move`, { stageId });
await load();
} catch {
error.value = 'جابه‌جایی معامله ناموفق بود.';
}
}
function openDeal(id: string) {
void router.push({ name: 'deal-detail', params: { id } });
}
watch(
() => route.query.pipelineId,
(id, prev) => {
if (id === prev) return;
void load();
},
);
onMounted(load);
</script>
<template>
<section class="page">
<header class="page-head">
<div>
<p class="eyebrow">فروش</p>
<h1>{{ board?.pipeline.name || 'کاریز فروش' }}</h1>
<p class="lead">
کارت‌ها را با کشیدن بین ستون‌ها جابه‌جا کنید.
<span v-if="board">{{ openCount }} معامله باز</span>
</p>
</div>
<div class="actions">
<button class="btn btn-ghost" type="button" :disabled="loading" @click="load">بروزرسانی</button>
<RouterLink
class="btn"
:to="{
name: 'deal-new',
query: selectedPipelineId ? { pipelineId: selectedPipelineId } : undefined,
}"
>
معامله جدید
</RouterLink>
</div>
</header>
<div class="pipeline-bar">
<label class="picker">
<span>کاریز</span>
<select
:value="selectedPipelineId"
:disabled="loading || !pipelines.length"
@change="onSelectPipeline"
>
<option v-for="p in pipelines" :key="p.id" :value="p.id">
{{ p.name }}{{ p.isDefault ? ' (پیش‌فرض)' : '' }}
</option>
</select>
</label>
<button
v-if="canCreatePipeline"
class="btn btn-ghost"
type="button"
@click="showCreate = !showCreate"
>
کاریز جدید
</button>
</div>
<form v-if="showCreate && canCreatePipeline" class="create-box" @submit.prevent="createPipeline">
<input
v-model="newName"
type="text"
placeholder="نام کاریز جدید…"
maxlength="120"
required
minlength="2"
/>
<button class="btn" type="submit" :disabled="saving || newName.trim().length < 2">
{{ saving ? 'در حال ساخت…' : 'ساخت' }}
</button>
<button class="btn btn-ghost" type="button" @click="showCreate = false">انصراف</button>
</form>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading && !board" class="muted">در حال بارگذاری کاریز…</p>
<div v-if="board" class="board">
<div
v-for="stage in board.stages"
:key="stage.id"
class="column"
:class="{
won: stage.isWon,
lost: stage.isLost,
drop: dropStageId === stage.id,
}"
@dragover="onDragOver(stage.id, $event)"
@dragleave="dropStageId = null"
@drop="onDrop(stage.id, $event)"
>
<div class="column-head">
<h2>{{ stage.name }}</h2>
<span>{{ stage.deals.length }}</span>
</div>
<p class="column-sum">
{{ formatAmount(stage.totalAmount, 'IRR') }}
</p>
<div class="cards">
<article
v-for="deal in stage.deals"
:key="deal.id"
class="card"
:class="{ dragging: draggingId === deal.id }"
draggable="true"
@dragstart="onDragStart(deal, $event)"
@dragend="onDragEnd"
@dblclick="openDeal(deal.id)"
>
<h3 @click="openDeal(deal.id)">{{ deal.title }}</h3>
<p class="amount">{{ formatAmount(deal.amount, deal.currency) }}</p>
<p v-if="deal.person || deal.company" class="meta">
<span v-if="deal.person">{{ deal.person.fullName }}</span>
<span v-if="deal.person && deal.company"> · </span>
<span v-if="deal.company">{{ deal.company.name }}</span>
</p>
<div class="badges">
<span v-if="deal.isStale" class="badge stale">راکد</span>
<span v-if="deal.isRotten" class="badge rotten">فاسد</span>
<span v-for="tag in (deal.tags ?? []).slice(0, 3)" :key="tag" class="badge tag">
{{ tag }}
</span>
</div>
<p v-if="deal.source" class="meta source">منبع: {{ deal.source }}</p>
</article>
<p v-if="!stage.deals.length" class="empty">خالی</p>
</div>
</div>
</div>
</section>
</template>
<style scoped>
.page {
display: grid;
gap: 1rem;
min-width: 0;
}
.page-head {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
flex-wrap: wrap;
}
.eyebrow {
margin: 0;
color: var(--accent);
font-weight: 700;
}
h1 {
margin: 0.2rem 0;
font-size: 1.85rem;
}
.lead,
.muted {
color: var(--muted);
}
.actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.pipeline-bar {
display: flex;
flex-wrap: wrap;
gap: 0.65rem;
align-items: center;
min-width: 0;
}
.picker {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1 1 200px;
min-width: 0;
color: var(--muted);
font-size: 0.9rem;
}
.picker select {
flex: 1;
min-width: 0;
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.65rem 0.85rem;
background: #fff;
font: inherit;
color: var(--ink);
}
.create-box {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
min-width: 0;
}
.create-box input {
flex: 1 1 180px;
min-width: 0;
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.7rem 0.85rem;
background: #fff;
font: inherit;
}
.board {
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(220px, 260px);
gap: 0.85rem;
overflow-x: auto;
padding-bottom: 0.75rem;
-webkit-overflow-scrolling: touch;
}
.column {
background: #f4f7f5;
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0.75rem;
min-height: 420px;
transition: border-color 0.15s ease, background 0.15s ease;
}
.column.drop {
border-color: var(--accent);
background: var(--accent-soft);
}
.column.won {
background: #eef8f2;
}
.column.lost {
background: #f8f1f0;
}
.column-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
}
.column-head h2 {
margin: 0;
font-size: 0.98rem;
}
.column-head span,
.column-sum,
.empty,
.meta {
color: var(--muted);
font-size: 0.85rem;
}
.column-sum {
margin: 0.25rem 0 0.7rem;
}
.cards {
display: grid;
gap: 0.55rem;
}
.card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.8rem;
cursor: grab;
box-shadow: var(--shadow);
}
.card:active {
cursor: grabbing;
}
.card.dragging {
opacity: 0.55;
}
.card h3 {
margin: 0 0 0.35rem;
font-size: 0.95rem;
line-height: 1.45;
}
.amount {
margin: 0;
font-weight: 700;
color: var(--accent);
font-size: 0.9rem;
}
.meta {
margin: 0.35rem 0 0;
}
.badges {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
margin-top: 0.45rem;
}
.badge {
font-size: 0.72rem;
padding: 0.15rem 0.45rem;
border-radius: 999px;
font-weight: 600;
}
.badge.stale {
background: #fff4e5;
color: #9a6700;
}
.badge.rotten {
background: #fde8e8;
color: #b42318;
}
.badge.tag {
background: #eef6f2;
color: #0f5c63;
}
.source {
font-size: 0.78rem;
}
.empty {
margin: 0.5rem 0;
text-align: center;
}
@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 auto;
text-align: center;
}
.pipeline-bar {
flex-direction: column;
align-items: stretch;
}
.picker {
flex: 1 1 auto;
width: 100%;
}
.create-box {
flex-direction: column;
align-items: stretch;
}
.create-box input {
flex: 0 0 auto;
width: 100%;
height: 2.75rem;
max-height: 2.75rem;
padding: 0 0.85rem;
}
.board {
grid-auto-columns: minmax(200px, 78vw);
}
.column {
min-height: 320px;
}
}
</style>
+591
View File
@@ -0,0 +1,591 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import {
getPortalToken,
portalApi,
setPortalToken,
} from '@/lib/portalApi';
import type {
FaqArticleDto,
PortalLoginResponse,
PortalOrgInfoDto,
PortalPersonDto,
PortalRequestOtpResponse,
PortalTicketDto,
} from '@mento/shared';
const route = useRoute();
const router = useRouter();
const slug = computed(() =>
typeof route.params.slug === 'string' ? route.params.slug : '',
);
const org = ref<PortalOrgInfoDto | null>(null);
const person = ref<PortalPersonDto | null>(null);
const loading = ref(true);
const error = ref('');
const step = ref<'login' | 'home' | 'new' | 'ticket'>('login');
const mobile = ref('09121234567');
const otpSent = ref(false);
const code = ref('');
const devCode = ref('');
const sending = ref(false);
const faq = ref<FaqArticleDto[]>([]);
const tickets = ref<PortalTicketDto[]>([]);
const ticket = ref<PortalTicketDto | null>(null);
const subject = ref('');
const description = ref('');
const reply = ref('');
const csatScore = ref<number | null>(null);
const csatComment = ref('');
const statusLabel: Record<string, string> = {
open: 'باز',
in_progress: 'در حال رسیدگی',
waiting: 'در انتظار شما',
resolved: 'حل‌شده',
closed: 'بسته',
};
function formatWhen(iso: string) {
try {
return new Intl.DateTimeFormat('fa-IR', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(iso));
} catch {
return iso;
}
}
async function loadOrg() {
const { data } = await portalApi.get<PortalOrgInfoDto>(
`/public/portal/${slug.value}`,
);
org.value = data;
}
async function loadFaq() {
const { data } = await portalApi.get<FaqArticleDto[]>(
`/public/portal/${slug.value}/faq`,
);
faq.value = data;
}
async function loadTickets() {
const { data } = await portalApi.get<PortalTicketDto[]>(
`/public/portal/${slug.value}/tickets`,
);
tickets.value = data;
}
async function requestOtp() {
sending.value = true;
error.value = '';
try {
const { data } = await portalApi.post<PortalRequestOtpResponse>(
`/public/portal/${slug.value}/otp/request`,
{ mobile: mobile.value },
);
otpSent.value = true;
devCode.value = data.devCode ?? '';
if (data.devCode) code.value = data.devCode;
} catch (e) {
error.value = axios.isAxiosError(e)
? String(
(e.response?.data as { message?: string | string[] })?.message ??
'ارسال کد ناموفق بود',
)
: 'ارسال کد ناموفق بود';
} finally {
sending.value = false;
}
}
async function verifyOtp() {
sending.value = true;
error.value = '';
try {
const { data } = await portalApi.post<PortalLoginResponse>(
`/public/portal/${slug.value}/otp/verify`,
{ mobile: mobile.value, code: code.value },
);
setPortalToken(data.accessToken);
person.value = data.person;
org.value = data.organization;
localStorage.setItem(
'mento_portal_person',
JSON.stringify(data.person),
);
step.value = 'home';
await Promise.all([loadFaq(), loadTickets()]);
} catch (e) {
error.value = axios.isAxiosError(e)
? String(
(e.response?.data as { message?: string | string[] })?.message ??
'تأیید کد ناموفق بود',
)
: 'تأیید کد ناموفق بود';
} finally {
sending.value = false;
}
}
async function openTicket(id: string) {
error.value = '';
const { data } = await portalApi.get<PortalTicketDto>(
`/public/portal/${slug.value}/tickets/${id}`,
);
ticket.value = data;
csatScore.value = data.csatScore;
csatComment.value = data.csatComment ?? '';
step.value = 'ticket';
await router.replace({
name: 'portal',
params: { slug: slug.value },
query: { t: id },
});
}
async function createTicket() {
if (!subject.value.trim()) {
error.value = 'موضوع را وارد کنید';
return;
}
sending.value = true;
error.value = '';
try {
const { data } = await portalApi.post<PortalTicketDto>(
`/public/portal/${slug.value}/tickets`,
{
subject: subject.value.trim(),
description: description.value || undefined,
},
);
subject.value = '';
description.value = '';
await openTicket(data.id);
} catch (e) {
error.value = axios.isAxiosError(e)
? String(
(e.response?.data as { message?: string | string[] })?.message ??
'ثبت تیکت ناموفق بود',
)
: 'ثبت تیکت ناموفق بود';
} finally {
sending.value = false;
}
}
async function sendReply() {
if (!ticket.value || !reply.value.trim()) return;
sending.value = true;
try {
const { data } = await portalApi.post<PortalTicketDto>(
`/public/portal/${slug.value}/tickets/${ticket.value.id}/messages`,
{ body: reply.value.trim() },
);
ticket.value = data;
reply.value = '';
await loadTickets();
} catch (e) {
error.value = 'ارسال پیام ناموفق بود';
} finally {
sending.value = false;
}
}
async function saveCsat() {
if (!ticket.value || csatScore.value == null) return;
sending.value = true;
try {
const { data } = await portalApi.patch<PortalTicketDto>(
`/public/portal/${slug.value}/tickets/${ticket.value.id}/csat`,
{
csatScore: csatScore.value,
csatComment: csatComment.value || null,
},
);
ticket.value = data;
} catch {
error.value = 'ثبت رضایت ناموفق بود';
} finally {
sending.value = false;
}
}
function logout() {
setPortalToken(null);
localStorage.removeItem('mento_portal_person');
person.value = null;
step.value = 'login';
otpSent.value = false;
code.value = '';
ticket.value = null;
void router.replace({ name: 'portal', params: { slug: slug.value } });
}
function goHome() {
step.value = 'home';
ticket.value = null;
void router.replace({ name: 'portal', params: { slug: slug.value } });
void loadTickets();
}
onMounted(async () => {
loading.value = true;
error.value = '';
try {
await loadOrg();
if (getPortalToken()) {
const raw = localStorage.getItem('mento_portal_person');
if (raw) person.value = JSON.parse(raw) as PortalPersonDto;
await Promise.all([loadFaq(), loadTickets()]);
const tid = typeof route.query.t === 'string' ? route.query.t : '';
if (tid) await openTicket(tid);
else step.value = 'home';
} else {
step.value = 'login';
await loadFaq();
}
} catch {
error.value = 'بارگذاری پرتال ناموفق بود.';
} finally {
loading.value = false;
}
});
watch(slug, () => {
void router.go(0);
});
</script>
<template>
<div class="portal">
<header class="top">
<div>
<p class="brand">منتو · پرتال مشتری</p>
<h1>{{ org?.name || '…' }}</h1>
</div>
<button v-if="person" class="linkish" type="button" @click="logout">
خروج {{ person.fullName }}
</button>
</header>
<p v-if="loading" class="muted">در حال بارگذاری…</p>
<p v-if="error" class="error">{{ error }}</p>
<section v-if="!loading && step === 'login'" class="card">
<h2>ورود با موبایل</h2>
<p class="muted">کد تأیید با پیامک ارسال می‌شود. در محیط توسعه ممکن است کد روی صفحه هم دیده شود.</p>
<label>موبایل</label>
<input v-model="mobile" inputmode="tel" placeholder="0912…" />
<template v-if="otpSent">
<label>کد تأیید</label>
<input v-model="code" inputmode="numeric" maxlength="8" />
<p v-if="devCode" class="dev">کد دمو: {{ devCode }}</p>
</template>
<div class="actions">
<button
v-if="!otpSent"
class="btn"
type="button"
:disabled="sending"
@click="requestOtp"
>
دریافت کد
</button>
<button
v-else
class="btn"
type="button"
:disabled="sending"
@click="verifyOtp"
>
ورود
</button>
</div>
<div v-if="faq.length" class="faq">
<h3>قبل از تیکت، این‌ها را بخوانید</h3>
<details v-for="f in faq" :key="f.id">
<summary>{{ f.question }}</summary>
<p>{{ f.answer }}</p>
</details>
</div>
</section>
<template v-else-if="!loading && step === 'home'">
<section class="card">
<div class="row-head">
<h2>تیکت‌های من</h2>
<button class="btn" type="button" @click="step = 'new'">تیکت جدید</button>
</div>
<button
v-for="t in tickets"
:key="t.id"
type="button"
class="ticket-row"
@click="openTicket(t.id)"
>
<strong>{{ t.subject }}</strong>
<span class="muted"
>{{ t.number }} · {{ statusLabel[t.status] || t.status }}</span
>
</button>
<p v-if="!tickets.length" class="muted">هنوز تیکتی ندارید.</p>
</section>
<section v-if="faq.length" class="card">
<h2>سؤالات پرتکرار</h2>
<details v-for="f in faq" :key="f.id">
<summary>{{ f.question }}</summary>
<p>{{ f.answer }}</p>
</details>
</section>
</template>
<section v-else-if="!loading && step === 'new'" class="card">
<button class="linkish" type="button" @click="goHome">← بازگشت</button>
<h2>تیکت جدید</h2>
<label>موضوع</label>
<input v-model="subject" maxlength="200" />
<label>شرح</label>
<textarea v-model="description" rows="4" />
<button class="btn" type="button" :disabled="sending" @click="createTicket">
ثبت تیکت
</button>
</section>
<section v-else-if="!loading && step === 'ticket' && ticket" class="card">
<button class="linkish" type="button" @click="goHome">← بازگشت</button>
<h2>{{ ticket.subject }}</h2>
<p class="muted">
{{ ticket.number }} · {{ statusLabel[ticket.status] || ticket.status }}
</p>
<div class="msgs">
<article
v-for="m in ticket.messages"
:key="m.id"
class="msg"
:class="{ staff: m.fromStaff }"
>
<header>
<strong>{{ m.fromStaff ? 'پشتیبانی' : 'شما' }}</strong>
<span class="muted">{{ formatWhen(m.createdAt) }}</span>
</header>
<p>{{ m.body }}</p>
</article>
</div>
<form class="reply" @submit.prevent="sendReply">
<textarea v-model="reply" rows="3" placeholder="پیام شما…" required />
<button class="btn" type="submit" :disabled="sending">ارسال</button>
</form>
<div
v-if="ticket.status === 'resolved' || ticket.status === 'closed'"
class="csat"
>
<h3>رضایت از پاسخ</h3>
<div class="scores">
<button
v-for="n in 5"
:key="n"
type="button"
class="score"
:class="{ active: csatScore === n }"
@click="csatScore = n"
>
{{ n }}
</button>
</div>
<textarea v-model="csatComment" rows="2" placeholder="نظر اختیاری" />
<button class="btn" type="button" :disabled="sending" @click="saveCsat">
ثبت رضایت
</button>
</div>
</section>
</div>
</template>
<style scoped>
.portal {
min-height: 100vh;
padding: 1.25rem;
max-width: 640px;
margin: 0 auto;
display: grid;
gap: 1rem;
background:
radial-gradient(1200px 400px at 10% -10%, #dceee8, transparent),
linear-gradient(180deg, #f4f7f5, #eef1ef);
}
.top {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
}
.brand {
margin: 0;
color: #0f6b54;
font-weight: 700;
font-size: 0.85rem;
}
h1 {
margin: 0.2rem 0 0;
font-size: 1.45rem;
}
h2 {
margin: 0 0 0.75rem;
font-size: 1.1rem;
}
h3 {
margin: 0 0 0.5rem;
font-size: 1rem;
}
.card {
background: #fff;
border: 1px solid #d7e0db;
border-radius: 16px;
padding: 1rem;
display: grid;
gap: 0.65rem;
}
label {
font-size: 0.9rem;
color: #5b6b63;
}
input,
textarea {
border: 1px solid #cfd9d3;
border-radius: 12px;
padding: 0.7rem 0.85rem;
font: inherit;
width: 100%;
background: #fff;
}
.btn {
border: 0;
border-radius: 12px;
padding: 0.7rem 1rem;
background: #0f6b54;
color: #fff;
font: inherit;
cursor: pointer;
justify-self: start;
}
.linkish {
border: 0;
background: none;
color: #0f6b54;
cursor: pointer;
font: inherit;
padding: 0;
justify-self: start;
}
.row-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
}
.ticket-row {
display: grid;
gap: 0.2rem;
padding: 0.75rem 0;
border: 0;
border-top: 1px solid #e6eeea;
background: none;
text-align: inherit;
font: inherit;
cursor: pointer;
width: 100%;
color: inherit;
}
.msgs {
display: grid;
gap: 0.55rem;
}
.msg {
border: 1px solid #e0e8e3;
border-radius: 12px;
padding: 0.7rem 0.85rem;
}
.msg.staff {
background: #f2faf6;
}
.msg header {
display: flex;
gap: 0.5rem;
margin-bottom: 0.3rem;
}
.msg p {
margin: 0;
white-space: pre-wrap;
}
.csat .scores {
display: flex;
gap: 0.35rem;
}
.score {
width: 2.3rem;
height: 2.3rem;
border-radius: 10px;
border: 1px solid #cfd9d3;
background: #fff;
cursor: pointer;
font: inherit;
}
.score.active {
border-color: #0f6b54;
background: #e7f5ef;
font-weight: 700;
}
.dev {
color: #0f6b54;
font-weight: 700;
margin: 0;
}
.muted {
color: #6a7a72;
}
.error {
color: #b42318;
}
details p {
color: #5b6b63;
white-space: pre-wrap;
}
</style>
+266
View File
@@ -0,0 +1,266 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import axios from 'axios';
import { api } from '@/lib/api';
import { formatMoney } from '@/lib/money';
import type {
CreateProductRequest,
OrganizationDto,
ProductDto,
} from '@mento/shared';
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const products = ref<ProductDto[]>([]);
const enabledCurrencies = ref<string[]>(['IRR']);
const name = ref('');
const sku = ref('');
const unitPrice = ref('');
const currency = ref('IRR');
const description = ref('');
async function load() {
loading.value = true;
error.value = '';
try {
const [productsRes, orgRes] = await Promise.all([
api.get<ProductDto[]>('/products'),
api.get<OrganizationDto>('/organizations/current'),
]);
products.value = productsRes.data;
enabledCurrencies.value = orgRes.data.enabledCurrencies?.length
? orgRes.data.enabledCurrencies
: [orgRes.data.baseCurrency || 'IRR'];
currency.value = orgRes.data.baseCurrency || 'IRR';
} catch (e) {
if (axios.isAxiosError(e) && !e.response) {
error.value = 'اتصال به API برقرار نشد.';
} else {
error.value = 'بارگذاری محصولات ناموفق بود.';
}
} finally {
loading.value = false;
}
}
async function createProduct() {
if (!name.value.trim()) {
error.value = 'نام محصول را وارد کنید.';
return;
}
saving.value = true;
error.value = '';
try {
const payload: CreateProductRequest = {
name: name.value.trim(),
sku: sku.value || undefined,
description: description.value || undefined,
unitPrice: unitPrice.value === '' ? null : Number(unitPrice.value),
currency: currency.value || 'IRR',
};
await api.post('/products', payload);
name.value = '';
sku.value = '';
unitPrice.value = '';
description.value = '';
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 {
saving.value = false;
}
}
async function toggleActive(product: ProductDto) {
await api.patch(`/products/${product.id}`, { isActive: !product.isActive });
await load();
}
async function removeProduct(product: ProductDto) {
if (!confirm(`محصول «${product.name}» حذف شود؟`)) return;
await api.delete(`/products/${product.id}`);
await load();
}
function formatPrice(value: number | null, curr: string) {
if (value == null) return '—';
return formatMoney(value, curr);
}
onMounted(load);
</script>
<template>
<section class="page">
<header class="page-head">
<div>
<p class="eyebrow">تنظیمات</p>
<h1>محصولات و خدمات</h1>
<p class="muted">کاتالوگ محصول برای افزودن به معاملات.</p>
</div>
</header>
<form class="form-card" @submit.prevent="createProduct">
<h2>محصول جدید</h2>
<div class="grid">
<div class="field">
<label for="p-name">نام</label>
<input id="p-name" v-model="name" required placeholder="مثلاً لایسنس سازمانی" />
</div>
<div class="field">
<label for="p-sku">کد / SKU</label>
<input id="p-sku" v-model="sku" placeholder="اختیاری" />
</div>
<div class="field">
<label for="p-price">قیمت واحد</label>
<input id="p-price" v-model="unitPrice" type="number" min="0" step="1" />
</div>
<div class="field">
<label for="p-currency">ارز</label>
<select id="p-currency" v-model="currency">
<option v-for="code in enabledCurrencies" :key="code" :value="code">
{{ code }}
</option>
</select>
</div>
<div class="field wide">
<label for="p-desc">توضیح</label>
<input id="p-desc" v-model="description" />
</div>
</div>
<button class="btn" type="submit" :disabled="saving">
{{ saving ? 'در حال ثبت…' : 'افزودن محصول' }}
</button>
</form>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="loading" class="muted">در حال بارگذاری…</p>
<div v-else class="list">
<article v-for="product in products" :key="product.id" class="row">
<div>
<strong>{{ product.name }}</strong>
<p class="meta">
{{ formatPrice(product.unitPrice, product.currency) }}
<span v-if="product.sku"> · {{ product.sku }}</span>
<span v-if="!product.isActive"> · غیرفعال</span>
</p>
</div>
<div class="actions">
<button class="btn btn-ghost" type="button" @click="toggleActive(product)">
{{ product.isActive ? 'غیرفعال' : 'فعال' }}
</button>
<button class="btn btn-danger" type="button" @click="removeProduct(product)">
حذف
</button>
</div>
</article>
<p v-if="!products.length" class="muted">هنوز محصولی ثبت نشده است.</p>
</div>
</section>
</template>
<style scoped>
.page {
display: grid;
gap: 1.1rem;
max-width: 920px;
}
.page-head h1 {
margin: 0.2rem 0 0.35rem;
font-size: 1.7rem;
}
.eyebrow {
margin: 0;
color: var(--accent);
font-weight: 700;
}
.muted {
color: var(--muted);
}
.form-card,
.row {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 1.1rem;
}
.form-card {
display: grid;
gap: 0.9rem;
}
.form-card h2 {
margin: 0;
font-size: 1.05rem;
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.85rem;
}
.wide {
grid-column: 1 / -1;
}
input {
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.8rem 0.95rem;
background: #fff;
font: inherit;
width: 100%;
}
.list {
display: grid;
gap: 0.7rem;
}
.row {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: center;
}
.meta {
margin: 0.25rem 0 0;
color: var(--muted);
font-size: 0.92rem;
}
.actions {
display: flex;
gap: 0.45rem;
flex-wrap: wrap;
}
@media (max-width: 700px) {
.grid {
grid-template-columns: 1fr;
}
.row {
flex-direction: column;
align-items: stretch;
}
}
</style>
+463
View File
@@ -0,0 +1,463 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import { formatMoney as formatMoneyLib } from '@/lib/money';
import type {
CompanyDto,
CreateQuoteRequest,
DealLineItemInput,
OrganizationDto,
PaginatedResponse,
PersonDto,
ProductDto,
QuoteDto,
QuoteStatus,
} from '@mento/shared';
type LineDraft = {
key: string;
productId: string;
name: string;
quantity: string;
unitPrice: string;
};
const route = useRoute();
const router = useRouter();
const id = computed(() =>
typeof route.params.id === 'string' ? route.params.id : '',
);
const isNew = computed(() => route.name === 'quote-new');
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const number = ref('');
const title = ref('پیش‌فاکتور');
const status = ref<QuoteStatus>('draft');
const notes = ref('');
const validUntil = ref('');
const currency = ref('IRR');
const enabledCurrencies = ref<string[]>(['IRR']);
const personId = ref('');
const companyId = ref('');
const dealId = ref('');
const people = ref<PersonDto[]>([]);
const companies = ref<CompanyDto[]>([]);
const products = ref<ProductDto[]>([]);
const lines = ref<LineDraft[]>([]);
const statusLabel: Record<QuoteStatus, string> = {
draft: 'پیش‌نویس',
sent: 'ارسال‌شده',
accepted: 'تأییدشده',
rejected: 'ردشده',
expired: 'منقضی',
};
const totalAmount = computed(() =>
toLineInputs().reduce((sum, l) => sum + l.quantity * l.unitPrice, 0),
);
function formatMoney(amount: number) {
return formatMoneyLib(amount, currency.value);
}
function newLine(): LineDraft {
return {
key: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
productId: '',
name: '',
quantity: '1',
unitPrice: '',
};
}
function addLine() {
lines.value.push(newLine());
}
function removeLine(key: string) {
lines.value = lines.value.filter((l) => l.key !== key);
}
function onPickProduct(line: LineDraft) {
const product = products.value.find((p) => p.id === line.productId);
if (!product) return;
line.name = product.name;
if (product.unitPrice != null) line.unitPrice = String(product.unitPrice);
}
function toLineInputs(): DealLineItemInput[] {
return lines.value
.filter((l) => l.name.trim() && l.quantity !== '' && l.unitPrice !== '')
.map((l) => ({
productId: l.productId || null,
name: l.name.trim(),
quantity: Number(l.quantity),
unitPrice: Number(l.unitPrice),
currency: currency.value || 'IRR',
}));
}
async function load() {
loading.value = true;
error.value = '';
try {
const fromDealId =
typeof route.query.fromDealId === 'string'
? route.query.fromDealId
: typeof route.query.dealId === 'string'
? route.query.dealId
: '';
const [peopleRes, companiesRes, productsRes, orgRes] = await Promise.all([
api.get<PaginatedResponse<PersonDto>>('/people', { params: { limit: 100 } }),
api.get<PaginatedResponse<CompanyDto>>('/companies', { params: { limit: 100 } }),
api.get<ProductDto[]>('/products', { params: { activeOnly: true } }),
api.get<OrganizationDto>('/organizations/current'),
]);
people.value = peopleRes.data.items;
companies.value = companiesRes.data.items;
products.value = productsRes.data;
enabledCurrencies.value = orgRes.data.enabledCurrencies?.length
? orgRes.data.enabledCurrencies
: [orgRes.data.baseCurrency || 'IRR'];
currency.value = orgRes.data.baseCurrency || 'IRR';
if (!isNew.value && id.value) {
const { data } = await api.get<QuoteDto>(`/quotes/${id.value}`);
number.value = data.number;
title.value = data.title;
status.value = data.status as QuoteStatus;
notes.value = data.notes ?? '';
currency.value = data.currency || currency.value;
validUntil.value = data.validUntil ? data.validUntil.slice(0, 10) : '';
personId.value = data.person?.id ?? '';
companyId.value = data.company?.id ?? '';
dealId.value = data.deal?.id ?? '';
lines.value = (data.lineItems ?? []).map((item) => ({
key: item.id,
productId: item.productId ?? '',
name: item.name,
quantity: String(item.quantity),
unitPrice: String(item.unitPrice),
}));
} else if (fromDealId) {
dealId.value = fromDealId;
title.value = 'پیش‌فاکتور';
} else if (!lines.value.length) {
lines.value = [newLine()];
}
} catch {
error.value = 'بارگذاری فرم پیش‌فاکتور ناموفق بود.';
} finally {
loading.value = false;
}
}
function payload(): CreateQuoteRequest {
const fromDealId =
isNew.value && typeof route.query.fromDealId === 'string'
? route.query.fromDealId
: undefined;
return {
title: title.value.trim(),
status: status.value,
notes: notes.value || undefined,
validUntil: validUntil.value ? new Date(validUntil.value).toISOString() : null,
personId: personId.value || null,
companyId: companyId.value || null,
dealId: dealId.value || null,
fromDealId: fromDealId || null,
lineItems: toLineInputs(),
currency: currency.value || 'IRR',
};
}
async function save() {
if (!title.value.trim()) {
error.value = 'عنوان را وارد کنید.';
return;
}
saving.value = true;
error.value = '';
try {
if (isNew.value) {
const { data } = await api.post<QuoteDto>('/quotes', payload());
await router.replace({ name: 'quote-detail', params: { id: data.id } });
} else {
await api.patch(`/quotes/${id.value}`, payload());
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 {
saving.value = false;
}
}
async function remove() {
if (!id.value || !confirm('این پیش‌فاکتور حذف شود؟')) return;
await api.delete(`/quotes/${id.value}`);
await router.push({ name: 'quotes' });
}
function printQuote() {
window.print();
}
onMounted(load);
</script>
<template>
<section class="page">
<header class="page-head no-print">
<div>
<RouterLink class="back" :to="{ name: 'quotes' }">← بازگشت به لیست</RouterLink>
<h1>{{ isNew ? 'پیش‌فاکتور جدید' : number || 'پیش‌فاکتور' }}</h1>
</div>
<div class="head-actions">
<button v-if="!isNew" class="btn btn-ghost" type="button" @click="printQuote">چاپ</button>
<RouterLink
v-if="!isNew && id"
class="btn btn-ghost"
:to="{ name: 'invoice-new', query: { fromQuoteId: id } }"
>
صدور فاکتور
</RouterLink>
</div>
</header>
<p v-if="loading" class="muted">در حال بارگذاری…</p>
<form v-else class="form" @submit.prevent="save">
<div class="print-banner">
<strong>منتو</strong>
<span v-if="number">{{ number }}</span>
</div>
<div class="grid">
<div class="field wide">
<label>عنوان</label>
<input v-model="title" required maxlength="200" />
</div>
<div class="field">
<label>وضعیت</label>
<select v-model="status">
<option v-for="(label, key) in statusLabel" :key="key" :value="key">{{ label }}</option>
</select>
</div>
<div class="field">
<label>اعتبار تا</label>
<input v-model="validUntil" type="date" />
</div>
<div class="field">
<label>ارز</label>
<select v-model="currency">
<option v-for="code in enabledCurrencies" :key="code" :value="code">
{{ code }}
</option>
</select>
</div>
<div class="field">
<label>شخص</label>
<select v-model="personId">
<option value="">بدون شخص</option>
<option v-for="p in people" :key="p.id" :value="p.id">{{ p.fullName }}</option>
</select>
</div>
<div class="field">
<label>شرکت</label>
<select v-model="companyId">
<option value="">بدون شرکت</option>
<option v-for="c in companies" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>
</div>
<div class="field wide">
<label>یادداشت</label>
<textarea v-model="notes" rows="3" />
</div>
</div>
<div class="lines-block">
<div class="lines-head">
<h3>ردیف‌های پیش‌فاکتور</h3>
<button class="btn btn-ghost no-print" type="button" @click="addLine">افزودن ردیف</button>
</div>
<div v-for="line in lines" :key="line.key" class="line-row">
<select v-model="line.productId" class="no-print" @change="onPickProduct(line)">
<option value="">محصول از کاتالوگ…</option>
<option v-for="p in products" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
<input v-model="line.name" placeholder="نام ردیف" required />
<input v-model="line.quantity" type="number" min="0.01" step="0.01" placeholder="تعداد" />
<input v-model="line.unitPrice" type="number" min="0" step="1" placeholder="قیمت واحد" />
<button class="btn btn-ghost no-print" type="button" @click="removeLine(line.key)">حذف</button>
</div>
<p class="total"><strong>جمع:</strong> {{ formatMoney(totalAmount) }}</p>
</div>
<p v-if="error" class="error">{{ error }}</p>
<div class="form-actions no-print">
<button class="btn" type="submit" :disabled="saving">
{{ saving ? 'در حال ذخیره…' : 'ذخیره' }}
</button>
<button
v-if="!isNew"
class="btn btn-danger"
type="button"
:disabled="saving"
@click="remove"
>
حذف
</button>
</div>
</form>
</section>
</template>
<style scoped>
.page {
display: grid;
gap: 1.1rem;
max-width: 860px;
}
.page-head {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
}
.head-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.back {
color: var(--muted);
text-decoration: none;
font-size: 0.9rem;
}
h1 {
margin: 0.25rem 0 0;
}
.form,
.lines-block {
display: grid;
gap: 0.85rem;
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
.field {
display: grid;
gap: 0.35rem;
}
.field.wide {
grid-column: 1 / -1;
}
label {
color: var(--muted);
font-size: 0.9rem;
}
input,
select,
textarea {
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.7rem 0.85rem;
font: inherit;
background: #fff;
width: 100%;
}
.lines-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
}
.lines-head h3 {
margin: 0;
font-size: 1rem;
}
.line-row {
display: grid;
grid-template-columns: 1.2fr 1.4fr 0.7fr 0.9fr auto;
gap: 0.45rem;
}
.total {
margin: 0;
text-align: start;
font-size: 1.05rem;
}
.form-actions {
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
}
.error {
color: #b42318;
}
.print-banner {
display: none;
}
@media (max-width: 700px) {
.grid,
.line-row {
grid-template-columns: 1fr;
}
}
@media print {
.no-print {
display: none !important;
}
.print-banner {
display: flex;
justify-content: space-between;
border-bottom: 1px solid #ccc;
padding-bottom: 0.5rem;
margin-bottom: 0.5rem;
}
.page {
max-width: none;
}
input,
select,
textarea {
border: 0;
padding: 0.2rem 0;
}
}
</style>
+217
View File
@@ -0,0 +1,217 @@
<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 { formatMoney } from '@/lib/money';
import type { PaginatedResponse, QuoteDto, QuoteStatus } from '@mento/shared';
const route = useRoute();
const router = useRouter();
const loading = ref(false);
const error = ref('');
const data = ref<PaginatedResponse<QuoteDto> | null>(null);
const q = ref(typeof route.query.q === 'string' ? route.query.q : '');
const status = ref<QuoteStatus | 'all'>(
typeof route.query.status === 'string' &&
['draft', 'sent', 'accepted', 'rejected', 'expired'].includes(route.query.status)
? (route.query.status as QuoteStatus)
: 'all',
);
const statusLabel: Record<QuoteStatus, string> = {
draft: 'پیش‌نویس',
sent: 'ارسال‌شده',
accepted: 'تأییدشده',
rejected: 'ردشده',
expired: 'منقضی',
};
const items = computed(() => data.value?.items ?? []);
async function load() {
loading.value = true;
error.value = '';
try {
const { data: res } = await api.get<PaginatedResponse<QuoteDto>>('/quotes', {
params: {
q: q.value || undefined,
status: status.value === 'all' ? undefined : status.value,
dealId: typeof route.query.dealId === 'string' ? route.query.dealId : 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 onSearch() {
router.replace({
name: 'quotes',
query: {
...route.query,
q: q.value.trim() || undefined,
status: status.value === 'all' ? undefined : status.value,
},
});
}
watch(
() => [route.query.q, route.query.status, route.query.dealId],
() => {
q.value = typeof route.query.q === 'string' ? route.query.q : '';
status.value =
typeof route.query.status === 'string' &&
['draft', 'sent', 'accepted', 'rejected', 'expired'].includes(route.query.status)
? (route.query.status as QuoteStatus)
: 'all';
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>
<RouterLink class="btn" :to="{ name: 'quote-new', query: route.query }">پیش‌فاکتور جدید</RouterLink>
</header>
<form class="toolbar" @submit.prevent="onSearch">
<input v-model="q" type="search" placeholder="جستجو در عنوان یا شماره…" />
<select v-model="status" @change="onSearch">
<option value="all">همه وضعیت‌ها</option>
<option v-for="(label, key) in statusLabel" :key="key" :value="key">{{ label }}</option>
</select>
<button class="btn btn-ghost" type="submit">جستجو</button>
</form>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
<div v-else class="list">
<RouterLink
v-for="quote in items"
:key="quote.id"
class="row"
:to="{ name: 'quote-detail', params: { id: quote.id } }"
>
<div>
<strong>{{ quote.title }}</strong>
<p class="muted">
{{ quote.number }}
· {{ statusLabel[quote.status as QuoteStatus] || quote.status }}
<span v-if="quote.person"> · {{ quote.person.fullName }}</span>
<span v-if="quote.deal"> · {{ quote.deal.title }}</span>
</p>
</div>
<strong class="amount">{{ formatMoney(quote.totalAmount, quote.currency) }}</strong>
</RouterLink>
<p v-if="!items.length" class="empty">هنوز پیش‌فاکتوری نیست.</p>
</div>
</section>
</template>
<style scoped>
.page {
display: grid;
gap: 1.1rem;
}
.page-head {
display: flex;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
align-items: flex-start;
}
.eyebrow {
margin: 0;
color: var(--accent);
font-weight: 700;
}
h1 {
margin: 0.2rem 0;
}
.lead,
.muted {
color: var(--muted);
}
.toolbar {
display: flex;
gap: 0.55rem;
flex-wrap: wrap;
}
.toolbar input,
.toolbar select {
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.7rem 0.85rem;
font: inherit;
background: #fff;
min-width: 0;
flex: 1;
}
.list {
display: grid;
gap: 0.55rem;
}
.row {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: center;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0.9rem 1rem;
text-decoration: none;
color: inherit;
}
.row p {
margin: 0.25rem 0 0;
}
.amount {
white-space: nowrap;
}
.empty,
.error {
margin: 0;
}
.error {
color: #b42318;
}
.empty {
padding: 1rem;
border: 1px dashed var(--line);
border-radius: var(--radius);
color: var(--muted);
}
</style>
+123
View File
@@ -0,0 +1,123 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter, RouterLink } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
import axios from 'axios';
const auth = useAuthStore();
const router = useRouter();
const organizationName = ref('');
const fullName = ref('');
const email = ref('');
const password = ref('');
const error = ref('');
const loading = ref(false);
async function submit() {
error.value = '';
loading.value = true;
try {
await auth.register({
organizationName: organizationName.value,
fullName: fullName.value,
email: email.value,
password: password.value,
});
await router.push('/');
} catch (e) {
if (axios.isAxiosError(e)) {
if (!e.response) {
error.value = 'اتصال به سرور برقرار نشد. مطمئن شوید API روی پورت ۳۰۰۰ در حال اجراست.';
} else {
const msg = (e.response.data as { message?: string | string[] })?.message;
error.value = Array.isArray(msg) ? msg.join('، ') : (msg ?? 'ثبت‌نام ناموفق بود');
}
} else {
error.value = 'ثبت‌نام ناموفق بود';
}
} finally {
loading.value = false;
}
}
</script>
<template>
<div class="auth">
<section class="panel">
<p class="eyebrow">شروع با منتو</p>
<h1>ساخت سازمان</h1>
<p class="lead">اولین کاربر به‌عنوان مالک سازمان ثبت می‌شود.</p>
<form class="form" @submit.prevent="submit">
<div class="field">
<label for="org">نام سازمان</label>
<input id="org" v-model="organizationName" required />
</div>
<div class="field">
<label for="name">نام شما</label>
<input id="name" v-model="fullName" required />
</div>
<div class="field">
<label for="email">ایمیل</label>
<input id="email" v-model="email" type="email" required />
</div>
<div class="field">
<label for="password">رمز عبور</label>
<input id="password" v-model="password" type="password" minlength="6" required />
</div>
<p v-if="error" class="error">{{ error }}</p>
<button class="btn" type="submit" :disabled="loading">
{{ loading ? 'در حال ثبت…' : 'ایجاد سازمان' }}
</button>
</form>
<p class="switch">
حساب دارید؟
<RouterLink to="/login">ورود</RouterLink>
</p>
</section>
</div>
</template>
<style scoped>
.auth {
min-height: 100vh;
min-height: 100dvh;
display: grid;
place-items: center;
padding: 1rem;
padding-bottom: max(1rem, env(safe-area-inset-bottom));
}
.panel {
width: min(480px, 100%);
background: var(--surface);
border: 1px solid var(--line);
border-radius: calc(var(--radius) + 4px);
box-shadow: var(--shadow);
padding: clamp(1.1rem, 4vw, 1.6rem);
}
.eyebrow {
margin: 0;
color: var(--accent);
font-weight: 700;
}
h1 {
margin: 0.35rem 0 0.4rem;
font-size: 1.8rem;
}
.lead,
.switch {
color: var(--muted);
}
.form {
display: grid;
gap: 0.9rem;
margin: 1.4rem 0 1rem;
}
</style>
+333
View File
@@ -0,0 +1,333 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import axios from 'axios';
import { api } from '@/lib/api';
import { formatMoney } from '@/lib/money';
import type { OrganizationDto, ReportsDashboardDto } from '@mento/shared';
const loading = ref(false);
const error = ref('');
const data = ref<ReportsDashboardDto | null>(null);
const org = ref<OrganizationDto | null>(null);
const from = ref('');
const to = ref('');
const typeLabel: Record<string, string> = {
call: 'تماس',
meeting: 'جلسه',
task: 'وظیفه',
note: 'یادداشت',
};
function defaultRange() {
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth(), 1);
from.value = start.toISOString().slice(0, 10);
to.value = now.toISOString().slice(0, 10);
}
function money(n: number) {
return formatMoney(n, data.value?.currency || org.value?.baseCurrency || 'IRR');
}
async function load() {
loading.value = true;
error.value = '';
try {
const [dash, orgRes] = await Promise.all([
api.get<ReportsDashboardDto>('/reports/dashboard', {
params: {
from: from.value || undefined,
to: to.value || undefined,
},
}),
api.get<OrganizationDto>('/organizations/current'),
]);
data.value = dash.data;
org.value = orgRes.data;
} catch (e) {
if (axios.isAxiosError(e) && !e.response) {
error.value = 'اتصال به API برقرار نشد.';
} else {
error.value = 'بارگذاری گزارش ناموفق بود.';
}
} finally {
loading.value = false;
}
}
onMounted(() => {
defaultRange();
void load();
});
</script>
<template>
<section class="page">
<header class="page-head">
<div>
<p class="eyebrow">مدیریت</p>
<h1>گزارشات و داشبورد</h1>
<p class="lead">نمای یک‌صفحه‌ای فروش، فعالیت، تیم و پشتیبانی.</p>
</div>
</header>
<form class="toolbar" @submit.prevent="load">
<label>
از
<input v-model="from" type="date" />
</label>
<label>
تا
<input v-model="to" type="date" />
</label>
<button class="btn" type="submit" :disabled="loading">اعمال</button>
<a
v-if="org"
class="portal-link"
:href="`/p/${org.slug}`"
target="_blank"
rel="noopener"
>
پرتال مشتری (/p/{{ org.slug }})
</a>
</form>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
<template v-else-if="data">
<div class="kpis">
<article>
<span>برد دوره</span>
<strong>{{ money(data.sales.wonAmount) }}</strong>
<small>{{ data.sales.wonCount }} معامله</small>
</article>
<article>
<span>کاریز باز</span>
<strong>{{ money(data.sales.openAmount) }}</strong>
<small>{{ data.sales.openCount }} معامله</small>
</article>
<article>
<span>نرخ برد</span>
<strong>{{ data.sales.winRate }}٪</strong>
<small>{{ data.sales.lostCount }} باخت</small>
</article>
<article>
<span>اشخاص جدید</span>
<strong>{{ data.peopleCreated }}</strong>
<small>در بازه</small>
</article>
</div>
<div class="grid">
<section class="panel">
<h2>قیف فروش (باز)</h2>
<table>
<thead>
<tr>
<th>مرحله</th>
<th>تعداد</th>
<th>مبلغ</th>
</tr>
</thead>
<tbody>
<tr v-for="s in data.funnel" :key="s.stageId">
<td>
{{ s.stageName }}
<span class="muted"> · {{ s.pipelineName }}</span>
</td>
<td>{{ s.count }}</td>
<td>{{ money(s.amount) }}</td>
</tr>
<tr v-if="!data.funnel.length">
<td colspan="3" class="muted">داده‌ای نیست</td>
</tr>
</tbody>
</table>
</section>
<section class="panel">
<h2>فعالیت‌ها</h2>
<ul class="stats">
<li>باز: {{ data.activities.open }}</li>
<li>انجام‌شده: {{ data.activities.done }}</li>
<li>عقب‌افتاده: {{ data.activities.overdue }}</li>
</ul>
<ul class="stats">
<li v-for="t in data.activities.byType" :key="t.type">
{{ typeLabel[t.type] || t.type }}: {{ t.count }}
</li>
</ul>
</section>
<section class="panel">
<h2>تیم</h2>
<table>
<thead>
<tr>
<th>کاربر</th>
<th>برد</th>
<th>فعالیت انجام‌شده</th>
</tr>
</thead>
<tbody>
<tr v-for="u in data.team" :key="u.userId">
<td>{{ u.fullName }}</td>
<td>{{ u.wonCount }} · {{ money(u.wonAmount) }}</td>
<td>{{ u.activitiesDone }}</td>
</tr>
<tr v-if="!data.team.length">
<td colspan="3" class="muted">داده‌ای نیست</td>
</tr>
</tbody>
</table>
</section>
<section class="panel">
<h2>پشتیبانی</h2>
<ul class="stats">
<li>تیکت باز: {{ data.tickets.open }}</li>
<li>حل‌شده: {{ data.tickets.resolved }}</li>
<li>بسته: {{ data.tickets.closed }}</li>
<li>تأخیر SLA: {{ data.tickets.slaBreached }}</li>
<li>
میانگین CSAT:
{{
data.tickets.avgCsat == null
? '—'
: `${data.tickets.avgCsat} (${data.tickets.csatCount})`
}}
</li>
</ul>
</section>
</div>
</template>
</section>
</template>
<style scoped>
.page {
display: grid;
gap: 1.1rem;
}
.eyebrow {
margin: 0;
color: var(--accent);
font-weight: 700;
}
h1 {
margin: 0.2rem 0;
}
.lead,
.muted {
color: var(--muted);
}
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: end;
}
.toolbar label {
display: grid;
gap: 0.25rem;
font-size: 0.85rem;
color: var(--muted);
}
.toolbar input {
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.55rem 0.7rem;
font: inherit;
background: #fff;
}
.portal-link {
margin-inline-start: auto;
color: var(--accent);
font-size: 0.9rem;
}
.kpis {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.75rem;
}
.kpis article {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0.9rem 1rem;
display: grid;
gap: 0.25rem;
}
.kpis span,
.kpis small {
color: var(--muted);
font-size: 0.85rem;
}
.kpis strong {
font-size: 1.15rem;
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.85rem;
}
.panel {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 1rem;
}
.panel h2 {
margin: 0 0 0.75rem;
font-size: 1.05rem;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.92rem;
}
th,
td {
text-align: start;
padding: 0.45rem 0.25rem;
border-bottom: 1px solid var(--line);
}
.stats {
margin: 0;
padding: 0;
list-style: none;
display: grid;
gap: 0.35rem;
}
.error {
color: #b42318;
}
@media (max-width: 900px) {
.kpis,
.grid {
grid-template-columns: 1fr;
}
}
</style>
+438
View File
@@ -0,0 +1,438 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { RouterLink } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth';
import { canReassignRecords, canViewAllRecords } from '@/lib/roles';
import { formatMoney as formatMoneyLib } from '@/lib/money';
import type {
CreateSalesGoalRequest,
OrgUserDto,
SalesForecastDto,
SalesGoalDto,
} from '@mento/shared';
const auth = useAuthStore();
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const forecast = ref<SalesForecastDto | null>(null);
const users = ref<OrgUserDto[]>([]);
const title = ref('هدف فروش ماه جاری');
const targetAmount = ref('');
const notes = ref('');
const userId = ref('');
const periodStart = ref('');
const periodEnd = ref('');
const canManageTeamGoals = computed(() => canReassignRecords(auth.user?.role));
const showAllHint = computed(() => canViewAllRecords(auth.user?.role));
function monthBounds() {
const now = new Date();
const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
const end = new Date(
Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 0),
);
return {
start: start.toISOString().slice(0, 10),
end: end.toISOString().slice(0, 10),
};
}
function formatMoney(amount: number) {
return formatMoneyLib(amount, forecast.value?.currency || 'IRR');
}
function formatDate(iso: string) {
try {
return new Intl.DateTimeFormat('fa-IR').format(new Date(iso));
} catch {
return iso.slice(0, 10);
}
}
async function load() {
loading.value = true;
error.value = '';
try {
const { data } = await api.get<SalesForecastDto>('/sales/forecast');
forecast.value = data;
if (canManageTeamGoals.value && !users.value.length) {
try {
const res = await api.get<OrgUserDto[]>('/users');
users.value = res.data;
} catch {
// optional
}
}
} catch (e) {
if (axios.isAxiosError(e) && !e.response) {
error.value = 'اتصال به API برقرار نشد.';
} else {
error.value = 'بارگذاری پیش‌بینی ناموفق بود.';
}
} finally {
loading.value = false;
}
}
async function createGoal() {
if (!title.value.trim() || !targetAmount.value) {
error.value = 'عنوان و مبلغ هدف را وارد کنید.';
return;
}
saving.value = true;
error.value = '';
try {
const bounds = monthBounds();
const body: CreateSalesGoalRequest = {
title: title.value.trim(),
periodStart: new Date(periodStart.value || bounds.start).toISOString(),
periodEnd: new Date(
(periodEnd.value || bounds.end) + 'T23:59:59.000Z',
).toISOString(),
targetAmount: Number(targetAmount.value),
notes: notes.value || undefined,
userId: canManageTeamGoals.value
? userId.value === ''
? null
: userId.value
: undefined,
currency: 'IRR',
};
await api.post<SalesGoalDto>('/sales/goals', body);
targetAmount.value = '';
notes.value = '';
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 {
saving.value = false;
}
}
async function removeGoal(id: string) {
if (!confirm('این هدف حذف شود؟')) return;
await api.delete(`/sales/goals/${id}`);
await load();
}
onMounted(() => {
const b = monthBounds();
periodStart.value = b.start;
periodEnd.value = b.end;
void load();
});
</script>
<template>
<section class="page">
<header class="page-head">
<div>
<p class="eyebrow">فروش</p>
<h1>هدف و پیش‌بینی</h1>
<p class="lead">
هدف ماهانه را بگذارید و تحقق، کاریز باز و پیش‌بینی ساده را ببینید.
<span v-if="!showAllHint"> اعداد بر اساس پرونده‌های شماست.</span>
</p>
</div>
<RouterLink class="btn btn-ghost" :to="{ name: 'home' }">خانه</RouterLink>
</header>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
<div v-if="forecast" class="metrics">
<article>
<p class="label">هدف دوره</p>
<strong>{{ formatMoney(forecast.targetAmount) }}</strong>
</article>
<article>
<p class="label">تحقق (برنده)</p>
<strong>{{ formatMoney(forecast.wonAmount) }}</strong>
<span class="muted">{{ forecast.wonDealsCount }} معامله</span>
</article>
<article>
<p class="label">کاریز باز</p>
<strong>{{ formatMoney(forecast.openPipelineAmount) }}</strong>
<span class="muted">{{ forecast.openDealsCount }} معامله</span>
</article>
<article class="accent">
<p class="label">پیش‌بینی ساده</p>
<strong>{{ formatMoney(forecast.forecastAmount) }}</strong>
<span class="muted">تحقق + ۴۰٪ کاریز باز</span>
</article>
</div>
<div v-if="forecast" class="progress-block">
<div class="progress-head">
<span>پیشرفت نسبت به هدف</span>
<strong>
{{
forecast.progressPercent == null
? '—'
: `${new Intl.NumberFormat('fa-IR').format(forecast.progressPercent)}٪`
}}
</strong>
</div>
<div class="bar">
<div
class="fill"
:style="{
width: `${Math.min(100, forecast.progressPercent ?? 0)}%`,
}"
/>
</div>
<p class="muted period">
{{ formatDate(forecast.periodStart) }}
تا
{{ formatDate(forecast.periodEnd) }}
</p>
</div>
<form class="form-card" @submit.prevent="createGoal">
<h2>هدف جدید</h2>
<div class="grid">
<div class="field wide">
<label>عنوان</label>
<input v-model="title" required maxlength="200" />
</div>
<div class="field">
<label>مبلغ هدف (ریال)</label>
<input v-model="targetAmount" type="number" min="0" step="1" required />
</div>
<div v-if="canManageTeamGoals" class="field">
<label>برای</label>
<select v-model="userId">
<option value="">کل سازمان</option>
<option v-for="u in users" :key="u.id" :value="u.id">
{{ u.fullName }}
</option>
</select>
</div>
<div class="field">
<label>از تاریخ</label>
<input v-model="periodStart" type="date" required />
</div>
<div class="field">
<label>تا تاریخ</label>
<input v-model="periodEnd" type="date" required />
</div>
<div class="field wide">
<label>یادداشت</label>
<input v-model="notes" maxlength="2000" />
</div>
</div>
<button class="btn" type="submit" :disabled="saving">
{{ saving ? 'در حال ذخیره…' : 'ثبت هدف' }}
</button>
</form>
<div class="goals">
<h2>اهداف این دوره</h2>
<div v-if="forecast?.goals.length" class="goal-list">
<div v-for="g in forecast.goals" :key="g.id" class="goal-row">
<div>
<strong>{{ g.title }}</strong>
<p class="muted">
{{ formatMoney(g.targetAmount) }}
· {{ formatDate(g.periodStart) }} تا {{ formatDate(g.periodEnd) }}
<span v-if="g.user"> · {{ g.user.fullName }}</span>
<span v-else> · سازمانی</span>
</p>
</div>
<button class="btn btn-ghost" type="button" @click="removeGoal(g.id)">
حذف
</button>
</div>
</div>
<p v-else class="empty">هنوز هدفی برای این دوره ثبت نشده.</p>
</div>
</section>
</template>
<style scoped>
.page {
display: grid;
gap: 1.1rem;
max-width: 920px;
}
.page-head {
display: flex;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
align-items: flex-start;
}
.eyebrow {
margin: 0;
color: var(--accent);
font-weight: 700;
}
h1 {
margin: 0.2rem 0;
}
h2 {
margin: 0 0 0.75rem;
font-size: 1.05rem;
}
.lead,
.muted {
color: var(--muted);
}
.metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.65rem;
}
.metrics article {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0.85rem 1rem;
display: grid;
gap: 0.25rem;
}
.metrics .accent {
border-color: color-mix(in srgb, var(--accent) 35%, var(--line));
}
.label {
margin: 0;
color: var(--muted);
font-size: 0.85rem;
}
.progress-block {
display: grid;
gap: 0.45rem;
}
.progress-head {
display: flex;
justify-content: space-between;
gap: 0.5rem;
}
.bar {
height: 10px;
border-radius: 999px;
background: color-mix(in srgb, var(--line) 80%, #fff);
overflow: hidden;
}
.fill {
height: 100%;
background: var(--accent);
border-radius: 999px;
min-width: 0;
}
.period {
margin: 0;
font-size: 0.9rem;
}
.form-card,
.goals {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 1rem;
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.7rem;
margin-bottom: 0.85rem;
}
.field {
display: grid;
gap: 0.3rem;
}
.field.wide {
grid-column: 1 / -1;
}
label {
color: var(--muted);
font-size: 0.9rem;
}
input,
select {
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.7rem 0.85rem;
font: inherit;
background: #fff;
width: 100%;
}
.goal-list {
display: grid;
gap: 0.5rem;
}
.goal-row {
display: flex;
justify-content: space-between;
gap: 0.75rem;
align-items: center;
padding: 0.7rem 0.8rem;
border: 1px solid var(--line);
border-radius: 12px;
}
.goal-row p {
margin: 0.2rem 0 0;
}
.empty,
.error {
margin: 0;
}
.error {
color: #b42318;
}
.empty {
color: var(--muted);
}
@media (max-width: 800px) {
.metrics,
.grid {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 520px) {
.metrics,
.grid {
grid-template-columns: 1fr;
}
}
</style>
+277
View File
@@ -0,0 +1,277 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import axios from 'axios';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth';
import type {
InviteUserRequest,
OrgUserDto,
UserRole,
} from '@mento/shared';
import { USER_ROLE_LABELS, canManageUsers } from '@/lib/roles';
const auth = useAuthStore();
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const users = ref<OrgUserDto[]>([]);
const email = ref('');
const fullName = ref('');
const password = ref('');
const role = ref<Exclude<UserRole, 'owner'>>('sales');
const inviteRoles: Array<{ value: Exclude<UserRole, 'owner'>; label: string }> = [
{ value: 'admin', label: USER_ROLE_LABELS.admin },
{ value: 'sales', label: USER_ROLE_LABELS.sales },
{ value: 'support', label: USER_ROLE_LABELS.support },
];
const isOwner = computed(() => auth.user?.role === 'owner');
const canInvite = computed(() => canManageUsers(auth.user?.role));
function roleOptionsFor(user: OrgUserDto): UserRole[] {
if (isOwner.value) return ['owner', 'admin', 'sales', 'support'];
if (user.role === 'owner') return ['owner'];
return ['admin', 'sales', 'support'];
}
async function load() {
loading.value = true;
error.value = '';
try {
users.value = (await api.get<OrgUserDto[]>('/users')).data;
} catch (e) {
if (axios.isAxiosError(e) && !e.response) {
error.value = 'اتصال به API برقرار نشد.';
} else {
error.value = 'بارگذاری کاربران ناموفق بود.';
}
} finally {
loading.value = false;
}
}
async function invite() {
if (!email.value.trim() || !fullName.value.trim() || password.value.length < 8) {
error.value = 'نام، ایمیل و رمز حداقل ۸ کاراکتر لازم است.';
return;
}
saving.value = true;
error.value = '';
try {
const payload: InviteUserRequest = {
email: email.value.trim(),
fullName: fullName.value.trim(),
password: password.value,
role: role.value,
};
await api.post('/users', payload);
email.value = '';
fullName.value = '';
password.value = '';
role.value = 'sales';
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 {
saving.value = false;
}
}
async function changeRole(user: OrgUserDto, next: string) {
const nextRole = next as UserRole;
if (nextRole === user.role) return;
error.value = '';
try {
await api.patch(`/users/${user.id}/role`, { role: nextRole });
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 = 'تغییر نقش ناموفق بود';
}
}
}
onMounted(load);
</script>
<template>
<section class="page">
<header>
<p class="eyebrow">تنظیمات</p>
<h1>تیم و نقش‌ها</h1>
<p class="lead">
کاربران سازمان را اضافه کنید و نقش بدهید. تنظیمات سازمانی فقط برای مالک و مدیر است.
</p>
</header>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
<form v-if="canInvite" class="invite" @submit.prevent="invite">
<h2>دعوت کاربر</h2>
<div class="grid">
<label>
نام کامل
<input v-model="fullName" required maxlength="120" />
</label>
<label>
ایمیل
<input v-model="email" type="email" required />
</label>
<label>
رمز عبور موقت
<input v-model="password" type="password" required minlength="8" />
</label>
<label>
نقش
<select v-model="role">
<option v-for="opt in inviteRoles" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
</label>
</div>
<button class="btn" type="submit" :disabled="saving">
{{ saving ? 'در حال ثبت…' : 'افزودن کاربر' }}
</button>
</form>
<div class="list">
<article v-for="user in users" :key="user.id" class="row">
<div>
<strong>{{ user.fullName }}</strong>
<p class="muted">{{ user.email }}</p>
</div>
<div class="role-cell">
<template v-if="canInvite">
<select
:value="user.role"
:disabled="!isOwner && user.role === 'owner'"
@change="changeRole(user, ($event.target as HTMLSelectElement).value)"
>
<option
v-for="r in roleOptionsFor(user)"
:key="r"
:value="r"
>
{{ USER_ROLE_LABELS[r] }}
</option>
</select>
</template>
<span v-else class="role-pill">{{ USER_ROLE_LABELS[user.role] }}</span>
</div>
</article>
<p v-if="!loading && !users.length" class="muted">کاربری نیست.</p>
</div>
</section>
</template>
<style scoped>
.page {
display: grid;
gap: 1.2rem;
max-width: 820px;
}
.eyebrow {
margin: 0;
color: var(--accent);
font-weight: 700;
}
h1 {
margin: 0.2rem 0;
}
.lead,
.muted {
color: var(--muted);
}
.error {
color: #b42318;
}
.invite,
.row {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 1rem;
}
.invite {
display: grid;
gap: 0.85rem;
}
.invite h2 {
margin: 0;
font-size: 1rem;
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
label {
display: grid;
gap: 0.35rem;
font-size: 0.9rem;
color: var(--muted);
}
input,
select {
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.7rem 0.85rem;
font: inherit;
background: #fff;
color: var(--text);
}
.list {
display: grid;
gap: 0.65rem;
}
.row {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: center;
flex-wrap: wrap;
}
.row p {
margin: 0.2rem 0 0;
}
.role-pill {
background: var(--accent-soft);
color: var(--accent);
border-radius: 999px;
padding: 0.25rem 0.7rem;
font-size: 0.85rem;
font-weight: 700;
}
@media (max-width: 700px) {
.grid {
grid-template-columns: 1fr;
}
}
</style>
+652
View File
@@ -0,0 +1,652 @@
<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 { canViewAllRecords } from '@/lib/roles';
import { useAuthStore } from '@/stores/auth';
import type {
CompanyDto,
CreateTicketRequest,
FaqArticleDto,
OrgUserDto,
PaginatedResponse,
PersonDto,
TicketDto,
TicketPriority,
TicketStatus,
} from '@mento/shared';
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const id = computed(() =>
typeof route.params.id === 'string' ? route.params.id : '',
);
const isNew = computed(() => route.name === 'ticket-new');
const loading = ref(false);
const saving = ref(false);
const messaging = ref(false);
const csatSaving = ref(false);
const error = ref('');
const number = ref('');
const subject = ref('');
const description = ref('');
const status = ref<TicketStatus>('open');
const priority = ref<TicketPriority>('normal');
const personId = ref('');
const companyId = ref('');
const assigneeId = ref('');
const people = ref<PersonDto[]>([]);
const companies = ref<CompanyDto[]>([]);
const users = ref<OrgUserDto[]>([]);
const ticket = ref<TicketDto | null>(null);
const reply = ref('');
const replyInternal = ref(false);
const faqHints = ref<FaqArticleDto[]>([]);
const csatScore = ref<number | null>(null);
const csatComment = ref('');
const canAssignOthers = computed(() => canViewAllRecords(auth.user?.role));
const showCsat = computed(() => {
if (!ticket.value || isNew.value) return false;
const s = status.value;
return s === 'resolved' || s === 'closed';
});
const statusLabel: Record<TicketStatus, string> = {
open: 'باز',
in_progress: 'در حال رسیدگی',
waiting: 'در انتظار',
resolved: 'حل‌شده',
closed: 'بسته',
};
const priorityLabel: Record<TicketPriority, string> = {
low: 'کم',
normal: 'عادی',
high: 'بالا',
urgent: 'فوری',
};
function formatWhen(iso: string) {
try {
return new Intl.DateTimeFormat('fa-IR', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(iso));
} catch {
return iso;
}
}
async function loadFaqHints() {
if (!isNew.value) {
faqHints.value = [];
return;
}
const q = subject.value.trim();
if (q.length < 2) {
faqHints.value = [];
return;
}
try {
const { data } = await api.get<FaqArticleDto[]>('/faq', {
params: { q, publishedOnly: true },
});
faqHints.value = data.slice(0, 5);
} catch {
faqHints.value = [];
}
}
let faqTimer: ReturnType<typeof setTimeout> | null = null;
watch(subject, () => {
if (!isNew.value) return;
if (faqTimer) clearTimeout(faqTimer);
faqTimer = setTimeout(() => void loadFaqHints(), 350);
});
async function load() {
loading.value = true;
error.value = '';
try {
const [peopleRes, companiesRes] = await Promise.all([
api.get<PaginatedResponse<PersonDto>>('/people', {
params: { limit: 100 },
}),
api.get<PaginatedResponse<CompanyDto>>('/companies', {
params: { limit: 100 },
}),
]);
people.value = peopleRes.data.items;
companies.value = companiesRes.data.items;
if (canAssignOthers.value) {
try {
users.value = (await api.get<OrgUserDto[]>('/users')).data;
} catch {
users.value = [];
}
}
if (!isNew.value && id.value) {
const { data } = await api.get<TicketDto>(`/tickets/${id.value}`);
ticket.value = data;
number.value = data.number;
subject.value = data.subject;
description.value = data.description ?? '';
status.value = data.status as TicketStatus;
priority.value = data.priority as TicketPriority;
personId.value = data.person?.id ?? '';
companyId.value = data.company?.id ?? '';
assigneeId.value = data.assignee?.id ?? '';
csatScore.value = data.csatScore;
csatComment.value = data.csatComment ?? '';
} else {
ticket.value = null;
assigneeId.value = auth.user?.id ?? '';
csatScore.value = null;
csatComment.value = '';
void loadFaqHints();
}
} catch {
error.value = 'بارگذاری تیکت ناموفق بود.';
} finally {
loading.value = false;
}
}
function payload(): CreateTicketRequest {
return {
subject: subject.value.trim(),
description: description.value || undefined,
status: status.value,
priority: priority.value,
personId: personId.value || null,
companyId: companyId.value || null,
assigneeId: assigneeId.value || null,
};
}
async function save() {
if (!subject.value.trim()) {
error.value = 'موضوع را وارد کنید.';
return;
}
saving.value = true;
error.value = '';
try {
if (isNew.value) {
const { data } = await api.post<TicketDto>('/tickets', payload());
await router.replace({ name: 'ticket-detail', params: { id: data.id } });
} else {
await api.patch(`/tickets/${id.value}`, payload());
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 {
saving.value = false;
}
}
async function saveCsat() {
if (!id.value || csatScore.value == null) {
error.value = 'امتیاز رضایت را انتخاب کنید.';
return;
}
csatSaving.value = true;
error.value = '';
try {
const { data } = await api.patch<TicketDto>(`/tickets/${id.value}`, {
csatScore: csatScore.value,
csatComment: csatComment.value.trim() || null,
});
ticket.value = data;
csatScore.value = data.csatScore;
csatComment.value = data.csatComment ?? '';
} 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 {
csatSaving.value = false;
}
}
async function sendReply() {
if (!id.value || !reply.value.trim()) return;
messaging.value = true;
error.value = '';
try {
const { data } = await api.post<TicketDto>(`/tickets/${id.value}/messages`, {
body: reply.value.trim(),
isInternal: replyInternal.value,
});
ticket.value = data;
reply.value = '';
replyInternal.value = false;
} 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 {
messaging.value = false;
}
}
async function remove() {
if (!id.value || !confirm('این تیکت حذف شود؟')) return;
await api.delete(`/tickets/${id.value}`);
await router.push({ name: 'tickets' });
}
onMounted(load);
</script>
<template>
<section class="page">
<header class="page-head">
<div>
<RouterLink class="back" :to="{ name: 'tickets' }"
>← بازگشت به لیست</RouterLink
>
<h1>{{ isNew ? 'تیکت جدید' : number || 'تیکت' }}</h1>
</div>
</header>
<p v-if="loading" class="muted">در حال بارگذاری…</p>
<template v-else>
<aside
v-if="!isNew && ticket?.slaDueAt"
class="sla-box"
:class="{ bad: ticket.slaBreached }"
>
<template v-if="ticket.slaBreached">مهلت پاسخ اول گذشته است.</template>
<template v-else-if="ticket.firstRespondedAt">
پاسخ اول ثبت شد ({{ formatWhen(ticket.firstRespondedAt) }}).
</template>
<template v-else>
مهلت پاسخ اول: {{ formatWhen(ticket.slaDueAt) }}
</template>
</aside>
<form class="form" @submit.prevent="save">
<div class="grid">
<div class="field wide">
<label>موضوع</label>
<input v-model="subject" required maxlength="200" />
</div>
<div class="field">
<label>وضعیت</label>
<select v-model="status">
<option
v-for="(label, key) in statusLabel"
:key="key"
:value="key"
>
{{ label }}
</option>
</select>
</div>
<div class="field">
<label>اولویت</label>
<select v-model="priority">
<option
v-for="(label, key) in priorityLabel"
:key="key"
:value="key"
>
{{ label }}
</option>
</select>
</div>
<div class="field">
<label>شخص</label>
<select v-model="personId">
<option value="">بدون شخص</option>
<option v-for="p in people" :key="p.id" :value="p.id">
{{ p.fullName }}
</option>
</select>
</div>
<div class="field">
<label>شرکت</label>
<select v-model="companyId">
<option value="">بدون شرکت</option>
<option v-for="c in companies" :key="c.id" :value="c.id">
{{ c.name }}
</option>
</select>
</div>
<div v-if="canAssignOthers" class="field">
<label>مسئول</label>
<select v-model="assigneeId">
<option value="">بدون مسئول</option>
<option v-for="u in users" :key="u.id" :value="u.id">
{{ u.fullName }}
</option>
</select>
</div>
<div class="field wide">
<label>{{ isNew ? 'شرح اولیه' : 'شرح' }}</label>
<textarea v-model="description" rows="4" />
</div>
</div>
<div v-if="isNew && faqHints.length" class="faq-hints">
<p class="faq-title">شاید این FAQ کمک کند:</p>
<details v-for="f in faqHints" :key="f.id">
<summary>{{ f.question }}</summary>
<p>{{ f.answer }}</p>
</details>
</div>
<p v-if="error" class="error">{{ error }}</p>
<div class="form-actions">
<button class="btn" type="submit" :disabled="saving">
{{ saving ? 'در حال ذخیره…' : 'ذخیره' }}
</button>
<button
v-if="!isNew"
class="btn btn-danger"
type="button"
:disabled="saving"
@click="remove"
>
حذف
</button>
</div>
</form>
<section v-if="showCsat" class="csat">
<h2>رضایت مشتری (CSAT)</h2>
<div class="csat-scores">
<button
v-for="n in 5"
:key="n"
type="button"
class="score"
:class="{ active: csatScore === n }"
@click="csatScore = n"
>
{{ n }}
</button>
</div>
<textarea
v-model="csatComment"
rows="2"
placeholder="نظر اختیاری…"
/>
<button
class="btn"
type="button"
:disabled="csatSaving || csatScore == null"
@click="saveCsat"
>
{{ csatSaving ? '…' : ticket?.csatAt ? 'به‌روزرسانی رضایت' : 'ثبت رضایت' }}
</button>
<p v-if="ticket?.csatAt" class="muted">
آخرین ثبت: {{ formatWhen(ticket.csatAt) }}
</p>
</section>
<section v-if="!isNew && ticket" class="thread">
<h2>گفتگو</h2>
<div v-if="ticket.messages.length" class="messages">
<article
v-for="msg in ticket.messages"
:key="msg.id"
class="msg"
:class="{ internal: msg.isInternal }"
>
<header>
<strong>{{ msg.author?.fullName || 'سیستم' }}</strong>
<span class="muted">{{ formatWhen(msg.createdAt) }}</span>
<span v-if="msg.isInternal" class="tag">داخلی</span>
</header>
<p>{{ msg.body }}</p>
</article>
</div>
<p v-else class="muted">هنوز پیامی نیست.</p>
<form class="reply" @submit.prevent="sendReply">
<textarea
v-model="reply"
rows="3"
placeholder="پاسخ یا یادداشت…"
required
/>
<label class="check">
<input v-model="replyInternal" type="checkbox" />
یادداشت داخلی (فقط تیم)
</label>
<button class="btn" type="submit" :disabled="messaging">
{{ messaging ? '…' : 'ارسال' }}
</button>
</form>
</section>
</template>
</section>
</template>
<style scoped>
.page {
display: grid;
gap: 1.1rem;
max-width: 760px;
}
.page-head {
display: flex;
justify-content: space-between;
gap: 1rem;
}
.back {
color: var(--muted);
text-decoration: none;
font-size: 0.9rem;
}
h1 {
margin: 0.25rem 0 0;
}
h2 {
margin: 0 0 0.75rem;
font-size: 1.05rem;
}
.form,
.thread,
.csat {
display: grid;
gap: 0.85rem;
}
.sla-box {
padding: 0.75rem 1rem;
border-radius: var(--radius);
border: 1px solid var(--line);
background: color-mix(in srgb, var(--accent) 8%, #fff);
font-size: 0.95rem;
}
.sla-box.bad {
border-color: color-mix(in srgb, #b42318 40%, var(--line));
background: color-mix(in srgb, #b42318 8%, #fff);
color: #b42318;
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
.field {
display: grid;
gap: 0.35rem;
}
.field.wide {
grid-column: 1 / -1;
}
label {
color: var(--muted);
font-size: 0.9rem;
}
input,
select,
textarea {
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.7rem 0.85rem;
font: inherit;
background: #fff;
width: 100%;
}
.faq-hints {
display: grid;
gap: 0.45rem;
padding: 0.75rem;
border: 1px dashed var(--line);
border-radius: var(--radius);
}
.faq-title {
margin: 0;
font-weight: 700;
font-size: 0.9rem;
}
.faq-hints details {
font-size: 0.9rem;
}
.faq-hints p {
margin: 0.4rem 0 0;
color: var(--muted);
white-space: pre-wrap;
}
.form-actions {
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
}
.csat,
.thread {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 1rem;
}
.csat-scores {
display: flex;
gap: 0.4rem;
}
.score {
width: 2.4rem;
height: 2.4rem;
border-radius: 10px;
border: 1px solid var(--line);
background: #fff;
font: inherit;
cursor: pointer;
}
.score.active {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 12%, #fff);
font-weight: 700;
}
.messages {
display: grid;
gap: 0.65rem;
}
.msg {
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.75rem 0.9rem;
}
.msg.internal {
background: color-mix(in srgb, var(--accent) 6%, #fff);
}
.msg header {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
margin-bottom: 0.35rem;
}
.msg p {
margin: 0;
white-space: pre-wrap;
}
.tag {
font-size: 0.75rem;
color: var(--accent);
font-weight: 700;
}
.reply {
display: grid;
gap: 0.55rem;
}
.check {
display: flex;
align-items: center;
gap: 0.4rem;
color: inherit;
}
.muted {
color: var(--muted);
}
.error {
color: #b42318;
}
@media (max-width: 640px) {
.grid {
grid-template-columns: 1fr;
}
}
</style>
+289
View File
@@ -0,0 +1,289 @@
<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 {
PaginatedResponse,
TicketDto,
TicketPriority,
TicketStatus,
} from '@mento/shared';
const route = useRoute();
const router = useRouter();
const loading = ref(false);
const error = ref('');
const data = ref<PaginatedResponse<TicketDto> | null>(null);
const q = ref(typeof route.query.q === 'string' ? route.query.q : '');
const status = ref<TicketStatus | 'all'>(
typeof route.query.status === 'string' &&
['open', 'in_progress', 'waiting', 'resolved', 'closed'].includes(
route.query.status,
)
? (route.query.status as TicketStatus)
: 'all',
);
const slaOnly = ref(route.query.sla === 'overdue');
const statusLabel: Record<TicketStatus, string> = {
open: 'باز',
in_progress: 'در حال رسیدگی',
waiting: 'در انتظار',
resolved: 'حل‌شده',
closed: 'بسته',
};
const priorityLabel: Record<TicketPriority, string> = {
low: 'کم',
normal: 'عادی',
high: 'بالا',
urgent: 'فوری',
};
const items = computed(() => data.value?.items ?? []);
function formatSla(iso: string | null) {
if (!iso) return '';
try {
return new Intl.DateTimeFormat('fa-IR', {
dateStyle: 'short',
timeStyle: 'short',
}).format(new Date(iso));
} catch {
return iso;
}
}
async function load() {
loading.value = true;
error.value = '';
try {
const { data: res } = await api.get<PaginatedResponse<TicketDto>>(
'/tickets',
{
params: {
q: q.value || undefined,
status: status.value === 'all' ? undefined : status.value,
sla: slaOnly.value ? 'overdue' : 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 onSearch() {
router.replace({
name: 'tickets',
query: {
q: q.value.trim() || undefined,
status: status.value === 'all' ? undefined : status.value,
sla: slaOnly.value ? 'overdue' : undefined,
},
});
}
watch(
() => [route.query.q, route.query.status, route.query.sla],
() => {
q.value = typeof route.query.q === 'string' ? route.query.q : '';
status.value =
typeof route.query.status === 'string' &&
['open', 'in_progress', 'waiting', 'resolved', 'closed'].includes(
route.query.status,
)
? (route.query.status as TicketStatus)
: 'all';
slaOnly.value = route.query.sla === 'overdue';
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>
<RouterLink class="btn" :to="{ name: 'ticket-new' }">تیکت جدید</RouterLink>
</header>
<form class="toolbar" @submit.prevent="onSearch">
<input v-model="q" type="search" placeholder="جستجو در موضوع یا شماره…" />
<select v-model="status" @change="onSearch">
<option value="all">همه وضعیت‌ها</option>
<option v-for="(label, key) in statusLabel" :key="key" :value="key">
{{ label }}
</option>
</select>
<label class="check">
<input v-model="slaOnly" type="checkbox" @change="onSearch" />
فقط تأخیر SLA
</label>
<button class="btn btn-ghost" type="submit">جستجو</button>
</form>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
<div v-else class="list">
<RouterLink
v-for="ticket in items"
:key="ticket.id"
class="row"
:class="{ breached: ticket.slaBreached }"
:to="{ name: 'ticket-detail', params: { id: ticket.id } }"
>
<div>
<strong>{{ ticket.subject }}</strong>
<p class="muted">
{{ ticket.number }}
· {{ statusLabel[ticket.status as TicketStatus] || ticket.status }}
· {{ priorityLabel[ticket.priority as TicketPriority] || ticket.priority }}
<span v-if="ticket.person"> · {{ ticket.person.fullName }}</span>
<span v-if="ticket.assignee"> · {{ ticket.assignee.fullName }}</span>
</p>
<p v-if="ticket.slaDueAt" class="sla-line">
<span v-if="ticket.slaBreached" class="badge-sla bad">تأخیر SLA</span>
<span v-else-if="ticket.firstRespondedAt" class="badge-sla ok"
>پاسخ داده شد</span
>
<span v-else class="badge-sla">مهلت {{ formatSla(ticket.slaDueAt) }}</span>
</p>
</div>
</RouterLink>
<p v-if="!items.length" class="empty">هنوز تیکتی نیست.</p>
</div>
</section>
</template>
<style scoped>
.page {
display: grid;
gap: 1.1rem;
}
.page-head {
display: flex;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
align-items: flex-start;
}
.eyebrow {
margin: 0;
color: var(--accent);
font-weight: 700;
}
h1 {
margin: 0.2rem 0;
}
.lead,
.muted {
color: var(--muted);
}
.toolbar {
display: flex;
gap: 0.55rem;
flex-wrap: wrap;
align-items: center;
}
.toolbar input,
.toolbar select {
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.7rem 0.85rem;
font: inherit;
background: #fff;
min-width: 0;
flex: 1;
}
.check {
display: flex;
align-items: center;
gap: 0.35rem;
white-space: nowrap;
font-size: 0.9rem;
}
.list {
display: grid;
gap: 0.55rem;
}
.row {
display: block;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0.9rem 1rem;
text-decoration: none;
color: inherit;
}
.row.breached {
border-color: color-mix(in srgb, #b42318 35%, var(--line));
}
.row p {
margin: 0.25rem 0 0;
}
.sla-line {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.badge-sla {
font-size: 0.75rem;
font-weight: 700;
color: var(--accent);
}
.badge-sla.bad {
color: #b42318;
}
.badge-sla.ok {
color: #027a48;
}
.empty,
.error {
margin: 0;
}
.error {
color: #b42318;
}
.empty {
padding: 1rem;
border: 1px dashed var(--line);
border-radius: var(--radius);
color: var(--muted);
}
</style>
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue';
const component: DefineComponent<object, object, unknown>;
export default component;
}