Initial admin edits

This commit is contained in:
2026-10-01 20:50:23 +03:30
parent 990883ae11
commit 16806a4d3c
124 changed files with 1964 additions and 740 deletions
+25
View File
@@ -9,6 +9,7 @@ import AdminSelect from '@/components/ui/AdminSelect.vue'
import { bannersApi, type AppBanner } from '@/services/api'
import { ApiError } from '@/services/http'
import { useToast } from '@/composables/useToast'
import { isValidBannerLink } from '@/utils/bannerLink'
const SLOT_OPTIONS = [
{ value: 'home_hero', label: 'خانه — بنر اصلی' },
@@ -28,6 +29,7 @@ const form = reactive({
subtitle: '',
ctaLabel: '',
ctaLink: '/categories',
bannerLink: '',
imageUrl: null as string | null,
displayOrder: 0,
isActive: true,
@@ -61,6 +63,7 @@ function openAdd() {
subtitle: '',
ctaLabel: 'مشاهده کالکشن',
ctaLink: '/categories',
bannerLink: '',
imageUrl: null,
displayOrder: list.value.length,
isActive: true,
@@ -79,6 +82,7 @@ function openEdit(banner: AppBanner) {
subtitle: banner.subtitle,
ctaLabel: banner.ctaLabel,
ctaLink: banner.ctaLink || '/categories',
bannerLink: banner.bannerLink || '',
imageUrl: banner.imageUrl || null,
displayOrder: banner.displayOrder,
isActive: banner.isActive,
@@ -91,6 +95,10 @@ function openEdit(banner: AppBanner) {
async function save() {
if (!form.title.trim()) return
if (!isValidBannerLink(form.bannerLink)) {
toast.error('لینک بنر معتبر نیست. مسیر داخلی (مثل /categories) یا آدرس https وارد کنید.')
return
}
saving.value = true
try {
const payload = {
@@ -99,6 +107,7 @@ async function save() {
subtitle: form.subtitle.trim() || null,
ctaLabel: form.ctaLabel.trim() || null,
ctaLink: form.ctaLink.trim() || null,
bannerLink: form.bannerLink.trim() || null,
imageUrl: form.imageUrl,
displayOrder: form.displayOrder,
isActive: form.isActive,
@@ -222,6 +231,16 @@ onMounted(() => {
لینک دکمه
<input v-model="form.ctaLink" class="admin-input" dir="ltr" placeholder="/categories" />
</label>
<label class="field">
لینک بنر
<input
v-model="form.bannerLink"
class="admin-input"
dir="ltr"
placeholder="/categories یا https://..."
/>
<span class="field-hint">با کلیک روی تصویر بنر در اپ؛ خالی = بدون لینک روی تصویر</span>
</label>
<label class="field">ترتیب نمایش<input v-model.number="form.displayOrder" type="number" min="0" class="admin-input" /></label>
<AdminImagePicker v-model="form.imageUrl" label="تصویر بنر" />
<BaseSwitch v-model="form.isActive" label="فعال" />
@@ -274,4 +293,10 @@ onMounted(() => {
justify-content: space-between;
gap: 12px;
}
.field-hint {
font-size: 0.75rem;
color: var(--color-text-muted);
line-height: 1.4;
}
</style>
-130
View File
@@ -1,130 +0,0 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import AdminPageSkeleton from '@/components/ui/AdminPageSkeleton.vue'
import BaseModal from '@/components/ui/BaseModal.vue'
import BaseSwitch from '@/components/ui/BaseSwitch.vue'
import BaseEmptyState from '@/components/ui/BaseEmptyState.vue'
import { brandsApi, type Brand } from '@/services/api'
import { ApiError } from '@/services/http'
import { useToast } from '@/composables/useToast'
const list = ref<Brand[]>([])
const loading = ref(true)
const modalOpen = ref(false)
const editingId = ref<string | null>(null)
const toast = useToast()
const form = reactive({ name: '', slug: '', description: '', displayOrder: 0, isActive: true })
const title = computed(() => (editingId.value ? 'ویرایش برند' : 'افزودن برند'))
async function load() {
loading.value = true
try {
list.value = await brandsApi.list()
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'بارگذاری برندها ناموفق بود')
} finally {
loading.value = false
}
}
function openAdd() {
editingId.value = null
Object.assign(form, { name: '', slug: '', description: '', displayOrder: list.value.length + 1, isActive: true })
modalOpen.value = true
}
function openEdit(b: Brand) {
editingId.value = b.id
Object.assign(form, {
name: b.name,
slug: b.slug ?? '',
description: b.description ?? '',
displayOrder: b.displayOrder,
isActive: b.isActive,
})
modalOpen.value = true
}
async function save() {
try {
const payload = {
name: form.name.trim(),
slug: form.slug.trim() || undefined,
description: form.description.trim() || undefined,
displayOrder: form.displayOrder,
isActive: form.isActive,
}
if (editingId.value) await brandsApi.update(editingId.value, payload)
else await brandsApi.create(payload)
modalOpen.value = false
toast.success('ذخیره شد')
await load()
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'ذخیره ناموفق بود')
}
}
onMounted(() => {
void load()
})
</script>
<template>
<div class="admin-page">
<header class="admin-page__head">
<div>
<h1>برندها</h1>
</div>
<button type="button" class="admin-btn admin-btn--primary" @click="openAdd">افزودن برند</button>
</header>
<AdminPageSkeleton v-if="loading" variant="table" />
<BaseEmptyState v-else-if="!list.length" title="برندی ثبت نشده" />
<table v-else class="admin-table">
<thead>
<tr>
<th>نام</th>
<th>شناسه لاتین</th>
<th>محصولات</th>
<th>فعال</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="b in list" :key="b.id">
<td>{{ b.name }}</td>
<td dir="ltr">{{ b.slug || '—' }}</td>
<td>{{ b.productsCount ?? 0 }}</td>
<td>{{ b.isActive ? 'بله' : 'خیر' }}</td>
<td><button type="button" class="link" @click="openEdit(b)">ویرایش</button></td>
</tr>
</tbody>
</table>
<BaseModal :open="modalOpen" :title="title" @close="modalOpen = false">
<label class="field">نام<input v-model="form.name" class="admin-input" /></label>
<label class="field">شناسه لاتین<input v-model="form.slug" class="admin-input" dir="ltr" placeholder="مثلاً nademi-silk" /></label>
<label class="field">توضیح<textarea v-model="form.description" class="admin-input" rows="3" /></label>
<label class="field">ترتیب<input v-model.number="form.displayOrder" type="number" class="admin-input" /></label>
<BaseSwitch v-model="form.isActive" label="فعال" />
<template #footer>
<button type="button" class="admin-btn admin-btn--primary" :disabled="!form.name.trim()" @click="save">ذخیره</button>
</template>
</BaseModal>
</div>
</template>
<style scoped>
.admin-page__head {
display: flex;
justify-content: space-between;
margin-bottom: 20px;
}
.admin-page__head h1 { margin: 0 0 4px; font-size: 1.25rem; }
.admin-page__head p { margin: 0; color: var(--color-text-muted); font-size: 0.875rem; }
.admin-table { width: 100%; border-collapse: collapse; }
.admin-table th, .admin-table td { text-align: right; padding: 10px 8px; border-bottom: 1px solid var(--color-border); }
.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; font-size: 0.875rem; }
.link { background: none; border: none; color: var(--color-primary); cursor: pointer; font-family: inherit; }
</style>
+409 -140
View File
@@ -1,47 +1,74 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import AdminPageSkeleton from '@/components/ui/AdminPageSkeleton.vue'
import BaseEmptyState from '@/components/ui/BaseEmptyState.vue'
import AdminSelect from '@/components/ui/AdminSelect.vue'
import PersianDateInput from '@/components/ui/PersianDateInput.vue'
import { catalogRequestsApi, type CatalogRequestRow } from '@/services/api'
import { ApiError } from '@/services/http'
import { useToast } from '@/composables/useToast'
import { formatDateTime } from '@/utils/format'
import { CATALOG_REQUEST_STATUS_LABELS } from '@/utils/adminLabels'
import { formatDate } from '@/utils/format'
import { groupCatalogRequestsByCustomer } from '@/utils/catalogRequestGroups'
import { filterCatalogRequestsByRegisteredOn } from '@/utils/catalogRequestDateFilter'
import AdminIcon from '@/components/icons/AdminIcon.vue'
const STATUS_OPTIONS = [
{ value: '', label: 'همه وضعیت‌ها' },
{ value: 'new', label: 'درخواست جدید' },
{ value: 'approved', label: 'تأیید شده' },
{ value: 'ready_to_ship', label: 'آماده ارسال' },
{ value: 'delivered', label: 'تحویل به مشتری' },
{ value: 'returned', label: 'بازگردانده شده' },
{ value: 'cancelled', label: 'لغو شده' },
{ value: 'new', label: CATALOG_REQUEST_STATUS_LABELS.new },
{ value: 'approved', label: CATALOG_REQUEST_STATUS_LABELS.approved },
{ value: 'ready_to_ship', label: CATALOG_REQUEST_STATUS_LABELS.ready_to_ship },
{ value: 'delivered', label: CATALOG_REQUEST_STATUS_LABELS.delivered },
{ value: 'returned', label: CATALOG_REQUEST_STATUS_LABELS.returned },
{ value: 'cancelled', label: CATALOG_REQUEST_STATUS_LABELS.cancelled },
]
const HOLDER_OPTIONS = [
{ value: 'warehouse', label: 'انبار' },
{ value: 'customer', label: 'مشتری' },
{ value: 'agent', label: 'واسطه' },
]
const STATUS_LABELS: Record<string, string> = Object.fromEntries(
STATUS_OPTIONS.filter((s) => s.value).map((s) => [s.value, s.label]),
)
const HOLDER_LABELS: Record<string, string> = Object.fromEntries(
HOLDER_OPTIONS.map((h) => [h.value, h.label]),
)
const STATUS_CHANGE_OPTIONS = STATUS_OPTIONS.filter((s) => s.value)
const rows = ref<CatalogRequestRow[]>([])
const statusFilter = ref('')
const registeredOn = ref('')
const searchQuery = ref('')
const loading = ref(true)
const updatingHolderId = ref<string | null>(null)
const toast = useToast()
const collapsedCustomerIds = ref<Set<string>>(new Set())
const rowsForView = computed(() =>
filterCatalogRequestsByRegisteredOn(rows.value, registeredOn.value),
)
const groups = computed(() => groupCatalogRequestsByCustomer(rowsForView.value))
const filteredGroups = computed(() => {
const q = searchQuery.value.trim()
if (!q) return groups.value
return groups.value.filter(
(g) => g.customerName.includes(q) || g.customerPhone.includes(q),
)
})
const hasFilters = computed(
() => Boolean(statusFilter.value || registeredOn.value || searchQuery.value.trim()),
)
function isCardExpanded(customerId: string) {
return !collapsedCustomerIds.value.has(customerId)
}
function toggleCard(customerId: string) {
const next = new Set(collapsedCustomerIds.value)
if (next.has(customerId)) next.delete(customerId)
else next.add(customerId)
collapsedCustomerIds.value = next
}
async function load() {
loading.value = true
try {
rows.value = await catalogRequestsApi.list(statusFilter.value || undefined)
rows.value = await catalogRequestsApi.list({
status: statusFilter.value || undefined,
registeredOn: registeredOn.value || undefined,
})
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'بارگذاری انجام نشد')
} finally {
@@ -59,22 +86,9 @@ async function setStatus(id: string, status: string) {
}
}
async function setHolder(itemId: string, holderType: string) {
if (updatingHolderId.value) return
updatingHolderId.value = itemId
try {
await catalogRequestsApi.updateHolder(itemId, { holderType })
toast.success('محل نگهداری به‌روز شد')
await load()
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'به‌روزرسانی محل ناموفق بود')
} finally {
updatingHolderId.value = null
}
}
function holderLabel(value: string): string {
return HOLDER_LABELS[value] ?? value
function clearDateFilter() {
registeredOn.value = ''
void load()
}
onMounted(() => {
@@ -83,137 +97,392 @@ onMounted(() => {
</script>
<template>
<div class="admin-page">
<header class="admin-page__head">
<div class="admin-page catalog-requests-page">
<header class="catalog-requests-page__head">
<div>
<h1>درخواست کالیته فیزیکی</h1>
<p>بررسی، گردش وضعیت و محل نگهداری اقلام</p>
<p>درخواست‌های هر مشتری در یک کارت</p>
</div>
<AdminSelect
v-model="statusFilter"
:options="STATUS_OPTIONS"
@update:model-value="load"
/>
</header>
<AdminPageSkeleton v-if="loading" variant="cards" />
<BaseEmptyState v-else-if="!rows.length" title="درخواستی نیست" />
<template v-else>
<div v-for="row in rows" :key="row.id" class="request-card">
<div class="request-card__top">
<div>
<strong>{{ row.customerName }}</strong>
<span class="muted" dir="ltr">{{ row.customerPhone }}</span>
<span class="badge">{{ STATUS_LABELS[row.status] ?? row.status }}</span>
</div>
<AdminSelect
:model-value="row.status"
:options="STATUS_OPTIONS.filter((s) => s.value)"
@update:model-value="(v) => setStatus(row.id, String(v))"
<div class="catalog-requests-page__toolbar admin-card">
<div class="catalog-requests-page__search">
<AdminIcon name="search" :size="18" />
<input
v-model="searchQuery"
type="search"
class="catalog-requests-page__search-input"
placeholder="جستجو در نام یا شماره مشتری..."
autocomplete="off"
/>
</div>
<p v-if="row.note" class="muted">{{ row.note }}</p>
<table class="admin-table">
<thead>
<tr>
<th>محصول</th>
<th>کد</th>
<th>تعداد</th>
<th>محل</th>
</tr>
</thead>
<tbody>
<tr v-for="item in row.items" :key="item.id">
<td>{{ item.productName }}</td>
<td dir="ltr">{{ item.productCode }}</td>
<td>{{ item.quantity }}</td>
<td>
<AdminSelect
:model-value="item.holderType"
:options="HOLDER_OPTIONS"
@update:model-value="(v) => setHolder(item.id, String(v))"
/>
<ul v-if="item.movements?.length" class="movements">
<li v-for="move in item.movements" :key="move.id">
{{ holderLabel(move.fromHolder) }} → {{ holderLabel(move.toHolder) }}
<span class="muted">{{ formatDateTime(move.movedAt) }}</span>
</li>
</ul>
</td>
</tr>
</tbody>
</table>
<div class="catalog-requests-page__filters">
<div class="catalog-requests-page__date-wrap">
<PersianDateInput
v-model="registeredOn"
class="catalog-requests-page__date"
compact
placeholder="انتخاب روز"
@update:model-value="load"
/>
<button
v-if="registeredOn"
type="button"
class="catalog-requests-page__date-clear"
aria-label="پاک کردن تاریخ"
title="پاک کردن تاریخ"
@click="clearDateFilter"
>
×
</button>
</div>
<AdminSelect
v-model="statusFilter"
class="catalog-requests-page__status"
aria-label="فیلتر وضعیت"
:options="STATUS_OPTIONS"
@update:model-value="load"
/>
</div>
</template>
</div>
<AdminPageSkeleton v-if="loading" variant="cards" />
<BaseEmptyState
v-else-if="!filteredGroups.length"
:title="
hasFilters
? searchQuery.trim()
? 'مشتری با این جستجو پیدا نشد'
: 'درخواستی برای این فیلتر نیست'
: 'درخواستی نیست'
"
:description="
hasFilters
? searchQuery.trim()
? 'عبارت جستجو یا فیلترها را تغییر دهید.'
: 'تاریخ یا وضعیت را تغییر دهید.'
: undefined
"
/>
<div v-else class="catalog-requests-page__list">
<article
v-for="group in filteredGroups"
:key="group.customerId"
class="customer-card"
:class="{ 'customer-card--collapsed': !isCardExpanded(group.customerId) }"
>
<button
type="button"
class="customer-card__toggle"
:aria-expanded="isCardExpanded(group.customerId)"
@click="toggleCard(group.customerId)"
>
<span class="customer-card__chevron" aria-hidden="true">
{{ isCardExpanded(group.customerId) ? '▾' : '◂' }}
</span>
<span class="customer-card__toggle-main">
<span class="customer-card__identity">
<strong class="customer-card__name">{{ group.customerName }}</strong>
<span class="customer-card__phone" dir="ltr">{{ group.customerPhone }}</span>
</span>
<span class="customer-card__badge">{{ group.lines.length }} درخواست</span>
</span>
</button>
<p
v-if="isCardExpanded(group.customerId)"
class="customer-card__address"
>
<span class="customer-card__address-label">آدرس:</span>
{{ group.address }}
</p>
<div v-show="isCardExpanded(group.customerId)" class="customer-card__table-wrap">
<table class="customer-card__table">
<thead>
<tr>
<th>تاریخ ثبت</th>
<th>محصول</th>
<th>کد</th>
<th>وضعیت</th>
</tr>
</thead>
<tbody>
<tr v-for="line in group.lines" :key="line.lineKey">
<td class="customer-card__date">{{ formatDate(line.createdAt) }}</td>
<td>{{ line.productName }}</td>
<td dir="ltr" class="customer-card__code">{{ line.productCode }}</td>
<td class="customer-card__status">
<AdminSelect
:model-value="line.status"
class="customer-card__status-select"
:options="STATUS_CHANGE_OPTIONS"
@update:model-value="(v) => setStatus(line.requestId, String(v))"
/>
</td>
</tr>
</tbody>
</table>
</div>
</article>
</div>
</div>
</template>
<style scoped>
.admin-page__head {
.catalog-requests-page__head {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
margin-bottom: 20px;
gap: 12px 16px;
margin-bottom: 16px;
}
.admin-page__head h1 {
margin: 0 0 4px;
font-size: 1.25rem;
.catalog-requests-page__head h1 {
margin: 0 0 2px;
font-size: 1.15rem;
}
.admin-page__head p {
.catalog-requests-page__head p {
margin: 0;
color: var(--color-text-muted);
font-size: 0.875rem;
font-size: 0.8125rem;
}
.request-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 12px;
padding: 16px;
margin-bottom: 12px;
}
.request-card__top {
.catalog-requests-page__toolbar {
display: flex;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
align-items: center;
margin-bottom: 10px;
gap: 12px;
padding: 12px 14px;
margin-bottom: 14px;
}
.request-card__top strong {
margin-left: 8px;
}
.muted {
.catalog-requests-page__search {
flex: 1 1 220px;
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
color: var(--color-text-muted);
font-size: 0.8125rem;
margin-left: 8px;
}
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
background: var(--color-primary-soft);
color: var(--color-primary);
font-size: 0.75rem;
.catalog-requests-page__search-input {
flex: 1;
min-width: 0;
border: none;
background: transparent;
font-size: 0.875rem;
outline: none;
color: var(--color-text);
}
.admin-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8125rem;
.catalog-requests-page__filters {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 8px;
flex: 1 1 280px;
justify-content: flex-end;
}
.admin-table th,
.admin-table td {
text-align: right;
padding: 8px 6px;
border-top: 1px solid var(--color-border);
vertical-align: top;
.catalog-requests-page__date-wrap {
position: relative;
display: flex;
align-items: flex-end;
gap: 4px;
}
.movements {
list-style: none;
margin: 8px 0 0;
padding: 0;
.catalog-requests-page__date {
width: min(168px, 42vw);
min-width: 132px;
}
.catalog-requests-page__date-clear {
flex-shrink: 0;
width: 28px;
height: 36px;
margin-bottom: 0;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-elevated);
color: var(--color-text-muted);
font-size: 1.125rem;
line-height: 1;
cursor: pointer;
transition:
color 0.15s,
border-color 0.15s;
}
.catalog-requests-page__date-clear:hover {
color: var(--color-danger);
border-color: var(--color-danger);
}
.catalog-requests-page__status {
width: min(168px, 42vw);
min-width: 140px;
}
.catalog-requests-page__list {
display: flex;
flex-direction: column;
gap: 4px;
gap: 10px;
}
.customer-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 10px;
padding: 10px 12px;
}
.customer-card__toggle {
display: flex;
align-items: flex-start;
gap: 8px;
width: 100%;
margin: 0;
padding: 0;
border: none;
background: none;
text-align: right;
cursor: pointer;
color: inherit;
}
.customer-card__toggle:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
border-radius: 6px;
}
.customer-card__chevron {
flex-shrink: 0;
width: 22px;
margin-top: 2px;
font-size: 0.75rem;
color: var(--color-text-muted);
}
.customer-card__toggle-main {
flex: 1;
min-width: 0;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 6px 10px;
}
.customer-card--collapsed .customer-card__table-wrap {
display: none;
}
.customer-card__badge {
flex-shrink: 0;
padding: 2px 8px;
border-radius: 999px;
background: var(--color-elevated);
font-size: 0.6875rem;
font-weight: 600;
color: var(--color-text-muted);
}
.customer-card__identity {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 6px 10px;
min-width: 0;
}
.customer-card__name {
font-size: 0.9rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: min(240px, 55vw);
}
.customer-card__phone {
font-size: 0.8125rem;
color: var(--color-text-muted);
}
.customer-card__address {
margin: 6px 0 8px;
padding: 0 30px 8px 0;
border-bottom: 1px solid var(--color-border);
font-size: 0.75rem;
line-height: 1.45;
color: var(--color-text-muted);
}
.customer-card--collapsed .customer-card__address {
display: none;
}
.customer-card__address-label {
color: var(--color-text);
font-weight: 600;
}
.customer-card__table-wrap {
overflow-x: auto;
}
.customer-card__table {
width: 100%;
border-collapse: collapse;
font-size: 0.75rem;
}
.customer-card__table th,
.customer-card__table td {
text-align: right;
padding: 5px 6px;
border-top: 1px solid var(--color-border);
vertical-align: middle;
}
.customer-card__table thead th {
border-top: none;
color: var(--color-text-muted);
font-weight: 600;
white-space: nowrap;
}
.customer-card__date {
white-space: nowrap;
}
.customer-card__code {
white-space: nowrap;
}
.customer-card__status {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
min-width: 140px;
}
.customer-card__status-select {
flex: 1 1 120px;
min-width: 120px;
}
@media (min-width: 768px) {
.customer-card {
padding: 12px 14px;
}
.customer-card__table {
font-size: 0.8125rem;
}
}
</style>
+1 -1
View File
@@ -412,7 +412,7 @@ onMounted(() => {
</p>
</div>
<button type="button" class="admin-btn admin-btn--primary" :disabled="saving || loading" @click="save">
{{ saving ? 'در حال ذخیره...' : 'ذخیره تغییرات' }}
{{ saving ? 'در حال ذخیره...' : 'ثبت تغییرات' }}
</button>
</div>
+48 -9
View File
@@ -22,6 +22,12 @@ const saving = ref(false)
const companyId = computed(() => String(route.params.companyId))
const isPaymentRecipient = computed(
() =>
company.value?.isPaymentRecipient === true ||
company.value?.slug === 'nademitex',
)
const accounts = computed(() => accountList.value)
const emptyForm = () => ({
@@ -44,12 +50,15 @@ const isFormValid = computed(
async function loadData() {
try {
const [companyData, accountsData] = await Promise.all([
companiesApi.get(companyId.value),
bankAccountsApi.listByCompany(companyId.value),
])
const companyData = await companiesApi.get(companyId.value)
company.value = companyData
accountList.value = accountsData
const recipient =
companyData.isPaymentRecipient === true || companyData.slug === 'nademitex'
if (!recipient) {
accountList.value = []
return
}
accountList.value = await bankAccountsApi.listByCompany(companyId.value)
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'بارگذاری حساب‌ها انجام نشد.')
}
@@ -71,6 +80,10 @@ function closeAddModal() {
}
async function submitAddAccount() {
if (!isPaymentRecipient.value) {
formError.value = 'حساب بانکی فقط برای شرکت نادمی تکس (دریافت‌کننده پرداخت) ثبت می‌شود.'
return
}
if (!isFormValid.value) {
formError.value = 'فیلدهای ستاره‌دار الزامی هستند.'
return
@@ -108,14 +121,31 @@ function goBack() {
<template>
<div class="accounts-page">
<p
v-if="company && !isPaymentRecipient"
class="admin-card accounts-page__notice"
role="status"
>
حساب‌های دریافت پرداخت مشتریان فقط برای شرکت نادمی تکس تعریف می‌شوند. شرکت‌های دیگر
صرفاً برای مشخص‌کردن مالکیت محصول هستند و حساب بانکی مستقل ندارند.
</p>
<div class="admin-page-header">
<div>
<h1 class="admin-page-header__title">
مدیریت حساب‌های بانکی — {{ company?.name ?? '…' }}
</h1>
<p v-if="isPaymentRecipient" class="admin-page-header__subtitle">
تمام پرداخت‌های اپ به این شرکت واریز می‌شود.
</p>
</div>
<div class="admin-action-group">
<button type="button" class="admin-btn admin-btn--primary" @click="openAddModal">
<button
v-if="isPaymentRecipient"
type="button"
class="admin-btn admin-btn--primary"
@click="openAddModal"
>
افزودن حساب
</button>
<button type="button" class="admin-btn admin-btn--outline admin-btn--sm" @click="goBack">
@@ -128,10 +158,19 @@ function goBack() {
v-if="!accounts.length"
class="admin-card"
icon="wallet"
title="حساب بانکی ثبت نشده"
description="برای این شرکت هنوز حساب بانکی ثبت نشده است."
:title="isPaymentRecipient ? 'حساب بانکی ثبت نشده' : 'حساب بانکی برای این شرکت تعریف نمی‌شود'"
:description="
isPaymentRecipient
? 'هنوز حساب بانکی برای دریافت پرداخت ثبت نشده است.'
: 'از منوی شرکت‌ها، حساب‌های نادمی تکس را مدیریت کنید.'
"
>
<button type="button" class="admin-btn admin-btn--primary admin-btn--sm" @click="openAddModal">
<button
v-if="isPaymentRecipient"
type="button"
class="admin-btn admin-btn--primary admin-btn--sm"
@click="openAddModal"
>
افزودن حساب
</button>
</BaseEmptyState>
+10 -4
View File
@@ -53,7 +53,7 @@ const formError = ref('')
const isModalOpen = computed(() => modalMode.value !== null)
const modalTitle = computed(() => (modalMode.value === 'edit' ? 'ویرایش شرکت' : 'افزودن شرکت'))
const submitLabel = computed(() => (modalMode.value === 'edit' ? 'ذخیره تغییرات' : 'ثبت شرکت'))
const submitLabel = computed(() => (modalMode.value === 'edit' ? 'ثبت تغییرات' : 'ثبت شرکت'))
const isFormValid = computed(() => form.name.trim().length > 0 && logoPreview.value !== null)
function resetLogo() {
@@ -266,11 +266,12 @@ function companyInitial(name: string) {
ویرایش
</button>
<button
v-if="company.isPaymentRecipient || company.slug === 'nademitex'"
type="button"
class="admin-btn admin-btn--outline admin-btn--xs"
@click="goAccounts(company.id)"
>
حساب‌ها
حساب‌های پرداخت
</button>
</div>
</td>
@@ -313,8 +314,13 @@ function companyInitial(name: string) {
<button type="button" class="admin-btn admin-btn--ghost admin-btn--xs" @click="openEditModal(company)">
ویرایش
</button>
<button type="button" class="admin-btn admin-btn--outline admin-btn--xs" @click="goAccounts(company.id)">
حساب‌ها
<button
v-if="company.isPaymentRecipient || company.slug === 'nademitex'"
type="button"
class="admin-btn admin-btn--outline admin-btn--xs"
@click="goAccounts(company.id)"
>
حساب‌های پرداخت
</button>
</div>
</article>
+1 -1
View File
@@ -485,7 +485,7 @@ function clearSearch() {
<span v-if="saving" class="admin-btn__spinner" aria-hidden="true" />
{{
modalMode === 'edit'
? 'ذخیره تغییرات'
? 'ثبت تغییرات'
: form.appRole === 'agent'
? 'ثبت واسطه'
: 'ثبت مشتری'
@@ -7,6 +7,7 @@ import BaseModal from '@/components/ui/BaseModal.vue'
import OrderDetailContent from '@/views/orders/components/OrderDetailContent.vue'
import { useOrdersPageContext } from '@/composables/useOrdersPage'
import { ORDER_TABS, canFinalApproveOrder, canPriceOrder } from '@/utils/orderStatus'
import { orderShowsCashActions, orderShowsCheckActions } from '@/utils/settlement'
import { formatCurrency, formatDateTime, formatMeterage } from '@/utils/format'
import type { Order } from '@/types'
@@ -269,7 +270,7 @@ function runMenuAction(action: 'detail' | 'pricing' | 'checks' | 'cash' | 'final
قیمت‌گذاری
</button>
<button
v-if="menuOrder.settlementMethod === 'check'"
v-if="menuOrder && orderShowsCheckActions(menuOrder)"
type="button"
role="menuitem"
class="orders-page__menu-item"
@@ -278,7 +279,7 @@ function runMenuAction(action: 'detail' | 'pricing' | 'checks' | 'cash' | 'final
مدیریت چک‌ها
</button>
<button
v-if="menuOrder.settlementMethod === 'cash' && menuOrder.rawStatus !== 'cash_confirmed'"
v-if="menuOrder && orderShowsCashActions(menuOrder)"
type="button"
role="menuitem"
class="orders-page__menu-item"
@@ -8,6 +8,7 @@ import { CASH_PAYMENT_STATUS_LABELS, CUSTOMER_TYPE_LABELS } from '@/utils/adminL
import AdminImageLightbox from '@/components/ui/AdminImageLightbox.vue'
import { ORDER_STATUS_META, canFinalApproveOrder, canPriceOrder, rawStatusLabel } from '@/utils/orderStatus'
import type { CustomerType, Order, RawOrderStatus } from '@/types'
import { orderShowsCashActions, orderShowsCheckActions } from '@/utils/settlement'
const props = withDefaults(
defineProps<{
@@ -75,13 +76,10 @@ const customerTypeLabel = computed(() => {
const showPricingAction = computed(() => canPriceOrder(props.order))
const showFinalApprove = computed(() => canFinalApproveOrder(props.order))
const showChecksAction = computed(
() => props.order.settlementMethod === 'check' && !showPricingAction.value,
() => orderShowsCheckActions(props.order) && !showPricingAction.value,
)
const showCashAction = computed(
() =>
props.order.settlementMethod === 'cash' &&
!showPricingAction.value &&
props.order.rawStatus !== 'cash_confirmed',
() => orderShowsCashActions(props.order) && !showPricingAction.value,
)
/** Primary CTA follows the current workflow stage. */
const primaryAction = computed<'pricing' | 'final' | 'checks' | 'cash' | null>(() => {
+218 -115
View File
@@ -8,17 +8,22 @@ import BaseEmptyState from '@/components/ui/BaseEmptyState.vue'
import ActiveSwitchCell from '@/components/ui/ActiveSwitchCell.vue'
import AdminIcon from '@/components/icons/AdminIcon.vue'
import AdminSelect from '@/components/ui/AdminSelect.vue'
import { brandsApi, companiesApi, productsApi, settingsApi, type Brand } from '@/services/api'
import { companiesApi, productsApi, settingsApi } from '@/services/api'
import { ApiError } from '@/services/http'
import { useToast } from '@/composables/useToast'
import { confirm } from '@/composables/useConfirm'
import { formatCurrency, formatNumber } from '@/utils/format'
import type { Company, Product } from '@/types'
import AdminCurrencyInput from '@/components/ui/AdminCurrencyInput.vue'
import { toCssBackgroundUrl } from '@/utils/catalogImage'
import {
PAYMENT_RECIPIENT_COMPANY_SLUG,
productCompanyIdForForm,
resolveProductCompanyPayload,
} from '@/utils/company'
const productList = ref<Product[]>([])
const companies = ref<Company[]>([])
const brands = ref<Brand[]>([])
const usageOptions = ref<Array<{ value: string; label: string }>>([
{ value: 'trousers', label: 'شلوار' },
{ value: 'suit', label: 'کت‌وشلوار' },
@@ -47,25 +52,17 @@ const companyFilterOptions = computed(() => [
})),
])
const companyFormOptions = computed(() =>
companies.value
.filter((c) => c.isActive)
.map((company) => ({
value: company.id,
label: company.name,
})),
)
const brandFormOptions = computed(() => [
{ value: '', label: 'بدون برند' },
...brands.value
.filter((b) => b.isActive)
.map((b) => ({ value: b.id, label: b.name })),
])
const companyFormOptions = computed(() => {
const opts = [{ value: '', label: 'نادمی تکس (پیش‌فرض)' }]
for (const company of companies.value) {
if (!company.isActive || company.slug === PAYMENT_RECIPIENT_COMPANY_SLUG) continue
opts.push({ value: company.id, label: company.name })
}
return opts
})
const emptyForm = () => ({
companyId: companies.value[0]?.id ?? '',
brandId: '',
companyId: '',
name: '',
color: '',
productCode: '',
@@ -79,6 +76,7 @@ const emptyForm = () => ({
composition: '',
weaveType: '',
subtitle: '',
specs: '',
rollMeters: 50,
isNew: false,
generalImages: [] as string[],
@@ -93,7 +91,7 @@ const form = reactive(emptyForm())
const isModalOpen = computed(() => modalMode.value !== null)
const modalTitle = computed(() => (modalMode.value === 'edit' ? 'ویرایش محصول' : 'افزودن محصول'))
const isFormValid = computed(() => form.name.trim() && form.companyId && form.minMeterage > 0)
const isFormValid = computed(() => form.name.trim() && form.minMeterage > 0)
const hasActiveFilters = computed(
() => searchQuery.value.trim().length > 0 || companyFilter.value !== 'all',
)
@@ -119,15 +117,13 @@ const filteredProducts = computed(() => {
async function loadData() {
loading.value = true
try {
const [productsData, companiesData, brandsData, settings] = await Promise.all([
const [productsData, companiesData, settings] = await Promise.all([
productsApi.list(),
companiesApi.list(),
brandsApi.list(),
settingsApi.get().catch(() => null),
])
productList.value = productsData
companies.value = companiesData
brands.value = brandsData
if (settings?.categoryDefsJson?.usage?.length) {
usageOptions.value = [
...settings.categoryDefsJson.usage.map((item) => ({
@@ -169,8 +165,7 @@ async function openEditModal(product: Product) {
} catch {
/* از ردیف جدول */
}
form.companyId = product.companyId ?? companies.value[0]?.id ?? ''
form.brandId = product.brandId ?? ''
form.companyId = productCompanyIdForForm(product.companyId, companies.value)
form.name = product.name
form.color = product.color
form.productCode = product.productCode
@@ -184,10 +179,15 @@ async function openEditModal(product: Product) {
form.composition = (product as { composition?: string }).composition ?? ''
form.weaveType = (product as { weaveType?: string }).weaveType ?? ''
form.subtitle = (product as { subtitle?: string }).subtitle ?? ''
form.specs = (product as { specs?: string }).specs ?? ''
form.rollMeters = (product as { rollMeters?: number }).rollMeters ?? 50
form.isNew = (product as { isNew?: boolean }).isNew ?? false
form.generalImages = [...(product.imageUrls ?? [])]
const colors = product.colors ?? []
const colors = product.colors?.length
? product.colors
: product.color?.trim()
? [product.color.trim()]
: []
if (colors.length) {
form.colorRows = colors.map((name, index) => {
const fromList = product.colorVariantImages?.[index]?.[0]
@@ -199,7 +199,7 @@ async function openEditModal(product: Product) {
}
})
} else {
form.colorRows = [{ id: crypto.randomUUID(), name: product.color || '', imageUrl: '' }]
form.colorRows = [{ id: crypto.randomUUID(), name: '', imageUrl: '' }]
}
formError.value = ''
}
@@ -270,14 +270,17 @@ function closeModal() {
async function submitProduct() {
if (!isFormValid.value) {
formError.value = 'نام محصول، شرکت و حداقل متراژ الزامی هستند.'
formError.value = 'نام محصول و حداقل متراژ الزامی هستند.'
return
}
const company = companies.value.find((c) => c.id === form.companyId)
if (!company) {
formError.value = 'شرکت معتبر انتخاب کنید.'
return
const companyIdPayload = resolveProductCompanyPayload(form.companyId)
if (form.companyId.trim()) {
const company = companies.value.find((c) => c.id === form.companyId)
if (!company?.isActive) {
formError.value = 'شرکت معتبر انتخاب کنید.'
return
}
}
const { colorEntries, imageUrls } = buildProductMedia()
@@ -287,6 +290,7 @@ async function submitProduct() {
composition: form.composition.trim() || null,
weaveType: form.weaveType.trim() || null,
subtitle: form.subtitle.trim() || null,
specs: form.specs.trim() || undefined,
rollMeters: form.rollMeters,
isNew: form.isNew,
colorsJson: colorEntries,
@@ -297,6 +301,7 @@ async function submitProduct() {
try {
if (modalMode.value === 'edit' && editingId.value) {
await productsApi.update(editingId.value, {
companyId: companyIdPayload,
name: form.name.trim(),
color: form.color.trim() || colorEntries[0]?.name || '—',
code: productCode,
@@ -314,7 +319,7 @@ async function submitProduct() {
toast.success('اطلاعات محصول به‌روزرسانی شد.')
} else {
const created = await productsApi.create({
companyId: company.id,
companyId: companyIdPayload,
name: form.name.trim(),
color: form.color.trim() || colorEntries[0]?.name || '—',
code: productCode,
@@ -324,7 +329,6 @@ async function submitProduct() {
price: form.price,
internalStock: form.internalStock,
isActive: form.isActive,
brandId: form.brandId || null,
...catalogPayload,
})
productList.value.push(created)
@@ -345,6 +349,24 @@ async function persistActive(id: string, isActive: boolean) {
await productsApi.setActive(id, isActive)
}
async function removeProduct(product: Product) {
const ok = await confirm({
title: 'حذف محصول',
message: `آیا از حذف دائمی «${product.name}» مطمئن هستید؟ این عمل قابل بازگشت نیست.`,
confirmLabel: 'حذف',
cancelLabel: 'انصراف',
tone: 'danger',
})
if (!ok) return
try {
await productsApi.remove(product.id)
productList.value = productList.value.filter((item) => item.id !== product.id)
toast.success('محصول حذف شد.')
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'حذف محصول انجام نشد.')
}
}
function clearFilters() {
searchQuery.value = ''
companyFilter.value = 'all'
@@ -409,8 +431,6 @@ function clearFilters() {
<th>کد محصول</th>
<th>نام</th>
<th>رنگ</th>
<th>برند</th>
<th>کد محصول</th>
<th>شرکت</th>
<th class="numeric">حداقل متراژ</th>
<th class="numeric">ضریب</th>
@@ -432,8 +452,6 @@ function clearFilters() {
<td>{{ product.productCode }}</td>
<td>{{ product.name }}</td>
<td>{{ product.color }}</td>
<td>{{ product.brandName || '—' }}</td>
<td dir="ltr">{{ product.productCode }}</td>
<td>{{ product.companyName }}</td>
<td class="numeric">{{ formatNumber(product.minMeterage) }} متر</td>
<td class="numeric">{{ formatNumber(product.orderStep) }}</td>
@@ -454,6 +472,13 @@ function clearFilters() {
<button type="button" class="admin-btn admin-btn--ghost admin-btn--xs" @click="openEditModal(product)">
ویرایش
</button>
<button
type="button"
class="admin-btn admin-btn--outline-danger admin-btn--xs"
@click="removeProduct(product)"
>
حذف
</button>
</td>
</tr>
</tbody>
@@ -476,6 +501,13 @@ function clearFilters() {
<button type="button" class="admin-btn admin-btn--ghost admin-btn--xs" @click="openEditModal(product)">
ویرایش
</button>
<button
type="button"
class="admin-btn admin-btn--outline-danger admin-btn--xs"
@click="removeProduct(product)"
>
حذف
</button>
</div>
</div>
<div class="admin-card-item__meta">
@@ -492,27 +524,16 @@ function clearFilters() {
<p v-if="formError" class="admin-form__error" role="alert">{{ formError }}</p>
<div class="admin-form__field">
<label>شرکت <span class="admin-form__required">*</span></label>
<label>شرکت مرتبط با محصول</label>
<AdminSelect
v-model="form.companyId"
block
aria-label="انتخاب شرکت"
placeholder="انتخاب شرکت"
aria-label="انتخاب شرکت مرتبط با محصول"
placeholder="نادمی تکس (پیش‌فرض)"
:options="companyFormOptions"
/>
</div>
<div class="admin-form__field">
<label>برند</label>
<AdminSelect
v-model="form.brandId"
block
aria-label="برند محصول"
placeholder="انتخاب برند"
:options="brandFormOptions"
/>
</div>
<div class="admin-form__row">
<div class="admin-form__field">
<label for="product-name">نام محصول <span class="admin-form__required">*</span></label>
@@ -544,9 +565,7 @@ function clearFilters() {
<div class="admin-form__field">
<label for="product-price">قیمت هر متر (تومان)</label>
<AdminCurrencyInput id="product-price" v-model="form.price" placeholder="مثلاً ۲۵۰٬۰۰۰" />
<p class="admin-form__hint">
مبلغ به‌ازای هر متر پارچه؛ در سفارش با متراژ تأیید‌شده ضرب می‌شود.
</p>
</div>
<div class="admin-form__field">
<label for="product-stock">موجودی داخلی (متر)</label>
@@ -557,19 +576,25 @@ function clearFilters() {
<div class="admin-form__row">
<div class="admin-form__field">
<label for="product-usage">کاربرد</label>
<select id="product-usage" v-model="form.usageCategory" class="admin-input">
<option v-for="opt in usageOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
<AdminSelect
id="product-usage"
v-model="form.usageCategory"
block
aria-label="کاربرد محصول"
placeholder="انتخاب کاربرد"
:options="usageOptions"
/>
</div>
<div class="admin-form__field">
<label for="product-season">فصل</label>
<select id="product-season" v-model="form.season" class="admin-input">
<option v-for="opt in seasonOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
<AdminSelect
id="product-season"
v-model="form.season"
block
aria-label="فصل محصول"
placeholder="انتخاب فصل"
:options="seasonOptions"
/>
</div>
</div>
@@ -595,30 +620,50 @@ function clearFilters() {
</div>
</div>
<div class="admin-form__field">
<label for="product-specs">مشخصات محصول</label>
<p class="admin-form__hint">
متن آزاد؛ در بخش «مشاهده همه مشخصات» صفحه محصول در اپ نمایش داده می‌شود.
</p>
<textarea
id="product-specs"
v-model="form.specs"
class="admin-form__textarea"
rows="5"
placeholder="مثلاً جنس، عرض، وزن و نکات نگهداری — هر خط یک بند"
/>
</div>
<div class="admin-form__field">
<label>تصاویر عمومی محصول</label>
<p class="color-variants__hint">
فقط برای گالری اصلی در اپ کاربر (بدون وابستگی به رنگ). با «ذخیره تغییرات» ثبت می‌شود.
فقط برای گالری اصلی در اپ کاربر (بدون وابستگی به رنگ). با «ثبت تغییرات» ثبت می‌شود.
</p>
<div class="general-images">
<div
v-for="(url, gIdx) in form.generalImages"
:key="`general-${gIdx}`"
class="general-images__row"
class="product-media-row"
role="group"
:aria-label="`تصویر عمومی ${gIdx + 1}`"
>
<AdminImagePicker
compact
:show-remove="false"
:model-value="url || null"
@update:model-value="form.generalImages[gIdx] = $event ?? ''"
/>
<button
type="button"
class="admin-btn admin-btn--ghost admin-btn--xs"
class="product-media-row__delete"
aria-label="حذف این ردیف تصویر"
title="حذف این ردیف"
@click="removeGeneralImage(gIdx)"
>
حذف تصویر
<AdminIcon name="trash" :size="17" />
</button>
</div>
<button type="button" class="admin-btn admin-btn--outline" @click="addGeneralImageSlot">
<button type="button" class="admin-btn admin-btn--outline product-media-add" @click="addGeneralImageSlot">
+ افزودن تصویر عمومی
</button>
</div>
@@ -630,25 +675,37 @@ function clearFilters() {
برای هر رنگ یک نام و یک تصویر بگذارید. تصویر رنگ در بخش رنگ‌بندی اپ نمایش داده می‌شود، نه در گالری عمومی.
</p>
<div class="color-variants">
<div v-for="(row, index) in form.colorRows" :key="row.id" class="color-variant">
<div
v-for="(row, index) in form.colorRows"
:key="row.id"
class="product-media-row product-media-row--color"
role="group"
:aria-label="`رنگ ${index + 1}`"
>
<AdminImagePicker
:model-value="row.imageUrl || null"
compact
:show-remove="false"
:model-value="row.imageUrl || null"
@update:model-value="row.imageUrl = $event ?? ''"
/>
<div class="color-variant__fields">
<input
v-model="row.name"
type="text"
class="admin-input"
:placeholder="`نام رنگ ${index + 1}`"
/>
<button type="button" class="admin-btn admin-btn--ghost admin-btn--xs" @click="removeColorRow(index)">
حذف این رنگ
</button>
</div>
<input
v-model="row.name"
type="text"
class="admin-input product-media-row__input"
:placeholder="`نام رنگ ${index + 1}`"
:aria-label="`نام رنگ ${index + 1}`"
/>
<button
type="button"
class="product-media-row__delete"
aria-label="حذف این رنگ"
title="حذف این رنگ"
@click="removeColorRow(index)"
>
<AdminIcon name="trash" :size="17" />
</button>
</div>
<button type="button" class="admin-btn admin-btn--outline" @click="addColorRow">
<button type="button" class="admin-btn admin-btn--outline product-media-add" @click="addColorRow">
+ افزودن رنگ
</button>
</div>
@@ -666,7 +723,7 @@ function clearFilters() {
<template #footer>
<button type="button" class="admin-btn admin-btn--outline" @click="closeModal">انصراف</button>
<button type="button" class="admin-btn admin-btn--primary" :disabled="!isFormValid || saving" @click="submitProduct">
{{ modalMode === 'edit' ? 'ذخیره تغییرات' : 'ثبت محصول' }}
{{ modalMode === 'edit' ? 'ثبت تغییرات' : 'ثبت محصول' }}
</button>
</template>
</BaseModal>
@@ -727,12 +784,6 @@ function clearFilters() {
background: #eef1f0;
}
.color-variants {
display: flex;
flex-direction: column;
gap: 14px;
}
.color-variants__hint {
margin: 0 0 10px;
color: var(--color-text-muted);
@@ -740,41 +791,93 @@ function clearFilters() {
line-height: 1.6;
}
.general-images {
.general-images,
.color-variants {
display: flex;
flex-direction: column;
gap: 12px;
gap: 10px;
}
.general-images__row {
display: flex;
flex-wrap: wrap;
gap: 12px;
.product-media-row {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
padding: 10px;
gap: 12px 14px;
padding: 12px 14px;
border: 1px solid var(--color-border);
border-radius: 12px;
background: var(--color-bg, #f7f8f7);
border-radius: 14px;
background: var(--color-elevated, #fff);
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
}
.color-variant {
display: flex;
flex-wrap: wrap;
gap: 12px;
.product-media-row--color {
grid-template-columns: auto 1fr auto;
align-items: center;
padding: 10px;
border: 1px solid var(--color-border);
border-radius: 12px;
background: var(--color-bg, #f7f8f7);
}
.color-variant__fields {
flex: 1;
min-width: 180px;
display: flex;
flex-direction: column;
gap: 8px;
align-items: flex-start;
.product-media-row :deep(.image-picker) {
min-width: 0;
}
.product-media-row__input {
min-width: 0;
height: 44px;
}
.product-media-row__delete {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
padding: 0;
border: 1px solid transparent;
border-radius: 12px;
background: transparent;
color: var(--color-text-muted);
cursor: pointer;
transition:
color 0.15s ease,
background 0.15s ease,
border-color 0.15s ease;
}
.product-media-row__delete:hover {
color: #c53030;
background: rgba(197, 48, 48, 0.08);
border-color: rgba(197, 48, 48, 0.18);
}
.product-media-row__delete:focus-visible {
outline: 2px solid var(--color-primary, #2d6a4f);
outline-offset: 2px;
}
.product-media-add {
align-self: stretch;
justify-content: center;
}
@media (max-width: 520px) {
.product-media-row--color {
grid-template-columns: 1fr auto;
grid-template-rows: auto auto;
}
.product-media-row--color :deep(.image-picker) {
grid-column: 1 / -1;
}
.product-media-row--color .product-media-row__delete {
grid-row: 2;
grid-column: 2;
}
.product-media-row--color .product-media-row__input {
grid-row: 2;
grid-column: 1;
}
}
</style>
+647 -208
View File
@@ -1,7 +1,9 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { computed, onMounted, reactive, ref } from 'vue'
import AdminPageSkeleton from '@/components/ui/AdminPageSkeleton.vue'
import AdminMultiSelect from '@/components/ui/AdminMultiSelect.vue'
import AdminCurrencyInput from '@/components/ui/AdminCurrencyInput.vue'
import BaseSwitch from '@/components/ui/BaseSwitch.vue'
import { useToast } from '@/composables/useToast'
import {
customersApi,
@@ -11,9 +13,31 @@ import {
} from '@/services/api'
import type { Customer } from '@/types'
type SettingsFormState = {
maxMeteragePerInvoice: number
cashInvoiceValidityHours: number
smsOrderRegistered: boolean
smsPricingReady: boolean
smsPaymentReminder: boolean
smsCheckReview: boolean
smsShippingUpdate: boolean
defaultSettlementCheck: boolean
defaultSettlementCash: boolean
defaultSettlementCashCheck: boolean
minOrderRollsPerProduct: number
showCatalogPublicPrice: boolean
allowAgentPricing: boolean
agentMinUnitPrice: number | null
supportPhone: string
supportEmail: string
supportWhatsapp: string
supportTelegram: string
supportInstagram: string
}
const toast = useToast()
const settings = reactive<UpdateSettingsPayload>({
const settings = reactive<SettingsFormState>({
maxMeteragePerInvoice: 5000,
cashInvoiceValidityHours: 8,
smsOrderRegistered: true,
@@ -23,12 +47,16 @@ const settings = reactive<UpdateSettingsPayload>({
smsShippingUpdate: true,
defaultSettlementCheck: true,
defaultSettlementCash: true,
defaultSettlementCashCheck: false,
minOrderRollsPerProduct: 1,
showCatalogPublicPrice: false,
allowAgentPricing: false,
agentMinUnitPrice: null,
supportPhone: '',
supportEmail: '',
supportWhatsapp: '',
supportTelegram: '',
supportInstagram: '',
})
const broadcast = reactive({ title: '', body: '' })
@@ -39,19 +67,43 @@ const sendToAllActive = ref(false)
const loading = ref(true)
const saving = ref(false)
const settingsBaseline = ref('')
const activeCustomerOptions = computed(() =>
customers.value
const NAV_SECTIONS = [
{ id: 'order-settings', label: 'سفارش و کاتالوگ' },
{ id: 'sms-settings', label: 'پیامک' },
{ id: 'support-settings', label: 'پشتیبانی' },
{ id: 'broadcast-settings', label: 'اعلان هدفمند' },
] as const
const activeNavId = ref<string>(NAV_SECTIONS[0].id)
const activeCustomerOptions = computed(() => {
const list = Array.isArray(customers.value) ? customers.value : []
return list
.filter((c) => c.isActive)
.map((c) => ({
value: c.id,
label: `${c.name} · ${c.phone}${c.appRole === 'agent' ? ' (واسطه)' : ''}`,
})),
}))
})
function serializeSettings() {
return JSON.stringify(settings)
}
const isDirty = computed(
() => !loading.value && settingsBaseline.value !== '' && serializeSettings() !== settingsBaseline.value,
)
watch(sendToAllActive, (all) => {
if (all) selectedCustomerIds.value = []
})
function onSendToAllActiveChange(enabled: boolean) {
sendToAllActive.value = enabled
if (enabled) selectedCustomerIds.value = []
}
function markBaseline() {
settingsBaseline.value = serializeSettings()
}
onMounted(async () => {
loading.value = true
@@ -67,14 +119,19 @@ onMounted(async () => {
smsShippingUpdate: data.smsShippingUpdate,
defaultSettlementCheck: data.defaultSettlementCheck,
defaultSettlementCash: data.defaultSettlementCash,
defaultSettlementCashCheck: data.defaultSettlementCashCheck ?? false,
minOrderRollsPerProduct: data.minOrderRollsPerProduct ?? 1,
showCatalogPublicPrice: data.showCatalogPublicPrice ?? false,
allowAgentPricing: data.allowAgentPricing ?? false,
agentMinUnitPrice: data.agentMinUnitPrice ?? null,
supportPhone: data.supportPhone ?? '',
supportEmail: data.supportEmail ?? '',
supportWhatsapp: data.supportWhatsapp ?? '',
supportTelegram: data.supportTelegram ?? '',
supportInstagram: data.supportInstagram ?? '',
})
customers.value = customerList
markBaseline()
} catch (e) {
toast.error(e instanceof Error ? e.message : 'دریافت تنظیمات ناموفق بود.')
} finally {
@@ -83,10 +140,11 @@ onMounted(async () => {
})
async function saveSettings() {
if (saving.value) return
if (saving.value || !isDirty.value) return
saving.value = true
try {
await settingsApi.update({ ...settings })
markBaseline()
toast.success('تنظیمات با موفقیت ذخیره شد.')
} catch (e) {
toast.error(e instanceof Error ? e.message : 'ذخیره تنظیمات ناموفق بود.')
@@ -137,238 +195,619 @@ async function sendBroadcast() {
broadcasting.value = false
}
}
function scrollToSection(id: string) {
activeNavId.value = id
const el = document.getElementById(id)
el?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
</script>
<template>
<div class="settings-page">
<div class="admin-page-header">
<div>
<header class="settings-page__header admin-page-header">
<div class="settings-page__header-text">
<h1 class="admin-page-header__title">تنظیمات</h1>
<p class="admin-page-header__subtitle">
مدیریت و پیکربندی سفارش، پیامک، پشتیبانی و اعلان‌های اپ
</p>
</div>
<button
type="button"
class="admin-btn admin-btn--primary"
:disabled="loading || saving"
class="admin-btn admin-btn--primary settings-page__save"
:disabled="loading || saving || !isDirty"
:aria-busy="saving"
@click="saveSettings"
>
{{ saving ? 'در حال ذخیره…' : 'ذخیره تنظیمات' }}
{{ saving ? 'در حال ذخیره…' : 'ذخیره تغییرات' }}
</button>
</header>
<AdminPageSkeleton v-if="loading" variant="form" :rows="10" />
<div v-else class="settings-page__body">
<nav class="settings-nav" aria-label="بخش‌های تنظیمات">
<button
v-for="item in NAV_SECTIONS"
:key="item.id"
type="button"
class="settings-nav__item"
:class="{ 'settings-nav__item--active': activeNavId === item.id }"
@click="scrollToSection(item.id)"
>
{{ item.label }}
</button>
</nav>
<div class="settings-page__sections">
<section id="order-settings" class="settings-section admin-card">
<header class="settings-section__head">
<h2 class="settings-section__title">سفارش و کاتالوگ</h2>
<p class="settings-section__desc">محدودیت‌های سفارش، تسویه و نمایش قیمت در اپ</p>
</header>
<div class="settings-section__grid settings-section__grid--3">
<div class="settings-field">
<label class="settings-field__label" for="max-meterage">حداکثر متراژ هر فاکتور</label>
<p class="settings-field__hint">بر حسب متر؛ سقف متراژ در یک فاکتور</p>
<input
id="max-meterage"
v-model.number="settings.maxMeteragePerInvoice"
type="number"
min="1000"
class="admin-input"
inputmode="numeric"
/>
</div>
<div class="settings-field">
<label class="settings-field__label" for="validity-hours">اعتبار فاکتور نقدی</label>
<p class="settings-field__hint">مدت اعتبار پرداخت نقدی (ساعت)</p>
<input
id="validity-hours"
v-model.number="settings.cashInvoiceValidityHours"
type="number"
min="1"
class="admin-input"
inputmode="numeric"
/>
</div>
<div class="settings-field">
<label class="settings-field__label" for="min-rolls">حداقل طاقه از هر محصول</label>
<p class="settings-field__hint">حداقل تعداد طاقه در هر خط سفارش</p>
<input
id="min-rolls"
v-model.number="settings.minOrderRollsPerProduct"
type="number"
min="1"
class="admin-input"
inputmode="numeric"
/>
</div>
</div>
<div class="settings-divider" role="presentation" />
<div class="settings-toggles">
<div class="settings-toggle">
<div class="settings-toggle__text">
<span class="settings-toggle__label">نمایش قیمت عمومی در کاتالوگ</span>
<span class="settings-toggle__hint">قیمت برای مشتریان در لیست محصولات نمایش داده شود</span>
</div>
<BaseSwitch v-model="settings.showCatalogPublicPrice" />
</div>
<div class="settings-toggle">
<div class="settings-toggle__text">
<span class="settings-toggle__label">قیمت‌گذاری توسط واسطه</span>
<span class="settings-toggle__hint">واسطه می‌تواند قیمت واحد تعیین کند</span>
</div>
<BaseSwitch v-model="settings.allowAgentPricing" />
</div>
</div>
<div class="settings-field settings-field--narrow">
<label class="settings-field__label" for="agent-min-price">حداقل قیمت هر متر واسطه</label>
<p class="settings-field__hint">تومان؛ فقط وقتی قیمت‌گذاری واسطه فعال است</p>
<AdminCurrencyInput
id="agent-min-price"
v-model="settings.agentMinUnitPrice"
placeholder="مثلاً ۲۰۰٬۰۰۰"
:disabled="!settings.allowAgentPricing"
/>
</div>
<div class="settings-divider" role="presentation" />
<p class="settings-subgroup__title">روش‌های تسویه پیش‌فرض</p>
<div class="settings-toggles settings-toggles--inline">
<div class="settings-toggle settings-toggle--compact">
<span class="settings-toggle__label">تسویه چکی</span>
<BaseSwitch v-model="settings.defaultSettlementCheck" />
</div>
<div class="settings-toggle settings-toggle--compact">
<span class="settings-toggle__label">تسویه نقدی</span>
<BaseSwitch v-model="settings.defaultSettlementCash" />
</div>
<div class="settings-toggle settings-toggle--compact">
<span class="settings-toggle__label">تسویه نقدی-چکی</span>
<BaseSwitch v-model="settings.defaultSettlementCashCheck" />
</div>
</div>
</section>
<section id="sms-settings" class="settings-section admin-card">
<header class="settings-section__head">
<h2 class="settings-section__title">پیامک و اعلان سیستمی</h2>
<p class="settings-section__desc">پیامک‌های خودکار در مراحل مختلف سفارش</p>
</header>
<div class="settings-toggles">
<div class="settings-toggle">
<div class="settings-toggle__text">
<span class="settings-toggle__label">ثبت سفارش</span>
<span class="settings-toggle__hint">پیامک بعد از ثبت سفارش توسط مشتری</span>
</div>
<BaseSwitch v-model="settings.smsOrderRegistered" />
</div>
<div class="settings-toggle">
<div class="settings-toggle__text">
<span class="settings-toggle__label">صدور فاکتور و قیمت</span>
<span class="settings-toggle__hint">وقتی قیمت‌گذاری انجام شد</span>
</div>
<BaseSwitch v-model="settings.smsPricingReady" />
</div>
<div class="settings-toggle">
<div class="settings-toggle__text">
<span class="settings-toggle__label">پایان اعتبار فاکتور</span>
<span class="settings-toggle__hint">یادآوری قبل از انقضای فاکتور نقدی</span>
</div>
<BaseSwitch v-model="settings.smsPaymentReminder" />
</div>
<div class="settings-toggle">
<div class="settings-toggle__text">
<span class="settings-toggle__label">بررسی چک</span>
<span class="settings-toggle__hint">اعلان وضعیت بررسی چک</span>
</div>
<BaseSwitch v-model="settings.smsCheckReview" />
</div>
<div class="settings-toggle">
<div class="settings-toggle__text">
<span class="settings-toggle__label">وضعیت ارسال</span>
<span class="settings-toggle__hint">به‌روزرسانی مرحله ارسال سفارش</span>
</div>
<BaseSwitch v-model="settings.smsShippingUpdate" />
</div>
</div>
</section>
<section id="support-settings" class="settings-section admin-card">
<header class="settings-section__head">
<h2 class="settings-section__title">تماس پشتیبانی</h2>
<p class="settings-section__desc">
دکمه شناور پشتیبانی و سایدبار اپ — فیلد خالی در اپ نمایش داده نمی‌شود
</p>
</header>
<div class="settings-section__grid settings-section__grid--2">
<div class="settings-field">
<label class="settings-field__label" for="support-phone">تلفن پشتیبانی</label>
<p class="settings-field__hint">صفحه ورود اپ و سایدبار دسکتاپ</p>
<input
id="support-phone"
v-model="settings.supportPhone"
type="text"
class="admin-input"
dir="ltr"
autocomplete="tel"
/>
</div>
<div class="settings-field">
<label class="settings-field__label" for="support-email">ایمیل پشتیبانی</label>
<p class="settings-field__hint">برای تماس و نمایش در بخش پشتیبانی</p>
<input
id="support-email"
v-model="settings.supportEmail"
type="email"
class="admin-input"
dir="ltr"
autocomplete="email"
/>
</div>
<div class="settings-field">
<label class="settings-field__label" for="support-whatsapp">واتساپ</label>
<p class="settings-field__hint">شماره یا لینک کامل (مثلاً wa.me/…)</p>
<input
id="support-whatsapp"
v-model="settings.supportWhatsapp"
type="text"
class="admin-input"
dir="ltr"
/>
</div>
<div class="settings-field">
<label class="settings-field__label" for="support-telegram">تلگرام</label>
<p class="settings-field__hint">آیدی، @username یا لینک t.me</p>
<input
id="support-telegram"
v-model="settings.supportTelegram"
type="text"
class="admin-input"
dir="ltr"
/>
</div>
<div class="settings-field">
<label class="settings-field__label" for="support-instagram">اینستاگرام</label>
<p class="settings-field__hint">آیدی یا لینک اینستاگرام</p>
<input
id="support-instagram"
v-model="settings.supportInstagram"
type="text"
class="admin-input"
dir="ltr"
/>
</div>
</div>
</section>
<section id="broadcast-settings" class="settings-section admin-card settings-section--broadcast">
<header class="settings-section__head">
<h2 class="settings-section__title">اعلان هدفمند</h2>
<p class="settings-section__desc">
ارسال اعلان درون‌اپ به مشتریان؛ مستقل از ذخیره تنظیمات بالا
</p>
</header>
<div class="settings-field">
<label class="settings-field__label" for="broadcast-title">عنوان اعلان</label>
<input id="broadcast-title" v-model="broadcast.title" type="text" class="admin-input" />
</div>
<div class="settings-field">
<label class="settings-field__label" for="broadcast-body">متن اعلان</label>
<textarea
id="broadcast-body"
v-model="broadcast.body"
class="admin-form__textarea"
rows="4"
placeholder="متن پیام برای مشتریان…"
/>
</div>
<div class="settings-toggle settings-toggle--block">
<div class="settings-toggle__text">
<span class="settings-toggle__label">ارسال به همه مشتریان فعال</span>
<span class="settings-toggle__hint">نقش مشتری در اپ؛ بدون انتخاب دستی گیرنده</span>
</div>
<BaseSwitch
:model-value="sendToAllActive"
@update:model-value="onSendToAllActiveChange"
/>
</div>
<div v-if="!sendToAllActive" class="settings-field">
<label class="settings-field__label" for="broadcast-recipients">گیرندگان مشخص</label>
<p class="settings-field__hint">
وقتی «ارسال به همه» خاموش است، یک یا چند مشتری فعال انتخاب کنید
</p>
<AdminMultiSelect
id="broadcast-recipients"
v-model="selectedCustomerIds"
:options="activeCustomerOptions"
:disabled="!activeCustomerOptions.length"
block
placeholder="انتخاب مشتریان…"
aria-label="گیرندگان اعلان"
/>
</div>
<button
type="button"
class="admin-btn admin-btn--primary settings-section__broadcast-btn"
:disabled="broadcasting"
:aria-busy="broadcasting"
@click="sendBroadcast"
>
{{ broadcasting ? 'در حال ارسال…' : 'ثبت و ارسال اعلان' }}
</button>
</section>
</div>
</div>
<AdminPageSkeleton v-if="loading" variant="form" :rows="8" />
<div v-else class="settings-page__grid">
<section class="admin-card settings-page__section">
<h2 class="settings-page__title">تنظیمات عمومی سفارش</h2>
<div class="admin-form__field">
<label for="max-meterage">حداکثر متراژ هر فاکتور (متر)</label>
<input
id="max-meterage"
v-model.number="settings.maxMeteragePerInvoice"
type="number"
min="1000"
class="admin-input"
/>
</div>
<div class="admin-form__field">
<label for="validity-hours">اعتبار فاکتور نقدی (ساعت)</label>
<input
id="validity-hours"
v-model.number="settings.cashInvoiceValidityHours"
type="number"
min="1"
class="admin-input"
/>
</div>
<div class="admin-form__field">
<label for="min-rolls">حداقل طاقه از هر محصول</label>
<input
id="min-rolls"
v-model.number="settings.minOrderRollsPerProduct"
type="number"
min="1"
class="admin-input"
/>
</div>
<label class="settings-page__check">
<input v-model="settings.showCatalogPublicPrice" type="checkbox" />
نمایش قیمت عمومی در کاتالوگ
</label>
<label class="settings-page__check">
<input v-model="settings.allowAgentPricing" type="checkbox" />
اجازه قیمت‌گذاری توسط واسطه
</label>
<div class="admin-form__field">
<label for="agent-min-price">حداقل قیمت هر متر واسطه (تومان)</label>
<input
id="agent-min-price"
v-model.number="settings.agentMinUnitPrice"
type="number"
min="0"
class="admin-input"
:disabled="!settings.allowAgentPricing"
/>
</div>
<div class="settings-page__checks">
<label class="settings-page__check">
<input v-model="settings.defaultSettlementCheck" type="checkbox" />
<span>فعال‌سازی تسویه چکی</span>
</label>
<label class="settings-page__check">
<input v-model="settings.defaultSettlementCash" type="checkbox" />
<span>فعال‌سازی تسویه نقدی</span>
</label>
</div>
</section>
<section class="admin-card settings-page__section">
<h2 class="settings-page__title">پیامک و اعلان</h2>
<div class="settings-page__checks">
<label class="settings-page__check">
<input v-model="settings.smsOrderRegistered" type="checkbox" />
<span>پیامک ثبت سفارش</span>
</label>
<label class="settings-page__check">
<input v-model="settings.smsPricingReady" type="checkbox" />
<span>پیامک صدور فاکتور و آماده‌بودن قیمت</span>
</label>
<label class="settings-page__check">
<input v-model="settings.smsPaymentReminder" type="checkbox" />
<span>یادآوری پایان اعتبار فاکتور</span>
</label>
<label class="settings-page__check">
<input v-model="settings.smsCheckReview" type="checkbox" />
<span>اعلان بررسی چک</span>
</label>
<label class="settings-page__check">
<input v-model="settings.smsShippingUpdate" type="checkbox" />
<span>اعلان وضعیت ارسال</span>
</label>
</div>
</section>
<section class="admin-card settings-page__section">
<h2 class="settings-page__title">اطلاعات تماس پشتیبانی</h2>
<div class="admin-form__field">
<label for="support-phone">تلفن پشتیبانی</label>
<input id="support-phone" v-model="settings.supportPhone" type="text" class="admin-input" dir="ltr" />
<p class="settings-page__hint">
این شماره در صفحه ورود اپ و بخش پشتیبانی سایدبار دسکتاپ نمایش داده می‌شود.
</p>
</div>
<div class="admin-form__field">
<label for="support-email">ایمیل پشتیبانی</label>
<input id="support-email" v-model="settings.supportEmail" type="email" class="admin-input" dir="ltr" />
</div>
</section>
<section class="admin-card settings-page__section">
<h2 class="settings-page__title">اعلان هدفمند</h2>
<div class="admin-form__field">
<label for="broadcast-title">عنوان</label>
<input id="broadcast-title" v-model="broadcast.title" type="text" class="admin-input" />
</div>
<div class="admin-form__field">
<label for="broadcast-body">متن</label>
<textarea id="broadcast-body" v-model="broadcast.body" class="admin-input" rows="4" />
</div>
<label class="settings-page__check settings-page__check--block">
<input v-model="sendToAllActive" type="checkbox" />
<span>ارسال به همه مشتریان فعال (نقش مشتری در اپ)</span>
</label>
<div class="admin-form__field">
<label for="broadcast-recipients">گیرندگان</label>
<AdminMultiSelect
id="broadcast-recipients"
v-model="selectedCustomerIds"
:options="activeCustomerOptions"
:disabled="sendToAllActive || !activeCustomerOptions.length"
block
placeholder="انتخاب مشتریان…"
aria-label="گیرندگان اعلان"
/>
<p class="settings-page__hint">
با غیرفعال بودن «ارسال به همه»، یک یا چند مشتری فعال را انتخاب کنید. اعلان در بخش
اعلان‌های اپ مشتری نمایش داده می‌شود.
</p>
</div>
<button
type="button"
class="admin-btn admin-btn--primary settings-page__broadcast-submit"
:disabled="broadcasting"
@click="sendBroadcast"
>
{{ broadcasting ? 'در حال ارسال…' : 'ثبت و ارسال اعلان' }}
</button>
</section>
<div
v-if="!loading && isDirty"
class="settings-page__dirty-hint compact-only"
role="status"
>
تغییرات ذخیره نشده — «ذخیره تغییرات» را بزنید
</div>
</div>
</template>
<style scoped>
.settings-page__grid {
.settings-page {
max-width: 1120px;
padding-bottom: 24px;
}
.settings-page__header {
flex-wrap: wrap;
align-items: flex-start;
gap: 12px 16px;
margin-bottom: 20px;
}
.settings-page__header-text {
flex: 1;
min-width: min(100%, 240px);
}
.admin-page-header__subtitle {
margin: 6px 0 0;
color: var(--color-text-muted);
font-size: 0.8125rem;
line-height: 1.55;
font-weight: 400;
}
.settings-page__save {
flex-shrink: 0;
min-height: 42px;
}
.settings-page__body {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-columns: 200px minmax(0, 1fr);
gap: 20px;
align-items: start;
}
.settings-nav {
position: sticky;
top: calc(var(--header-height, 64px) + 12px);
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px;
border-radius: var(--radius-md, 12px);
border: 1px solid var(--color-border);
background: var(--color-surface, var(--color-bg));
}
.settings-nav__item {
width: 100%;
padding: 10px 12px;
border: none;
border-radius: 8px;
background: transparent;
color: var(--color-text-muted);
font-size: 0.8125rem;
font-weight: 600;
text-align: start;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
}
.settings-nav__item:hover {
background: var(--color-primary-soft, rgba(0, 0, 0, 0.04));
color: var(--color-text);
}
.settings-nav__item--active {
background: var(--color-primary-soft);
color: var(--color-primary);
}
.settings-nav__item:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.settings-page__sections {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 0;
}
.settings-section {
padding: 20px 22px;
scroll-margin-top: calc(var(--header-height, 64px) + 16px);
}
.settings-section__head {
margin-bottom: 20px;
}
.settings-section__title {
margin: 0;
font-size: 1.0625rem;
font-weight: 700;
line-height: 1.35;
}
.settings-section__desc {
margin: 6px 0 0;
color: var(--color-text-muted);
font-size: 0.8125rem;
line-height: 1.55;
}
.settings-section__grid {
display: grid;
gap: 16px 20px;
}
.settings-section__grid--2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.settings-section__grid--3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.settings-field {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.settings-field--narrow {
max-width: 320px;
margin-top: 16px;
}
.settings-field__label {
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-text);
}
.settings-field__hint {
margin: 0 0 4px;
color: var(--color-text-muted);
font-size: 0.75rem;
line-height: 1.45;
}
.settings-divider {
height: 1px;
margin: 20px 0;
background: var(--color-border);
}
.settings-subgroup__title {
margin: 0 0 12px;
font-size: 0.8125rem;
font-weight: 700;
color: var(--color-text);
}
.settings-toggles {
display: flex;
flex-direction: column;
gap: 14px;
}
.settings-toggles--inline {
flex-direction: row;
flex-wrap: wrap;
gap: 16px 24px;
}
.settings-toggle {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.settings-page__section {
padding: 20px;
}
.settings-page__title {
margin: 0 0 16px;
font-size: 1rem;
font-weight: 700;
}
.settings-page__checks {
display: flex;
flex-direction: column;
.settings-toggle--compact {
flex: 0 0 auto;
gap: 12px;
}
.settings-page__check {
.settings-toggle--block {
margin: 8px 0 4px;
}
.settings-toggle__text {
display: flex;
align-items: center;
gap: 10px;
flex-direction: column;
gap: 2px;
min-width: 0;
flex: 1;
}
.settings-toggle__label {
font-size: 0.875rem;
cursor: pointer;
font-weight: 600;
color: var(--color-text);
}
.settings-page__check input {
width: 16px;
height: 16px;
accent-color: var(--color-primary);
}
.settings-page__check--block {
margin-bottom: 12px;
}
.settings-page__broadcast-submit {
margin-top: 4px;
}
.settings-page__hint {
margin: 6px 0 0;
color: var(--color-text-muted);
.settings-toggle__hint {
font-size: 0.75rem;
line-height: 1.5;
color: var(--color-text-muted);
line-height: 1.45;
}
@media (max-width: 1279px) {
.settings-page__grid {
.settings-section--broadcast {
border-style: dashed;
}
.settings-section__broadcast-btn {
margin-top: 8px;
min-height: 42px;
}
.settings-page__dirty-hint {
position: fixed;
bottom: 12px;
left: 12px;
right: 12px;
z-index: 40;
padding: 10px 14px;
border-radius: 10px;
background: var(--color-warning-light, #fff8e6);
border: 1px solid var(--color-warning, #e6a700);
color: var(--color-text);
font-size: 0.8125rem;
font-weight: 600;
text-align: center;
box-shadow: var(--shadow-card);
}
@media (max-width: 1023px) {
.settings-page__body {
grid-template-columns: 1fr;
}
.settings-nav {
position: static;
flex-direction: row;
overflow-x: auto;
overscroll-behavior-x: contain;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
}
.settings-nav__item {
flex: 0 0 auto;
white-space: nowrap;
}
.settings-section__grid--3 {
grid-template-columns: 1fr;
}
.settings-section__grid--2 {
grid-template-columns: 1fr;
}
}
@media (max-width: 468px) {
.settings-section {
padding: 16px;
}
.settings-page__save {
width: 100%;
}
.settings-page__header {
flex-direction: column;
align-items: stretch;
}
.settings-toggles--inline {
flex-direction: column;
align-items: stretch;
}
.settings-toggle--compact {
justify-content: space-between;
}
.settings-field--narrow {
max-width: none;
}
}
</style>
+1 -1
View File
@@ -290,7 +290,7 @@ function clearSearch() {
<button type="button" class="admin-btn admin-btn--outline" @click="closeModal">انصراف</button>
<button type="button" class="admin-btn admin-btn--primary" :disabled="!isFormValid || saving" @click="submitUser">
<span v-if="saving" class="admin-btn__spinner" aria-hidden="true" />
{{ modalMode === 'edit' ? 'ذخیره تغییرات' : 'ثبت کاربر' }}
{{ modalMode === 'edit' ? 'ثبت تغییرات' : 'ثبت کاربر' }}
</button>
</template>
</BaseModal>