Initialize the admin app with banner management for app sections.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user