Initialize the admin app with banner management for app sections.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-09 17:08:46 +03:30
co-authored by Cursor
commit d670921f74
104 changed files with 26137 additions and 0 deletions
+301
View File
@@ -0,0 +1,301 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import AdminIcon from '@/components/icons/AdminIcon.vue'
import { useAuthStore } from '@/stores/auth'
import { useToast } from '@/composables/useToast'
import { isMockAuthEnabled, MOCK_ADMIN } from '@/services/authService'
const router = useRouter()
const route = useRoute()
const auth = useAuthStore()
const toast = useToast()
const phone = ref(isMockAuthEnabled() ? MOCK_ADMIN.phone : '')
const password = ref(isMockAuthEnabled() ? MOCK_ADMIN.password : '')
const remember = ref(true)
const showPassword = ref(false)
const mockAuth = isMockAuthEnabled()
const isFormValid = computed(() => phone.value.trim().length > 0 && password.value.length > 0)
function toAsciiDigits(value: string) {
return value.replace(/[۰-۹]/g, (d) => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(d))).replace(/[٠-٩]/g, (d) =>
String('٠١٢٣٤٥٦٧٨٩'.indexOf(d)),
)
}
async function submit() {
if (!isFormValid.value) {
auth.error = 'لطفاً شماره موبایل و رمز عبور را وارد کنید.'
return
}
const ok = await auth.login(toAsciiDigits(phone.value.trim()), password.value, remember.value)
if (!ok) return
toast.success(`خوش آمدید، ${auth.fullName}`)
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/'
router.replace(redirect)
}
</script>
<template>
<div class="login-page">
<div class="login-page__form-wrap">
<div class="login-page__card admin-card">
<div class="login-page__header">
<h2 class="login-page__title">ورود به پنل مدیریت</h2>
<p class="login-page__subtitle">برای ادامه، اطلاعات حساب کاربری خود را وارد کنید.</p>
<p v-if="mockAuth" class="login-page__mock-hint">
حالت موک فعال است —
<span dir="ltr">{{ MOCK_ADMIN.phone }}</span>
/
<span dir="ltr">{{ MOCK_ADMIN.password }}</span>
</p>
</div>
<form class="admin-form login-page__form" @submit.prevent="submit">
<p v-if="auth.error" class="admin-form__error" role="alert">{{ auth.error }}</p>
<div class="admin-form__field">
<label for="login-phone">شماره موبایل</label>
<div class="login-page__input-wrap">
<span class="login-page__field-icon" aria-hidden="true">
<AdminIcon name="phone" :size="18" />
</span>
<input
id="login-phone"
v-model="phone"
type="tel"
class="admin-input login-page__input"
placeholder="۰۹۱۲۱۱۱۱۲۲۲۲"
autocomplete="username"
inputmode="tel"
dir="ltr"
@input="auth.clearError()"
/>
</div>
</div>
<div class="admin-form__field">
<label for="login-password">رمز عبور</label>
<div class="login-page__input-wrap login-page__input-wrap--password">
<span class="login-page__field-icon" aria-hidden="true">
<AdminIcon name="lock" :size="18" />
</span>
<input
id="login-password"
v-model="password"
:type="showPassword ? 'text' : 'password'"
class="admin-input login-page__input login-page__input--with-toggle"
placeholder="رمز عبور"
autocomplete="current-password"
@input="auth.clearError()"
/>
<button
type="button"
class="login-page__toggle-password"
:aria-label="showPassword ? 'پنهان کردن رمز' : 'نمایش رمز'"
@click="showPassword = !showPassword"
>
<AdminIcon :name="showPassword ? 'eye-off' : 'eye'" :size="18" />
</button>
</div>
</div>
<label class="login-page__remember">
<input v-model="remember" type="checkbox" />
<span>مرا به خاطر بسپار</span>
</label>
<button type="submit" class="admin-btn admin-btn--primary login-page__submit" :disabled="!isFormValid || auth.loading">
<span v-if="auth.loading" class="admin-btn__spinner" aria-hidden="true" />
ورود به پنل
</button>
</form>
</div>
</div>
<div class="login-page__brand">
<div class="login-page__brand-inner">
<p class="login-page__brand-badge">پارچه</p>
<h1 class="login-page__brand-title">پنل مدیریت سفارش‌های عمده</h1>
<p class="login-page__brand-text">
مدیریت سفارش‌ها، مشتریان، قیمت‌گذاری، چک‌ها و گزارش‌های فروش در یک محیط یکپارچه.
</p>
</div>
</div>
</div>
</template>
<style scoped>
.login-page {
min-height: 100dvh;
display: grid;
grid-template-columns: 1.05fr 1fr;
background: var(--color-bg);
}
.login-page__brand {
display: flex;
align-items: center;
justify-content: center;
padding: 48px 40px;
background: linear-gradient(160deg, #062428 0%, #0a353c 55%, #0e454e 100%);
color: #fff;
}
.login-page__brand-inner {
max-width: 420px;
}
.login-page__brand-badge {
display: inline-flex;
margin: 0 0 16px;
padding: 6px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.12);
font-size: 0.8125rem;
font-weight: 700;
}
.login-page__brand-title {
margin: 0 0 12px;
font-size: clamp(1.5rem, 2.4vw, 2rem);
line-height: 1.35;
}
.login-page__brand-text {
margin: 0;
color: rgba(255, 255, 255, 0.78);
line-height: 1.8;
font-size: 0.9375rem;
}
.login-page__form-wrap {
display: flex;
align-items: center;
justify-content: center;
padding: 32px 24px;
}
.login-page__card {
width: min(100%, 420px);
padding: 28px 24px;
}
.login-page__header {
margin-bottom: 20px;
}
.login-page__title {
margin: 0 0 6px;
font-size: 1.25rem;
font-weight: 700;
}
.login-page__subtitle {
margin: 0;
font-size: 0.875rem;
color: var(--color-text-muted);
line-height: 1.6;
}
.login-page__mock-hint {
margin: 10px 0 0;
padding: 8px 10px;
border-radius: 8px;
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
color: var(--color-primary);
font-size: 0.8125rem;
line-height: 1.5;
}
.login-page__input-wrap {
position: relative;
color: var(--color-text-muted);
}
.login-page__field-icon {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
display: inline-flex;
pointer-events: none;
z-index: 1;
}
.login-page__input {
padding-right: 42px;
}
.login-page__input--with-toggle {
padding-left: 44px;
}
.login-page__toggle-password {
position: absolute;
left: 6px;
top: 50%;
transform: translateY(-50%);
z-index: 1;
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
border: none;
border-radius: 8px;
background: transparent;
color: var(--color-text-muted);
cursor: pointer;
transition: color 0.15s ease, background-color 0.15s ease;
}
.login-page__toggle-password:hover {
color: var(--color-primary);
background: var(--color-bg-muted, rgba(0, 0, 0, 0.04));
}
.login-page__remember {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.8125rem;
color: var(--color-text-muted);
}
.login-page__remember input {
width: 16px;
height: 16px;
accent-color: var(--color-primary);
}
.login-page__submit {
width: 100%;
margin-top: 4px;
}
@media (max-width: 900px) {
.login-page {
grid-template-columns: 1fr;
}
.login-page__form-wrap {
order: 2;
}
.login-page__brand {
order: 1;
padding: 28px 20px;
min-height: auto;
}
.login-page__brand-text {
font-size: 0.875rem;
}
}
</style>
+240
View File
@@ -0,0 +1,240 @@
<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 AdminImagePicker from '@/components/ui/AdminImagePicker.vue'
import AdminSelect from '@/components/ui/AdminSelect.vue'
import { bannersApi, type AppBanner } from '@/services/api'
import { ApiError } from '@/services/http'
import { useToast } from '@/composables/useToast'
const SLOT_OPTIONS = [
{ value: 'home_hero', label: 'خانه — بنر اصلی' },
{ value: 'products_hero', label: 'لیست محصولات' },
{ value: 'categories_hero', label: 'دسته‌بندی‌ها' },
]
const list = ref<AppBanner[]>([])
const loading = ref(true)
const saving = ref(false)
const modalOpen = ref(false)
const editingId = ref<string | null>(null)
const toast = useToast()
const form = reactive({
slot: 'home_hero',
title: '',
subtitle: '',
ctaLabel: '',
ctaLink: '/categories',
imageUrl: null as string | null,
displayOrder: 0,
isActive: true,
})
const modalTitle = computed(() => (editingId.value ? 'ویرایش بنر' : 'افزودن بنر'))
function slotLabel(slot: string) {
return SLOT_OPTIONS.find((item) => item.value === slot)?.label ?? slot
}
async function load() {
loading.value = true
try {
list.value = await bannersApi.list()
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'بارگذاری بنرها ناموفق بود.')
} finally {
loading.value = false
}
}
function openAdd() {
editingId.value = null
Object.assign(form, {
slot: 'home_hero',
title: '',
subtitle: '',
ctaLabel: 'مشاهده کالکشن',
ctaLink: '/categories',
imageUrl: null,
displayOrder: list.value.length,
isActive: true,
})
modalOpen.value = true
}
function openEdit(banner: AppBanner) {
editingId.value = banner.id
Object.assign(form, {
slot: banner.slot,
title: banner.title,
subtitle: banner.subtitle,
ctaLabel: banner.ctaLabel,
ctaLink: banner.ctaLink || '/categories',
imageUrl: banner.imageUrl || null,
displayOrder: banner.displayOrder,
isActive: banner.isActive,
})
modalOpen.value = true
}
async function save() {
if (!form.title.trim()) return
saving.value = true
try {
const payload = {
slot: form.slot,
title: form.title.trim(),
subtitle: form.subtitle.trim() || null,
ctaLabel: form.ctaLabel.trim() || null,
ctaLink: form.ctaLink.trim() || null,
imageUrl: form.imageUrl,
displayOrder: form.displayOrder,
isActive: form.isActive,
}
if (editingId.value) await bannersApi.update(editingId.value, payload)
else await bannersApi.create(payload)
modalOpen.value = false
toast.success('بنر ذخیره شد.')
await load()
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'ذخیره بنر ناموفق بود.')
} finally {
saving.value = false
}
}
async function remove(banner: AppBanner) {
if (!window.confirm(`بنر «${banner.title}» حذف شود؟`)) return
try {
await bannersApi.remove(banner.id)
toast.success('بنر حذف شد.')
await load()
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'حذف بنر ناموفق بود.')
}
}
onMounted(() => {
void load()
})
</script>
<template>
<div class="banners-page">
<div class="admin-page-header">
<div>
<h1 class="admin-page-header__title">بنرها</h1>
<p class="admin-page-header__sub">بنر بخش‌های مختلف اپ کاربر را از اینجا مدیریت کنید.</p>
</div>
<button type="button" class="admin-btn admin-btn--primary" @click="openAdd">افزودن بنر</button>
</div>
<AdminPageSkeleton v-if="loading" variant="table" />
<BaseEmptyState v-else-if="!list.length" title="بنری ثبت نشده" description="اولین بنر خانه را اضافه کنید.">
<button type="button" class="admin-btn admin-btn--primary admin-btn--sm" @click="openAdd">
افزودن بنر
</button>
</BaseEmptyState>
<div v-else class="admin-card">
<table class="admin-table">
<thead>
<tr>
<th>تصویر</th>
<th>عنوان</th>
<th>بخش</th>
<th>دکمه</th>
<th>فعال</th>
<th class="actions">عملیات</th>
</tr>
</thead>
<tbody>
<tr v-for="banner in list" :key="banner.id">
<td>
<span
class="banner-thumb"
:class="{ 'banner-thumb--empty': !banner.imageUrl }"
:style="banner.imageUrl ? { backgroundImage: `url(${banner.imageUrl})` } : undefined"
/>
</td>
<td>
<strong>{{ banner.title }}</strong>
<p v-if="banner.subtitle" class="banner-sub">{{ banner.subtitle }}</p>
</td>
<td>{{ slotLabel(banner.slot) }}</td>
<td>{{ banner.ctaLabel || '—' }}</td>
<td>{{ banner.isActive ? 'بله' : 'خیر' }}</td>
<td class="actions">
<button type="button" class="admin-btn admin-btn--ghost admin-btn--xs" @click="openEdit(banner)">
ویرایش
</button>
<button type="button" class="admin-btn admin-btn--ghost admin-btn--xs" @click="remove(banner)">
حذف
</button>
</td>
</tr>
</tbody>
</table>
</div>
<BaseModal :open="modalOpen" :title="modalTitle" @close="modalOpen = false">
<label class="field">
بخش نمایش
<AdminSelect v-model="form.slot" :options="SLOT_OPTIONS" />
</label>
<label class="field">عنوان<input v-model="form.title" class="admin-input" /></label>
<label class="field">زیرعنوان<input v-model="form.subtitle" class="admin-input" /></label>
<label class="field">متن دکمه<input v-model="form.ctaLabel" class="admin-input" /></label>
<label class="field">
لینک دکمه
<input v-model="form.ctaLink" class="admin-input" dir="ltr" placeholder="/categories" />
</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="فعال" />
<template #footer>
<button type="button" class="admin-btn admin-btn--outline" @click="modalOpen = false">انصراف</button>
<button type="button" class="admin-btn admin-btn--primary" :disabled="!form.title.trim() || saving" @click="save">
{{ saving ? 'در حال ذخیره...' : 'ذخیره' }}
</button>
</template>
</BaseModal>
</div>
</template>
<style scoped>
.admin-page-header__sub {
margin: 4px 0 0;
color: var(--color-text-muted);
font-size: 0.85rem;
}
.banner-thumb {
display: block;
width: 72px;
height: 44px;
border-radius: 8px;
background: #e8eceb center / cover no-repeat;
}
.banner-thumb--empty {
background: #eef1f0;
}
.banner-sub {
margin: 2px 0 0;
color: var(--color-text-muted);
font-size: 0.8rem;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 12px;
font-size: 0.875rem;
}
</style>
+130
View File
@@ -0,0 +1,130 @@
<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.customersCount ?? 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>
@@ -0,0 +1,250 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import AdminIcon from '@/components/icons/AdminIcon.vue'
import AdminPageSkeleton from '@/components/ui/AdminPageSkeleton.vue'
import AdminSelect from '@/components/ui/AdminSelect.vue'
import { cashPaymentsApi } from '@/services/api'
import { ApiError } from '@/services/http'
import {
CASH_PAYMENT_STATUS_BADGE,
CASH_PAYMENT_STATUS_LABELS,
} from '@/utils/adminLabels'
import { formatCurrency, formatDateTime } from '@/utils/format'
import { useToast } from '@/composables/useToast'
import type { CashPayment } from '@/types'
const route = useRoute()
const paymentList = ref<CashPayment[]>([])
const searchQuery = ref((route.query.order as string) ?? '')
const statusFilter = ref<'all' | CashPayment['status']>('all')
const loading = ref(true)
const toast = useToast()
const busyId = ref<string | null>(null)
const statusFilterOptions = [
{ value: 'all', label: 'همه وضعیت‌ها' },
{ value: 'pending_review', label: 'در انتظار تأیید' },
{ value: 'approved', label: 'تأیید شده' },
{ value: 'rejected', label: 'رد شده' },
{ value: 'expired', label: 'منقضی‌شده' },
]
const filteredPayments = computed(() => {
let list = paymentList.value
if (statusFilter.value !== 'all') {
list = list.filter((p) => p.status === statusFilter.value)
}
const q = searchQuery.value.trim()
if (q) {
list = list.filter(
(p) => p.orderNumber.includes(q) || p.customerName.includes(q) || p.trackingCode.includes(q),
)
}
return list
})
async function loadPayments() {
loading.value = true
try {
paymentList.value = await cashPaymentsApi.list()
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'بارگذاری پرداخت‌ها انجام نشد.')
} finally {
loading.value = false
}
}
onMounted(() => {
void loadPayments()
})
async function approvePayment(payment: CashPayment) {
busyId.value = payment.id
try {
const updated = await cashPaymentsApi.updateStatus(payment.id, { status: 'approved' })
const index = paymentList.value.findIndex((p) => p.id === payment.id)
if (index !== -1) paymentList.value[index] = { ...paymentList.value[index]!, ...updated }
toast.success(`پرداخت سفارش ${payment.orderNumber} تأیید شد.`)
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'تأیید پرداخت انجام نشد.')
} finally {
busyId.value = null
}
}
async function rejectPayment(payment: CashPayment) {
busyId.value = payment.id
try {
const updated = await cashPaymentsApi.updateStatus(payment.id, { status: 'rejected' })
const index = paymentList.value.findIndex((p) => p.id === payment.id)
if (index !== -1) paymentList.value[index] = { ...paymentList.value[index]!, ...updated }
toast.warning(`پرداخت سفارش ${payment.orderNumber} رد شد.`)
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'رد پرداخت انجام نشد.')
} finally {
busyId.value = null
}
}
</script>
<template>
<div class="cash-payments-page">
<div class="admin-page-header">
<div>
<h1 class="admin-page-header__title">پرداخت‌های نقدی</h1>
</div>
</div>
<div class="cash-payments-page__filters admin-card">
<div class="cash-payments-page__search">
<AdminIcon name="search" :size="18" />
<input
v-model="searchQuery"
type="search"
class="cash-payments-page__search-input"
placeholder="جستجو در شماره سفارش، مشتری، پیگیری..."
/>
</div>
<AdminSelect
v-model="statusFilter"
class="cash-payments-page__status-select"
aria-label="فیلتر وضعیت پرداخت"
:options="statusFilterOptions"
/>
</div>
<AdminPageSkeleton v-if="loading" variant="table" />
<template v-else>
<div class="cash-payments-page__table admin-card desktop-only">
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>شماره سفارش</th>
<th>مشتری</th>
<th>مبلغ فاکتور</th>
<th>مبلغ واریزی</th>
<th>تاریخ واریز</th>
<th>شماره پیگیری</th>
<th>پایان اعتبار</th>
<th>وضعیت</th>
<th>عملیات</th>
</tr>
</thead>
<tbody>
<tr v-for="payment in filteredPayments" :key="payment.id">
<td>{{ payment.orderNumber }}</td>
<td>{{ payment.customerName }}</td>
<td>{{ formatCurrency(payment.amount) }}</td>
<td>{{ payment.paidAmount ? formatCurrency(payment.paidAmount) : '—' }}</td>
<td>{{ payment.transferDate ? formatDateTime(payment.transferDate) : '—' }}</td>
<td dir="ltr">{{ payment.trackingCode }}</td>
<td>{{ formatDateTime(payment.expiresAt) }}</td>
<td>
<span class="admin-badge" :class="CASH_PAYMENT_STATUS_BADGE[payment.status]">
{{ CASH_PAYMENT_STATUS_LABELS[payment.status] }}
</span>
</td>
<td>
<div v-if="payment.status === 'pending_review'" class="admin-action-group">
<button
type="button"
class="admin-btn admin-btn--outline-success admin-btn--xs"
:disabled="busyId === payment.id"
@click="approvePayment(payment)"
>
تأیید
</button>
<button
type="button"
class="admin-btn admin-btn--outline-danger admin-btn--xs"
:disabled="busyId === payment.id"
@click="rejectPayment(payment)"
>
رد
</button>
</div>
<span v-else>—</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="compact-only admin-card-list">
<article v-for="payment in filteredPayments" :key="payment.id" class="admin-card-item">
<div class="admin-card-item__header">
<p class="admin-card-item__title">{{ payment.orderNumber }}</p>
<span class="admin-badge" :class="CASH_PAYMENT_STATUS_BADGE[payment.status]">
{{ CASH_PAYMENT_STATUS_LABELS[payment.status] }}
</span>
</div>
<div class="admin-card-item__meta">
<span>{{ payment.customerName }}</span>
<span>{{ formatCurrency(payment.amount) }}</span>
<span v-if="payment.paidAmount != null">پرداختی: {{ formatCurrency(payment.paidAmount) }}</span>
<span dir="ltr">{{ payment.trackingCode }}</span>
</div>
<div v-if="payment.status === 'pending_review'" class="admin-card-item__actions">
<button
type="button"
class="admin-btn admin-btn--outline-success admin-btn--xs"
:disabled="busyId === payment.id"
@click="approvePayment(payment)"
>
تأیید
</button>
<button
type="button"
class="admin-btn admin-btn--outline-danger admin-btn--xs"
:disabled="busyId === payment.id"
@click="rejectPayment(payment)"
>
رد
</button>
</div>
</article>
</div>
</template>
</div>
</template>
<style scoped>
.cash-payments-page__filters {
display: flex;
flex-wrap: wrap;
gap: 12px;
padding: 12px 14px;
margin-bottom: 16px;
align-items: center;
}
.cash-payments-page__search {
flex: 1;
min-width: 200px;
display: flex;
align-items: center;
gap: 10px;
color: var(--color-text-muted);
}
.cash-payments-page__search-input {
flex: 1;
border: none;
background: transparent;
font-size: 0.875rem;
outline: none;
color: var(--color-text);
}
.cash-payments-page__status-select {
flex: 0 1 220px;
width: 220px;
}
.cash-payments-page__table {
overflow: hidden;
}
</style>
@@ -0,0 +1,219 @@
<script setup lang="ts">
import { 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 { catalogRequestsApi, type CatalogRequestRow } from '@/services/api'
import { ApiError } from '@/services/http'
import { useToast } from '@/composables/useToast'
import { formatDateTime } from '@/utils/format'
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: 'لغو شده' },
]
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 rows = ref<CatalogRequestRow[]>([])
const statusFilter = ref('')
const loading = ref(true)
const updatingHolderId = ref<string | null>(null)
const toast = useToast()
async function load() {
loading.value = true
try {
rows.value = await catalogRequestsApi.list(statusFilter.value || undefined)
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'بارگذاری انجام نشد')
} finally {
loading.value = false
}
}
async function setStatus(id: string, status: string) {
try {
await catalogRequestsApi.updateStatus(id, status)
toast.success('وضعیت به‌روز شد')
await load()
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'به‌روزرسانی ناموفق بود')
}
}
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
}
onMounted(() => {
void load()
})
</script>
<template>
<div class="admin-page">
<header class="admin-page__head">
<div>
<h1>درخواست کالیته فیزیکی</h1>
<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>
<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>
</template>
</div>
</template>
<style scoped>
.admin-page__head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
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;
}
.request-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 12px;
padding: 16px;
margin-bottom: 12px;
}
.request-card__top {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
margin-bottom: 10px;
}
.request-card__top strong {
margin-left: 8px;
}
.muted {
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;
}
.admin-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8125rem;
}
.admin-table th,
.admin-table td {
text-align: right;
padding: 8px 6px;
border-top: 1px solid var(--color-border);
vertical-align: top;
}
.movements {
list-style: none;
margin: 8px 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
font-size: 0.75rem;
}
</style>
+365
View File
@@ -0,0 +1,365 @@
<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 ActiveSwitchCell from '@/components/ui/ActiveSwitchCell.vue'
import AdminIcon from '@/components/icons/AdminIcon.vue'
import AdminSelect from '@/components/ui/AdminSelect.vue'
import { catalogsApi, companiesApi } from '@/services/api'
import { ApiError } from '@/services/http'
import { useToast } from '@/composables/useToast'
import type { Catalog, Company } from '@/types'
const catalogList = ref<Catalog[]>([])
const companies = ref<Company[]>([])
const searchQuery = ref('')
const companyFilter = ref('all')
const loading = ref(true)
const modalMode = ref<'add' | 'edit' | null>(null)
const editingId = ref<string | null>(null)
const formError = ref('')
const saving = ref(false)
const toast = useToast()
const companyFilterOptions = computed(() => [
{ value: 'all', label: 'همه شرکت‌ها' },
...companies.value.map((company) => ({ value: company.id, label: company.name })),
])
const companyFormOptions = computed(() =>
companies.value.map((company) => ({ value: company.id, label: company.name })),
)
const emptyForm = () => ({
companyId: companies.value[0]?.id ?? '',
code: '',
name: '',
displayOrder: catalogList.value.length + 1,
isActive: true,
})
const form = reactive(emptyForm())
const isModalOpen = computed(() => modalMode.value !== null)
const modalTitle = computed(() => (modalMode.value === 'edit' ? 'ویرایش کالیته' : 'افزودن کالیته'))
const isFormValid = computed(() => form.code.trim() && form.name.trim() && form.companyId)
const hasActiveFilters = computed(
() => searchQuery.value.trim().length > 0 || companyFilter.value !== 'all',
)
const filteredCatalogs = computed(() => {
let list = catalogList.value
if (companyFilter.value !== 'all') {
list = list.filter((c) => c.companyId === companyFilter.value)
}
const q = searchQuery.value.trim()
if (q) {
list = list.filter((c) => c.code.includes(q) || c.name.includes(q) || c.companyName.includes(q))
}
return list
})
async function loadData() {
loading.value = true
try {
const [catalogsData, companiesData] = await Promise.all([
catalogsApi.list(),
companiesApi.list(),
])
catalogList.value = catalogsData
companies.value = companiesData
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'بارگذاری کالیته‌ها انجام نشد.')
} finally {
loading.value = false
}
}
onMounted(() => {
void loadData()
})
function companyName(companyId: string) {
return companies.value.find((c) => c.id === companyId)?.name ?? '—'
}
function openAddModal() {
modalMode.value = 'add'
editingId.value = null
Object.assign(form, emptyForm())
formError.value = ''
}
function openEditModal(catalog: Catalog) {
modalMode.value = 'edit'
editingId.value = catalog.id
form.companyId = catalog.companyId
form.code = catalog.code
form.name = catalog.name
form.displayOrder = catalog.displayOrder
form.isActive = catalog.isActive
formError.value = ''
}
function closeModal() {
modalMode.value = null
editingId.value = null
formError.value = ''
}
async function submitCatalog() {
if (!isFormValid.value) {
formError.value = 'کد، نام و شرکت الزامی هستند.'
return
}
saving.value = true
try {
if (modalMode.value === 'edit' && editingId.value) {
const updated = await catalogsApi.update(editingId.value, {
code: form.code.trim(),
name: form.name.trim(),
displayOrder: Number(form.displayOrder) || 1,
isActive: form.isActive,
})
const index = catalogList.value.findIndex((c) => c.id === editingId.value)
if (index !== -1) {
catalogList.value[index] = {
...catalogList.value[index]!,
...updated,
companyName: companyName(form.companyId),
}
}
toast.success('اطلاعات کالیته به‌روزرسانی شد.')
} else {
const created = await catalogsApi.create({
companyId: form.companyId,
code: form.code.trim(),
name: form.name.trim(),
displayOrder: Number(form.displayOrder) || 1,
isActive: form.isActive,
})
catalogList.value.push({
...created,
companyName: created.companyName || companyName(form.companyId),
productCount: created.productCount ?? 0,
})
toast.success('کالیته جدید ثبت شد.')
}
closeModal()
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'ذخیره کالیته انجام نشد.')
} finally {
saving.value = false
}
}
async function persistActive(id: string, isActive: boolean) {
await catalogsApi.setActive(id, isActive)
}
function clearFilters() {
searchQuery.value = ''
companyFilter.value = 'all'
}
</script>
<template>
<div class="catalogs-page">
<div class="admin-page-header">
<div>
<h1 class="admin-page-header__title">کالیته‌ها</h1>
</div>
<button type="button" class="admin-btn admin-btn--primary" @click="openAddModal">
افزودن کالیته
</button>
</div>
<div class="catalogs-page__filters admin-card">
<div class="catalogs-page__search">
<AdminIcon name="search" :size="18" />
<input
v-model="searchQuery"
type="search"
class="catalogs-page__search-input"
placeholder="جستجو بر اساس کد یا نام..."
/>
</div>
<AdminSelect
v-model="companyFilter"
class="catalogs-page__company-filter"
aria-label="فیلتر شرکت"
:options="companyFilterOptions"
/>
</div>
<AdminPageSkeleton v-if="loading" variant="table" />
<BaseEmptyState
v-else-if="!filteredCatalogs.length"
class="admin-card"
icon="catalogs"
:title="hasActiveFilters ? 'نتیجه‌ای یافت نشد' : 'کالیته‌ای ثبت نشده'"
:description="
hasActiveFilters
? 'عبارت جستجو را تغییر دهید یا فیلترها را پاک کنید.'
: 'برای شروع، اولین کالیته را ثبت کنید.'
"
>
<button v-if="hasActiveFilters" type="button" class="admin-btn admin-btn--outline admin-btn--sm" @click="clearFilters">
پاک کردن فیلترها
</button>
<button v-else type="button" class="admin-btn admin-btn--primary admin-btn--sm" @click="openAddModal">
افزودن کالیته
</button>
</BaseEmptyState>
<div v-else class="catalogs-page__table admin-card desktop-only">
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>کد</th>
<th>نام کالیته</th>
<th>شرکت</th>
<th class="numeric">ترتیب</th>
<th class="numeric">تعداد محصول</th>
<th class="status">وضعیت</th>
<th class="actions">عملیات</th>
</tr>
</thead>
<tbody>
<tr v-for="catalog in filteredCatalogs" :key="catalog.id">
<td>{{ catalog.code }}</td>
<td>{{ catalog.name }}</td>
<td>{{ catalog.companyName }}</td>
<td class="numeric">{{ catalog.displayOrder }}</td>
<td class="numeric">{{ catalog.productCount }}</td>
<td class="status">
<ActiveSwitchCell
:id="catalog.id"
v-model="catalog.isActive"
:entity-name="catalog.name"
entity-type="کالیته"
:persist="persistActive"
/>
</td>
<td class="actions">
<button type="button" class="admin-btn admin-btn--ghost admin-btn--xs" @click="openEditModal(catalog)">
ویرایش
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-if="!loading && filteredCatalogs.length" class="compact-only admin-card-list">
<article v-for="catalog in filteredCatalogs" :key="catalog.id" class="admin-card-item">
<div class="admin-card-item__header">
<p class="admin-card-item__title">کالیته {{ catalog.code }} — {{ catalog.name }}</p>
<ActiveSwitchCell
:id="catalog.id"
v-model="catalog.isActive"
:entity-name="catalog.name"
entity-type="کالیته"
:persist="persistActive"
/>
</div>
<div class="admin-card-item__meta">
<span>{{ catalog.companyName }}</span>
<span>{{ catalog.productCount }} محصول</span>
<span>ترتیب: {{ catalog.displayOrder }}</span>
</div>
<div class="admin-card-item__actions">
<button type="button" class="admin-btn admin-btn--ghost admin-btn--xs" @click="openEditModal(catalog)">
ویرایش
</button>
</div>
</article>
</div>
<BaseModal :open="isModalOpen" :title="modalTitle" size="lg" @close="closeModal">
<form class="admin-form" @submit.prevent="submitCatalog">
<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>
<AdminSelect
v-model="form.companyId"
block
aria-label="انتخاب شرکت"
placeholder="انتخاب شرکت"
:options="companyFormOptions"
/>
</div>
<div class="admin-form__row">
<div class="admin-form__field">
<label for="catalog-code">کد کالیته <span class="admin-form__required">*</span></label>
<input id="catalog-code" v-model="form.code" type="text" class="admin-input" placeholder="377" />
</div>
<div class="admin-form__field">
<label for="catalog-name">نام کالیته <span class="admin-form__required">*</span></label>
<input id="catalog-name" v-model="form.name" type="text" class="admin-input" />
</div>
</div>
<div class="admin-form__row">
<div class="admin-form__field">
<label for="catalog-order">ترتیب نمایش</label>
<input id="catalog-order" v-model.number="form.displayOrder" type="number" min="1" class="admin-input" />
</div>
<div class="admin-form__field">
<BaseSwitch v-model="form.isActive" label="وضعیت" />
</div>
</div>
</form>
<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="submitCatalog">
{{ modalMode === 'edit' ? 'ذخیره تغییرات' : 'ثبت کالیته' }}
</button>
</template>
</BaseModal>
</div>
</template>
<style scoped>
.catalogs-page__filters {
display: flex;
flex-wrap: wrap;
gap: 12px;
padding: 12px 14px;
margin-bottom: 16px;
align-items: center;
}
.catalogs-page__search {
flex: 1;
min-width: 200px;
display: flex;
align-items: center;
gap: 10px;
color: var(--color-text-muted);
}
.catalogs-page__search-input {
flex: 1;
border: none;
background: transparent;
font-size: 0.875rem;
outline: none;
}
.catalogs-page__company-filter {
width: min(220px, 100%);
}
.catalogs-page__table {
overflow: hidden;
}
</style>
+139
View File
@@ -0,0 +1,139 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import AdminPageSkeleton from '@/components/ui/AdminPageSkeleton.vue'
import AdminImagePicker from '@/components/ui/AdminImagePicker.vue'
import { settingsApi } from '@/services/api'
import { ApiError } from '@/services/http'
import { useToast } from '@/composables/useToast'
const USAGE = [
{ id: 'trousers', title: 'پارچه‌های شلواری' },
{ id: 'suit', title: 'پارچه‌های کت‌شلواری' },
{ id: 'shirt', title: 'پارچه‌های پیراهنی' },
]
const SEASONS = [
{ id: 'summer', title: 'تابستانه' },
{ id: 'winter', title: 'زمستانه' },
{ id: 'all_season', title: 'چهارفصل' },
]
const images = reactive<Record<string, string | null>>({
trousers: null,
suit: null,
shirt: null,
summer: null,
winter: null,
all_season: null,
})
const loading = ref(true)
const saving = ref(false)
const toast = useToast()
async function load() {
loading.value = true
try {
const data = await settingsApi.get()
const stored = data.categoryImagesJson ?? {}
for (const key of Object.keys(images)) {
images[key] = stored[key] ?? null
}
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'بارگذاری تصاویر دسته‌ها ناموفق بود.')
} finally {
loading.value = false
}
}
async function save() {
saving.value = true
try {
const categoryImagesJson: Record<string, string> = {}
for (const [key, value] of Object.entries(images)) {
if (value) categoryImagesJson[key] = value
}
await settingsApi.update({ categoryImagesJson })
toast.success('تصاویر دسته‌بندی ذخیره شد.')
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'ذخیره تصاویر ناموفق بود.')
} finally {
saving.value = false
}
}
onMounted(() => {
void load()
})
</script>
<template>
<div class="categories-admin">
<div class="admin-page-header">
<div>
<h1 class="admin-page-header__title">دسته‌بندی‌ها</h1>
<p class="admin-page-header__sub">تصویر هر دسته در اپ کاربر نمایش داده می‌شود.</p>
</div>
<button type="button" class="admin-btn admin-btn--primary" :disabled="saving || loading" @click="save">
{{ saving ? 'در حال ذخیره...' : 'ذخیره تصاویر' }}
</button>
</div>
<AdminPageSkeleton v-if="loading" variant="cards" />
<div v-else class="categories-admin__grid">
<section class="admin-card">
<h2>بر اساس کاربرد</h2>
<div class="categories-admin__list">
<AdminImagePicker
v-for="item in USAGE"
:key="item.id"
:label="item.title"
:model-value="images[item.id]"
@update:model-value="images[item.id] = $event"
/>
</div>
</section>
<section class="admin-card">
<h2>بر اساس فصل</h2>
<div class="categories-admin__list">
<AdminImagePicker
v-for="item in SEASONS"
:key="item.id"
:label="item.title"
:model-value="images[item.id]"
@update:model-value="images[item.id] = $event"
/>
</div>
</section>
</div>
</div>
</template>
<style scoped>
.admin-page-header__sub {
margin: 4px 0 0;
color: var(--color-text-muted);
font-size: 0.85rem;
}
.categories-admin__grid {
display: grid;
gap: 16px;
}
.categories-admin__grid h2 {
margin: 0 0 14px;
font-size: 1rem;
}
.categories-admin__list {
display: grid;
gap: 16px;
}
@media (min-width: 900px) {
.categories-admin__grid {
grid-template-columns: 1fr 1fr;
}
}
</style>
+778
View File
@@ -0,0 +1,778 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import StatusBadge from '@/components/ui/StatusBadge.vue'
import { checksApi, mapAdminOrder, ordersApi, type ApiOrder } from '@/services/api'
import { ApiError } from '@/services/http'
import { formatCurrency, formatDate } from '@/utils/format'
import { useToast } from '@/composables/useToast'
import { confirm } from '@/composables/useConfirm'
import { rawStatusLabel } from '@/utils/orderStatus'
import type { Check } from '@/types'
const route = useRoute()
const router = useRouter()
const toast = useToast()
const activeTab = ref<'info' | 'history'>('info')
const order = ref<ApiOrder | null>(null)
const orderChecks = ref<Check[]>([])
const busyId = ref<string | null>(null)
const expandedId = ref<string | null>(null)
const previewImage = ref<string | null>(null)
const displayOrder = computed(() =>
order.value ? mapAdminOrder(order.value) : null,
)
const totalCheckAmount = computed(() =>
orderChecks.value.reduce((sum, c) => sum + c.amount, 0),
)
const pendingChecks = computed(() =>
orderChecks.value.filter((c) => c.status === 'pending_review'),
)
const allChecksApproved = computed(
() =>
orderChecks.value.length > 0 &&
orderChecks.value.every((c) => c.status === 'approved'),
)
const canFinalApprove = computed(() => {
if (!allChecksApproved.value || !order.value) return false
const status = order.value.status
return status === 'checks_approved' || status === 'awaiting_check_review'
})
const finalizing = ref(false)
const reviewHistory = computed(() => {
const items = order.value?.statusHistory ?? []
const checkRelated = new Set([
'check_in_progress',
'awaiting_check_review',
'check_needs_fix',
'check_rejected',
'checks_approved',
])
const filtered = items.filter(
(h) =>
checkRelated.has(h.toStatus) ||
(h.fromStatus && checkRelated.has(h.fromStatus)) ||
/چک/.test(h.note || '') ||
/چک/.test(h.reason || ''),
)
const source = filtered.length ? filtered : items
return [...source].sort(
(a, b) => new Date(b.changedAt).getTime() - new Date(a.changedAt).getTime(),
)
})
const checkReviewNotes = computed(() =>
orderChecks.value
.filter((c) => c.notes?.trim())
.map((c) => ({
id: c.id,
row: c.row,
status: c.status,
notes: c.notes!,
updatedAt: c.updatedAt,
})),
)
async function loadData() {
const orderId = String(route.params.id)
try {
const [orderData, checksData] = await Promise.all([
ordersApi.get(orderId),
checksApi.listByOrder(orderId),
])
order.value = orderData
orderChecks.value = checksData
if (checksData.length === 1) expandedId.value = checksData[0]!.id
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'بارگذاری چک‌ها انجام نشد.')
}
}
onMounted(() => {
void loadData()
})
function goBack() {
router.push('/orders')
}
function checkBadgeStatus(status: Check['status']) {
if (status === 'approved') return 'check_approved'
if (status === 'rejected') return 'check_rejected'
return 'pending_check_review'
}
function actorLabel(changedBy?: string) {
if (!changedBy) return '—'
if (changedBy === 'customer') return 'مشتری'
if (changedBy.startsWith('admin:')) return 'ادمین'
return changedBy
}
function formatDateTime(iso?: string) {
if (!iso) return '—'
try {
return new Intl.DateTimeFormat('fa-IR', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(iso))
} catch {
return iso.slice(0, 16).replace('T', ' ')
}
}
function ownerName(check: Check) {
return [check.ownerFirstName, check.ownerLastName].filter(Boolean).join(' ')
}
function detailFields(check: Check) {
return [
{ label: 'بانک', value: check.bankName },
{ label: 'شعبه', value: check.branchName },
{ label: 'شناسه صیادی', value: check.sayadId, ltr: true },
{ label: 'شماره چک', value: check.checkNumber, ltr: true },
{ label: 'صاحب چک', value: ownerName(check) },
{ label: 'کد ملی', value: check.ownerNationalId, ltr: true },
{ label: 'یادداشت', value: check.notes },
].filter((f) => !!f.value?.trim())
}
function toggleExpand(check: Check) {
expandedId.value = expandedId.value === check.id ? null : check.id
}
async function applyCheckStatus(check: Check, status: 'approved' | 'rejected') {
busyId.value = check.id
try {
const updated = await checksApi.updateStatus(check.id, { status })
const index = orderChecks.value.findIndex((c) => c.id === check.id)
if (index !== -1) orderChecks.value[index] = updated
order.value = await ordersApi.get(String(route.params.id))
return true
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'به‌روزرسانی وضعیت چک انجام نشد.')
return false
} finally {
busyId.value = null
}
}
async function updateCheckStatus(check: Check, status: Check['status']) {
if (status !== 'approved' && status !== 'rejected') return
const amountLabel = formatCurrency(check.amount)
const ok = await confirm(
status === 'approved'
? {
title: 'تأیید چک',
message: `آیا از تأیید چک ردیف ${check.row} به مبلغ ${amountLabel} مطمئن هستید؟`,
tone: 'primary',
confirmLabel: 'تأیید چک',
cancelLabel: 'انصراف',
}
: {
title: 'رد چک',
message: `آیا از رد چک ردیف ${check.row} به مبلغ ${amountLabel} مطمئن هستید؟`,
tone: 'danger',
confirmLabel: 'رد چک',
cancelLabel: 'انصراف',
},
)
if (!ok) return
const success = await applyCheckStatus(check, status)
if (success) {
toast.success(status === 'approved' ? 'چک تأیید شد.' : 'چک رد شد.')
}
}
async function approveAll() {
const pending = pendingChecks.value
if (!pending.length) {
toast.error('چکی برای تأیید وجود ندارد.')
return
}
const ok = await confirm({
title: 'تأیید همه چک‌ها',
message: `آیا از تأیید ${pending.length} چک در انتظار بررسی مطمئن هستید؟`,
tone: 'primary',
confirmLabel: 'تأیید همه',
cancelLabel: 'انصراف',
})
if (!ok) return
for (const check of pending) {
const success = await applyCheckStatus(check, 'approved')
if (!success) return
}
toast.success('همه چک‌های در انتظار تأیید شدند.')
}
async function finalApproveOrder() {
if (!order.value || finalizing.value) return
const ok = await confirm({
title: 'تأیید نهایی سفارش',
message:
'با تأیید نهایی، سفارش از مرحله بررسی چک خارج می‌شود و وارد آماده‌سازی می‌گردد. ادامه می‌دهید؟',
tone: 'primary',
confirmLabel: 'تأیید نهایی',
cancelLabel: 'انصراف',
})
if (!ok) return
finalizing.value = true
try {
const updated = await ordersApi.updateStatus(order.value.id, {
status: 'final_approved',
note: 'تأیید نهایی پس از تأیید همه چک‌ها',
})
order.value = updated
toast.success('سفارش تأیید نهایی شد.')
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'تأیید نهایی انجام نشد.')
} finally {
finalizing.value = false
}
}
</script>
<template>
<div class="checks-page">
<div class="admin-page-header">
<div>
<h1 class="admin-page-header__title">مدیریت چک‌های سفارش</h1>
</div>
<div v-if="displayOrder" class="checks-page__order-meta">
<StatusBadge :status="displayOrder.status" />
</div>
</div>
<div class="checks-page__stats">
<div class="checks-page__stat">
<span>تعداد</span>
<strong>{{ orderChecks.length }}</strong>
</div>
<div class="checks-page__stat">
<span>راس</span>
<strong>{{ order?.checkRassDays ?? '—' }} روز</strong>
</div>
<div class="checks-page__stat">
<span>مجموع چک</span>
<strong>{{ formatCurrency(totalCheckAmount) }}</strong>
</div>
<div class="checks-page__stat">
<span>مبلغ سفارش</span>
<strong>{{ displayOrder?.totalAmount ? formatCurrency(displayOrder.totalAmount) : '—' }}</strong>
</div>
</div>
<div class="admin-tabs">
<button
type="button"
class="admin-tab"
:class="{ active: activeTab === 'info' }"
@click="activeTab = 'info'"
>
چک‌ها ({{ orderChecks.length }})
</button>
<button
type="button"
class="admin-tab"
:class="{ active: activeTab === 'history' }"
@click="activeTab = 'history'"
>
تاریخچه
</button>
</div>
<template v-if="activeTab === 'info'">
<div v-if="!orderChecks.length" class="admin-empty-placeholder admin-card">
چکی برای این سفارش ثبت نشده است.
</div>
<div v-else class="checks-page__list admin-card">
<div
v-for="check in orderChecks"
:key="check.id"
class="check-row"
:class="{ open: expandedId === check.id }"
>
<button type="button" class="check-row__main" @click="toggleExpand(check)">
<span class="check-row__chevron" aria-hidden="true">{{ expandedId === check.id ? '▾' : '◂' }}</span>
<span
v-if="check.frontImage || check.backImage"
class="check-row__thumb"
@click.stop="previewImage = (check.frontImage || check.backImage)!"
>
<img :src="check.frontImage || check.backImage" alt="" />
</span>
<span v-else class="check-row__thumb check-row__thumb--empty">بدون عکس</span>
<span class="check-row__meta">
<strong>چک {{ check.row }}</strong>
<small>
{{ formatCurrency(check.amount) }}
<template v-if="check.bankName"> · {{ check.bankName }}</template>
· {{ formatDate(check.dueDate) }}
</small>
</span>
<StatusBadge :status="checkBadgeStatus(check.status)" />
</button>
<div class="check-row__actions" @click.stop>
<template v-if="check.status === 'pending_review'">
<button
type="button"
class="admin-btn admin-btn--outline-success admin-btn--xs"
:disabled="busyId === check.id"
@click="updateCheckStatus(check, 'approved')"
>
تأیید
</button>
<button
type="button"
class="admin-btn admin-btn--outline-danger admin-btn--xs"
:disabled="busyId === check.id"
@click="updateCheckStatus(check, 'rejected')"
>
رد
</button>
</template>
</div>
<div v-if="expandedId === check.id" class="check-row__panel">
<div v-if="detailFields(check).length" class="check-row__fields">
<div
v-for="field in detailFields(check)"
:key="field.label"
class="check-row__field"
>
<span>{{ field.label }}</span>
<strong :dir="field.ltr ? 'ltr' : undefined">{{ field.value }}</strong>
</div>
</div>
<p v-else class="check-row__hint">جزئیات متنی بیشتری ثبت نشده است.</p>
<div class="check-row__photos">
<button
v-if="check.frontImage"
type="button"
class="check-row__photo"
@click="previewImage = check.frontImage!"
>
<img :src="check.frontImage" alt="روی چک" />
<span>روی</span>
</button>
<button
v-if="check.backImage"
type="button"
class="check-row__photo"
@click="previewImage = check.backImage!"
>
<img :src="check.backImage" alt="پشت چک" />
<span>پشت</span>
</button>
<p v-if="!check.frontImage && !check.backImage" class="check-row__hint">
تصویری ثبت نشده.
</p>
</div>
</div>
</div>
</div>
<div class="checks-page__footer">
<p class="checks-page__note">
<template v-if="canFinalApprove">
همه چک‌ها تأیید شده‌اند. برای ادامه، تأیید نهایی سفارش را ثبت کنید.
</template>
<template v-else-if="order?.checkRassDate">
راس: {{ formatDate(order.checkRassDate) }}
</template>
<template v-else>راس بر اساس تاریخ ثبت سفارش محاسبه می‌شود.</template>
</p>
<div class="admin-action-group">
<button
v-if="canFinalApprove"
type="button"
class="admin-btn admin-btn--success admin-btn--sm"
:disabled="finalizing"
@click="finalApproveOrder"
>
{{ finalizing ? 'در حال ثبت…' : 'تأیید نهایی سفارش' }}
</button>
<button
v-if="pendingChecks.length"
type="button"
class="admin-btn admin-btn--success admin-btn--sm"
@click="approveAll"
>
تأیید همه چک‌ها
</button>
<button type="button" class="admin-btn admin-btn--outline admin-btn--sm" @click="goBack">
بازگشت
</button>
</div>
</div>
</template>
<template v-else>
<div
v-if="!reviewHistory.length && !checkReviewNotes.length"
class="admin-empty-placeholder admin-card"
>
هنوز تاریخچه‌ای ثبت نشده است.
</div>
<div v-else class="checks-page__history">
<article
v-for="item in reviewHistory"
:key="item.id"
class="admin-card checks-page__history-item"
>
<div class="checks-page__history-item-head">
<strong>
{{ item.fromStatus ? rawStatusLabel(item.fromStatus) : '—' }}
→
{{ rawStatusLabel(item.toStatus) }}
</strong>
<span>{{ formatDateTime(item.changedAt) }}</span>
</div>
<p class="checks-page__history-meta">توسط {{ actorLabel(item.changedBy) }}</p>
<p v-if="item.note" class="checks-page__history-note">{{ item.note }}</p>
<p v-if="item.reason" class="checks-page__history-note">دلیل: {{ item.reason }}</p>
</article>
<article
v-for="note in checkReviewNotes"
:key="`note-${note.id}`"
class="admin-card checks-page__history-item"
>
<div class="checks-page__history-item-head">
<strong>یادداشت چک {{ note.row }}</strong>
<span>{{ formatDateTime(note.updatedAt) }}</span>
</div>
<StatusBadge :status="checkBadgeStatus(note.status)" />
<p class="checks-page__history-note">{{ note.notes }}</p>
</article>
</div>
</template>
<Teleport to="body">
<div
v-if="previewImage"
class="checks-page__lightbox"
@click.self="previewImage = null"
>
<button type="button" class="checks-page__lightbox-close" @click="previewImage = null">
بستن
</button>
<img :src="previewImage" alt="پیش‌نمایش" />
</div>
</Teleport>
</div>
</template>
<style scoped>
.checks-page__order-meta {
display: flex;
align-items: center;
gap: 8px;
}
.checks-page__stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
margin-bottom: 14px;
}
.checks-page__stat {
display: flex;
flex-direction: column;
gap: 2px;
padding: 10px 12px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface);
}
.checks-page__stat span {
font-size: 0.75rem;
color: var(--color-text-muted);
}
.checks-page__stat strong {
font-size: 0.875rem;
font-weight: 700;
}
.checks-page__list {
overflow: hidden;
}
.check-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px 12px;
align-items: center;
padding: 8px 12px;
border-bottom: 1px solid var(--color-border-light);
}
.check-row:last-child {
border-bottom: 0;
}
.check-row.open {
background: color-mix(in srgb, var(--color-primary-soft) 55%, transparent);
}
.check-row__main {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
border: 0;
background: transparent;
color: inherit;
text-align: start;
cursor: pointer;
padding: 4px 0;
}
.check-row__chevron {
width: 14px;
color: var(--color-text-muted);
font-size: 0.75rem;
flex-shrink: 0;
}
.check-row__thumb {
width: 44px;
height: 32px;
border-radius: 6px;
overflow: hidden;
border: 1px solid var(--color-border);
flex-shrink: 0;
padding: 0;
background: #111;
display: grid;
place-items: center;
cursor: zoom-in;
}
.check-row__thumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.check-row__thumb--empty {
font-size: 0.625rem;
color: var(--color-text-muted);
cursor: default;
}
.check-row__meta {
min-width: 0;
display: grid;
gap: 2px;
flex: 1;
}
.check-row__meta strong {
font-size: 0.875rem;
font-weight: 700;
}
.check-row__meta small {
font-size: 0.75rem;
color: var(--color-text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.check-row__actions {
display: flex;
gap: 6px;
flex-shrink: 0;
}
.check-row__panel {
grid-column: 1 / -1;
display: flex;
flex-wrap: wrap;
gap: 12px 16px;
align-items: flex-start;
padding: 8px 0 4px 24px;
border-top: 1px dashed var(--color-border);
}
.check-row__fields {
display: flex;
flex-wrap: wrap;
gap: 8px;
flex: 1;
min-width: 200px;
}
.check-row__field {
display: inline-flex;
align-items: baseline;
gap: 6px;
padding: 4px 8px;
border-radius: 999px;
background: var(--color-border-light);
font-size: 0.75rem;
}
.check-row__field span {
color: var(--color-text-muted);
}
.check-row__field strong {
font-weight: 600;
}
.check-row__photos {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.check-row__photo {
width: 96px;
border: 1px solid var(--color-border);
border-radius: 8px;
overflow: hidden;
background: transparent;
color: inherit;
padding: 0;
cursor: zoom-in;
text-align: center;
}
.check-row__photo img {
width: 100%;
height: 60px;
object-fit: cover;
display: block;
background: #111;
}
.check-row__photo span {
display: block;
padding: 4px;
font-size: 0.6875rem;
color: var(--color-text-muted);
}
.check-row__hint {
margin: 0;
font-size: 0.75rem;
color: var(--color-text-muted);
}
.checks-page__footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 14px;
flex-wrap: wrap;
}
.checks-page__note {
margin: 0;
font-size: 0.75rem;
color: var(--color-text-muted);
}
.checks-page__history {
display: grid;
gap: 8px;
}
.checks-page__history-item {
padding: 12px;
display: grid;
gap: 6px;
}
.checks-page__history-item-head {
display: flex;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
font-size: 0.875rem;
}
.checks-page__history-meta,
.checks-page__history-note {
margin: 0;
font-size: 0.8125rem;
line-height: 1.5;
}
.checks-page__history-meta {
color: var(--color-text-muted);
}
.checks-page__lightbox {
position: fixed;
inset: 0;
z-index: 300000;
display: grid;
place-items: center;
background: rgba(0, 0, 0, 0.84);
padding: 20px;
}
.checks-page__lightbox img {
max-width: min(900px, 100%);
max-height: 88vh;
object-fit: contain;
border-radius: 8px;
}
.checks-page__lightbox-close {
position: absolute;
top: 14px;
inset-inline-end: 14px;
border: 0;
border-radius: 8px;
padding: 8px 12px;
cursor: pointer;
}
@media (max-width: 800px) {
.checks-page__stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.check-row {
grid-template-columns: 1fr;
}
.check-row__actions {
padding-inline-start: 24px;
}
.check-row__panel {
padding-inline-start: 0;
}
}
</style>
+344
View File
@@ -0,0 +1,344 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import AdminIcon from '@/components/icons/AdminIcon.vue'
import AdminPageSkeleton from '@/components/ui/AdminPageSkeleton.vue'
import AdminSelect from '@/components/ui/AdminSelect.vue'
import StatusBadge from '@/components/ui/StatusBadge.vue'
import { checksApi, ordersApi } from '@/services/api'
import { ApiError } from '@/services/http'
import { formatCurrency, formatDateTime } from '@/utils/format'
import { useToast } from '@/composables/useToast'
import type { Check } from '@/types'
const router = useRouter()
const toast = useToast()
const checkList = ref<Check[]>([])
const loading = ref(true)
const searchQuery = ref('')
const statusFilter = ref<'all' | Check['status']>('all')
const busyId = ref<string | null>(null)
const statusFilterOptions = [
{ value: 'all', label: 'همه وضعیت‌ها' },
{ value: 'pending_review', label: 'در انتظار بررسی' },
{ value: 'approved', label: 'تأیید شده' },
{ value: 'rejected', label: 'رد شده' },
]
const filteredChecks = computed(() => {
let list = checkList.value
if (statusFilter.value !== 'all') {
list = list.filter((c) => c.status === statusFilter.value)
}
const q = searchQuery.value.trim()
if (q) {
list = list.filter(
(c) =>
(c.orderNumber ?? '').includes(q) ||
(c.customerName ?? '').includes(q) ||
(c.bankName ?? '').includes(q) ||
(c.sayadId ?? '').includes(q),
)
}
return list
})
const pendingCount = computed(
() => checkList.value.filter((c) => c.status === 'pending_review').length,
)
function checkBadgeStatus(status: Check['status']) {
if (status === 'approved') return 'final_approval'
if (status === 'rejected') return 'needs_correction'
return 'pending_check_review'
}
async function loadChecksFromOrders() {
const orders = await ordersApi.list()
checkList.value = orders.flatMap((order) =>
(order.checks ?? []).map((check) => ({
...check,
orderNumber: order.orderNumber,
customerName: order.customerName,
orderStatus: order.status,
settlementType: order.settlementMethod ?? order.settlementType ?? null,
})),
)
}
async function loadChecks() {
loading.value = true
try {
checkList.value = await checksApi.list()
} catch {
try {
await loadChecksFromOrders()
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'بارگذاری چک‌ها انجام نشد.')
}
} finally {
loading.value = false
}
}
onMounted(() => {
void loadChecks()
})
function openOrderChecks(check: Check) {
router.push(`/orders/${check.orderId}/checks`)
}
async function updateCheckStatus(check: Check, status: Check['status']) {
busyId.value = check.id
try {
const updated = await checksApi.updateStatus(check.id, { status })
const index = checkList.value.findIndex((c) => c.id === check.id)
if (index !== -1) {
checkList.value[index] = {
...checkList.value[index]!,
...updated,
orderNumber: check.orderNumber,
customerName: check.customerName,
orderStatus: check.orderStatus,
settlementType: check.settlementType,
}
}
toast.success(status === 'approved' ? 'چک تأیید شد.' : 'چک رد شد.')
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'به‌روزرسانی وضعیت چک انجام نشد.')
} finally {
busyId.value = null
}
}
</script>
<template>
<div class="checks-list-page">
<div class="admin-page-header">
<div>
<h1 class="admin-page-header__title">چک‌ها</h1>
</div>
<div class="checks-list-page__summary">
<span>{{ checkList.length }} چک</span>
<span v-if="pendingCount">{{ pendingCount }} در انتظار بررسی</span>
</div>
</div>
<div class="checks-list-page__filters admin-card">
<div class="checks-list-page__search">
<AdminIcon name="search" :size="18" />
<input
v-model="searchQuery"
type="search"
class="checks-list-page__search-input"
placeholder="جستجو در شماره سفارش، مشتری، بانک، صیاد..."
/>
</div>
<AdminSelect
v-model="statusFilter"
class="checks-list-page__status-select"
aria-label="فیلتر وضعیت چک"
:options="statusFilterOptions"
/>
</div>
<AdminPageSkeleton v-if="loading" variant="table" />
<template v-else>
<div class="checks-list-page__table admin-card desktop-only">
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>شماره سفارش</th>
<th>مشتری</th>
<th>ردیف</th>
<th>مبلغ</th>
<th>بانک</th>
<th>شناسه صیادی</th>
<th>سررسید</th>
<th>وضعیت</th>
<th>عملیات</th>
</tr>
</thead>
<tbody>
<tr v-for="check in filteredChecks" :key="check.id">
<td>
<button
type="button"
class="checks-list-page__order-link"
@click="openOrderChecks(check)"
>
{{ check.orderNumber || check.orderId }}
</button>
</td>
<td>{{ check.customerName || '—' }}</td>
<td>{{ check.row }}</td>
<td>{{ formatCurrency(check.amount) }}</td>
<td>{{ check.bankName || '—' }}</td>
<td dir="ltr">{{ check.sayadId || '—' }}</td>
<td>{{ check.dueDate.slice(0, 10) }}</td>
<td><StatusBadge :status="checkBadgeStatus(check.status)" /></td>
<td>
<div class="admin-action-group">
<template v-if="check.status === 'pending_review'">
<button
type="button"
class="admin-btn admin-btn--outline-success admin-btn--xs"
:disabled="busyId === check.id"
@click="updateCheckStatus(check, 'approved')"
>
تأیید
</button>
<button
type="button"
class="admin-btn admin-btn--outline-danger admin-btn--xs"
:disabled="busyId === check.id"
@click="updateCheckStatus(check, 'rejected')"
>
رد
</button>
</template>
<button
type="button"
class="admin-btn admin-btn--outline admin-btn--xs"
@click="openOrderChecks(check)"
>
جزئیات سفارش
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<p v-if="!filteredChecks.length" class="checks-list-page__empty">
چکی با این فیلتر یافت نشد.
</p>
</div>
<div class="compact-only admin-card-list">
<article v-for="check in filteredChecks" :key="check.id" class="admin-card-item">
<div class="admin-card-item__header">
<p class="admin-card-item__title">
{{ check.orderNumber || check.orderId }} — چک {{ check.row }}
</p>
<StatusBadge :status="checkBadgeStatus(check.status)" />
</div>
<div class="admin-card-item__meta">
<span>{{ check.customerName || '—' }}</span>
<span>{{ formatCurrency(check.amount) }}</span>
<span>{{ check.bankName || '—' }}</span>
<span>سررسید {{ check.dueDate.slice(0, 10) }}</span>
<span v-if="check.createdAt">ثبت {{ formatDateTime(check.createdAt) }}</span>
</div>
<div class="admin-card-item__actions">
<template v-if="check.status === 'pending_review'">
<button
type="button"
class="admin-btn admin-btn--outline-success admin-btn--xs"
:disabled="busyId === check.id"
@click="updateCheckStatus(check, 'approved')"
>
تأیید
</button>
<button
type="button"
class="admin-btn admin-btn--outline-danger admin-btn--xs"
:disabled="busyId === check.id"
@click="updateCheckStatus(check, 'rejected')"
>
رد
</button>
</template>
<button
type="button"
class="admin-btn admin-btn--outline admin-btn--xs"
@click="openOrderChecks(check)"
>
جزئیات سفارش
</button>
</div>
</article>
<p v-if="!filteredChecks.length" class="checks-list-page__empty">
چکی با این فیلتر یافت نشد.
</p>
</div>
</template>
</div>
</template>
<style scoped>
.checks-list-page__summary {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
font-size: 0.8125rem;
color: var(--color-text-muted);
}
.checks-list-page__filters {
display: flex;
gap: 12px;
align-items: center;
padding: 12px 14px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.checks-list-page__search {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
min-width: 220px;
padding: 8px 12px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-border-light);
color: var(--color-text-muted);
}
.checks-list-page__search-input {
flex: 1;
border: none;
background: transparent;
outline: none;
font-size: 0.875rem;
color: var(--color-text);
}
.checks-list-page__status-select {
flex: 0 1 220px;
width: 220px;
}
.checks-list-page__table {
padding: 0;
overflow: hidden;
}
.checks-list-page__order-link {
border: none;
background: transparent;
color: var(--color-primary);
cursor: pointer;
padding: 0;
font: inherit;
}
.checks-list-page__order-link:hover {
text-decoration: underline;
}
.checks-list-page__empty {
margin: 0;
padding: 24px;
text-align: center;
color: var(--color-text-muted);
font-size: 0.875rem;
}
</style>
+290
View File
@@ -0,0 +1,290 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import BaseModal from '@/components/ui/BaseModal.vue'
import BaseSwitch from '@/components/ui/BaseSwitch.vue'
import BaseEmptyState from '@/components/ui/BaseEmptyState.vue'
import ActiveSwitchCell from '@/components/ui/ActiveSwitchCell.vue'
import { bankAccountsApi, companiesApi } from '@/services/api'
import { ApiError } from '@/services/http'
import { useToast } from '@/composables/useToast'
import type { BankAccount, Company } from '@/types'
const route = useRoute()
const router = useRouter()
const toast = useToast()
const company = ref<Company | null>(null)
const accountList = ref<BankAccount[]>([])
const showAddModal = ref(false)
const formError = ref('')
const saving = ref(false)
const companyId = computed(() => String(route.params.companyId))
const accounts = computed(() => accountList.value)
const emptyForm = () => ({
bankName: '',
accountNumber: '',
accountHolder: company.value?.name ?? '',
cardNumber: '',
iban: '',
isActive: true,
})
const form = reactive(emptyForm())
const isFormValid = computed(
() =>
form.bankName.trim().length > 0 &&
form.accountNumber.trim().length > 0 &&
form.accountHolder.trim().length > 0,
)
async function loadData() {
try {
const [companyData, accountsData] = await Promise.all([
companiesApi.get(companyId.value),
bankAccountsApi.listByCompany(companyId.value),
])
company.value = companyData
accountList.value = accountsData
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'بارگذاری حساب‌ها انجام نشد.')
}
}
onMounted(() => {
void loadData()
})
function openAddModal() {
Object.assign(form, emptyForm())
formError.value = ''
showAddModal.value = true
}
function closeAddModal() {
showAddModal.value = false
formError.value = ''
}
async function submitAddAccount() {
if (!isFormValid.value) {
formError.value = 'فیلدهای ستاره‌دار الزامی هستند.'
return
}
saving.value = true
try {
const created = await bankAccountsApi.create(companyId.value, {
bankName: form.bankName.trim(),
accountNumber: form.accountNumber.trim(),
accountHolder: form.accountHolder.trim(),
cardNumber: form.cardNumber.trim() || '-',
iban: form.iban.trim() || '-',
isActive: form.isActive,
displayOrder: accountList.value.length + 1,
})
accountList.value.push(created)
toast.success('حساب بانکی جدید ثبت شد.')
closeAddModal()
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'ثبت حساب انجام نشد.')
} finally {
saving.value = false
}
}
async function persistActive(id: string, isActive: boolean) {
await bankAccountsApi.setActive(id, isActive)
}
function goBack() {
router.push('/companies')
}
</script>
<template>
<div class="accounts-page">
<div class="admin-page-header">
<div>
<h1 class="admin-page-header__title">
مدیریت حساب‌های بانکی — {{ company?.name ?? '…' }}
</h1>
</div>
<div class="admin-action-group">
<button 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">
بازگشت
</button>
</div>
</div>
<BaseEmptyState
v-if="!accounts.length"
class="admin-card"
icon="wallet"
title="حساب بانکی ثبت نشده"
description="برای این شرکت هنوز حساب بانکی ثبت نشده است."
>
<button type="button" class="admin-btn admin-btn--primary admin-btn--sm" @click="openAddModal">
افزودن حساب
</button>
</BaseEmptyState>
<div v-else class="accounts-page__table admin-card desktop-only">
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>بانک</th>
<th>حساب بانکی</th>
<th>نام صاحب</th>
<th>شماره کارت</th>
<th>شماره شبا</th>
<th class="status">وضعیت</th>
</tr>
</thead>
<tbody>
<tr v-for="account in accounts" :key="account.id">
<td>{{ account.bankName }}</td>
<td>{{ account.accountNumber }}</td>
<td>{{ account.accountHolder }}</td>
<td>{{ account.cardNumber || '—' }}</td>
<td>{{ account.iban || '—' }}</td>
<td class="status">
<ActiveSwitchCell
:id="account.id"
v-model="account.isActive"
:entity-name="account.bankName"
entity-type="حساب بانکی"
:persist="persistActive"
/>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-if="accounts.length" class="compact-only admin-card-list">
<article v-for="account in accounts" :key="account.id" class="admin-card-item">
<div class="admin-card-item__header">
<p class="admin-card-item__title">بانک {{ account.bankName }}</p>
<ActiveSwitchCell
:id="account.id"
v-model="account.isActive"
:entity-name="account.bankName"
entity-type="حساب بانکی"
:persist="persistActive"
/>
</div>
<div class="admin-card-item__meta">
<span>{{ account.accountHolder }}</span>
<span>حساب: {{ account.accountNumber }}</span>
<span v-if="account.cardNumber">کارت: {{ account.cardNumber }}</span>
<span v-if="account.iban" dir="ltr">{{ account.iban }}</span>
</div>
</article>
</div>
<BaseModal :open="showAddModal" title="افزودن حساب بانکی" size="lg" @close="closeAddModal">
<form class="admin-form" @submit.prevent="submitAddAccount">
<p v-if="formError" class="admin-form__error" role="alert">{{ formError }}</p>
<div class="admin-form__row">
<div class="admin-form__field">
<label for="bank-name">نام بانک <span class="admin-form__required">*</span></label>
<input
id="bank-name"
v-model="form.bankName"
type="text"
class="admin-input"
placeholder="مثال: ملت"
/>
</div>
<div class="admin-form__field">
<label for="account-number">
شماره حساب <span class="admin-form__required">*</span>
</label>
<input
id="account-number"
v-model="form.accountNumber"
type="text"
class="admin-input"
placeholder="1234567890"
inputmode="numeric"
dir="ltr"
/>
</div>
</div>
<div class="admin-form__field">
<label for="account-holder">
نام صاحب حساب <span class="admin-form__required">*</span>
</label>
<input
id="account-holder"
v-model="form.accountHolder"
type="text"
class="admin-input"
/>
</div>
<div class="admin-form__row">
<div class="admin-form__field">
<label for="card-number">شماره کارت</label>
<input
id="card-number"
v-model="form.cardNumber"
type="text"
class="admin-input"
placeholder="6104 3378 0077 1234"
inputmode="numeric"
dir="ltr"
/>
</div>
<div class="admin-form__field">
<label for="iban">شماره شبا</label>
<input
id="iban"
v-model="form.iban"
type="text"
class="admin-input"
placeholder="IR..."
dir="ltr"
/>
</div>
</div>
<div class="admin-form__field">
<BaseSwitch v-model="form.isActive" label="وضعیت" />
</div>
</form>
<template #footer>
<button type="button" class="admin-btn admin-btn--outline" @click="closeAddModal">
انصراف
</button>
<button
type="button"
class="admin-btn admin-btn--primary"
:disabled="!isFormValid || saving"
@click="submitAddAccount"
>
ثبت حساب
</button>
</template>
</BaseModal>
</div>
</template>
<style scoped>
.accounts-page__table {
overflow: hidden;
}
</style>
+550
View File
@@ -0,0 +1,550 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
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 ActiveSwitchCell from '@/components/ui/ActiveSwitchCell.vue'
import { companiesApi, slugifyCompanyName } from '@/services/api'
import { ApiError } from '@/services/http'
import { useToast } from '@/composables/useToast'
import { confirm } from '@/composables/useConfirm'
import type { Company } from '@/types'
const MAX_LOGO_SIZE = 2 * 1024 * 1024
const ACCEPTED_LOGO_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml']
const router = useRouter()
const toast = useToast()
const companyList = ref<Company[]>([])
const loading = ref(true)
const modalMode = ref<'add' | 'edit' | null>(null)
const editingCompanyId = ref<string | null>(null)
const logoPreview = ref<string | null>(null)
const logoInputRef = ref<HTMLInputElement | null>(null)
const saving = ref(false)
async function loadCompanies() {
loading.value = true
try {
companyList.value = await companiesApi.list()
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'بارگذاری شرکت‌ها انجام نشد.')
} finally {
loading.value = false
}
}
onMounted(() => {
void loadCompanies()
})
const emptyForm = () => ({
name: '',
description: '',
displayOrder: companyList.value.length + 1,
isActive: true,
})
const form = reactive(emptyForm())
const formError = ref('')
const isModalOpen = computed(() => modalMode.value !== null)
const modalTitle = computed(() => (modalMode.value === 'edit' ? 'ویرایش شرکت' : 'افزودن شرکت'))
const submitLabel = computed(() => (modalMode.value === 'edit' ? 'ذخیره تغییرات' : 'ثبت شرکت'))
const isFormValid = computed(() => form.name.trim().length > 0 && logoPreview.value !== null)
function resetLogo() {
logoPreview.value = null
if (logoInputRef.value) {
logoInputRef.value.value = ''
}
}
function openAddModal() {
modalMode.value = 'add'
editingCompanyId.value = null
Object.assign(form, emptyForm())
resetLogo()
formError.value = ''
}
function openEditModal(company: Company) {
modalMode.value = 'edit'
editingCompanyId.value = company.id
form.name = company.name
form.description = company.description === '—' ? '' : company.description
form.displayOrder = company.displayOrder
form.isActive = company.isActive
logoPreview.value = company.logoUrl
if (logoInputRef.value) {
logoInputRef.value.value = ''
}
formError.value = ''
}
function closeModal() {
modalMode.value = null
editingCompanyId.value = null
formError.value = ''
resetLogo()
}
function onLogoSelect(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
if (!ACCEPTED_LOGO_TYPES.includes(file.type)) {
formError.value = 'فرمت مجاز: PNG، JPG، WEBP یا SVG'
return
}
if (file.size > MAX_LOGO_SIZE) {
formError.value = 'حداکثر حجم تصویر ۲ مگابایت است.'
return
}
formError.value = ''
const reader = new FileReader()
reader.onload = () => {
logoPreview.value = reader.result as string
}
reader.readAsDataURL(file)
}
async function removeLogo() {
if (!logoPreview.value) return
const ok = await confirm({
title: 'حذف تصویر',
message: 'آیا از حذف لوگوی شرکت مطمئن هستید؟',
confirmLabel: 'حذف',
cancelLabel: 'انصراف',
tone: 'danger',
})
if (!ok) return
resetLogo()
}
async function submitCompany() {
const name = form.name.trim()
if (!name) {
formError.value = 'نام شرکت الزامی است.'
return
}
if (!logoPreview.value) {
formError.value = 'آپلود لوگو الزامی است.'
return
}
const duplicate = companyList.value.some(
(c) => c.name === name && c.id !== editingCompanyId.value,
)
if (duplicate) {
formError.value = 'شرکتی با این نام قبلاً ثبت شده است.'
return
}
saving.value = true
try {
if (modalMode.value === 'edit' && editingCompanyId.value) {
const updated = await companiesApi.update(editingCompanyId.value, {
name,
description: form.description.trim() || undefined,
displayOrder: Number(form.displayOrder) || 1,
isActive: form.isActive,
logoUrl: logoPreview.value,
})
const index = companyList.value.findIndex((c) => c.id === editingCompanyId.value)
if (index !== -1) companyList.value[index] = { ...companyList.value[index]!, ...updated }
toast.success('اطلاعات شرکت به‌روزرسانی شد.')
} else {
const created = await companiesApi.create({
name,
slug: slugifyCompanyName(name),
description: form.description.trim() || undefined,
displayOrder: Number(form.displayOrder) || companyList.value.length + 1,
isActive: form.isActive,
logoUrl: logoPreview.value,
})
companyList.value.push(created)
toast.success('شرکت جدید ثبت شد.')
}
closeModal()
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'ذخیره شرکت انجام نشد.')
} finally {
saving.value = false
}
}
async function persistActive(id: string, isActive: boolean) {
await companiesApi.setActive(id, isActive)
}
function goAccounts(companyId: string) {
router.push(`/companies/${companyId}/accounts`)
}
function companyInitial(name: string) {
return name.trim().charAt(0) || '؟'
}
</script>
<template>
<div class="companies-page">
<div class="admin-page-header">
<div>
<h1 class="admin-page-header__title">مدیریت شرکت‌ها</h1>
</div>
<button type="button" class="admin-btn admin-btn--primary" @click="openAddModal">
افزودن شرکت
</button>
</div>
<AdminPageSkeleton v-if="loading" variant="table" />
<BaseEmptyState
v-else-if="!companyList.length"
class="admin-card"
icon="companies"
title="شرکتی ثبت نشده"
description="برای شروع، اولین شرکت را ثبت کنید."
>
<button type="button" class="admin-btn admin-btn--primary admin-btn--sm" @click="openAddModal">
افزودن شرکت
</button>
</BaseEmptyState>
<div v-else class="companies-page__table admin-card desktop-only">
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>لوگو</th>
<th class="numeric">ترتیب</th>
<th class="status">وضعیت</th>
<th>توضیحات</th>
<th>نام شرکت</th>
<th class="actions">عملیات</th>
</tr>
</thead>
<tbody>
<tr v-for="company in companyList" :key="company.id">
<td>
<img
v-if="company.logoUrl"
:src="company.logoUrl"
:alt="`لوگوی ${company.name}`"
class="company-logo"
/>
<div v-else class="company-logo company-logo--fallback">
{{ companyInitial(company.name) }}
</div>
</td>
<td class="numeric">{{ company.displayOrder }}</td>
<td class="status">
<ActiveSwitchCell
:id="company.id"
v-model="company.isActive"
:entity-name="company.name"
entity-type="شرکت"
:persist="persistActive"
/>
</td>
<td>{{ company.description }}</td>
<td>{{ company.name }}</td>
<td class="actions">
<div class="admin-action-group">
<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>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-if="!loading && companyList.length" class="compact-only admin-card-list">
<article v-for="company in companyList" :key="company.id" class="admin-card-item">
<div class="admin-card-item__header">
<div class="companies-page__card-logo">
<img
v-if="company.logoUrl"
:src="company.logoUrl"
:alt="`لوگوی ${company.name}`"
class="company-logo"
/>
<div v-else class="company-logo company-logo--fallback">
{{ companyInitial(company.name) }}
</div>
<div>
<p class="admin-card-item__title">{{ company.name }}</p>
<p class="companies-page__card-desc">{{ company.description }}</p>
</div>
</div>
<ActiveSwitchCell
:id="company.id"
v-model="company.isActive"
:entity-name="company.name"
entity-type="شرکت"
:persist="persistActive"
/>
</div>
<div class="admin-card-item__meta">
<span>ترتیب: {{ company.displayOrder }}</span>
</div>
<div class="admin-card-item__actions">
<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>
</div>
</article>
</div>
<BaseModal :open="isModalOpen" :title="modalTitle" @close="closeModal">
<form class="admin-form" @submit.prevent="submitCompany">
<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>
<div class="logo-upload">
<div v-if="logoPreview" class="logo-upload__preview-wrap">
<img :src="logoPreview" alt="پیش‌نمایش لوگو" class="logo-upload__preview" />
<button type="button" class="logo-upload__remove" @click="removeLogo">
حذف تصویر
</button>
</div>
<label v-else class="logo-upload__dropzone" for="company-logo-file">
<span class="logo-upload__icon">+</span>
<span class="logo-upload__text">انتخاب یا رها کردن تصویر لوگو</span>
<span class="logo-upload__hint">PNG، JPG، WEBP یا SVG — حداکثر ۲ مگابایت</span>
</label>
<input
id="company-logo-file"
ref="logoInputRef"
type="file"
accept="image/png,image/jpeg,image/webp,image/svg+xml"
class="logo-upload__input"
@change="onLogoSelect"
/>
<button
v-if="logoPreview"
type="button"
class="admin-btn admin-btn--outline admin-btn--sm logo-upload__change"
@click="logoInputRef?.click()"
>
تغییر تصویر
</button>
</div>
</div>
<div class="admin-form__field">
<label for="company-name">نام شرکت <span class="admin-form__required">*</span></label>
<input
id="company-name"
v-model="form.name"
type="text"
class="admin-input"
placeholder="مثال: شرکت پَر"
autocomplete="off"
/>
</div>
<div class="admin-form__field">
<label for="company-description">توضیحات</label>
<textarea
id="company-description"
v-model="form.description"
class="admin-form__textarea"
placeholder="تولید و عرضه انواع پارچه"
rows="3"
/>
</div>
<div class="admin-form__row">
<div class="admin-form__field">
<label for="company-order">ترتیب نمایش</label>
<input
id="company-order"
v-model.number="form.displayOrder"
type="number"
min="1"
class="admin-input"
/>
</div>
<div class="admin-form__field">
<BaseSwitch v-model="form.isActive" label="وضعیت" />
</div>
</div>
</form>
<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="submitCompany"
>
{{ submitLabel }}
</button>
</template>
</BaseModal>
</div>
</template>
<style scoped>
.companies-page__table {
overflow: hidden;
}
.companies-page__card-logo {
display: flex;
align-items: center;
gap: 12px;
}
.companies-page__card-desc {
margin: 4px 0 0;
font-size: 0.8125rem;
color: var(--color-text-muted);
font-weight: 400;
}
.company-logo {
width: 40px;
height: 40px;
border-radius: var(--radius-sm);
object-fit: contain;
background: var(--color-bg);
border: 1px solid var(--color-border);
}
.company-logo--fallback {
display: flex;
align-items: center;
justify-content: center;
background: var(--color-primary);
color: #fff;
font-weight: 700;
font-size: 1rem;
}
.logo-upload {
display: flex;
flex-direction: column;
gap: 10px;
}
.logo-upload__input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.logo-upload__dropzone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 28px 16px;
border: 2px dashed var(--color-border);
border-radius: var(--radius-md);
background: var(--color-border-light);
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.logo-upload__dropzone:hover {
border-color: var(--color-primary);
background: var(--color-info-light);
}
.logo-upload__icon {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--color-surface);
border: 1px solid var(--color-border);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
color: var(--color-text-muted);
line-height: 1;
}
.logo-upload__text {
font-size: 0.875rem;
font-weight: 600;
color: var(--color-text);
}
.logo-upload__hint {
font-size: 0.75rem;
color: var(--color-text-muted);
}
.logo-upload__preview-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 16px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-border-light);
}
.logo-upload__preview {
width: 80px;
height: 80px;
object-fit: contain;
border-radius: var(--radius-sm);
background: var(--color-surface);
border: 1px solid var(--color-border);
}
.logo-upload__remove {
border: none;
background: none;
color: var(--color-danger);
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
padding: 0;
}
.logo-upload__remove:hover {
text-decoration: underline;
}
.logo-upload__change {
align-self: flex-start;
}
</style>
+498
View File
@@ -0,0 +1,498 @@
<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 ActiveSwitchCell from '@/components/ui/ActiveSwitchCell.vue'
import AdminIcon from '@/components/icons/AdminIcon.vue'
import AdminSelect from '@/components/ui/AdminSelect.vue'
import { brandsApi, customersApi, levelsApi, type Brand, type CustomerLevel } from '@/services/api'
import { ApiError } from '@/services/http'
import { useToast } from '@/composables/useToast'
import { CUSTOMER_TYPE_LABELS } from '@/utils/adminLabels'
import { formatDateTime, formatMeterage } from '@/utils/format'
import type { Customer, CustomerType } from '@/types'
const customerList = ref<Customer[]>([])
const levels = ref<CustomerLevel[]>([])
const agents = ref<Customer[]>([])
const brands = ref<Brand[]>([])
const searchQuery = ref('')
const loading = ref(true)
const modalMode = ref<'add' | 'edit' | null>(null)
const editingId = ref<string | null>(null)
const formError = ref('')
const saving = ref(false)
const toast = useToast()
function displayCell(value: string | number | null | undefined): string {
if (value == null) return '-'
if (typeof value === 'number') return Number.isFinite(value) ? String(value) : '-'
const text = value.trim()
if (!text || text === '—') return '-'
return text
}
function displayMeterage(value: number | null | undefined): string {
if (value == null || !Number.isFinite(value)) return '-'
return formatMeterage(value)
}
const customerTypeOptions = [
{ value: 'wholesale', label: 'عمده‌فروش' },
{ value: 'retail', label: 'خرده‌فروش' },
]
const appRoleOptions = [
{ value: 'customer', label: 'مشتری' },
{ value: 'agent', label: 'واسطه' },
]
const levelOptions = computed(() => [
{ value: '', label: 'بدون سطح' },
...levels.value.map((level) => ({ value: level.id, label: level.label })),
])
const agentOptions = computed(() => [
{ value: '', label: 'بدون واسطه' },
...agents.value
.filter((agent) => agent.id !== editingId.value)
.map((agent) => ({
value: agent.id,
label: agent.name + (agent.agentCode ? ` (${agent.agentCode})` : ''),
})),
])
const emptyForm = () => ({
name: '',
phone: '',
nationalId: '',
customerType: 'wholesale' as CustomerType,
companyName: '',
isActive: true,
levelDefId: '' as string,
assignedAgentId: '' as string,
brandIds: [] as string[],
appRole: 'customer' as 'customer' | 'agent',
agentCode: '',
})
const form = reactive(emptyForm())
const isModalOpen = computed(() => modalMode.value !== null)
const modalTitle = computed(() => (modalMode.value === 'edit' ? 'ویرایش مشتری' : 'افزودن مشتری'))
const isFormValid = computed(
() => form.name.trim() && form.phone.trim() && form.nationalId.trim(),
)
const filteredCustomers = computed(() => {
const q = searchQuery.value.trim()
if (!q) return customerList.value
return customerList.value.filter(
(c) =>
c.name.includes(q) ||
c.phone.includes(q) ||
c.nationalId.includes(q) ||
c.companyName.includes(q),
)
})
function toggleBrand(brandId: string, checked: boolean) {
if (checked) {
if (!form.brandIds.includes(brandId)) form.brandIds.push(brandId)
} else {
form.brandIds = form.brandIds.filter((id) => id !== brandId)
}
}
async function loadCustomers() {
loading.value = true
try {
customerList.value = await customersApi.list()
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'بارگذاری مشتریان انجام نشد.')
} finally {
loading.value = false
}
}
async function loadLookups() {
try {
const [levelsData, agentsData, brandsData] = await Promise.all([
levelsApi.list(),
customersApi.list(undefined, 'agent'),
brandsApi.list(),
])
levels.value = levelsData
agents.value = agentsData
brands.value = brandsData
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'بارگذاری اطلاعات کمکی انجام نشد.')
}
}
onMounted(() => {
void loadCustomers()
void loadLookups()
})
function openAddModal() {
modalMode.value = 'add'
editingId.value = null
Object.assign(form, emptyForm())
formError.value = ''
}
function openEditModal(customer: Customer) {
modalMode.value = 'edit'
editingId.value = customer.id
form.name = customer.name
form.phone = customer.phone
form.nationalId = customer.nationalId
form.customerType = customer.customerType
form.companyName = customer.companyName
form.isActive = customer.isActive
form.levelDefId = customer.levelDefId ?? ''
form.assignedAgentId = customer.assignedAgentId ?? ''
form.brandIds = [...(customer.brandIds ?? [])]
form.appRole = customer.appRole === 'agent' ? 'agent' : 'customer'
form.agentCode = customer.agentCode ?? ''
formError.value = ''
}
function closeModal() {
modalMode.value = null
editingId.value = null
formError.value = ''
}
async function submitCustomer() {
if (!isFormValid.value) {
formError.value = 'نام، تلفن و شناسه ملی الزامی هستند.'
return
}
saving.value = true
const payload = {
name: form.name.trim(),
phone: form.phone.trim(),
nationalId: form.nationalId.trim(),
customerType: form.customerType,
isActive: form.isActive,
levelDefId: form.levelDefId || null,
assignedAgentId: form.assignedAgentId || null,
brandIds: [...form.brandIds],
appRole: form.appRole,
agentCode: form.appRole === 'agent' ? form.agentCode.trim() || null : null,
}
try {
if (modalMode.value === 'edit' && editingId.value) {
const updated = await customersApi.update(editingId.value, payload)
const index = customerList.value.findIndex((c) => c.id === editingId.value)
if (index !== -1) customerList.value[index] = { ...customerList.value[index]!, ...updated }
} else {
const created = await customersApi.create(payload)
customerList.value.unshift(created)
}
toast.success(modalMode.value === 'edit' ? 'اطلاعات مشتری به‌روزرسانی شد.' : 'مشتری جدید ثبت شد.')
closeModal()
if (payload.appRole === 'agent') void loadLookups()
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'ذخیره مشتری انجام نشد.')
} finally {
saving.value = false
}
}
async function persistActive(id: string, isActive: boolean) {
await customersApi.setActive(id, isActive)
}
function clearSearch() {
searchQuery.value = ''
}
</script>
<template>
<div class="customers-page">
<div class="admin-page-header">
<div>
<h1 class="admin-page-header__title">مشتریان</h1>
</div>
<button type="button" class="admin-btn admin-btn--primary" @click="openAddModal">
افزودن مشتری
</button>
</div>
<div class="customers-page__search admin-card">
<AdminIcon name="search" :size="18" />
<input
v-model="searchQuery"
type="search"
class="customers-page__search-input"
placeholder="جستجو در نام، تلفن، شناسه ملی..."
/>
</div>
<AdminPageSkeleton v-if="loading" variant="table" />
<BaseEmptyState
v-else-if="!filteredCustomers.length"
class="admin-card"
icon="customers"
:title="searchQuery.trim() ? 'نتیجه‌ای یافت نشد' : 'مشتریان ثبت شده'"
:description="
searchQuery.trim()
? 'عبارت جستجو را تغییر دهید یا فیلترها را پاک کنید.'
: 'برای شروع، اولین مشتری را ثبت کنید.'
"
>
<button v-if="searchQuery.trim()" type="button" class="admin-btn admin-btn--outline admin-btn--sm" @click="clearSearch">
پاک کردن جستجو
</button>
<button v-else type="button" class="admin-btn admin-btn--primary admin-btn--sm" @click="openAddModal">
افزودن مشتری
</button>
</BaseEmptyState>
<div v-else class="customers-page__table admin-card desktop-only">
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>نام مشتری</th>
<th>تلفن</th>
<th>شناسه ملی</th>
<th>نوع</th>
<th>سطح</th>
<th>واسطه</th>
<th>شرکت</th>
<th class="numeric">تعداد سفارش</th>
<th class="numeric">مجموع متراژ</th>
<th class="status">وضعیت</th>
<th>تاریخ ثبت</th>
<th class="actions">عملیات</th>
</tr>
</thead>
<tbody>
<tr v-for="customer in filteredCustomers" :key="customer.id">
<td>{{ displayCell(customer.name) }}</td>
<td>{{ displayCell(customer.phone) }}</td>
<td>{{ displayCell(customer.nationalId) }}</td>
<td>{{ displayCell(CUSTOMER_TYPE_LABELS[customer.customerType]) }}</td>
<td>{{ displayCell(customer.levelLabel) }}</td>
<td>{{ displayCell(customer.assignedAgentName) }}</td>
<td>{{ displayCell(customer.companyName) }}</td>
<td class="numeric">{{ displayCell(customer.orderCount) }}</td>
<td class="numeric">{{ displayMeterage(customer.totalMeterage) }}</td>
<td class="status">
<ActiveSwitchCell
:id="customer.id"
v-model="customer.isActive"
:entity-name="customer.name"
entity-type="مشتری"
:persist="persistActive"
/>
</td>
<td>{{ displayCell(formatDateTime(customer.registeredAt)) }}</td>
<td class="actions">
<button type="button" class="admin-btn admin-btn--ghost admin-btn--xs" @click="openEditModal(customer)">
ویرایش
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-if="!loading && filteredCustomers.length" class="compact-only admin-card-list">
<article v-for="customer in filteredCustomers" :key="customer.id" class="admin-card-item">
<div class="admin-card-item__header">
<p class="admin-card-item__title">{{ displayCell(customer.name) }}</p>
<div class="admin-card-item__header-actions">
<ActiveSwitchCell
:id="customer.id"
v-model="customer.isActive"
:entity-name="customer.name"
entity-type="مشتری"
:persist="persistActive"
/>
<button type="button" class="admin-btn admin-btn--ghost admin-btn--xs" @click="openEditModal(customer)">
ویرایش
</button>
</div>
</div>
<div class="admin-card-item__meta">
<span>{{ displayCell(customer.phone) }}</span>
<span>{{ displayCell(CUSTOMER_TYPE_LABELS[customer.customerType]) }}</span>
<span>{{ displayCell(customer.levelLabel) }}</span>
<span>{{ displayCell(customer.assignedAgentName) }}</span>
<span>{{ displayCell(customer.companyName) }}</span>
<span>
{{ displayCell(customer.orderCount) }} سفارش · {{ displayMeterage(customer.totalMeterage) }}
</span>
</div>
</article>
</div>
<BaseModal :open="isModalOpen" :title="modalTitle" size="lg" @close="closeModal">
<form class="admin-form" @submit.prevent="submitCustomer">
<p v-if="formError" class="admin-form__error" role="alert">{{ formError }}</p>
<div class="admin-form__field">
<label for="customer-name">نام مشتری <span class="admin-form__required">*</span></label>
<input id="customer-name" v-model="form.name" type="text" class="admin-input" />
</div>
<div class="admin-form__row">
<div class="admin-form__field">
<label for="customer-phone">تلفن <span class="admin-form__required">*</span></label>
<input
id="customer-phone"
v-model="form.phone"
type="tel"
class="admin-input"
inputmode="tel"
dir="ltr"
/>
</div>
<div class="admin-form__field">
<label for="customer-national-id">شناسه ملی <span class="admin-form__required">*</span></label>
<input
id="customer-national-id"
v-model="form.nationalId"
type="text"
class="admin-input"
inputmode="numeric"
dir="ltr"
/>
</div>
</div>
<div class="admin-form__row">
<div class="admin-form__field">
<label>نوع مشتری</label>
<AdminSelect
v-model="form.customerType"
block
aria-label="نوع مشتری"
:options="customerTypeOptions"
/>
</div>
<div class="admin-form__field">
<label for="customer-company">شرکت مرتبط</label>
<input id="customer-company" v-model="form.companyName" type="text" class="admin-input" />
</div>
</div>
<div class="admin-form__row">
<div class="admin-form__field">
<label>سطح</label>
<AdminSelect
v-model="form.levelDefId"
block
aria-label="سطح مشتری"
:options="levelOptions"
/>
</div>
<div class="admin-form__field">
<label>واسطه</label>
<AdminSelect
v-model="form.assignedAgentId"
block
aria-label="واسطه اختصاص‌یافته"
:options="agentOptions"
/>
</div>
</div>
<div class="admin-form__row">
<div class="admin-form__field">
<label>نقش در اپ</label>
<AdminSelect
v-model="form.appRole"
block
aria-label="نقش در اپ"
:options="appRoleOptions"
/>
</div>
<div v-if="form.appRole === 'agent'" class="admin-form__field">
<label for="customer-agent-code">کد واسطه</label>
<input id="customer-agent-code" v-model="form.agentCode" type="text" class="admin-input" dir="ltr" />
</div>
</div>
<div v-if="brands.length" class="admin-form__field">
<label>برندها</label>
<div class="customers-page__brands">
<label v-for="brand in brands" :key="brand.id" class="customers-page__brand">
<input
type="checkbox"
:checked="form.brandIds.includes(brand.id)"
@change="toggleBrand(brand.id, ($event.target as HTMLInputElement).checked)"
/>
<span>{{ brand.name }}</span>
</label>
</div>
</div>
<div class="admin-form__field">
<BaseSwitch v-model="form.isActive" label="وضعیت" />
</div>
</form>
<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="submitCustomer">
<span v-if="saving" class="admin-btn__spinner" aria-hidden="true" />
{{ modalMode === 'edit' ? 'ذخیره تغییرات' : 'ثبت مشتری' }}
</button>
</template>
</BaseModal>
</div>
</template>
<style scoped>
.customers-page__search {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
margin-bottom: 16px;
color: var(--color-text-muted);
}
.customers-page__search-input {
flex: 1;
border: none;
background: transparent;
font-size: 0.875rem;
outline: none;
}
.customers-page__table {
overflow: hidden;
}
.customers-page__brands {
display: flex;
flex-wrap: wrap;
gap: 10px 16px;
}
.customers-page__brand {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.875rem;
cursor: pointer;
}
.customers-page__brand input {
width: 16px;
height: 16px;
accent-color: var(--color-primary);
}
</style>
+9
View File
@@ -0,0 +1,9 @@
<script setup lang="ts">
import DesktopDashboard from '@/views/dashboard/components/DesktopDashboard.vue'
import CompactDashboard from '@/views/dashboard/components/CompactDashboard.vue'
</script>
<template>
<DesktopDashboard class="desktop-only" />
<CompactDashboard class="compact-only" />
</template>
@@ -0,0 +1,116 @@
<script setup lang="ts">
import AdminIcon from '@/components/icons/AdminIcon.vue'
import StatusBadge from '@/components/ui/StatusBadge.vue'
import SalesAreaChart from '@/components/charts/SalesAreaChart.vue'
import { iconBg, iconColor, useDashboard } from '@/composables/useDashboard'
import { formatCurrency, formatDateTime } from '@/utils/format'
const { checkDeadlines, dashboardStats, recentOrders, salesChartData, chartAxisLabels, goOrders, statCards } =
useDashboard()
</script>
<template>
<div class="compact-dashboard page-shell">
<div class="flex justify-end mb-3">
<button type="button" class="admin-btn admin-btn--outline admin-btn--sm">
<AdminIcon name="calendar" :size="16" />
امروز
</button>
</div>
<div class="compact-stats-grid">
<div v-for="card in statCards" :key="card.label" class="admin-stat-card">
<div
class="admin-stat-card__icon"
:style="{ background: iconBg[card.tone], color: iconColor[card.tone] }"
>
<AdminIcon :name="card.icon" :size="20" />
</div>
<p class="admin-stat-card__label">{{ card.label }}</p>
<p class="admin-stat-card__value">{{ card.value }}</p>
<RouterLink :to="card.link" class="admin-stat-card__link">مشاهده</RouterLink>
</div>
<div class="admin-stat-card col-span-full">
<p class="admin-stat-card__label">فروش امروز</p>
<p class="admin-stat-card__value">{{ formatCurrency(dashboardStats.todaySales) }}</p>
<span class="text-xs font-semibold text-success">
+{{ dashboardStats.todaySalesGrowth }}% نسبت به دیروز
</span>
</div>
</div>
<div class="compact-section-grid">
<section class="compact-section-card admin-panel">
<SalesAreaChart
title="فروش ماه جاری"
:subtitle="formatCurrency(dashboardStats.monthSales)"
:data="salesChartData"
:labels="chartAxisLabels"
unit="میلیارد"
:height="140"
/>
</section>
<section class="compact-section-card admin-panel">
<h2 class="compact-section-card__title">سفارش‌های اخیر</h2>
<div class="admin-card-list">
<RouterLink
v-for="order in recentOrders"
:key="order.id"
to="/orders"
class="admin-card-item block no-underline text-inherit"
>
<div class="admin-card-item__header">
<p class="admin-card-item__title">{{ order.orderNumber }}</p>
<StatusBadge :status="order.status" />
</div>
<div class="admin-card-item__meta">
<span>{{ order.customerName }}</span>
<span>{{ formatDateTime(order.createdAt) }}</span>
</div>
</RouterLink>
</div>
<button
type="button"
class="admin-btn admin-btn--primary admin-btn--sm inline-flex mt-3"
@click="goOrders"
>
مشاهده همه سفارش‌ها
<AdminIcon name="chevron-left" :size="16" />
</button>
</section>
<section class="compact-section-card admin-panel">
<h2 class="compact-section-card__title">سررسید چک‌ها</h2>
<ul class="m-0 p-0 list-none flex flex-col gap-2.5">
<li
v-for="item in checkDeadlines"
:key="item.id"
class="flex flex-wrap items-center gap-2 text-sm"
>
<span
class="admin-badge"
:class="{
'admin-badge--danger': item.tone === 'danger',
'admin-badge--warning': item.tone === 'warning',
'admin-badge--neutral': item.tone === 'neutral',
}"
>
{{ item.label }}
</span>
<span>{{ item.count }} فقره</span>
<strong class="mr-auto text-xs">{{ formatCurrency(item.amount) }}</strong>
</li>
</ul>
<RouterLink
to="/checks"
class="admin-btn admin-btn--outline admin-btn--sm inline-flex mt-3 no-underline"
>
مشاهده همه
<AdminIcon name="chevron-left" :size="16" />
</RouterLink>
</section>
</div>
</div>
</template>
@@ -0,0 +1,257 @@
<script setup lang="ts">
import AdminIcon from '@/components/icons/AdminIcon.vue'
import StatusBadge from '@/components/ui/StatusBadge.vue'
import SalesAreaChart from '@/components/charts/SalesAreaChart.vue'
import { iconBg, iconColor, useDashboard } from '@/composables/useDashboard'
import { formatCurrency, formatDateTime } from '@/utils/format'
const { checkDeadlines, dashboardStats, recentOrders, salesChartData, chartAxisLabels, goOrders, statCards } =
useDashboard()
</script>
<template>
<div class="dashboard">
<div class="dashboard__toolbar">
<button type="button" class="admin-btn admin-btn--outline admin-btn--sm">
<AdminIcon name="calendar" :size="16" />
امروز
</button>
</div>
<div class="dashboard__stats">
<div v-for="card in statCards" :key="card.label" class="admin-stat-card">
<div
class="admin-stat-card__icon"
:style="{ background: iconBg[card.tone], color: iconColor[card.tone] }"
>
<AdminIcon :name="card.icon" :size="20" />
</div>
<p class="admin-stat-card__label">{{ card.label }}</p>
<p class="admin-stat-card__value">{{ card.value }}</p>
<RouterLink :to="card.link" class="admin-stat-card__link">مشاهده سفارش‌ها</RouterLink>
</div>
<div class="dashboard__sales-card admin-stat-card">
<div class="dashboard__sales-header">
<div>
<p class="admin-stat-card__label">فروش امروز</p>
<p class="admin-stat-card__value dashboard__sales-value">
{{ formatCurrency(dashboardStats.todaySales) }}
</p>
<span class="dashboard__sales-growth">
+{{ dashboardStats.todaySalesGrowth }}% نسبت به دیروز
</span>
</div>
<AdminIcon name="calendar" :size="20" class="dashboard__sales-icon" />
</div>
<svg class="dashboard__sparkline" viewBox="0 0 120 32" preserveAspectRatio="none">
<polyline
fill="none"
stroke="#159660"
stroke-width="2"
points="0,24 20,18 40,22 60,12 80,16 100,8 120,14"
/>
</svg>
</div>
</div>
<div class="dashboard__grid">
<div class="dashboard__chart-card admin-card">
<SalesAreaChart
title="فروش ماه جاری"
:subtitle="formatCurrency(dashboardStats.monthSales)"
:data="salesChartData"
:labels="chartAxisLabels"
unit="میلیارد"
:height="168"
/>
</div>
<div class="dashboard__orders-card admin-card">
<div class="dashboard__card-header">
<h2 class="dashboard__card-title">سفارش‌های اخیر</h2>
</div>
<div class="admin-table-wrap">
<table class="admin-table admin-table--compact">
<thead>
<tr>
<th>شماره سفارش</th>
<th>مشتری</th>
<th>شرکت</th>
<th>وضعیت</th>
<th>تاریخ ثبت</th>
</tr>
</thead>
<tbody>
<tr v-for="order in recentOrders" :key="order.id">
<td>{{ order.orderNumber }}</td>
<td>{{ order.customerName }}</td>
<td>{{ order.companyName }}</td>
<td><StatusBadge :status="order.status" /></td>
<td>{{ formatDateTime(order.createdAt) }}</td>
</tr>
</tbody>
</table>
</div>
<div class="dashboard__card-footer">
<button type="button" class="admin-btn admin-btn--primary admin-btn--sm" @click="goOrders">
مشاهده همه سفارش‌ها
<AdminIcon name="chevron-left" :size="16" />
</button>
</div>
</div>
<div class="dashboard__checks-card admin-card">
<div class="dashboard__card-header">
<h2 class="dashboard__card-title">سررسید چک‌ها</h2>
</div>
<ul class="dashboard__deadline-list">
<li v-for="item in checkDeadlines" :key="item.id" class="dashboard__deadline-item">
<span
class="admin-badge"
:class="{
'admin-badge--danger': item.tone === 'danger',
'admin-badge--warning': item.tone === 'warning',
'admin-badge--neutral': item.tone === 'neutral',
}"
>
{{ item.label }}
</span>
<span class="dashboard__deadline-count">{{ item.count }} فقره</span>
<span class="dashboard__deadline-amount">{{ formatCurrency(item.amount) }}</span>
</li>
</ul>
<div class="dashboard__card-footer">
<RouterLink to="/checks" class="admin-btn admin-btn--outline admin-btn--sm">
مشاهده همه
<AdminIcon name="chevron-left" :size="16" />
</RouterLink>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.dashboard__toolbar {
display: flex;
justify-content: flex-end;
margin-bottom: 16px;
}
.dashboard__stats {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 16px;
margin-bottom: 20px;
}
.dashboard__sales-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.dashboard__sales-value {
font-size: 1.25rem !important;
}
.dashboard__sales-growth {
font-size: 0.75rem;
color: var(--color-success);
font-weight: 600;
}
.dashboard__sales-icon {
color: var(--color-text-muted);
}
.dashboard__sparkline {
width: 100%;
height: 32px;
margin-top: 8px;
}
.dashboard__grid {
display: grid;
grid-template-columns: 1fr 1.4fr 0.9fr;
gap: 16px;
}
.dashboard__chart-card,
.dashboard__orders-card,
.dashboard__checks-card {
padding: 20px;
}
.dashboard__chart-card :deep(.sales-chart__header) {
margin-bottom: 0;
}
.dashboard__card-header {
margin-bottom: 16px;
}
.dashboard__card-title {
margin: 0;
font-size: 0.9375rem;
font-weight: 700;
}
.dashboard__card-value {
margin: 4px 0 0;
font-size: 1.125rem;
font-weight: 700;
}
.dashboard__chart {
width: 100%;
height: 140px;
}
.dashboard__chart-axis {
display: flex;
justify-content: space-between;
font-size: 0.6875rem;
color: var(--color-text-muted);
margin-top: 8px;
}
.dashboard__card-footer {
display: flex;
justify-content: flex-start;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--color-border-light);
}
.dashboard__card-footer .admin-btn {
text-decoration: none;
}
.dashboard__deadline-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.dashboard__deadline-item {
display: grid;
grid-template-columns: auto 1fr auto auto;
align-items: center;
gap: 10px;
font-size: 0.8125rem;
}
.dashboard__deadline-count {
color: var(--color-text-muted);
}
.dashboard__deadline-amount {
font-weight: 600;
font-size: 0.75rem;
}
</style>
+146
View File
@@ -0,0 +1,146 @@
<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 BaseEmptyState from '@/components/ui/BaseEmptyState.vue'
import { levelsApi, type CustomerLevel } from '@/services/api'
import { ApiError } from '@/services/http'
import { useToast } from '@/composables/useToast'
const list = ref<CustomerLevel[]>([])
const loading = ref(true)
const modalOpen = ref(false)
const editingId = ref<string | null>(null)
const toast = useToast()
const form = reactive({
code: '',
label: '',
sortOrder: 0,
benefitsText: '',
})
const title = computed(() => (editingId.value ? 'ویرایش سطح' : 'سطح جدید'))
function parseBenefits(text: string) {
return text
.split('\n')
.map((l) => l.trim())
.filter(Boolean)
.map((title) => ({ title }))
}
async function load() {
loading.value = true
try {
list.value = await levelsApi.list()
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'بارگذاری سطوح ناموفق بود')
} finally {
loading.value = false
}
}
function openAdd() {
editingId.value = null
Object.assign(form, { code: '', label: '', sortOrder: list.value.length + 1, benefitsText: '' })
modalOpen.value = true
}
function openEdit(row: CustomerLevel) {
editingId.value = row.id
Object.assign(form, {
code: row.code,
label: row.label,
sortOrder: row.sortOrder,
benefitsText: row.benefits.map((b) => b.title).join('\n'),
})
modalOpen.value = true
}
async function save() {
try {
const payload = {
code: form.code.trim(),
label: form.label.trim(),
sortOrder: form.sortOrder,
benefits: parseBenefits(form.benefitsText),
}
if (editingId.value) await levelsApi.update(editingId.value, payload)
else await levelsApi.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>
<p>تعریف Level داخلی CRM و مزایای هر سطح (نمایش فقط در ادمین)</p>
</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="row in list" :key="row.id">
<td dir="ltr">{{ row.code }}</td>
<td>{{ row.label }}</td>
<td>{{ row.benefits.map((b) => b.title).join('، ') || '—' }}</td>
<td>{{ row.customersCount }}</td>
<td><button type="button" class="link" @click="openEdit(row)">ویرایش</button></td>
</tr>
</tbody>
</table>
<BaseModal :open="modalOpen" :title="title" @close="modalOpen = false">
<label class="field">کد<input v-model="form.code" class="admin-input" dir="ltr" /></label>
<label class="field">عنوان<input v-model="form.label" class="admin-input" /></label>
<label class="field">ترتیب<input v-model.number="form.sortOrder" type="number" class="admin-input" /></label>
<label class="field">
مزایا (هر خط یک مورد)
<textarea v-model="form.benefitsText" class="admin-input" rows="4" />
</label>
<template #footer>
<button
type="button"
class="admin-btn admin-btn--primary"
:disabled="!form.code.trim() || !form.label.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; }
.link { background: none; border: none; color: var(--color-primary); cursor: pointer; font-family: inherit; }
</style>
+580
View File
@@ -0,0 +1,580 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import AdminPageSkeleton from '@/components/ui/AdminPageSkeleton.vue'
import BaseModal from '@/components/ui/BaseModal.vue'
import AdminIcon from '@/components/icons/AdminIcon.vue'
import StatusBadge from '@/components/ui/StatusBadge.vue'
import { mapAdminOrder, ordersApi, type ApiOrder } from '@/services/api'
import { ApiError } from '@/services/http'
import { formatCurrency, formatNumber } from '@/utils/format'
import { confirm } from '@/composables/useConfirm'
import { useToast } from '@/composables/useToast'
import AdminCurrencyInput from '@/components/ui/AdminCurrencyInput.vue'
import { PRE_INVOICE_RAW_STATUSES } from '@/utils/orderStatus'
import type { OrderItem, RawOrderStatus } from '@/types'
const route = useRoute()
const router = useRouter()
const toast = useToast()
const order = ref<ApiOrder | null>(null)
const items = ref<OrderItem[]>([])
const shippingCost = ref(0)
const customerNote = ref('')
const showAddModal = ref(false)
const formError = ref('')
const saving = ref(false)
const sending = ref(false)
const loading = ref(true)
const emptyForm = () => ({
catalogCode: '',
productName: '',
color: '',
requestedMeterage: 100,
confirmedMeterage: 100,
})
const form = reactive(emptyForm())
const isFormValid = computed(
() =>
form.catalogCode.trim().length > 0 &&
form.productName.trim().length > 0 &&
form.requestedMeterage > 0,
)
const displayOrder = computed(() =>
order.value ? mapAdminOrder(order.value) : null,
)
/** تا وقتی سفارش در این وضعیت‌هاست، فاکتور برای مشتری ارسال نشده است. */
const invoiceSent = computed(() =>
order.value
? !PRE_INVOICE_RAW_STATUSES.includes(order.value.status as RawOrderStatus)
: false,
)
const canSendInvoice = computed(() => !!order.value?.finalAmount && !invoiceSent.value)
async function loadOrder() {
loading.value = true
try {
const data = await ordersApi.get(String(route.params.id))
order.value = data
items.value = (data.items ?? []).map((item) => ({
id: item.id,
catalogCode: item.catalogCode,
productName: item.productName,
color: item.color,
requestedMeterage: item.requestedMeterage,
confirmedMeterage: item.confirmedMeterage,
supplyPrice: item.supplyPrice,
totalAmount: item.totalAmount,
companyId: item.companyId,
companyName: item.companyName,
}))
shippingCost.value = data.shippingCost ?? 0
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'بارگذاری سفارش انجام نشد.')
} finally {
loading.value = false
}
}
onMounted(() => {
void loadOrder()
})
function goBack() {
router.push('/orders')
}
async function removeItem(id: string) {
const item = items.value.find((entry) => entry.id === id)
const ok = await confirm({
title: 'حذف محصول',
message: item
? `آیا از حذف «${item.productName}» از سفارش مطمئن هستید؟`
: 'آیا از حذف این محصول از سفارش مطمئن هستید؟',
confirmLabel: 'حذف',
cancelLabel: 'انصراف',
tone: 'danger',
})
if (!ok) return
items.value = items.value.filter((i) => i.id !== id)
}
function openAddModal() {
Object.assign(form, emptyForm())
formError.value = ''
showAddModal.value = true
}
function closeAddModal() {
showAddModal.value = false
formError.value = ''
}
function submitAddProduct() {
if (!isFormValid.value) {
formError.value = 'کالیته، نام محصول و متراژ الزامی هستند.'
return
}
items.value.push({
id: `oi-${crypto.randomUUID().slice(0, 8)}`,
catalogCode: form.catalogCode.trim(),
productName: form.productName.trim(),
color: form.color.trim() || '—',
requestedMeterage: form.requestedMeterage,
confirmedMeterage: form.confirmedMeterage || form.requestedMeterage,
supplyPrice: null,
totalAmount: null,
companyId: order.value?.companyId,
companyName: order.value?.companyName,
})
closeAddModal()
}
async function savePricing() {
if (!order.value) return
const pricedItems = items.value.filter((i) => !i.id.startsWith('oi-'))
if (!pricedItems.length) {
toast.error('اقلام قابل قیمت‌گذاری یافت نشد.')
return
}
saving.value = true
try {
const updated = await ordersApi.updatePricing(order.value.id, {
items: pricedItems.map((item) => ({
id: item.id,
confirmedMeterage: item.confirmedMeterage ?? undefined,
supplyPrice: item.supplyPrice ?? undefined,
})),
shippingCost: shippingCost.value,
note: customerNote.value.trim() || undefined,
})
order.value = updated
items.value = (updated.items ?? []).map((item) => ({
id: item.id,
catalogCode: item.catalogCode,
productName: item.productName,
color: item.color,
requestedMeterage: item.requestedMeterage,
confirmedMeterage: item.confirmedMeterage,
supplyPrice: item.supplyPrice,
totalAmount: item.totalAmount,
companyId: item.companyId,
companyName: item.companyName,
}))
toast.success('قیمت‌گذاری ذخیره شد.')
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'ذخیره قیمت‌گذاری انجام نشد.')
} finally {
saving.value = false
}
}
async function sendInvoice() {
if (!order.value) return
if (!order.value.finalAmount) {
toast.error('ابتدا قیمت‌گذاری را ذخیره کنید تا مبلغ فاکتور مشخص شود.')
return
}
const ok = await confirm({
title: 'ارسال فاکتور برای مشتری',
message: `فاکتور ${formatCurrency(order.value.finalAmount)} برای مشتری ارسال می‌شود و او روش تسویه را انتخاب می‌کند. ادامه می‌دهید؟`,
confirmLabel: 'ارسال فاکتور',
cancelLabel: 'انصراف',
})
if (!ok) return
sending.value = true
try {
const updated = await ordersApi.updateStatus(order.value.id, {
status: 'awaiting_settlement',
note: customerNote.value.trim() || undefined,
})
order.value = updated
toast.success('فاکتور برای مشتری ارسال شد.')
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'ارسال فاکتور انجام نشد.')
} finally {
sending.value = false
}
}
</script>
<template>
<div class="pricing-page">
<div class="admin-page-header">
<div>
<h1 class="admin-page-header__title">قیمت‌گذاری سفارش</h1>
</div>
<div class="admin-action-group">
<button
type="button"
class="admin-btn admin-btn--success admin-btn--sm"
:disabled="saving || loading"
@click="savePricing"
>
{{ saving ? 'در حال ذخیره…' : 'ذخیره قیمت‌گذاری' }}
</button>
<button
type="button"
class="admin-btn admin-btn--primary admin-btn--sm"
:disabled="!canSendInvoice || sending || saving"
@click="sendInvoice"
>
{{ sending ? 'در حال ارسال…' : 'ارسال فاکتور برای مشتری' }}
</button>
<button type="button" class="admin-btn admin-btn--outline admin-btn--sm" @click="goBack">
بازگشت
</button>
</div>
</div>
<AdminPageSkeleton v-if="loading" variant="detail" />
<template v-else-if="displayOrder">
<div class="pricing-page__summary admin-card">
<div class="pricing-page__order-meta">
<div>
<strong class="pricing-page__order-id">{{ displayOrder.orderNumber }}</strong>
<p class="pricing-page__customer">{{ displayOrder.customerName }}</p>
<StatusBadge :status="displayOrder.status" />
</div>
</div>
<div class="admin-summary-grid">
<div class="admin-summary-item">
<p class="admin-summary-item__label">مجموع مبلغ</p>
<p class="admin-summary-item__value">
{{ displayOrder.totalAmount ? formatCurrency(displayOrder.totalAmount) : '—' }}
</p>
</div>
<div class="admin-summary-item">
<p class="admin-summary-item__label">هزینه حمل</p>
<p class="admin-summary-item__value">
{{ formatCurrency(shippingCost) }}
</p>
</div>
<div class="admin-summary-item">
<p class="admin-summary-item__label">مبلغ نهایی</p>
<p class="admin-summary-item__value">
{{ order?.finalAmount ? formatCurrency(order.finalAmount) : '—' }}
</p>
</div>
</div>
</div>
<div class="pricing-page__table admin-card desktop-only">
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>ردیف</th>
<th>شرکت</th>
<th>کالیته</th>
<th>محصول</th>
<th>رنگ / نوع</th>
<th>متراژ درخواستی</th>
<th>متراژ تأیید شده</th>
<th>قیمت تأمینی (واحد)</th>
<th>مبلغ کل (تومان)</th>
<th>عملیات</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, index) in items" :key="item.id">
<td>{{ index + 1 }}</td>
<td>{{ item.companyName || order?.companyName || '—' }}</td>
<td>{{ item.catalogCode }}</td>
<td>{{ item.productName }}</td>
<td>{{ item.color }}</td>
<td>{{ formatNumber(item.requestedMeterage) }}</td>
<td>
<input
v-model.number="item.confirmedMeterage"
type="number"
class="admin-input admin-input--sm"
/>
</td>
<td>
<AdminCurrencyInput
v-model="item.supplyPrice"
input-class="admin-input admin-input--sm"
placeholder="—"
/>
</td>
<td>{{ item.totalAmount ? formatCurrency(item.totalAmount) : '—' }}</td>
<td>
<button
type="button"
class="admin-btn admin-btn--outline-danger admin-btn--xs"
@click="removeItem(item.id)"
>
حذف
</button>
</td>
</tr>
</tbody>
</table>
</div>
<button type="button" class="admin-btn admin-btn--ghost pricing-page__add-btn" @click="openAddModal">
<AdminIcon name="products" :size="16" />
افزودن محصول
</button>
</div>
<div class="compact-only admin-card-list pricing-page__compact-items">
<article v-for="(item, index) in items" :key="item.id" class="admin-card-item">
<div class="admin-card-item__header">
<p class="admin-card-item__title">{{ item.productName }}</p>
<span class="pricing-page__item-index">#{{ index + 1 }}</span>
</div>
<div class="admin-card-item__meta">
<span v-if="item.companyName || order?.companyName">{{ item.companyName || order?.companyName }}</span>
<span>کالیته {{ item.catalogCode }}</span>
<span>{{ item.color }}</span>
<span>درخواست: {{ formatNumber(item.requestedMeterage) }} متر</span>
</div>
<div class="pricing-page__compact-fields">
<label>
متراژ تأیید شده
<input v-model.number="item.confirmedMeterage" type="number" class="admin-input" />
</label>
<label>
قیمت تأمینی
<AdminCurrencyInput v-model="item.supplyPrice" placeholder="—" />
</label>
<p v-if="item.totalAmount" class="pricing-page__line-total">
مبلغ کل: {{ formatCurrency(item.totalAmount) }}
</p>
</div>
<div class="admin-card-item__actions">
<button type="button" class="admin-btn admin-btn--outline-danger admin-btn--xs" @click="removeItem(item.id)">
حذف
</button>
</div>
</article>
<button type="button" class="admin-btn admin-btn--ghost" @click="openAddModal">
<AdminIcon name="products" :size="16" />
افزودن محصول
</button>
</div>
<div class="admin-form-grid">
<div class="admin-form-field">
<label>هزینه حمل (تومان)</label>
<AdminCurrencyInput v-model="shippingCost" :allow-empty="false" />
</div>
<div class="admin-form-field">
<label>توضیحات برای مشتری</label>
<textarea v-model="customerNote" placeholder="توضیحات خود را وارد کنید..." />
</div>
</div>
<div class="admin-footer-actions">
<button
type="button"
class="admin-btn admin-btn--success"
:disabled="saving"
@click="savePricing"
>
{{ saving ? 'در حال ذخیره…' : 'ذخیره قیمت‌گذاری' }}
</button>
<button
type="button"
class="admin-btn admin-btn--primary"
:disabled="!canSendInvoice || sending || saving"
@click="sendInvoice"
>
{{ sending ? 'در حال ارسال…' : 'ارسال فاکتور برای مشتری' }}
</button>
</div>
<p class="pricing-page__invoice-hint">
<template v-if="invoiceSent">
فاکتور این سفارش برای مشتری ارسال شده است؛ انتخاب روش تسویه در اختیار اوست.
</template>
<template v-else-if="canSendInvoice">
مبلغ نهایی آماده است. با ارسال فاکتور، مشتری برای انتخاب نقدی یا چک مطلع می‌شود.
</template>
<template v-else>
ابتدا قیمت واحد اقلام و هزینه حمل را ذخیره کنید تا مبلغ نهایی فاکتور ساخته شود.
</template>
</p>
</template>
<BaseModal :open="showAddModal" title="افزودن محصول" size="lg" @close="closeAddModal">
<form class="admin-form" @submit.prevent="submitAddProduct">
<p v-if="formError" class="admin-form__error" role="alert">{{ formError }}</p>
<div class="admin-form__row">
<div class="admin-form__field">
<label for="catalog-code">کالیته <span class="admin-form__required">*</span></label>
<input
id="catalog-code"
v-model="form.catalogCode"
type="text"
class="admin-input"
placeholder="377"
/>
</div>
<div class="admin-form__field">
<label for="product-name">محصول <span class="admin-form__required">*</span></label>
<input
id="product-name"
v-model="form.productName"
type="text"
class="admin-input"
placeholder="پارچه آبی"
/>
</div>
</div>
<div class="admin-form__field">
<label for="product-color">رنگ / نوع</label>
<input
id="product-color"
v-model="form.color"
type="text"
class="admin-input"
placeholder="آبی"
/>
</div>
<div class="admin-form__row">
<div class="admin-form__field">
<label for="requested-meterage">
متراژ درخواستی <span class="admin-form__required">*</span>
</label>
<input
id="requested-meterage"
v-model.number="form.requestedMeterage"
type="number"
min="1"
class="admin-input"
/>
</div>
<div class="admin-form__field">
<label for="confirmed-meterage">متراژ تأیید شده</label>
<input
id="confirmed-meterage"
v-model.number="form.confirmedMeterage"
type="number"
min="0"
class="admin-input"
/>
</div>
</div>
</form>
<template #footer>
<button type="button" class="admin-btn admin-btn--outline" @click="closeAddModal">
انصراف
</button>
<button
type="button"
class="admin-btn admin-btn--primary"
:disabled="!isFormValid"
@click="submitAddProduct"
>
افزودن به سفارش
</button>
</template>
</BaseModal>
</div>
</template>
<style scoped>
.pricing-page__summary {
padding: 20px;
margin-bottom: 16px;
}
.pricing-page__order-meta {
margin-bottom: 16px;
}
.pricing-page__order-id {
font-size: 1rem;
display: block;
margin-bottom: 4px;
}
.pricing-page__customer {
margin: 0 0 8px;
font-size: 0.875rem;
color: var(--color-text-muted);
}
.pricing-page__table {
padding: 0;
overflow: hidden;
margin-bottom: 16px;
}
.pricing-page__add-btn {
margin: 12px 16px 16px;
display: inline-flex;
align-items: center;
gap: 6px;
}
.pricing-page__compact-items {
margin-bottom: 16px;
}
.pricing-page__item-index {
font-size: 0.75rem;
color: var(--color-text-muted);
}
.pricing-page__compact-fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin-top: 12px;
}
.pricing-page__compact-fields label {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 0.75rem;
color: var(--color-text-muted);
min-width: 0;
}
.pricing-page__compact-fields :deep(.admin-input),
.pricing-page__compact-fields :deep(input) {
width: 100%;
min-width: 0;
}
.pricing-page__invoice-hint {
margin: 12px 0 0;
font-size: 0.75rem;
line-height: 1.8;
color: var(--color-text-muted);
}
.pricing-page__line-total {
grid-column: 1 / -1;
margin: 0;
font-size: 0.875rem;
font-weight: 700;
color: var(--color-text);
}
@media (max-width: 479px) {
.pricing-page__compact-fields {
grid-template-columns: 1fr;
}
}
</style>
+14
View File
@@ -0,0 +1,14 @@
<script setup lang="ts">
import { provideOrdersPage } from '@/composables/useOrdersPage'
import DesktopOrdersPage from '@/views/orders/components/DesktopOrdersPage.vue'
import CompactOrdersPage from '@/views/orders/components/CompactOrdersPage.vue'
import OrderAddModal from '@/views/orders/components/OrderAddModal.vue'
provideOrdersPage()
</script>
<template>
<DesktopOrdersPage class="desktop-only" />
<CompactOrdersPage class="compact-only" />
<OrderAddModal />
</template>
@@ -0,0 +1,365 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import AdminIcon from '@/components/icons/AdminIcon.vue'
import StatusBadge from '@/components/ui/StatusBadge.vue'
import BaseModal from '@/components/ui/BaseModal.vue'
import OrderDetailContent from '@/views/orders/components/OrderDetailContent.vue'
import { useOrdersPageContext } from '@/composables/useOrdersPage'
import { ORDER_TABS } from '@/utils/orderStatus'
import { formatCurrency, formatDateTime, formatMeterage } from '@/utils/format'
const isMobile = useMediaQuery('(max-width: 767px)')
/** Matches `.compact-only` visible range (< 1280px) */
const isCompactLayout = useMediaQuery('(max-width: 1279px)')
const {
activeTab,
selectedOrderId,
searchQuery,
filteredOrders,
selectedOrder,
showDetailPanel,
showDetailModal,
selectOrder,
openOrderDetail,
closeDetailPanel,
closeDetailModal,
settlementLabel,
goPricing,
goChecks,
goCashReceipt,
changeStatus,
statusSaving,
openAddModal,
} = useOrdersPageContext()
const showFilterModal = ref(false)
const hasActiveFilter = computed(() => activeTab.value !== 'all')
const activeFilterLabel = computed(
() => ORDER_TABS.find((tab) => tab.key === activeTab.value)?.label ?? 'همه',
)
function openFilters() {
showFilterModal.value = true
}
function closeFilters() {
showFilterModal.value = false
}
function clearFilters() {
activeTab.value = 'all'
showFilterModal.value = false
}
function applyStatusFilter(key: string) {
activeTab.value = key
showFilterModal.value = false
}
function openDetail(order: (typeof filteredOrders.value)[number]) {
if (isMobile.value) {
selectOrder(order, true)
return
}
openOrderDetail(order)
}
function handlePricing() {
if (!selectedOrder.value) return
closeDetailModal()
closeDetailPanel()
goPricing(selectedOrder.value)
}
function handleChecks() {
if (!selectedOrder.value) return
closeDetailModal()
closeDetailPanel()
goChecks(selectedOrder.value)
}
function handleCashReceipt() {
if (!selectedOrder.value) return
closeDetailModal()
closeDetailPanel()
goCashReceipt(selectedOrder.value)
}
</script>
<template>
<div class="compact-orders">
<div class="compact-page-header">
<div class="compact-page-header__row">
<div>
<h2 class="compact-page-header__title">سفارش‌ها</h2>
</div>
<div class="compact-page-header__actions">
<button
type="button"
class="admin-btn admin-btn--outline admin-btn--sm"
:class="{ 'admin-btn--filter-active': hasActiveFilter }"
@click="openFilters"
>
<AdminIcon name="filter" :size="16" />
فیلتر
<span v-if="hasActiveFilter" class="compact-orders__filter-dot" aria-hidden="true" />
</button>
<button type="button" class="admin-btn admin-btn--primary admin-btn--sm" @click="openAddModal">
سفارش جدید
</button>
</div>
</div>
</div>
<div class="orders-page__search admin-card">
<AdminIcon name="search" :size="18" />
<input
v-model="searchQuery"
type="search"
placeholder="جستجو در شماره سفارش، مشتری..."
class="orders-page__search-input"
/>
</div>
<!-- Mobile: chip instead of status tabs -->
<div v-if="isMobile && hasActiveFilter" class="compact-orders__filter-chip admin-card">
<span>وضعیت: {{ activeFilterLabel }}</span>
<button type="button" class="compact-orders__filter-clear" @click="clearFilters">پاک کردن</button>
</div>
<!-- Tablet+: status tabs -->
<div v-if="!isMobile" class="admin-tabs compact-orders__tabs">
<button
v-for="tab in ORDER_TABS"
:key="tab.key"
type="button"
class="admin-tab"
:class="{ active: activeTab === tab.key }"
@click="activeTab = tab.key"
>
{{ tab.label }}
<template v-if="'count' in tab && tab.count"> ({{ tab.count }})</template>
</button>
</div>
<BaseModal :open="showFilterModal" title="فیلتر سفارش‌ها" @close="closeFilters">
<div class="compact-orders__filter-form" role="listbox" aria-label="وضعیت سفارش">
<button
v-for="tab in ORDER_TABS"
:key="tab.key"
type="button"
class="compact-orders__filter-option"
:class="{ 'is-active': activeTab === tab.key }"
role="option"
:aria-selected="activeTab === tab.key"
@click="applyStatusFilter(tab.key)"
>
<span>{{ tab.label }}</span>
<span v-if="activeTab === tab.key" class="compact-orders__filter-check" aria-hidden="true">✓</span>
</button>
</div>
<template #footer>
<button type="button" class="admin-btn admin-btn--outline" @click="clearFilters">
پاک کردن
</button>
<button type="button" class="admin-btn admin-btn--primary" @click="closeFilters">
بستن
</button>
</template>
</BaseModal>
<div class="admin-card-list">
<button
v-for="order in filteredOrders"
:key="order.id"
type="button"
class="admin-card-item"
:class="{ selected: selectedOrderId === order.id }"
@click="openDetail(order)"
>
<div class="admin-card-item__header">
<p class="admin-card-item__title">{{ order.orderNumber }}</p>
<StatusBadge :status="order.status" />
</div>
<div class="admin-card-item__meta">
<span><strong>مشتری:</strong> {{ order.customerName }}</span>
<span><strong>متراژ:</strong> {{ formatMeterage(order.meterage) }}</span>
<span><strong>مبلغ:</strong> {{ order.totalAmount ? formatCurrency(order.totalAmount) : '—' }}</span>
<span>{{ formatDateTime(order.createdAt) }}</span>
</div>
</button>
</div>
<!-- Mobile: full-screen slide panel -->
<div
class="admin-detail-panel compact-orders__mobile-panel"
:class="{ open: isMobile && showDetailPanel && selectedOrder }"
>
<div v-if="selectedOrder" class="admin-detail-panel__inner">
<div class="admin-detail-panel__header">
<button
type="button"
class="admin-detail-panel__back"
aria-label="بازگشت به لیست"
@click="closeDetailPanel"
>
<AdminIcon name="chevron-back" :size="20" />
</button>
<h2 class="admin-detail-panel__title">جزئیات سفارش</h2>
</div>
<div class="admin-detail-panel__body">
<OrderDetailContent
:order="selectedOrder"
:settlement-label="settlementLabel"
:status-saving="statusSaving"
@pricing="handlePricing"
@checks="handleChecks"
@cash-receipt="handleCashReceipt"
@status="changeStatus"
/>
</div>
</div>
</div>
<!-- Tablet / small desktop: modal -->
<BaseModal
:open="isCompactLayout && !isMobile && showDetailModal && !!selectedOrder"
title="جزئیات سفارش"
size="xl"
@close="closeDetailModal"
>
<template v-if="selectedOrder">
<OrderDetailContent
:order="selectedOrder"
:settlement-label="settlementLabel"
:status-saving="statusSaving"
@pricing="handlePricing"
@checks="handleChecks"
@cash-receipt="handleCashReceipt"
@status="changeStatus"
/>
</template>
</BaseModal>
</div>
</template>
<style scoped>
.compact-orders__tabs {
margin-bottom: 16px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 4px 8px 0;
border-bottom: 1px solid var(--color-border);
overflow: visible;
}
.orders-page__search {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
margin-bottom: 12px;
color: var(--color-text-muted);
}
.orders-page__search-input {
flex: 1;
border: none;
background: transparent;
font-size: 0.875rem;
outline: none;
color: var(--color-text);
}
.admin-btn--filter-active {
border-color: var(--color-primary);
color: var(--color-primary);
}
.compact-orders__filter-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--color-primary);
}
.compact-orders__filter-chip {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 14px;
margin-bottom: 12px;
font-size: 0.875rem;
color: var(--color-text);
}
.compact-orders__filter-clear {
border: none;
background: none;
padding: 0;
color: var(--color-primary);
font-family: inherit;
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
}
.compact-orders__filter-form {
display: flex;
flex-direction: column;
gap: 6px;
}
.compact-orders__filter-option {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
min-height: 44px;
padding: 10px 14px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg);
color: var(--color-text);
font-family: inherit;
font-size: 0.875rem;
text-align: right;
cursor: pointer;
}
.compact-orders__filter-option.is-active {
border-color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-surface));
color: var(--color-primary);
font-weight: 600;
}
.compact-orders__filter-check {
font-size: 0.875rem;
}
.admin-card-item {
cursor: pointer;
text-align: right;
}
.admin-detail-panel__inner {
display: flex;
flex-direction: column;
height: 100%;
background: var(--color-bg);
}
@media (min-width: 768px) {
.compact-orders__mobile-panel {
display: none !important;
}
}
</style>
@@ -0,0 +1,416 @@
<script setup lang="ts">
import { nextTick, onMounted, onUnmounted, ref } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import AdminIcon from '@/components/icons/AdminIcon.vue'
import StatusBadge from '@/components/ui/StatusBadge.vue'
import BaseModal from '@/components/ui/BaseModal.vue'
import OrderDetailContent from '@/views/orders/components/OrderDetailContent.vue'
import { useOrdersPageContext } from '@/composables/useOrdersPage'
import { ORDER_TABS, canPriceOrder } from '@/utils/orderStatus'
import { formatCurrency, formatDateTime, formatMeterage } from '@/utils/format'
import type { Order } from '@/types'
/** Matches `.desktop-only` in responsive.css (>= 1280px) */
const isDesktopLayout = useMediaQuery('(min-width: 1280px)')
const {
activeTab,
selectedOrderId,
searchQuery,
filteredOrders,
selectedOrder,
showDetailModal,
openOrderDetail,
closeDetailModal,
settlementLabel,
goPricing,
goChecks,
goCashReceipt,
changeStatus,
statusSaving,
openAddModal,
} = useOrdersPageContext()
const menuOrder = ref<Order | null>(null)
const menuStyle = ref<Record<string, string>>({})
function closeMenu() {
menuOrder.value = null
}
async function toggleMenu(order: Order, event: MouseEvent) {
event.stopPropagation()
if (menuOrder.value?.id === order.id) {
closeMenu()
return
}
const btn = event.currentTarget as HTMLElement
const rect = btn.getBoundingClientRect()
menuOrder.value = order
await nextTick()
const menuWidth = 168
const left = Math.min(
Math.max(8, rect.right - menuWidth),
window.innerWidth - menuWidth - 8,
)
menuStyle.value = {
position: 'fixed',
top: `${rect.bottom + 4}px`,
left: `${left}px`,
width: `${menuWidth}px`,
}
}
function onDocumentClick() {
closeMenu()
}
onMounted(() => {
document.addEventListener('click', onDocumentClick)
window.addEventListener('scroll', closeMenu, true)
window.addEventListener('resize', closeMenu)
})
onUnmounted(() => {
document.removeEventListener('click', onDocumentClick)
window.removeEventListener('scroll', closeMenu, true)
window.removeEventListener('resize', closeMenu)
})
function handlePricing() {
if (!selectedOrder.value) return
closeDetailModal()
goPricing(selectedOrder.value)
}
function handleChecks() {
if (!selectedOrder.value) return
closeDetailModal()
goChecks(selectedOrder.value)
}
function handleCashReceipt() {
if (!selectedOrder.value) return
closeDetailModal()
goCashReceipt(selectedOrder.value)
}
function printOrderDetail() {
if (!selectedOrder.value) return
document.documentElement.classList.add('printing-order-detail')
const cleanup = () => {
document.documentElement.classList.remove('printing-order-detail')
window.removeEventListener('afterprint', cleanup)
}
window.addEventListener('afterprint', cleanup)
window.setTimeout(cleanup, 60_000)
window.print()
}
function runMenuAction(action: 'detail' | 'pricing' | 'checks' | 'cash') {
const order = menuOrder.value
if (!order) return
closeMenu()
if (action === 'detail') {
openOrderDetail(order)
return
}
if (action === 'pricing') {
goPricing(order)
return
}
if (action === 'checks') {
goChecks(order)
return
}
goCashReceipt(order)
}
</script>
<template>
<div class="orders-page">
<div class="orders-page__list-panel admin-card">
<div class="orders-page__list-header">
<div>
<h2 class="orders-page__list-title">سفارش‌ها</h2>
</div>
<div class="orders-page__list-actions">
<button type="button" class="admin-btn admin-btn--outline admin-btn--sm">
<AdminIcon name="filter" :size="16" />
فیلتر
</button>
<button type="button" class="admin-btn admin-btn--primary admin-btn--sm" @click="openAddModal">
سفارش جدید
</button>
</div>
</div>
<div class="orders-page__search">
<AdminIcon name="search" :size="18" />
<input
v-model="searchQuery"
type="search"
placeholder="جستجو در شماره سفارش، مشتری..."
class="orders-page__search-input"
/>
</div>
<div class="admin-tabs">
<button
v-for="tab in ORDER_TABS"
:key="tab.key"
type="button"
class="admin-tab"
:class="{ active: activeTab === tab.key }"
@click="activeTab = tab.key"
>
{{ tab.label }}
<template v-if="'count' in tab && tab.count"> ({{ tab.count }})</template>
</button>
</div>
<div class="admin-table-wrap">
<table class="admin-table admin-table--compact">
<thead>
<tr>
<th>شماره سفارش</th>
<th>مشتری</th>
<th>روش تسویه</th>
<th>مبلغ کل</th>
<th>متراژ</th>
<th>وضعیت</th>
<th>تاریخ ثبت</th>
<th>عملیات</th>
</tr>
</thead>
<tbody>
<tr
v-for="order in filteredOrders"
:key="order.id"
:class="{ 'is-selected': selectedOrderId === order.id }"
@click="openOrderDetail(order)"
>
<td>{{ order.orderNumber }}</td>
<td>{{ order.customerName }}</td>
<td>{{ settlementLabel(order.settlementMethod) }}</td>
<td>{{ order.totalAmount ? formatCurrency(order.totalAmount) : '—' }}</td>
<td>{{ formatMeterage(order.meterage) }}</td>
<td><StatusBadge :status="order.status" /></td>
<td>{{ formatDateTime(order.createdAt) }}</td>
<td class="orders-page__actions-cell">
<button
type="button"
class="orders-page__more-btn"
:aria-expanded="menuOrder?.id === order.id"
aria-haspopup="menu"
aria-label="عملیات سفارش"
@click="toggleMenu(order, $event)"
>
<AdminIcon name="more" :size="18" />
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div class="admin-pagination">
<span>نمایش ۱ تا ۷ از ۱۰۳ سفارش</span>
<div class="admin-pagination__pages">
<button type="button" class="admin-pagination__page active">۱</button>
<button type="button" class="admin-pagination__page">۲</button>
<button type="button" class="admin-pagination__page">۳</button>
<span>...</span>
<button type="button" class="admin-pagination__page">۱۲</button>
</div>
</div>
</div>
<Teleport to="body">
<div
v-if="menuOrder"
class="orders-page__menu"
role="menu"
:style="menuStyle"
@click.stop
>
<button
type="button"
role="menuitem"
class="orders-page__menu-item"
@click="runMenuAction('detail')"
>
جزئیات سفارش
</button>
<button
v-if="menuOrder && canPriceOrder(menuOrder)"
type="button"
role="menuitem"
class="orders-page__menu-item"
@click="runMenuAction('pricing')"
>
قیمت‌گذاری
</button>
<button
v-if="menuOrder.settlementMethod === 'check'"
type="button"
role="menuitem"
class="orders-page__menu-item"
@click="runMenuAction('checks')"
>
مدیریت چک‌ها
</button>
<button
v-if="menuOrder.settlementMethod === 'cash'"
type="button"
role="menuitem"
class="orders-page__menu-item"
@click="runMenuAction('cash')"
>
رسید پرداخت
</button>
</div>
</Teleport>
<BaseModal
:open="isDesktopLayout && showDetailModal && !!selectedOrder"
title="جزئیات سفارش"
size="xl"
@close="closeDetailModal"
>
<template v-if="selectedOrder">
<OrderDetailContent
:order="selectedOrder"
layout="desktop"
:settlement-label="settlementLabel"
:status-saving="statusSaving"
@pricing="handlePricing"
@checks="handleChecks"
@cash-receipt="handleCashReceipt"
@status="changeStatus"
/>
</template>
<template #footer>
<button type="button" class="admin-btn admin-btn--outline admin-btn--sm">
<AdminIcon name="sms" :size="16" />
ارسال پیامک
</button>
<button
type="button"
class="admin-btn admin-btn--outline admin-btn--sm"
@click="printOrderDetail"
>
<AdminIcon name="print" :size="16" />
چاپ
</button>
</template>
</BaseModal>
</div>
</template>
<style scoped>
.orders-page {
min-height: calc(100dvh - var(--header-height) - 48px);
}
.orders-page__list-panel {
padding: 20px;
min-width: 0;
}
.orders-page__list-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.orders-page__list-title {
margin: 0;
font-size: 1.125rem;
font-weight: 700;
}
.orders-page__list-actions {
display: flex;
gap: 8px;
}
.orders-page__search {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-border-light);
margin-bottom: 16px;
color: var(--color-text-muted);
}
.orders-page__search-input {
flex: 1;
border: none;
background: transparent;
font-size: 0.875rem;
outline: none;
}
.orders-page__actions-cell {
width: 48px;
}
.orders-page__more-btn {
border: none;
background: transparent;
color: var(--color-text-muted);
padding: 4px;
border-radius: var(--radius-sm);
cursor: pointer;
}
.orders-page__more-btn:hover,
.orders-page__more-btn[aria-expanded='true'] {
color: var(--color-text);
background: var(--color-border-light);
}
.admin-table tbody tr {
cursor: pointer;
}
.admin-table tbody tr.is-selected {
background: var(--color-primary-soft);
}
</style>
<style>
.orders-page__menu {
z-index: 300000;
min-width: 160px;
padding: 6px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
display: grid;
gap: 2px;
}
.orders-page__menu-item {
border: none;
background: transparent;
color: var(--color-text);
text-align: start;
padding: 8px 10px;
border-radius: var(--radius-sm);
font-size: 0.8125rem;
cursor: pointer;
}
.orders-page__menu-item:hover {
background: var(--color-primary-soft);
}
</style>
@@ -0,0 +1,147 @@
<script setup lang="ts">
import { computed, watch } from 'vue'
import BaseModal from '@/components/ui/BaseModal.vue'
import AdminSelect from '@/components/ui/AdminSelect.vue'
import { useOrdersPageContext } from '@/composables/useOrdersPage'
const {
showAddModal,
formError,
form,
isFormValid,
companies,
activeCustomers,
applyCustomer,
closeAddModal,
submitAddOrder,
} = useOrdersPageContext()
const companyOptions = computed(() =>
companies.value.map((company) => ({ value: company.id, label: company.name })),
)
const customerOptions = computed(() =>
activeCustomers.value.map((customer) => {
const phone = customer.phone ? ` — ${customer.phone}` : ''
const inactive = customer.isActive === false ? ' (غیرفعال)' : ''
return {
value: customer.id,
label: `${customer.name}${phone}${inactive}`,
}
}),
)
const settlementOptions = [
{ value: 'check', label: 'چکی' },
{ value: 'cash', label: 'نقدی' },
]
watch(
() => form.customerId,
(id) => {
if (!id) {
form.customerName = ''
form.customerPhone = ''
return
}
applyCustomer(id)
},
)
</script>
<template>
<BaseModal :open="showAddModal" title="سفارش جدید" size="lg" @close="closeAddModal">
<form class="admin-form" @submit.prevent="submitAddOrder">
<p v-if="formError" class="admin-form__error" role="alert">{{ formError }}</p>
<div class="admin-form__field">
<label id="order-customer-label">
نام مشتری <span class="admin-form__required">*</span>
</label>
<AdminSelect
id="order-customer"
v-model="form.customerId"
block
searchable
aria-label="انتخاب مشتری از لیست"
placeholder="انتخاب مشتری از لیست"
search-placeholder="جستجوی نام یا موبایل…"
:options="customerOptions"
/>
<p v-if="!customerOptions.length" class="admin-form__hint">
مشتری‌ای در سیستم نیست. ابتدا از بخش «مشتریان» یک مشتری ثبت کنید.
</p>
</div>
<div class="admin-form__row">
<div class="admin-form__field">
<label>شرکت <span class="admin-form__required">*</span></label>
<AdminSelect
v-model="form.companyId"
block
aria-label="انتخاب شرکت"
placeholder="انتخاب شرکت"
:options="companyOptions"
/>
</div>
<div class="admin-form__field">
<label>روش تسویه</label>
<AdminSelect
v-model="form.settlementMethod"
block
aria-label="روش تسویه"
:options="settlementOptions"
/>
</div>
</div>
<div class="admin-form__row">
<div class="admin-form__field">
<label for="order-meterage">متراژ (متر)</label>
<input
id="order-meterage"
v-model.number="form.meterage"
type="number"
min="1"
class="admin-input"
/>
</div>
<div class="admin-form__field">
<label for="order-phone">تلفن مشتری</label>
<input
id="order-phone"
v-model="form.customerPhone"
type="tel"
class="admin-input"
placeholder="پس از انتخاب مشتری پر می‌شود"
inputmode="tel"
dir="ltr"
readonly
/>
</div>
</div>
</form>
<template #footer>
<button type="button" class="admin-btn admin-btn--outline" @click="closeAddModal">
انصراف
</button>
<button
type="button"
class="admin-btn admin-btn--primary"
:disabled="!isFormValid"
@click="submitAddOrder"
>
ثبت سفارش
</button>
</template>
</BaseModal>
</template>
<style scoped>
.admin-form__hint {
margin: 6px 0 0;
font-size: 0.75rem;
color: var(--color-text-muted);
}
</style>
@@ -0,0 +1,516 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import AdminIcon from '@/components/icons/AdminIcon.vue'
import AdminSelect from '@/components/ui/AdminSelect.vue'
import StatusBadge from '@/components/ui/StatusBadge.vue'
import { formatCurrency, formatMeterage, formatNumber } from '@/utils/format'
import { CUSTOMER_TYPE_LABELS } from '@/utils/adminLabels'
import { ORDER_STATUS_META, RAW_ORDER_STATUS_LABELS, canPriceOrder, rawStatusLabel } from '@/utils/orderStatus'
import type { CustomerType, Order, RawOrderStatus } from '@/types'
const props = withDefaults(
defineProps<{
order: Order
settlementLabel: (method?: string | null) => string
layout?: 'desktop' | 'compact'
statusSaving?: boolean
}>(),
{ layout: 'compact' },
)
const emit = defineEmits<{
pricing: []
checks: []
cashReceipt: []
status: [
payload: {
orderId: string
status: RawOrderStatus
note?: string
reason?: string
force?: boolean
},
]
}>()
const manualMode = ref(false)
const nextStatus = ref<RawOrderStatus | ''>('')
const statusNote = ref('')
const allStatuses = Object.keys(RAW_ORDER_STATUS_LABELS) as RawOrderStatus[]
const transitions = computed(() => props.order.allowedTransitions ?? [])
const statusOptions = computed<RawOrderStatus[]>(() =>
manualMode.value
? allStatuses.filter((s) => s !== props.order.rawStatus)
: [...transitions.value],
)
const statusSelectOptions = computed(() => [
{ value: '', label: 'انتخاب کنید…' },
...statusOptions.value.map((status) => ({
value: status,
label: rawStatusLabel(status),
})),
])
const noteRequired = computed(() => manualMode.value)
const canSubmitStatus = computed(
() => !!nextStatus.value && (!noteRequired.value || statusNote.value.trim().length > 0),
)
const useTable = computed(() => props.layout === 'desktop')
const badgeStatusLabel = computed(
() => ORDER_STATUS_META[props.order.status]?.label ?? props.order.status,
)
/** Avoid duplicating the same Persian label under the badge. */
const distinctRawStatusLabel = computed(() => {
const raw = rawStatusLabel(props.order.rawStatus)
if (!raw || raw === badgeStatusLabel.value) return ''
return raw
})
const customerTypeLabel = computed(() => {
const type = props.order.customerType as CustomerType | undefined
if (!type) return '—'
return CUSTOMER_TYPE_LABELS[type] ?? props.order.customerType ?? '—'
})
const showPricingAction = computed(() => canPriceOrder(props.order))
const showChecksAction = computed(() => props.order.settlementMethod === 'check')
const showCashAction = computed(() => props.order.settlementMethod === 'cash')
/** Primary CTA follows the current workflow stage. */
const primaryAction = computed<'pricing' | 'checks' | 'cash' | null>(() => {
if (showPricingAction.value) return 'pricing'
if (showChecksAction.value) return 'checks'
if (showCashAction.value) return 'cash'
return null
})
watch([() => props.order.id, manualMode], () => {
nextStatus.value = ''
statusNote.value = ''
})
function submitStatus() {
if (!nextStatus.value || !canSubmitStatus.value) return
const note = statusNote.value.trim() || undefined
emit('status', {
orderId: props.order.id,
status: nextStatus.value,
note,
reason: manualMode.value ? note : undefined,
force: manualMode.value || undefined,
})
nextStatus.value = ''
statusNote.value = ''
}
</script>
<template>
<div class="order-detail" :class="`order-detail--${layout}`">
<header class="order-detail__hero">
<div class="order-detail__hero-text">
<div class="order-detail__id">
<strong>{{ order.orderNumber }}</strong>
<StatusBadge :status="order.status" />
</div>
<p v-if="distinctRawStatusLabel" class="order-detail__raw-status">
{{ distinctRawStatusLabel }}
</p>
</div>
<div class="order-detail__actions no-print">
<button
v-if="showPricingAction"
type="button"
class="admin-btn admin-btn--sm"
:class="primaryAction === 'pricing' ? 'admin-btn--primary' : 'admin-btn--outline'"
@click="emit('pricing')"
>
قیمت‌گذاری سفارش
</button>
<button
v-if="showChecksAction"
type="button"
class="admin-btn admin-btn--sm"
:class="primaryAction === 'checks' ? 'admin-btn--primary' : 'admin-btn--outline'"
@click="emit('checks')"
>
مدیریت چک‌ها
</button>
<button
v-if="showCashAction"
type="button"
class="admin-btn admin-btn--sm"
:class="primaryAction === 'cash' ? 'admin-btn--primary' : 'admin-btn--outline'"
@click="emit('cashReceipt')"
>
رسید پرداخت
</button>
</div>
</header>
<div class="order-detail__panels">
<section class="order-detail__section">
<h3 class="order-detail__section-title">اطلاعات مشتری</h3>
<div class="order-detail__info">
<div class="order-detail__info-row">
<AdminIcon name="building" :size="16" />
<span class="order-detail__info-label">مشتری</span>
<span class="order-detail__info-value">{{ order.customerName }}</span>
</div>
<div class="order-detail__info-row">
<AdminIcon name="phone" :size="16" />
<span class="order-detail__info-label">تلفن</span>
<span class="order-detail__info-value order-detail__info-value--phone" dir="ltr">{{ order.customerPhone ?? '—' }}</span>
</div>
<div class="order-detail__info-row">
<AdminIcon name="id-card" :size="16" />
<span class="order-detail__info-label">شناسه ملی</span>
<span class="order-detail__info-value">{{ order.customerNationalId ?? '—' }}</span>
</div>
<div class="order-detail__info-row">
<AdminIcon name="user" :size="16" />
<span class="order-detail__info-label">نوع مشتری</span>
<span class="order-detail__info-value">{{ customerTypeLabel }}</span>
</div>
</div>
</section>
<section class="order-detail__section">
<h3 class="order-detail__section-title">اطلاعات سفارش</h3>
<div class="order-detail__info">
<div class="order-detail__info-row">
<span class="order-detail__info-label">شرکت</span>
<span class="order-detail__info-value">{{ order.companyName }}</span>
</div>
<div class="order-detail__info-row">
<span class="order-detail__info-label">روش تسویه</span>
<span class="order-detail__info-value">{{ settlementLabel(order.settlementMethod) }}</span>
</div>
<div class="order-detail__info-row">
<span class="order-detail__info-label">متراژ کل</span>
<span class="order-detail__info-value">{{ formatMeterage(order.meterage) }}</span>
</div>
<div class="order-detail__info-row">
<span class="order-detail__info-label">مبلغ کل</span>
<span class="order-detail__info-value">
{{ order.totalAmount != null ? formatCurrency(order.totalAmount) : 'پس از قیمت‌گذاری' }}
</span>
</div>
</div>
</section>
</div>
<section v-if="order.items?.length" class="order-detail__section">
<h3 class="order-detail__section-title">محصولات سفارش</h3>
<div v-if="useTable" class="admin-table-wrap order-detail__table">
<table class="admin-table admin-table--compact">
<thead>
<tr>
<th>شرکت</th>
<th>کالیته</th>
<th>محصول</th>
<th>رنگ</th>
<th>متراژ</th>
<th>قیمت</th>
<th>مبلغ</th>
</tr>
</thead>
<tbody>
<tr v-for="item in order.items" :key="item.id">
<td>{{ item.companyName || order.companyName }}</td>
<td>{{ item.catalogCode }}</td>
<td>{{ item.productName }}</td>
<td>{{ item.color }}</td>
<td>{{ formatNumber(item.confirmedMeterage ?? item.requestedMeterage) }}</td>
<td>{{ item.supplyPrice != null ? formatCurrency(item.supplyPrice) : '—' }}</td>
<td>{{ item.totalAmount != null ? formatCurrency(item.totalAmount) : '—' }}</td>
</tr>
</tbody>
</table>
</div>
<ul v-else class="order-detail__items">
<li v-for="item in order.items" :key="item.id" class="order-detail__item">
<div class="order-detail__item-head">
<strong>{{ item.productName }}</strong>
<span>{{ item.catalogCode }}</span>
</div>
<div class="order-detail__item-meta">
<span v-if="item.companyName">{{ item.companyName }}</span>
<span>{{ item.color }}</span>
<span>{{ formatMeterage(item.confirmedMeterage ?? item.requestedMeterage) }}</span>
<span v-if="item.supplyPrice != null">{{ formatCurrency(item.supplyPrice) }}</span>
<span v-if="item.totalAmount != null" class="order-detail__item-total">
{{ formatCurrency(item.totalAmount) }}
</span>
</div>
</li>
</ul>
</section>
<section class="order-detail__section order-detail__status no-print">
<div class="order-detail__status-head">
<h3 class="order-detail__section-title">تغییر وضعیت</h3>
<label class="order-detail__manual">
<input v-model="manualMode" type="checkbox" />
<span>تغییر دستی</span>
</label>
</div>
<p v-if="manualMode" class="order-detail__status-hint">
این گذر خارج از روال استاندارد است؛ ثبت یادداشت اجباری است.
</p>
<p v-else-if="!statusOptions.length" class="order-detail__status-hint">
در این وضعیت گذر خودکاری وجود ندارد. برای تغییر، حالت دستی را فعال کنید.
</p>
<div v-if="statusOptions.length" class="order-detail__status-form">
<div class="admin-form__field">
<label>وضعیت بعدی</label>
<AdminSelect
v-model="nextStatus"
block
aria-label="وضعیت بعدی"
placeholder="انتخاب کنید…"
:options="statusSelectOptions"
/>
</div>
<div class="admin-form__field">
<label for="status-note">
یادداشت{{ noteRequired ? ' (اجباری)' : ' (اختیاری)' }}
</label>
<input
id="status-note"
v-model="statusNote"
type="text"
class="admin-input"
placeholder="دلیل یا توضیح این تغییر"
/>
</div>
<button
type="button"
class="admin-btn admin-btn--primary admin-btn--sm"
:disabled="!canSubmitStatus || statusSaving"
@click="submitStatus"
>
{{ statusSaving ? 'در حال ثبت…' : 'ثبت وضعیت' }}
</button>
</div>
</section>
</div>
</template>
<style scoped>
.order-detail {
display: flex;
flex-direction: column;
gap: 16px;
}
.order-detail__hero {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-elevated, var(--color-bg));
}
.order-detail__id {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
margin-bottom: 0;
font-size: 1rem;
}
.order-detail__id strong {
line-height: 1.3;
}
.order-detail__raw-status {
margin: 0;
font-size: 0.75rem;
color: var(--color-text-muted);
}
.order-detail__actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.order-detail__actions .admin-btn {
min-height: 40px;
}
.order-detail__panels {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.order-detail__section {
margin: 0;
padding: 14px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface);
}
.order-detail__section-title {
margin: 0 0 12px;
font-size: 0.8125rem;
font-weight: 700;
color: var(--color-text);
}
.order-detail__info {
display: grid;
gap: 10px;
}
.order-detail__info-row {
display: grid;
grid-template-columns: auto minmax(72px, auto) 1fr;
align-items: center;
gap: 8px;
font-size: 0.8125rem;
}
.order-detail__info-row:not(:has(svg)) {
grid-template-columns: minmax(72px, auto) 1fr;
}
.order-detail__info-label {
color: var(--color-text-muted);
}
.order-detail__info-value {
font-weight: 600;
color: var(--color-text);
word-break: break-word;
}
.order-detail__info-value--phone {
text-align: right;
justify-self: stretch;
}
.order-detail__table {
overflow-x: auto;
}
.order-detail__items {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 8px;
}
.order-detail__item {
padding: 12px;
border-radius: var(--radius-sm);
background: var(--color-elevated, var(--color-bg));
border: 1px solid var(--color-border);
}
.order-detail__item-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
margin-bottom: 6px;
font-size: 0.875rem;
}
.order-detail__item-head span {
font-size: 0.75rem;
color: var(--color-text-muted);
}
.order-detail__item-meta {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
font-size: 0.75rem;
color: var(--color-text-muted);
}
.order-detail__item-total {
margin-inline-start: auto;
font-weight: 700;
color: var(--color-text);
}
.order-detail__status-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
}
.order-detail__status-head .order-detail__section-title {
margin: 0;
}
.order-detail__manual {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.8125rem;
color: var(--color-text-muted);
cursor: pointer;
}
.order-detail__manual input {
width: 15px;
height: 15px;
accent-color: var(--color-primary);
}
.order-detail__status-hint {
margin: 0 0 12px;
font-size: 0.75rem;
line-height: 1.7;
color: var(--color-text-muted);
}
.order-detail__status-form {
display: grid;
gap: 12px;
}
@media (max-width: 639px) {
.order-detail__panels {
grid-template-columns: 1fr;
}
.order-detail__hero {
flex-direction: column;
}
.order-detail__actions {
width: 100%;
}
.order-detail__actions .admin-btn {
flex: 1;
}
}
</style>
+623
View File
@@ -0,0 +1,623 @@
<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 ActiveSwitchCell from '@/components/ui/ActiveSwitchCell.vue'
import AdminIcon from '@/components/icons/AdminIcon.vue'
import AdminSelect from '@/components/ui/AdminSelect.vue'
import { catalogsApi, productsApi } from '@/services/api'
import { ApiError } from '@/services/http'
import { useToast } from '@/composables/useToast'
import { formatCurrency, formatNumber } from '@/utils/format'
import type { Catalog, Product } from '@/types'
import AdminCurrencyInput from '@/components/ui/AdminCurrencyInput.vue'
import { fileToCatalogImage } from '@/utils/catalogImage'
const productList = ref<Product[]>([])
const catalogs = ref<Catalog[]>([])
const searchQuery = ref('')
const catalogFilter = ref('all')
const loading = ref(true)
const modalMode = ref<'add' | 'edit' | null>(null)
const editingId = ref<string | null>(null)
const formError = ref('')
const saving = ref(false)
const toast = useToast()
const catalogFilterOptions = computed(() => [
{ value: 'all', label: 'همه کالیته‌ها' },
...catalogs.value.map((catalog) => ({
value: catalog.id,
label: `${catalog.code} — ${catalog.name}`,
})),
])
const catalogFormOptions = computed(() =>
catalogs.value.map((catalog) => ({
value: catalog.id,
label: `${catalog.code} — ${catalog.name} (${catalog.companyName})`,
})),
)
const emptyForm = () => ({
catalogId: catalogs.value[0]?.id ?? '',
name: '',
color: '',
productCode: '',
minMeterage: 100,
orderStep: 50,
price: null as number | null,
internalStock: 0,
isActive: true,
usageCategory: 'other',
season: 'all_season',
composition: '',
weaveType: '',
subtitle: '',
rollMeters: 50,
isNew: false,
colorsText: '',
imageUrls: [] as string[],
})
const form = reactive(emptyForm())
const isModalOpen = computed(() => modalMode.value !== null)
const modalTitle = computed(() => (modalMode.value === 'edit' ? 'ویرایش محصول' : 'افزودن محصول'))
const isFormValid = computed(() => form.name.trim() && form.catalogId && form.minMeterage > 0)
const hasActiveFilters = computed(
() => searchQuery.value.trim().length > 0 || catalogFilter.value !== 'all',
)
const filteredProducts = computed(() => {
let list = productList.value
if (catalogFilter.value !== 'all') {
list = list.filter((p) => p.catalogId === catalogFilter.value)
}
const q = searchQuery.value.trim()
if (q) {
list = list.filter(
(p) =>
p.name.includes(q) ||
p.color.includes(q) ||
p.productCode.includes(q) ||
p.catalogCode.includes(q),
)
}
return list
})
async function loadData() {
loading.value = true
try {
const [productsData, catalogsData] = await Promise.all([
productsApi.list(),
catalogsApi.list(),
])
productList.value = productsData
catalogs.value = catalogsData
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'بارگذاری محصولات انجام نشد.')
} finally {
loading.value = false
}
}
onMounted(() => {
void loadData()
})
function catalogMeta(catalogId: string) {
return catalogs.value.find((c) => c.id === catalogId)
}
function openAddModal() {
modalMode.value = 'add'
editingId.value = null
Object.assign(form, emptyForm())
formError.value = ''
}
function openEditModal(product: Product) {
modalMode.value = 'edit'
editingId.value = product.id
form.catalogId = product.catalogId
form.name = product.name
form.color = product.color
form.productCode = product.productCode
form.minMeterage = product.minMeterage
form.orderStep = product.orderStep
form.price = product.price
form.internalStock = product.internalStock
form.isActive = product.isActive
form.usageCategory = (product as { usageCategory?: string }).usageCategory ?? 'other'
form.season = (product as { season?: string }).season ?? 'all_season'
form.composition = (product as { composition?: string }).composition ?? ''
form.weaveType = (product as { weaveType?: string }).weaveType ?? ''
form.subtitle = (product as { subtitle?: string }).subtitle ?? ''
form.rollMeters = (product as { rollMeters?: number }).rollMeters ?? 50
form.isNew = (product as { isNew?: boolean }).isNew ?? false
form.colorsText = (product.colors ?? []).join(', ')
form.imageUrls = [...(product.imageUrls ?? [])]
formError.value = ''
}
function parseListText(value: string): string[] {
return value.split(',').map((part) => part.trim()).filter(Boolean)
}
const imageBusy = ref(false)
async function addProductImages(event: Event) {
const input = event.target as HTMLInputElement
const files = [...(input.files ?? [])]
input.value = ''
if (!files.length) return
imageBusy.value = true
formError.value = ''
try {
const next = await Promise.all(files.map((file) => fileToCatalogImage(file)))
form.imageUrls = [...form.imageUrls, ...next]
} catch (e) {
formError.value = e instanceof Error ? e.message : 'بارگذاری تصویر ناموفق بود.'
} finally {
imageBusy.value = false
}
}
function removeProductImage(index: number) {
form.imageUrls.splice(index, 1)
}
function closeModal() {
modalMode.value = null
editingId.value = null
formError.value = ''
}
async function submitProduct() {
if (!isFormValid.value) {
formError.value = 'نام محصول، کالیته و حداقل متراژ الزامی هستند.'
return
}
const cat = catalogMeta(form.catalogId)
if (!cat) {
formError.value = 'کالیته معتبر انتخاب کنید.'
return
}
const catalogPayload = {
usageCategory: form.usageCategory,
season: form.season,
composition: form.composition.trim() || null,
weaveType: form.weaveType.trim() || null,
subtitle: form.subtitle.trim() || null,
rollMeters: form.rollMeters,
isNew: form.isNew,
colorsJson: parseListText(form.colorsText),
imageUrlsJson: form.imageUrls,
}
const productCode = form.productCode.trim() || `${cat.code}-NEW`
saving.value = true
try {
if (modalMode.value === 'edit' && editingId.value) {
const updated = await productsApi.update(editingId.value, {
catalogId: form.catalogId,
name: form.name.trim(),
color: form.color.trim() || '—',
code: productCode,
productCode,
minMeterage: form.minMeterage,
orderStep: form.orderStep,
price: form.price,
internalStock: form.internalStock,
isActive: form.isActive,
...catalogPayload,
})
const index = productList.value.findIndex((p) => p.id === editingId.value)
if (index !== -1) productList.value[index] = { ...productList.value[index]!, ...updated }
toast.success('اطلاعات محصول به‌روزرسانی شد.')
} else {
const created = await productsApi.create({
companyId: cat.companyId,
catalogId: form.catalogId,
name: form.name.trim(),
color: form.color.trim() || '—',
code: productCode,
productCode,
minMeterage: form.minMeterage,
orderStep: form.orderStep,
price: form.price,
internalStock: form.internalStock,
isActive: form.isActive,
...catalogPayload,
})
productList.value.push(created)
toast.success('محصول جدید ثبت شد.')
}
closeModal()
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'ذخیره محصول انجام نشد.')
} finally {
saving.value = false
}
}
async function persistActive(id: string, isActive: boolean) {
await productsApi.setActive(id, isActive)
}
function clearFilters() {
searchQuery.value = ''
catalogFilter.value = 'all'
}
</script>
<template>
<div class="products-page">
<div class="admin-page-header">
<div>
<h1 class="admin-page-header__title">محصولات</h1>
</div>
<button type="button" class="admin-btn admin-btn--primary" @click="openAddModal">
افزودن محصول
</button>
</div>
<div class="products-page__filters admin-card">
<div class="products-page__search">
<AdminIcon name="search" :size="18" />
<input
v-model="searchQuery"
type="search"
class="products-page__search-input"
placeholder="جستجو در نام، رنگ، کد..."
/>
</div>
<AdminSelect
v-model="catalogFilter"
class="products-page__catalog-filter"
aria-label="فیلتر کالیته"
:options="catalogFilterOptions"
/>
</div>
<AdminPageSkeleton v-if="loading" variant="table" />
<BaseEmptyState
v-else-if="!filteredProducts.length"
class="admin-card"
icon="products"
:title="hasActiveFilters ? 'نتیجه‌ای یافت نشد' : 'محصولی ثبت نشده'"
:description="
hasActiveFilters
? 'عبارت جستجو را تغییر دهید یا فیلترها را پاک کنید.'
: 'برای شروع، اولین محصول را ثبت کنید.'
"
>
<button v-if="hasActiveFilters" type="button" class="admin-btn admin-btn--outline admin-btn--sm" @click="clearFilters">
پاک کردن فیلترها
</button>
<button v-else type="button" class="admin-btn admin-btn--primary admin-btn--sm" @click="openAddModal">
افزودن محصول
</button>
</BaseEmptyState>
<div v-else class="products-page__table admin-card desktop-only">
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>تصویر</th>
<th>کد محصول</th>
<th>نام</th>
<th>رنگ</th>
<th>کالیته</th>
<th>شرکت</th>
<th class="numeric">حداقل متراژ</th>
<th class="numeric">ضریب</th>
<th class="numeric">قیمت</th>
<th class="numeric">موجودی</th>
<th class="status">وضعیت</th>
<th class="actions">عملیات</th>
</tr>
</thead>
<tbody>
<tr v-for="product in filteredProducts" :key="product.id">
<td>
<span
class="product-thumb"
:class="{ 'product-thumb--empty': !product.imageUrls?.[0] }"
:style="product.imageUrls?.[0] ? { backgroundImage: `url(${product.imageUrls[0]})` } : undefined"
/>
</td>
<td>{{ product.productCode }}</td>
<td>{{ product.name }}</td>
<td>{{ product.color }}</td>
<td>{{ product.catalogCode }}</td>
<td>{{ product.companyName }}</td>
<td class="numeric">{{ formatNumber(product.minMeterage) }} متر</td>
<td class="numeric">{{ formatNumber(product.orderStep) }}</td>
<td class="numeric">{{ product.price ? formatCurrency(product.price) : '—' }}</td>
<td class="numeric">{{ formatNumber(product.internalStock) }} متر</td>
<td class="status">
<ActiveSwitchCell
:id="product.id"
v-model="product.isActive"
:entity-name="product.name"
entity-type="محصول"
:persist="persistActive"
/>
</td>
<td class="actions">
<button type="button" class="admin-btn admin-btn--ghost admin-btn--xs" @click="openEditModal(product)">
ویرایش
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-if="!loading && filteredProducts.length" class="compact-only admin-card-list">
<article v-for="product in filteredProducts" :key="product.id" class="admin-card-item">
<div class="admin-card-item__header">
<p class="admin-card-item__title">{{ product.name }}</p>
<div class="admin-card-item__header-actions">
<ActiveSwitchCell
:id="product.id"
v-model="product.isActive"
:entity-name="product.name"
entity-type="محصول"
:persist="persistActive"
/>
<button type="button" class="admin-btn admin-btn--ghost admin-btn--xs" @click="openEditModal(product)">
ویرایش
</button>
</div>
</div>
<div class="admin-card-item__meta">
<span>{{ product.productCode }}</span>
<span>{{ product.color }}</span>
<span>کالیته {{ product.catalogCode }}</span>
<span>{{ product.price ? formatCurrency(product.price) : 'بدون قیمت' }}</span>
</div>
</article>
</div>
<BaseModal :open="isModalOpen" :title="modalTitle" size="lg" @close="closeModal">
<form class="admin-form" @submit.prevent="submitProduct">
<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>
<AdminSelect
v-model="form.catalogId"
block
aria-label="انتخاب کالیته"
placeholder="انتخاب کالیته"
:options="catalogFormOptions"
/>
</div>
<div class="admin-form__row">
<div class="admin-form__field">
<label for="product-name">نام محصول <span class="admin-form__required">*</span></label>
<input id="product-name" v-model="form.name" type="text" class="admin-input" />
</div>
<div class="admin-form__field">
<label for="product-color">رنگ / نوع</label>
<input id="product-color" v-model="form.color" type="text" class="admin-input" />
</div>
</div>
<div class="admin-form__field">
<label for="product-code">کد محصول</label>
<input id="product-code" v-model="form.productCode" type="text" class="admin-input" dir="ltr" />
</div>
<div class="admin-form__row">
<div class="admin-form__field">
<label for="product-min">حداقل متراژ</label>
<input id="product-min" v-model.number="form.minMeterage" type="number" min="1" class="admin-input" />
</div>
<div class="admin-form__field">
<label for="product-step">ضریب سفارش</label>
<input id="product-step" v-model.number="form.orderStep" type="number" min="1" class="admin-input" />
</div>
</div>
<div class="admin-form__row">
<div class="admin-form__field">
<label for="product-price">قیمت (تومان)</label>
<AdminCurrencyInput id="product-price" v-model="form.price" />
</div>
<div class="admin-form__field">
<label for="product-stock">موجودی داخلی (متر)</label>
<input id="product-stock" v-model.number="form.internalStock" type="number" min="0" class="admin-input" />
</div>
</div>
<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 value="trousers">شلوار</option>
<option value="suit">کت‌وشلوار</option>
<option value="shirt">پیراهن</option>
<option value="other">سایر</option>
</select>
</div>
<div class="admin-form__field">
<label for="product-season">فصل</label>
<select id="product-season" v-model="form.season" class="admin-input">
<option value="summer">تابستان</option>
<option value="winter">زمستان</option>
<option value="all_season">چهارفصل</option>
</select>
</div>
</div>
<div class="admin-form__row">
<div class="admin-form__field">
<label for="product-composition">جنس / ترکیب</label>
<input id="product-composition" v-model="form.composition" type="text" class="admin-input" />
</div>
<div class="admin-form__field">
<label for="product-weave">نوع بافت</label>
<input id="product-weave" v-model="form.weaveType" type="text" class="admin-input" />
</div>
</div>
<div class="admin-form__row">
<div class="admin-form__field">
<label for="product-subtitle">زیرعنوان</label>
<input id="product-subtitle" v-model="form.subtitle" type="text" class="admin-input" />
</div>
<div class="admin-form__field">
<label for="product-roll">متراژ طاقه</label>
<input id="product-roll" v-model.number="form.rollMeters" type="number" min="1" class="admin-input" />
</div>
</div>
<div class="admin-form__field">
<label for="product-colors">رنگ‌ها (با ویرگول)</label>
<input
id="product-colors"
v-model="form.colorsText"
type="text"
class="admin-input"
placeholder="مثلاً: سرمه‌ای، خاکستری، بژ"
/>
</div>
<div class="admin-form__field">
<label>تصاویر محصول</label>
<div class="product-images">
<div
v-for="(src, index) in form.imageUrls"
:key="`${index}-${src.slice(0, 24)}`"
class="product-images__item"
>
<span class="product-images__thumb" :style="{ backgroundImage: `url(${src})` }" />
<button type="button" class="product-images__remove" @click="removeProductImage(index)">
حذف
</button>
</div>
<label class="product-images__add">
<input type="file" accept="image/*" multiple hidden :disabled="imageBusy" @change="addProductImages" />
{{ imageBusy ? '...' : '+ افزودن تصویر' }}
</label>
</div>
</div>
<div class="admin-form__field">
<BaseSwitch v-model="form.isNew" label="محصول جدید" />
</div>
<div class="admin-form__field">
<BaseSwitch v-model="form.isActive" label="وضعیت" />
</div>
</form>
<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' ? 'ذخیره تغییرات' : 'ثبت محصول' }}
</button>
</template>
</BaseModal>
</div>
</template>
<style scoped>
.products-page__filters {
display: flex;
flex-wrap: wrap;
gap: 12px;
padding: 12px 14px;
margin-bottom: 16px;
align-items: center;
}
.products-page__search {
flex: 1;
min-width: 200px;
display: flex;
align-items: center;
gap: 10px;
color: var(--color-text-muted);
}
.products-page__search-input {
flex: 1;
border: none;
background: transparent;
font-size: 0.875rem;
outline: none;
}
.products-page__catalog-filter {
width: min(260px, 100%);
}
.products-page__table {
overflow: hidden;
}
.product-thumb {
display: block;
width: 44px;
height: 36px;
border-radius: 8px;
background: #e8eceb center / cover no-repeat;
}
.product-thumb--empty {
background: #eef1f0;
}
.product-images {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.product-images__item {
display: flex;
flex-direction: column;
gap: 6px;
width: 92px;
}
.product-images__thumb {
display: block;
width: 92px;
height: 72px;
border-radius: 10px;
background: #e8eceb center / cover no-repeat;
border: 1px solid var(--color-border);
}
.product-images__remove,
.product-images__add {
border: 1px dashed var(--color-border);
background: transparent;
border-radius: 8px;
padding: 6px 8px;
font-size: 0.75rem;
cursor: pointer;
text-align: center;
}
.product-images__add {
display: grid;
place-items: center;
min-width: 92px;
min-height: 72px;
color: var(--color-text-muted);
}
</style>
+332
View File
@@ -0,0 +1,332 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import AdminIcon from '@/components/icons/AdminIcon.vue'
import AdminPageSkeleton from '@/components/ui/AdminPageSkeleton.vue'
import { dashboardApi, type DashboardStats, type ReportsResponse } from '@/services/api'
import { ApiError } from '@/services/http'
import { useToast } from '@/composables/useToast'
import { formatCurrency, formatMeterage, formatNumber } from '@/utils/format'
const toast = useToast()
const loading = ref(true)
const dashboardStats = ref<DashboardStats>({
newOrders: 0,
pendingReview: 0,
pendingCheck: 0,
finalApproval: 0,
todaySales: 0,
todaySalesGrowth: 0,
monthSales: 0,
})
const reportSalesByCompany = ref<ReportsResponse['reportSalesByCompany']>([])
const reportOrdersByStatus = ref<ReportsResponse['reportOrdersByStatus']>([])
const reportSalesByMonth = ref<NonNullable<ReportsResponse['reportSalesByMonth']>>([])
const reportSalesByProduct = ref<NonNullable<ReportsResponse['reportSalesByProduct']>>([])
const reportSalesByCustomer = ref<NonNullable<ReportsResponse['reportSalesByCustomer']>>([])
const reportCustomers = ref<ReportsResponse['reportCustomers'] | null>(null)
onMounted(async () => {
loading.value = true
try {
const [dashboard, reports] = await Promise.all([
dashboardApi.get(),
dashboardApi.reports(),
])
dashboardStats.value = dashboard.stats
reportSalesByCompany.value = reports.reportSalesByCompany
reportOrdersByStatus.value = reports.reportOrdersByStatus
reportSalesByMonth.value = reports.reportSalesByMonth ?? []
reportSalesByProduct.value = reports.reportSalesByProduct ?? []
reportSalesByCustomer.value = reports.reportSalesByCustomer ?? []
reportCustomers.value = reports.reportCustomers ?? null
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'بارگذاری گزارش‌ها انجام نشد.')
} finally {
loading.value = false
}
})
function printReport() {
window.print()
}
</script>
<template>
<div class="reports-page">
<div class="admin-page-header">
<div>
<h1 class="admin-page-header__title">گزارش‌ها</h1>
</div>
<button type="button" class="admin-btn admin-btn--outline admin-btn--sm" @click="printReport">
<AdminIcon name="print" :size="16" />
چاپ گزارش
</button>
</div>
<AdminPageSkeleton v-if="loading" variant="table" />
<template v-else>
<div class="reports-page__stats">
<div class="admin-stat-card admin-card">
<p class="admin-stat-card__label">فروش امروز</p>
<p class="admin-stat-card__value">{{ formatCurrency(dashboardStats.todaySales) }}</p>
<span class="reports-page__growth">+{{ dashboardStats.todaySalesGrowth }}% نسبت به دیروز</span>
</div>
<div class="admin-stat-card admin-card">
<p class="admin-stat-card__label">فروش ماه جاری</p>
<p class="admin-stat-card__value">{{ formatCurrency(dashboardStats.monthSales) }}</p>
</div>
<div class="admin-stat-card admin-card">
<p class="admin-stat-card__label">سفارش‌های جدید</p>
<p class="admin-stat-card__value">{{ formatNumber(dashboardStats.newOrders) }}</p>
</div>
<div class="admin-stat-card admin-card">
<p class="admin-stat-card__label">در انتظار بررسی</p>
<p class="admin-stat-card__value">{{ formatNumber(dashboardStats.pendingReview) }}</p>
</div>
</div>
<div v-if="reportCustomers" class="reports-page__stats">
<div class="admin-stat-card admin-card">
<p class="admin-stat-card__label">مشتریان فعال</p>
<p class="admin-stat-card__value">{{ formatNumber(reportCustomers.active) }}</p>
</div>
<div class="admin-stat-card admin-card">
<p class="admin-stat-card__label">غیرفعال</p>
<p class="admin-stat-card__value">{{ formatNumber(reportCustomers.inactive) }}</p>
</div>
<div class="admin-stat-card admin-card">
<p class="admin-stat-card__label">جدید ۳۰ روز</p>
<p class="admin-stat-card__value">{{ formatNumber(reportCustomers.newLast30Days) }}</p>
</div>
<div class="admin-stat-card admin-card">
<p class="admin-stat-card__label">بدون خرید</p>
<p class="admin-stat-card__value">{{ formatNumber(reportCustomers.noPurchase) }}</p>
</div>
<div class="admin-stat-card admin-card">
<p class="admin-stat-card__label">بدون خرید ۹۰ روز</p>
<p class="admin-stat-card__value">{{ formatNumber(reportCustomers.noPurchase90Days) }}</p>
</div>
<div class="admin-stat-card admin-card">
<p class="admin-stat-card__label">دارای واسطه</p>
<p class="admin-stat-card__value">{{ formatNumber(reportCustomers.withAgent) }}</p>
</div>
</div>
<div class="reports-page__grid">
<section class="admin-card reports-page__section">
<h2 class="reports-page__section-title">فروش به تفکیک شرکت</h2>
<div class="admin-table-wrap desktop-only">
<table class="admin-table">
<thead>
<tr>
<th>شرکت</th>
<th>تعداد سفارش</th>
<th>مجموع متراژ</th>
<th>مجموع مبلغ</th>
</tr>
</thead>
<tbody>
<tr v-for="row in reportSalesByCompany" :key="row.companyName">
<td>{{ row.companyName }}</td>
<td>{{ formatNumber(row.orderCount) }}</td>
<td>{{ formatMeterage(row.meterage) }}</td>
<td>{{ formatCurrency(row.totalAmount) }}</td>
</tr>
</tbody>
</table>
</div>
<div class="compact-only admin-card-list">
<article v-for="row in reportSalesByCompany" :key="row.companyName" class="admin-card-item">
<p class="admin-card-item__title">{{ row.companyName }}</p>
<div class="admin-card-item__meta">
<span>{{ row.orderCount }} سفارش</span>
<span>{{ formatMeterage(row.meterage) }}</span>
<span>{{ formatCurrency(row.totalAmount) }}</span>
</div>
</article>
</div>
</section>
<section class="admin-card reports-page__section">
<h2 class="reports-page__section-title">سفارش‌ها به تفکیک وضعیت</h2>
<div class="admin-table-wrap desktop-only">
<table class="admin-table">
<thead>
<tr>
<th>وضعیت</th>
<th>تعداد</th>
</tr>
</thead>
<tbody>
<tr v-for="row in reportOrdersByStatus" :key="row.status">
<td>{{ row.status }}</td>
<td>{{ formatNumber(row.count) }}</td>
</tr>
</tbody>
</table>
</div>
<ul class="compact-only reports-page__status-list">
<li v-for="row in reportOrdersByStatus" :key="row.status">
<span>{{ row.status }}</span>
<strong>{{ formatNumber(row.count) }}</strong>
</li>
</ul>
</section>
</div>
<div v-if="reportSalesByMonth.length" class="reports-page__grid reports-page__grid--full">
<section class="admin-card reports-page__section">
<h2 class="reports-page__section-title">فروش ماهانه</h2>
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>ماه</th>
<th>تعداد سفارش</th>
<th>مجموع متراژ</th>
<th>مجموع مبلغ</th>
</tr>
</thead>
<tbody>
<tr v-for="row in reportSalesByMonth" :key="row.month">
<td>{{ row.month }}</td>
<td>{{ formatNumber(row.orderCount) }}</td>
<td>{{ formatMeterage(row.meterage) }}</td>
<td>{{ formatCurrency(row.totalAmount) }}</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
<div
v-if="reportSalesByProduct.length || reportSalesByCustomer.length"
class="reports-page__grid"
>
<section v-if="reportSalesByProduct.length" class="admin-card reports-page__section">
<h2 class="reports-page__section-title">فروش به تفکیک محصول</h2>
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>محصول</th>
<th>کد</th>
<th>تعداد سفارش</th>
<th>متراژ</th>
<th>مبلغ</th>
</tr>
</thead>
<tbody>
<tr v-for="row in reportSalesByProduct" :key="row.productId">
<td>{{ row.productName }}</td>
<td dir="ltr">{{ row.productCode }}</td>
<td>{{ formatNumber(row.orderCount) }}</td>
<td>{{ formatMeterage(row.meterage) }}</td>
<td>{{ formatCurrency(row.totalAmount) }}</td>
</tr>
</tbody>
</table>
</div>
</section>
<section v-if="reportSalesByCustomer.length" class="admin-card reports-page__section">
<h2 class="reports-page__section-title">فروش به تفکیک مشتری</h2>
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>مشتری</th>
<th>تعداد سفارش</th>
<th>متراژ</th>
<th>مبلغ</th>
</tr>
</thead>
<tbody>
<tr v-for="row in reportSalesByCustomer" :key="row.customerName">
<td>{{ row.customerName }}</td>
<td>{{ formatNumber(row.orderCount) }}</td>
<td>{{ formatMeterage(row.meterage) }}</td>
<td>{{ formatCurrency(row.totalAmount) }}</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
</template>
</div>
</template>
<style scoped>
.reports-page__stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 20px;
}
.reports-page__growth {
font-size: 0.75rem;
color: var(--color-success-dark);
margin-top: 6px;
display: block;
}
.reports-page__grid {
display: grid;
grid-template-columns: 1.4fr 1fr;
gap: 16px;
margin-bottom: 16px;
}
.reports-page__grid--full {
grid-template-columns: 1fr;
}
.reports-page__section {
padding: 20px;
overflow: hidden;
}
.reports-page__section-title {
margin: 0 0 16px;
font-size: 1rem;
font-weight: 700;
}
.reports-page__status-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.reports-page__status-list li {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
background: var(--color-border-light);
border-radius: var(--radius-sm);
font-size: 0.875rem;
}
@media (max-width: 1279px) {
.reports-page__stats {
grid-template-columns: repeat(2, 1fr);
}
.reports-page__grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 767px) {
.reports-page__stats {
grid-template-columns: 1fr;
}
}
</style>
+345
View File
@@ -0,0 +1,345 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import AdminPageSkeleton from '@/components/ui/AdminPageSkeleton.vue'
import { useToast } from '@/composables/useToast'
import {
customersApi,
settingsApi,
notificationsApi,
type UpdateSettingsPayload,
} from '@/services/api'
import type { Customer } from '@/types'
const toast = useToast()
const settings = reactive<UpdateSettingsPayload>({
maxMeteragePerInvoice: 5000,
cashInvoiceValidityHours: 8,
smsOrderRegistered: true,
smsPricingReady: true,
smsPaymentReminder: true,
smsCheckReview: true,
smsShippingUpdate: true,
defaultSettlementCheck: true,
defaultSettlementCash: true,
minOrderRollsPerProduct: 1,
showCatalogPublicPrice: false,
allowAgentPricing: false,
agentMinUnitPrice: null,
supportPhone: '',
supportEmail: '',
})
const broadcast = reactive({ title: '', body: '' })
const broadcasting = ref(false)
const customers = ref<Customer[]>([])
const selectedCustomerIds = ref<string[]>([])
const loading = ref(true)
const saving = ref(false)
const broadcastButtonLabel = computed(() => {
if (broadcasting.value) return 'در حال ارسال…'
if (selectedCustomerIds.value.length) {
return `ارسال به ${selectedCustomerIds.value.length} مشتری انتخاب‌شده`
}
return 'ارسال به همه مشتریان فعال'
})
function toggleCustomer(id: string, checked: boolean) {
if (checked) {
if (!selectedCustomerIds.value.includes(id)) selectedCustomerIds.value.push(id)
} else {
selectedCustomerIds.value = selectedCustomerIds.value.filter((cid) => cid !== id)
}
}
onMounted(async () => {
loading.value = true
try {
const [data, customerList] = await Promise.all([settingsApi.get(), customersApi.list()])
Object.assign(settings, {
maxMeteragePerInvoice: data.maxMeteragePerInvoice,
cashInvoiceValidityHours: data.cashInvoiceValidityHours,
smsOrderRegistered: data.smsOrderRegistered,
smsPricingReady: data.smsPricingReady,
smsPaymentReminder: data.smsPaymentReminder,
smsCheckReview: data.smsCheckReview,
smsShippingUpdate: data.smsShippingUpdate,
defaultSettlementCheck: data.defaultSettlementCheck,
defaultSettlementCash: data.defaultSettlementCash,
minOrderRollsPerProduct: data.minOrderRollsPerProduct ?? 1,
showCatalogPublicPrice: data.showCatalogPublicPrice ?? false,
allowAgentPricing: data.allowAgentPricing ?? false,
agentMinUnitPrice: data.agentMinUnitPrice ?? null,
supportPhone: data.supportPhone ?? '',
supportEmail: data.supportEmail ?? '',
})
customers.value = customerList
} catch (e) {
toast.error(e instanceof Error ? e.message : 'دریافت تنظیمات ناموفق بود.')
} finally {
loading.value = false
}
})
async function saveSettings() {
if (saving.value) return
saving.value = true
try {
await settingsApi.update({ ...settings })
toast.success('تنظیمات با موفقیت ذخیره شد.')
} catch (e) {
toast.error(e instanceof Error ? e.message : 'ذخیره تنظیمات ناموفق بود.')
} finally {
saving.value = false
}
}
async function sendBroadcast() {
if (!broadcast.title.trim() || !broadcast.body.trim() || broadcasting.value) return
broadcasting.value = true
try {
const payload: { title: string; body: string; customerIds?: string[] } = {
title: broadcast.title.trim(),
body: broadcast.body.trim(),
}
if (selectedCustomerIds.value.length) {
payload.customerIds = [...selectedCustomerIds.value]
}
const res = await notificationsApi.broadcast(payload)
toast.success(`اعلان برای ${res.sent} مشتری ارسال شد.`)
broadcast.title = ''
broadcast.body = ''
selectedCustomerIds.value = []
} catch (e) {
toast.error(e instanceof Error ? e.message : 'ارسال اعلان ناموفق بود.')
} finally {
broadcasting.value = false
}
}
</script>
<template>
<div class="settings-page">
<div class="admin-page-header">
<div>
<h1 class="admin-page-header__title">تنظیمات</h1>
</div>
<button
type="button"
class="admin-btn admin-btn--primary"
:disabled="loading || saving"
@click="saveSettings"
>
{{ saving ? 'در حال ذخیره…' : 'ذخیره تنظیمات' }}
</button>
</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" />
</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>
<div v-if="customers.length" class="admin-form__field">
<label>گیرندگان (بدون انتخاب = همه)</label>
<div class="settings-page__customers">
<label v-for="customer in customers" :key="customer.id" class="settings-page__check">
<input
type="checkbox"
:checked="selectedCustomerIds.includes(customer.id)"
@change="toggleCustomer(customer.id, ($event.target as HTMLInputElement).checked)"
/>
<span>{{ customer.name }} <span class="settings-page__phone" dir="ltr">{{ customer.phone }}</span></span>
</label>
</div>
</div>
<button
type="button"
class="admin-btn admin-btn--outline"
:disabled="broadcasting || !broadcast.title.trim() || !broadcast.body.trim()"
@click="sendBroadcast"
>
{{ broadcastButtonLabel }}
</button>
</section>
</div>
</div>
</template>
<style scoped>
.settings-page__grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
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;
gap: 12px;
}
.settings-page__check {
display: flex;
align-items: center;
gap: 10px;
font-size: 0.875rem;
cursor: pointer;
}
.settings-page__check input {
width: 16px;
height: 16px;
accent-color: var(--color-primary);
}
.settings-page__customers {
max-height: 220px;
overflow: auto;
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px 0;
}
.settings-page__phone {
color: var(--color-text-muted);
font-size: 0.8125rem;
margin-right: 6px;
}
@media (max-width: 1279px) {
.settings-page__grid {
grid-template-columns: 1fr;
}
}
</style>
+361
View File
@@ -0,0 +1,361 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import AdminPageSkeleton from '@/components/ui/AdminPageSkeleton.vue'
import BaseModal from '@/components/ui/BaseModal.vue'
import AdminSelect from '@/components/ui/AdminSelect.vue'
import AdminIcon from '@/components/icons/AdminIcon.vue'
import { mapAdminOrder, ordersApi, shipmentsApi } from '@/services/api'
import { ApiError } from '@/services/http'
import { SHIPMENT_STATUS_BADGE, SHIPMENT_STATUS_LABELS } from '@/utils/adminLabels'
import { formatDateTime, formatMeterage } from '@/utils/format'
import { useToast } from '@/composables/useToast'
import type { Order, Shipment, ShipmentStatus } from '@/types'
const route = useRoute()
const shipmentList = ref<Shipment[]>([])
const orders = ref<Order[]>([])
const searchQuery = ref(typeof route.query.order === 'string' ? route.query.order : '')
const statusFilter = ref<'all' | ShipmentStatus>('all')
const loading = ref(true)
const showModal = ref(false)
const formError = ref('')
const saving = ref(false)
const toast = useToast()
const statusFilterOptions = [
{ value: 'all', label: 'همه وضعیت‌ها' },
{ value: 'preparing', label: 'در حال آماده‌سازی' },
{ value: 'ready', label: 'آماده ارسال' },
{ value: 'shipped', label: 'ارسال شده' },
{ value: 'delivered', label: 'تحویل شده' },
]
const shipmentStatusOptions = [
{ value: 'preparing', label: 'در حال آماده‌سازی' },
{ value: 'ready', label: 'آماده ارسال' },
{ value: 'shipped', label: 'ارسال شده' },
{ value: 'delivered', label: 'تحویل شده' },
]
const emptyForm = () => ({
orderId: '',
carrier: '',
trackingNumber: '',
meterage: 1000,
status: 'preparing' as ShipmentStatus,
})
const form = reactive(emptyForm())
const filteredShipments = computed(() => {
let list = shipmentList.value
if (statusFilter.value !== 'all') {
list = list.filter((s) => s.status === statusFilter.value)
}
const q = searchQuery.value.trim()
if (q) {
list = list.filter(
(s) =>
s.orderNumber.includes(q) ||
s.customerName.includes(q) ||
s.trackingNumber.includes(q) ||
s.carrier.includes(q),
)
}
return list
})
const isFormValid = computed(() => form.orderId.trim() && form.carrier.trim())
const availableOrders = computed(() => {
const shippedIds = new Set(shipmentList.value.map((s) => s.orderId))
return orders.value.filter((o) => !shippedIds.has(o.id))
})
const orderSelectOptions = computed(() =>
availableOrders.value.map((order) => ({
value: order.id,
label: `${order.orderNumber} — ${order.customerName}`,
})),
)
async function loadData() {
loading.value = true
try {
const [shipmentsData, ordersData] = await Promise.all([
shipmentsApi.list(),
ordersApi.list(),
])
shipmentList.value = shipmentsData
orders.value = ordersData.map((o) => mapAdminOrder(o) as Order)
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'بارگذاری ارسال‌ها انجام نشد.')
} finally {
loading.value = false
}
}
onMounted(() => {
void loadData()
})
watch(
() => route.query.order,
(orderRef) => {
if (typeof orderRef === 'string' && orderRef.trim()) {
searchQuery.value = orderRef.trim()
}
},
)
function onOrderChange() {
const order = orders.value.find((o) => o.id === form.orderId)
if (order) form.meterage = order.meterage
}
function openAddModal() {
Object.assign(form, emptyForm())
formError.value = ''
showModal.value = true
}
function closeModal() {
showModal.value = false
formError.value = ''
}
async function submitShipment() {
if (!isFormValid.value) {
formError.value = 'سفارش و باربری الزامی هستند.'
return
}
saving.value = true
try {
const created = await shipmentsApi.create({
orderId: form.orderId,
carrier: form.carrier.trim(),
trackingNumber: form.trackingNumber.trim() || undefined,
meterage: form.meterage,
status: form.status,
})
shipmentList.value.unshift(created)
toast.success('بارنامه ثبت شد.')
closeModal()
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'ثبت بارنامه انجام نشد.')
} finally {
saving.value = false
}
}
async function advanceStatus(shipment: Shipment) {
try {
const updated = await shipmentsApi.advanceStatus(shipment.id)
const index = shipmentList.value.findIndex((s) => s.id === shipment.id)
if (index !== -1) shipmentList.value[index] = updated
toast.success('وضعیت ارسال به‌روزرسانی شد.')
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'به‌روزرسانی وضعیت انجام نشد.')
}
}
</script>
<template>
<div class="shipping-page">
<div class="admin-page-header">
<div>
<h1 class="admin-page-header__title">ارسال و باربری</h1>
</div>
<button type="button" class="admin-btn admin-btn--primary" @click="openAddModal">
ثبت بارنامه
</button>
</div>
<div class="shipping-page__filters admin-card">
<div class="shipping-page__search">
<AdminIcon name="search" :size="18" />
<input
v-model="searchQuery"
type="search"
class="shipping-page__search-input"
placeholder="جستجو در سفارش، مشتری، باربری..."
/>
</div>
<AdminSelect
v-model="statusFilter"
class="shipping-page__status-select"
aria-label="فیلتر وضعیت ارسال"
:options="statusFilterOptions"
/>
</div>
<AdminPageSkeleton v-if="loading" variant="table" />
<template v-else>
<div class="shipping-page__table admin-card desktop-only">
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>شماره سفارش</th>
<th>مشتری</th>
<th>باربری</th>
<th>شماره بارنامه</th>
<th>متراژ</th>
<th>وضعیت</th>
<th>تاریخ ارسال</th>
<th>تاریخ تحویل</th>
<th>عملیات</th>
</tr>
</thead>
<tbody>
<tr v-for="shipment in filteredShipments" :key="shipment.id">
<td>{{ shipment.orderNumber }}</td>
<td>{{ shipment.customerName }}</td>
<td>{{ shipment.carrier }}</td>
<td dir="ltr">{{ shipment.trackingNumber }}</td>
<td>{{ formatMeterage(shipment.meterage) }}</td>
<td>
<span class="admin-badge" :class="SHIPMENT_STATUS_BADGE[shipment.status]">
{{ SHIPMENT_STATUS_LABELS[shipment.status] }}
</span>
</td>
<td>{{ shipment.shippedAt ? formatDateTime(shipment.shippedAt) : '—' }}</td>
<td>{{ shipment.deliveredAt ? formatDateTime(shipment.deliveredAt) : '—' }}</td>
<td>
<button
v-if="shipment.status !== 'delivered'"
type="button"
class="admin-btn admin-btn--outline admin-btn--xs"
@click="advanceStatus(shipment)"
>
مرحله بعد
</button>
<span v-else>—</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="compact-only admin-card-list">
<article v-for="shipment in filteredShipments" :key="shipment.id" class="admin-card-item">
<div class="admin-card-item__header">
<p class="admin-card-item__title">{{ shipment.orderNumber }}</p>
<span class="admin-badge" :class="SHIPMENT_STATUS_BADGE[shipment.status]">
{{ SHIPMENT_STATUS_LABELS[shipment.status] }}
</span>
</div>
<div class="admin-card-item__meta">
<span>{{ shipment.customerName }}</span>
<span>{{ shipment.carrier }}</span>
<span>{{ formatMeterage(shipment.meterage) }}</span>
<span dir="ltr">{{ shipment.trackingNumber }}</span>
</div>
<div v-if="shipment.status !== 'delivered'" class="admin-card-item__actions">
<button type="button" class="admin-btn admin-btn--outline admin-btn--xs" @click="advanceStatus(shipment)">
مرحله بعد
</button>
</div>
</article>
</div>
</template>
<BaseModal :open="showModal" title="ثبت بارنامه" size="lg" @close="closeModal">
<form class="admin-form" @submit.prevent="submitShipment">
<p v-if="formError" class="admin-form__error" role="alert">{{ formError }}</p>
<div class="admin-form__field">
<label id="ship-order-label">سفارش <span class="admin-form__required">*</span></label>
<AdminSelect
v-model="form.orderId"
block
aria-label="انتخاب سفارش"
placeholder="انتخاب سفارش"
:options="orderSelectOptions"
@update:model-value="onOrderChange"
/>
</div>
<div class="admin-form__row">
<div class="admin-form__field">
<label for="ship-carrier">باربری <span class="admin-form__required">*</span></label>
<input id="ship-carrier" v-model="form.carrier" type="text" class="admin-input" />
</div>
<div class="admin-form__field">
<label for="ship-tracking">شماره بارنامه</label>
<input id="ship-tracking" v-model="form.trackingNumber" type="text" class="admin-input" dir="ltr" />
</div>
</div>
<div class="admin-form__row">
<div class="admin-form__field">
<label for="ship-meterage">متراژ</label>
<input id="ship-meterage" v-model.number="form.meterage" type="number" min="1" class="admin-input" />
</div>
<div class="admin-form__field">
<label id="ship-status-label">وضعیت</label>
<AdminSelect
v-model="form.status"
block
aria-label="وضعیت ارسال"
:options="shipmentStatusOptions"
/>
</div>
</div>
</form>
<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="submitShipment"
>
ثبت بارنامه
</button>
</template>
</BaseModal>
</div>
</template>
<style scoped>
.shipping-page__filters {
display: flex;
flex-wrap: wrap;
gap: 12px;
padding: 12px 14px;
margin-bottom: 16px;
align-items: center;
}
.shipping-page__search {
flex: 1;
min-width: 200px;
display: flex;
align-items: center;
gap: 10px;
color: var(--color-text-muted);
}
.shipping-page__search-input {
flex: 1;
border: none;
background: transparent;
font-size: 0.875rem;
outline: none;
color: var(--color-text);
}
.shipping-page__status-select {
flex: 0 1 220px;
width: 220px;
}
.shipping-page__table {
overflow: hidden;
}
</style>
@@ -0,0 +1,84 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import AdminPageSkeleton from '@/components/ui/AdminPageSkeleton.vue'
import BaseEmptyState from '@/components/ui/BaseEmptyState.vue'
import { stockAlertsApi, type StockAlert } from '@/services/api'
import { ApiError } from '@/services/http'
import { useToast } from '@/composables/useToast'
const list = ref<StockAlert[]>([])
const loading = ref(true)
const toast = useToast()
async function load() {
loading.value = true
try {
list.value = await stockAlertsApi.list()
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'بارگذاری ناموفق بود')
} finally {
loading.value = false
}
}
async function notify(id: string) {
try {
await stockAlertsApi.notify(id)
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>
<p>مشتریانی که برای محصول ناموجود درخواست اطلاع داده‌اند</p>
</div>
</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>
</tr>
</thead>
<tbody>
<tr v-for="row in list" :key="row.id">
<td>
{{ row.customerName }}
<span class="muted" dir="ltr">{{ row.customerPhone }}</span>
</td>
<td>{{ row.productName }} <span class="muted" dir="ltr">({{ row.productCode }})</span></td>
<td>{{ row.stock ?? '—' }}</td>
<td>
<button type="button" class="admin-btn admin-btn--primary admin-btn--sm" @click="notify(row.id)">ارسال اعلان</button>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<style scoped>
.admin-page__head { 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); }
.muted { color: var(--color-text-muted); font-size: 0.75rem; margin-right: 6px; }
</style>
+147
View File
@@ -0,0 +1,147 @@
<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 BaseEmptyState from '@/components/ui/BaseEmptyState.vue'
import AdminSelect from '@/components/ui/AdminSelect.vue'
import { customersApi, productsApi, suggestionsApi, type ProductSuggestion } from '@/services/api'
import { ApiError } from '@/services/http'
import { useToast } from '@/composables/useToast'
import type { Customer, Product } from '@/types'
const list = ref<ProductSuggestion[]>([])
const loading = ref(true)
const customers = ref<Customer[]>([])
const products = ref<Product[]>([])
const modalOpen = ref(false)
const toast = useToast()
const form = reactive({ customerId: '', productId: '', note: '' })
const customerOptions = computed(() =>
customers.value.map((c) => ({
value: c.id,
label: `${c.firstName} ${c.lastName}`.trim() || c.phone,
})),
)
const productOptions = computed(() =>
products.value.map((p) => ({ value: p.id, label: `${p.name} (${p.productCode || p.code})` })),
)
async function load() {
loading.value = true
try {
const [suggestions, customerRows, productRows] = await Promise.all([
suggestionsApi.list(),
customersApi.list(),
productsApi.list(),
])
list.value = suggestions
customers.value = customerRows
products.value = productRows
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'بارگذاری ناموفق بود')
} finally {
loading.value = false
}
}
async function save() {
try {
await suggestionsApi.create({
customerId: form.customerId,
productId: form.productId,
note: form.note.trim() || undefined,
})
modalOpen.value = false
form.note = ''
toast.success('پیشنهاد ثبت شد')
await load()
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'ثبت ناموفق بود')
}
}
async function toggle(row: ProductSuggestion) {
try {
await suggestionsApi.setActive(row.id, !row.isActive)
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>
<p>پیشنهاد دستی محصول به مشتری</p>
</div>
<button type="button" class="admin-btn admin-btn--primary" @click="modalOpen = true">پیشنهاد جدید</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="row in list" :key="row.id">
<td>{{ row.customerName }}</td>
<td>{{ row.productName }} <span class="muted" dir="ltr">({{ row.productCode }})</span></td>
<td>{{ row.note || '—' }}</td>
<td>{{ row.isActive ? 'فعال' : 'غیرفعال' }}</td>
<td>
<button type="button" class="link" @click="toggle(row)">
{{ row.isActive ? 'غیرفعال' : 'فعال' }}
</button>
</td>
</tr>
</tbody>
</table>
<BaseModal :open="modalOpen" title="پیشنهاد محصول" @close="modalOpen = false">
<label class="field">مشتری
<AdminSelect v-model="form.customerId" :options="customerOptions" />
</label>
<label class="field">محصول
<AdminSelect v-model="form.productId" :options="productOptions" />
</label>
<label class="field">یادداشت<textarea v-model="form.note" class="admin-input" rows="3" /></label>
<template #footer>
<button
type="button"
class="admin-btn admin-btn--primary"
:disabled="!form.customerId || !form.productId"
@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; }
.muted { color: var(--color-text-muted); font-size: 0.75rem; }
.link { background: none; border: none; color: var(--color-primary); cursor: pointer; font-family: inherit; }
</style>
+321
View File
@@ -0,0 +1,321 @@
<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 ActiveSwitchCell from '@/components/ui/ActiveSwitchCell.vue'
import AdminIcon from '@/components/icons/AdminIcon.vue'
import AdminSelect from '@/components/ui/AdminSelect.vue'
import { adminUsersApi } from '@/services/api'
import { ApiError } from '@/services/http'
import { useToast } from '@/composables/useToast'
import { USER_ROLE_LABELS } from '@/utils/adminLabels'
import { formatDateTime } from '@/utils/format'
import type { AdminUser, UserRole } from '@/types'
const userList = ref<AdminUser[]>([])
const searchQuery = ref('')
const loading = ref(true)
const modalMode = ref<'add' | 'edit' | null>(null)
const editingId = ref<string | null>(null)
const formError = ref('')
const saving = ref(false)
const toast = useToast()
const roleOptions = [
{ value: 'admin', label: 'مدیر سیستم' },
{ value: 'sales', label: 'واحد فروش' },
{ value: 'finance', label: 'واحد مالی' },
{ value: 'warehouse', label: 'انبار' },
]
const emptyForm = () => ({
fullName: '',
phone: '',
email: '',
role: 'sales' as UserRole,
isActive: true,
})
const form = reactive(emptyForm())
const isModalOpen = computed(() => modalMode.value !== null)
const modalTitle = computed(() => (modalMode.value === 'edit' ? 'ویرایش کاربر' : 'افزودن کاربر'))
const isFormValid = computed(() => form.fullName.trim() && form.phone.trim())
const filteredUsers = computed(() => {
const q = searchQuery.value.trim()
if (!q) return userList.value
return userList.value.filter(
(u) =>
u.fullName.includes(q) ||
u.phone.includes(q) ||
u.email.includes(q) ||
USER_ROLE_LABELS[u.role].includes(q),
)
})
async function loadUsers() {
loading.value = true
try {
userList.value = await adminUsersApi.list()
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'بارگذاری کاربران انجام نشد.')
} finally {
loading.value = false
}
}
onMounted(() => {
void loadUsers()
})
function openAddModal() {
modalMode.value = 'add'
editingId.value = null
Object.assign(form, emptyForm())
formError.value = ''
}
function openEditModal(user: AdminUser) {
modalMode.value = 'edit'
editingId.value = user.id
form.fullName = user.fullName
form.phone = user.phone
form.email = user.email
form.role = user.role
form.isActive = user.isActive
formError.value = ''
}
function closeModal() {
modalMode.value = null
editingId.value = null
formError.value = ''
}
async function submitUser() {
if (!isFormValid.value) {
formError.value = 'نام و تلفن الزامی هستند.'
return
}
saving.value = true
const payload = {
fullName: form.fullName.trim(),
phone: form.phone.trim(),
email: form.email.trim() || undefined,
role: form.role,
isActive: form.isActive,
}
try {
if (modalMode.value === 'edit' && editingId.value) {
const updated = await adminUsersApi.update(editingId.value, payload)
const index = userList.value.findIndex((u) => u.id === editingId.value)
if (index !== -1) userList.value[index] = { ...userList.value[index]!, ...updated }
} else {
const created = await adminUsersApi.create(payload)
userList.value.push(created)
}
toast.success(modalMode.value === 'edit' ? 'اطلاعات کاربر به‌روزرسانی شد.' : 'کاربر جدید ثبت شد.')
closeModal()
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'ذخیره کاربر انجام نشد.')
} finally {
saving.value = false
}
}
async function persistActive(id: string, isActive: boolean) {
await adminUsersApi.setActive(id, isActive)
}
function clearSearch() {
searchQuery.value = ''
}
</script>
<template>
<div class="users-page">
<div class="admin-page-header">
<div>
<h1 class="admin-page-header__title">کاربران و دسترسی‌ها</h1>
</div>
<button type="button" class="admin-btn admin-btn--primary" @click="openAddModal">
افزودن کاربر
</button>
</div>
<div class="users-page__search admin-card">
<AdminIcon name="search" :size="18" />
<input
v-model="searchQuery"
type="search"
class="users-page__search-input"
placeholder="جستجو در نام، تلفن، ایمیل..."
/>
</div>
<AdminPageSkeleton v-if="loading" variant="table" />
<BaseEmptyState
v-else-if="!filteredUsers.length"
class="admin-card"
icon="users"
:title="searchQuery.trim() ? 'نتیجه‌ای یافت نشد' : 'کاربری ثبت نشده'"
:description="
searchQuery.trim()
? 'عبارت جستجو را تغییر دهید یا فیلترها را پاک کنید.'
: 'برای شروع، اولین کاربر را ثبت کنید.'
"
>
<button v-if="searchQuery.trim()" type="button" class="admin-btn admin-btn--outline admin-btn--sm" @click="clearSearch">
پاک کردن جستجو
</button>
<button v-else type="button" class="admin-btn admin-btn--primary admin-btn--sm" @click="openAddModal">
افزودن کاربر
</button>
</BaseEmptyState>
<div v-else class="users-page__table admin-card desktop-only">
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>نام</th>
<th>تلفن</th>
<th>ایمیل</th>
<th>نقش</th>
<th class="status">وضعیت</th>
<th>آخرین ورود</th>
<th class="actions">عملیات</th>
</tr>
</thead>
<tbody>
<tr v-for="user in filteredUsers" :key="user.id">
<td>{{ user.fullName }}</td>
<td>{{ user.phone }}</td>
<td dir="ltr">{{ user.email }}</td>
<td>{{ USER_ROLE_LABELS[user.role] }}</td>
<td class="status">
<ActiveSwitchCell
:id="user.id"
v-model="user.isActive"
:entity-name="user.fullName"
entity-type="کاربر"
:persist="persistActive"
/>
</td>
<td>{{ user.lastLoginAt ? formatDateTime(user.lastLoginAt) : '—' }}</td>
<td class="actions">
<button type="button" class="admin-btn admin-btn--ghost admin-btn--xs" @click="openEditModal(user)">
ویرایش
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-if="!loading && filteredUsers.length" class="compact-only admin-card-list">
<article v-for="user in filteredUsers" :key="user.id" class="admin-card-item">
<div class="admin-card-item__header">
<p class="admin-card-item__title">{{ user.fullName }}</p>
<ActiveSwitchCell
:id="user.id"
v-model="user.isActive"
:entity-name="user.fullName"
entity-type="کاربر"
:persist="persistActive"
/>
</div>
<div class="admin-card-item__meta">
<span>{{ USER_ROLE_LABELS[user.role] }}</span>
<span>{{ user.phone }}</span>
<span dir="ltr">{{ user.email }}</span>
</div>
<div class="admin-card-item__actions">
<button type="button" class="admin-btn admin-btn--ghost admin-btn--xs" @click="openEditModal(user)">
ویرایش
</button>
</div>
</article>
</div>
<BaseModal :open="isModalOpen" :title="modalTitle" size="lg" @close="closeModal">
<form class="admin-form" @submit.prevent="submitUser">
<p v-if="formError" class="admin-form__error" role="alert">{{ formError }}</p>
<div class="admin-form__field">
<label for="user-name">نام کامل <span class="admin-form__required">*</span></label>
<input id="user-name" v-model="form.fullName" type="text" class="admin-input" />
</div>
<div class="admin-form__row">
<div class="admin-form__field">
<label for="user-phone">تلفن <span class="admin-form__required">*</span></label>
<input
id="user-phone"
v-model="form.phone"
type="tel"
class="admin-input"
inputmode="tel"
dir="ltr"
/>
</div>
<div class="admin-form__field">
<label for="user-email">ایمیل</label>
<input id="user-email" v-model="form.email" type="email" class="admin-input" dir="ltr" />
</div>
</div>
<div class="admin-form__field">
<label>نقش</label>
<AdminSelect
v-model="form.role"
block
aria-label="نقش کاربر"
:options="roleOptions"
/>
</div>
<div class="admin-form__field">
<BaseSwitch v-model="form.isActive" label="وضعیت" />
</div>
</form>
<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="submitUser">
<span v-if="saving" class="admin-btn__spinner" aria-hidden="true" />
{{ modalMode === 'edit' ? 'ذخیره تغییرات' : 'ثبت کاربر' }}
</button>
</template>
</BaseModal>
</div>
</template>
<style scoped>
.users-page__search {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
margin-bottom: 16px;
color: var(--color-text-muted);
}
.users-page__search-input {
flex: 1;
border: none;
background: transparent;
font-size: 0.875rem;
outline: none;
}
.users-page__table {
overflow: hidden;
}
</style>