Initial admin edits
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user