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:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
·
|
||||
<RouterLink to="/notes">یادداشتها ←</RouterLink>
|
||||
·
|
||||
<RouterLink to="/pipeline">کاریز فروش ←</RouterLink>
|
||||
·
|
||||
<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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user