Creating & Edit profile

This commit is contained in:
2026-09-15 14:10:52 +03:30
parent 8dcf4ed5e9
commit 9ae176bec0
13 changed files with 767 additions and 77 deletions
+106 -39
View File
@@ -280,8 +280,21 @@ async function toggle(row: MessageAutomationRuleDto) {
}
}
async function remove(row: MessageAutomationRuleDto) {
if (!confirm(`قانون «${row.name}» حذف شود؟`)) return;
const pendingDelete = ref<MessageAutomationRuleDto | null>(null);
function askRemove(row: MessageAutomationRuleDto) {
menuId.value = null;
pendingDelete.value = row;
}
function cancelRemove() {
pendingDelete.value = null;
}
async function confirmRemove() {
const row = pendingDelete.value;
if (!row) return;
pendingDelete.value = null;
try {
await api.delete(`/message-automations/${row.id}`);
if (editingId.value === row.id) resetForm();
@@ -410,7 +423,7 @@ onUnmounted(() => {
</button>
<div v-if="menuId === row.id" class="pop">
<button type="button" @click="openMobileEdit(row)">ویرایش</button>
<button type="button" class="danger" @click="remove(row)">حذف</button>
<button type="button" class="danger" @click="askRemove(row)">حذف</button>
</div>
</div>
<div class="m-copy">
@@ -1071,23 +1084,25 @@ onUnmounted(() => {
<td>{{ MESSAGE_AUTOMATION_TARGET_LABELS[row.notifyTarget] }}</td>
<td>{{ MESSAGE_AUTOMATION_CHANNEL_LABELS[row.channel] }}</td>
<td class="ops">
<button
class="icon-btn status"
type="button"
:class="{ on: row.isActive }"
:aria-pressed="row.isActive"
:aria-label="row.isActive ? 'فعال' : 'غیرفعال'"
:title="row.isActive ? 'فعال' : 'غیرفعال'"
@click="toggle(row)"
>
<UiIcon :name="row.isActive ? 'check-plain' : 'x'" :size="16" />
</button>
<button class="icon-btn" type="button" title="ویرایش" aria-label="ویرایش" @click="startEdit(row)">
<UiIcon name="pencil" :size="16" />
</button>
<button class="icon-btn danger" type="button" title="حذف" aria-label="حذف" @click="remove(row)">
<UiIcon name="trash" :size="16" />
</button>
<div class="ops-row">
<button
class="icon-btn status"
type="button"
:class="{ on: row.isActive }"
:aria-pressed="row.isActive"
:aria-label="row.isActive ? 'فعال' : 'غیرفعال'"
:title="row.isActive ? 'فعال' : 'غیرفعال'"
@click="toggle(row)"
>
<UiIcon :name="row.isActive ? 'check' : 'x-circle'" :size="24" />
</button>
<button class="icon-btn edit" type="button" title="ویرایش" aria-label="ویرایش" @click="startEdit(row)">
<UiIcon name="edit" :size="24" />
</button>
<button class="icon-btn danger" type="button" title="حذف" aria-label="حذف" @click="askRemove(row)">
<UiIcon name="trash" :size="24" />
</button>
</div>
</td>
</tr>
</tbody>
@@ -1123,12 +1138,24 @@ onUnmounted(() => {
<p class="preview">{{ row.description || row.body }}</p>
<div class="card-actions">
<button class="btn ghost" type="button" @click="startEdit(row)">ویرایش</button>
<button class="btn danger" type="button" @click="remove(row)">حذف</button>
<button class="btn danger" type="button" @click="askRemove(row)">حذف</button>
</div>
</article>
</div>
<p v-if="!loading && !items.length" class="empty">هنوز قانون پیامی نیست.</p>
</div>
<div v-if="pendingDelete" class="sheet-layer">
<button class="dim" type="button" aria-label="انصراف" @click="cancelRemove" />
<section class="confirm-box" role="dialog" aria-modal="true" aria-labelledby="delete-confirm-title">
<h3 id="delete-confirm-title">حذف قانون</h3>
<p>آیا از حذف مطمئن هستید؟</p>
<div class="confirm-actions">
<button class="btn danger" type="button" @click="confirmRemove">حذف</button>
<button class="btn ghost" type="button" @click="cancelRemove">انصراف</button>
</div>
</section>
</div>
</section>
</template>
@@ -1678,6 +1705,37 @@ textarea {
background: rgba(15, 23, 42, 0.32);
}
.confirm-box {
position: relative;
z-index: 1;
width: min(22rem, 100%);
display: grid;
gap: 0.85rem;
padding: 1.15rem 1.2rem 1.1rem;
border-radius: 18px;
background: #fff;
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.16);
}
.confirm-box h3 {
margin: 0;
font-size: 1.05rem;
font-weight: 800;
color: #0f172a;
}
.confirm-box p {
margin: 0;
color: #475569;
font-size: 0.95rem;
}
.confirm-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.sheet {
position: relative;
z-index: 1;
@@ -1802,50 +1860,59 @@ textarea {
}
.desk-table .ops {
display: flex;
align-items: center;
gap: 0.3rem;
flex-wrap: nowrap;
display: table-cell;
width: 1%;
white-space: nowrap;
vertical-align: middle;
}
.ops-row {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.icon-btn {
display: grid;
display: inline-grid;
place-items: center;
width: 36px;
height: 36px;
flex: 0 0 2.75rem;
width: 2.75rem;
height: 2.75rem;
padding: 0;
border: 1px solid #e2e8f0;
border-radius: var(--radius);
background: #fff;
border: 0;
border-radius: 8px;
background: transparent;
color: #64748b;
cursor: pointer;
}
.icon-btn:hover {
background: #f8fafc;
background: transparent;
color: #0f172a;
}
.icon-btn.edit {
color: #2563eb;
}
.icon-btn.edit:hover {
color: #1d4ed8;
}
.icon-btn.danger {
color: #b91c1c;
border-color: #fecaca;
color: #dc2626;
}
.icon-btn.danger:hover {
background: #fef2f2;
color: #b91c1c;
background: transparent;
}
.icon-btn.status {
border-color: #fecaca;
background: #fef2f2;
color: #dc2626;
}
.icon-btn.status.on {
border-color: #bbf7d0;
background: #f0fdf4;
color: #16a34a;
}
+320 -26
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { computed, nextTick, ref, watch } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { useAuthStore } from '@/stores/auth';
import { api } from '@/lib/api';
import type { AskMentoResponse } from '@mento/shared';
@@ -68,13 +69,24 @@ const tab = ref<TabKey>('summary');
const aiBrief = ref('');
const moreItems: ProfileMoreItem[] = [
{ key: 'info', label: 'ویرایش اطلاعات', to: { name: 'profile', query: { tab: 'info' } } },
{ key: 'info', label: 'ویرایش اطلاعات' },
{ key: 'activity', label: 'فعالیت جدید', to: { name: 'activity-new' } },
{ key: 'deal', label: 'معامله جدید', to: { name: 'deal-new' } },
{ key: 'quote', label: 'پیش‌فاکتور جدید', to: { name: 'quote-new' } },
{ key: 'ticket', label: 'تیکت جدید', to: { name: 'ticket-new' } },
];
const infoFullName = ref('');
const infoMobile = ref('');
const infoEmail = ref('');
const infoSaving = ref(false);
const infoError = ref('');
const infoOk = ref('');
const nameInput = ref<HTMLInputElement | null>(null);
const avatarInput = ref<HTMLInputElement | null>(null);
const avatarBusy = ref(false);
const avatarError = ref('');
const name = computed(() => auth.user?.fullName || 'کاربر');
const roleLabel = computed(() =>
auth.user?.role ? USER_ROLE_LABELS[auth.user.role] : 'کاربر',
@@ -270,7 +282,73 @@ function readTab() {
function setTab(next: TabKey) {
tab.value = next;
moreOpen.value = false;
void router.replace({ query: { ...route.query, tab: next === 'summary' ? undefined : next } });
const query = { ...route.query };
if (next === 'summary') delete query.tab;
else query.tab = next;
void router.replace({ query });
if (next === 'info') revealInfoForm();
}
function onMoreSelect(key: string) {
if (key === 'info') setTab('info');
}
function revealInfoForm() {
void nextTick(() => {
nameInput.value?.scrollIntoView({ block: 'center', behavior: 'smooth' });
nameInput.value?.focus();
});
}
function apiErrorMessage(e: unknown, fallback: string) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] } | undefined)?.message;
return Array.isArray(msg) ? msg.join('، ') : (msg ?? fallback);
}
return fallback;
}
function syncInfoForm() {
infoFullName.value = auth.user?.fullName ?? '';
infoMobile.value = auth.user?.mobile ?? '';
infoEmail.value = auth.user?.email ?? '';
infoError.value = '';
infoOk.value = '';
}
async function saveInfo() {
const fullName = infoFullName.value.trim();
const mobile = infoMobile.value.trim();
const email = infoEmail.value.trim();
if (fullName.length < 2) {
infoError.value = 'نام باید حداقل دو حرف باشد';
infoOk.value = '';
return;
}
if (!/^09\d{9}$|^\+98\d{10}$|^98\d{10}$|^9\d{9}$/.test(mobile.replace(/[\s\-()]/g, ''))) {
infoError.value = 'شماره موبایل معتبر نیست';
infoOk.value = '';
return;
}
if (!email.includes('@')) {
infoError.value = 'ایمیل معتبر نیست';
infoOk.value = '';
return;
}
infoSaving.value = true;
infoError.value = '';
infoOk.value = '';
try {
await auth.updateProfile({ fullName, mobile, email });
infoFullName.value = auth.user?.fullName ?? fullName;
infoMobile.value = auth.user?.mobile ?? mobile;
infoEmail.value = auth.user?.email ?? email;
infoOk.value = 'اطلاعات ذخیره شد';
} catch (e) {
infoError.value = apiErrorMessage(e, 'ذخیره ناموفق بود');
} finally {
infoSaving.value = false;
}
}
function closeMenus() {
@@ -283,6 +361,47 @@ function onSmartCta() {
void router.push({ name: 'activity-new', query: { type: 'call' } });
}
function openAvatarPicker() {
if (tab.value !== 'info' || avatarBusy.value) return;
avatarInput.value?.click();
}
async function onAvatarFile(ev: Event) {
const input = ev.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) return;
if (!file.type.startsWith('image/')) {
avatarError.value = 'فقط فایل تصویر انتخاب کنید';
return;
}
if (file.size > 2 * 1024 * 1024) {
avatarError.value = 'حجم تصویر باید کمتر از ۲ مگابایت باشد';
return;
}
avatarBusy.value = true;
avatarError.value = '';
try {
await auth.uploadAvatar(file);
} catch (e) {
avatarError.value = apiErrorMessage(e, 'آپلود عکس ناموفق بود');
} finally {
avatarBusy.value = false;
}
}
async function clearAvatar() {
avatarBusy.value = true;
avatarError.value = '';
try {
await auth.removeAvatar();
} catch (e) {
avatarError.value = apiErrorMessage(e, 'حذف عکس ناموفق بود');
} finally {
avatarBusy.value = false;
}
}
async function load() {
loading.value = true;
error.value = '';
@@ -324,6 +443,24 @@ async function loadAiBrief() {
watch(() => route.query.tab, readTab, { immediate: true });
watch(() => auth.user?.id, () => void load(), { immediate: true });
watch(
() => tab.value,
(next) => {
if (next === 'info') syncInfoForm();
},
{ immediate: true },
);
watch(
() =>
[auth.user?.fullName, auth.user?.mobile, auth.user?.email] as const,
() => {
if (tab.value === 'info' && !infoSaving.value) {
infoFullName.value = auth.user?.fullName ?? '';
infoMobile.value = auth.user?.mobile ?? '';
infoEmail.value = auth.user?.email ?? '';
}
},
);
</script>
<template>
@@ -333,7 +470,7 @@ watch(() => auth.user?.id, () => void load(), { immediate: true });
<UiIcon name="chevron" :size="18" />
</RouterLink>
<span class="nav-brand">mento</span>
<ProfileMoreMenu v-model="moreOpen" :items="moreItems" />
<ProfileMoreMenu v-model="moreOpen" :items="moreItems" @select="onMoreSelect" />
</header>
<p v-if="loading" class="muted pad">در حال بارگذاری…</p>
@@ -341,8 +478,25 @@ watch(() => auth.user?.id, () => void load(), { immediate: true });
<template v-else>
<div class="identity">
<div class="avatar online">
<img src="/brand/user-avatar.png" :alt="name" width="76" height="76" />
<div
class="avatar-edit"
:class="{ editable: tab === 'info' }"
@click.stop="openAvatarPicker"
>
<span class="avatar">
<img :src="auth.avatarSrc" :alt="name" width="76" height="76" />
</span>
<i class="online-dot" title="آنلاین" aria-hidden="true" />
<span v-if="tab === 'info'" class="camera-badge" aria-hidden="true">
<UiIcon name="camera" :size="13" />
</span>
<input
ref="avatarInput"
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
hidden
@change="onAvatarFile"
/>
</div>
<div class="who">
<h1>{{ name }}</h1>
@@ -350,6 +504,16 @@ watch(() => auth.user?.id, () => void load(), { immediate: true });
<div class="badges">
<span class="pill active">آنلاین</span>
</div>
<p v-if="avatarError && tab === 'info'" class="error">{{ avatarError }}</p>
<button
v-if="tab === 'info' && auth.user?.hasAvatar"
class="avatar-clear"
type="button"
:disabled="avatarBusy"
@click.stop="clearAvatar"
>
حذف عکس
</button>
</div>
</div>
@@ -531,11 +695,49 @@ watch(() => auth.user?.id, () => void load(), { immediate: true });
</div>
<div v-show="tab === 'info'" class="panel info-panel">
<dl>
<div><dt>نام</dt><dd>{{ name }}</dd></div>
<div><dt>ایمیل</dt><dd>{{ auth.user?.email || '—' }}</dd></div>
<div><dt>نقش</dt><dd>{{ roleLabel }}</dd></div>
</dl>
<form class="info-form" @submit.prevent="saveInfo">
<label class="info-row">
<span>نام</span>
<input
ref="nameInput"
v-model="infoFullName"
type="text"
autocomplete="name"
maxlength="80"
/>
</label>
<label class="info-row">
<span>موبایل</span>
<input
v-model="infoMobile"
type="tel"
inputmode="tel"
dir="ltr"
autocomplete="tel"
maxlength="14"
placeholder="09121234567"
/>
</label>
<label class="info-row">
<span>ایمیل</span>
<input
v-model="infoEmail"
type="email"
dir="ltr"
autocomplete="email"
maxlength="120"
/>
</label>
<div class="info-row">
<span>نقش</span>
<b>{{ roleLabel }}</b>
</div>
<p v-if="infoError" class="error">{{ infoError }}</p>
<p v-else-if="infoOk" class="ok">{{ infoOk }}</p>
<button class="save-btn" type="submit" :disabled="infoSaving">
{{ infoSaving ? 'در حال ذخیره…' : 'ذخیره تغییرات' }}
</button>
</form>
</div>
</template>
@@ -680,13 +882,23 @@ watch(() => auth.user?.id, () => void load(), { immediate: true });
min-width: 0;
}
.avatar {
.avatar-edit {
position: relative;
width: 76px;
height: 76px;
flex-shrink: 0;
}
.avatar-edit.editable {
cursor: pointer;
}
.avatar {
display: block;
width: 76px;
height: 76px;
border-radius: 999px;
overflow: hidden;
flex-shrink: 0;
box-shadow: 0 8px 18px rgba(13, 148, 136, 0.28);
}
@@ -697,16 +909,49 @@ watch(() => auth.user?.id, () => void load(), { immediate: true });
display: block;
}
.avatar.online::after {
content: '';
.online-dot {
position: absolute;
width: 14px;
height: 14px;
bottom: 2px;
inset-inline-start: 2px;
z-index: 2;
width: 16px;
height: 16px;
border-radius: 999px;
background: #22c55e;
border: 3px solid #eef2f6;
box-sizing: border-box;
pointer-events: none;
}
.camera-badge {
position: absolute;
inset-inline-end: 0;
bottom: 0;
z-index: 3;
width: 26px;
height: 26px;
border-radius: 999px;
inset-inline-start: 4px;
bottom: 4px;
background: #0f766e;
color: #fff;
display: grid;
place-items: center;
box-shadow: 0 0 0 3px #eef2f6;
pointer-events: none;
}
.avatar-clear {
margin: 0.15rem 0 0;
border: 0;
background: none;
padding: 0;
color: #64748b;
font: inherit;
font-size: 0.78rem;
cursor: pointer;
}
.avatar-clear:hover {
color: #b91c1c;
}
.who {
@@ -1045,28 +1290,70 @@ h1 {
gap: 0.65rem;
}
.info-panel dl {
.info-form {
margin: 0;
background: #fff;
border-radius: 16px;
padding: 0.4rem 1rem;
padding: 0.4rem 1rem 1rem;
display: grid;
gap: 0;
}
.info-panel dl > div {
.info-row {
display: grid;
grid-template-columns: 7.5rem 1fr;
gap: 0.6rem;
align-items: center;
padding: 0.7rem 0;
border-bottom: 1px solid #f1f5f9;
}
.info-panel dt {
.info-row span {
color: #94a3b8;
font-size: 0.8rem;
}
.info-panel dd {
margin: 0;
.info-row b {
font-weight: 600;
}
.info-row input {
width: 100%;
min-height: 2.5rem;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 0.4rem 0.7rem;
font: inherit;
background: #fff;
}
.info-form .error,
.info-form .ok {
margin: 0.75rem 0 0;
}
.ok {
color: #0f766e;
font-weight: 600;
}
.save-btn {
margin-top: 0.9rem;
justify-self: start;
min-height: 2.6rem;
padding: 0 1.1rem;
border: 0;
border-radius: 12px;
background: #14b8a6;
color: #fff;
font: inherit;
font-weight: 700;
cursor: pointer;
}
.save-btn:disabled {
opacity: 0.65;
cursor: wait;
}
.empty,
@@ -1171,11 +1458,18 @@ h1 {
gap: 0.2rem;
}
.avatar-edit,
.avatar {
width: 64px;
height: 64px;
}
.online-dot {
width: 14px;
height: 14px;
border-width: 2px;
}
.qa {
gap: 0.45rem;
}
@@ -1237,7 +1531,7 @@ h1 {
font-size: 0.8rem;
}
.info-panel dl > div {
.info-row {
grid-template-columns: 1fr;
gap: 0.12rem;
}