Add AdminSelect, polish tables, and enable order detail print.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-09 16:14:15 +02:00
co-authored by Cursor
parent f0e4bddd52
commit 3848617108
12 changed files with 560 additions and 107 deletions
+68 -2
View File
@@ -38,7 +38,7 @@
.admin-table th {
padding: 11px 16px;
text-align: right;
text-align: center;
font-weight: 600;
font-size: 0.8125rem;
color: var(--color-text-muted);
@@ -49,6 +49,7 @@
.admin-table td {
padding: 13px 16px;
text-align: center;
border-bottom: 1px solid var(--color-border-light);
vertical-align: middle;
color: var(--color-text);
@@ -69,7 +70,7 @@
.admin-table .col-numeric,
.admin-table td.numeric,
.admin-table th.numeric {
text-align: left;
text-align: center;
font-variant-numeric: tabular-nums;
}
@@ -319,6 +320,19 @@
color: var(--color-text);
}
select.admin-input {
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.75' d='m7 10 5 5 5-5'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: left 12px center;
padding-inline: 14px 36px;
cursor: pointer;
}
html.dark select.admin-input {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='%94a3b8' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.75' d='m7 10 5 5 5-5'/%3E%3C/svg%3E");
}
.admin-input:focus {
outline: none;
border-color: var(--color-primary);
@@ -633,3 +647,55 @@
grid-template-columns: 1fr 1fr;
gap: 12px;
}
@media print {
@page {
margin: 12mm;
}
html.printing-order-detail body > *:not(.admin-modal-backdrop) {
display: none !important;
}
html.printing-order-detail .admin-modal-backdrop {
position: static !important;
inset: auto !important;
z-index: auto !important;
display: block !important;
padding: 0 !important;
background: transparent !important;
}
html.printing-order-detail .admin-modal {
width: 100% !important;
max-height: none !important;
overflow: visible !important;
border-radius: 0 !important;
box-shadow: none !important;
background: #fff !important;
color: #111 !important;
}
html.printing-order-detail .admin-modal__header {
border-bottom-color: #ddd !important;
padding: 0 0 12px !important;
}
html.printing-order-detail .admin-modal__close,
html.printing-order-detail .admin-modal__footer,
html.printing-order-detail .no-print,
html.printing-order-detail .orders-page__modal-breadcrumb,
html.printing-order-detail .compact-orders__modal-breadcrumb {
display: none !important;
}
html.printing-order-detail .admin-modal__body {
overflow: visible !important;
max-height: none !important;
padding: 0 !important;
}
html.printing-order-detail .admin-modal__title {
color: #111 !important;
}
}
+227
View File
@@ -0,0 +1,227 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { onClickOutside } from '@vueuse/core'
import AdminIcon from '@/components/icons/AdminIcon.vue'
export type AdminSelectOption = {
value: string
label: string
}
const props = withDefaults(
defineProps<{
modelValue: string
options: AdminSelectOption[]
placeholder?: string
ariaLabel?: string
block?: boolean
}>(),
{
placeholder: 'انتخاب کنید',
ariaLabel: 'انتخاب',
block: false,
},
)
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const open = ref(false)
const rootRef = ref<HTMLElement | null>(null)
onClickOutside(rootRef, () => {
open.value = false
})
const selectedLabel = computed(
() => props.options.find((option) => option.value === props.modelValue)?.label ?? props.placeholder,
)
function toggle() {
open.value = !open.value
}
function pick(value: string) {
emit('update:modelValue', value)
open.value = false
}
</script>
<template>
<div
ref="rootRef"
class="admin-select"
:class="{ 'admin-select--open': open, 'admin-select--block': block }"
>
<button
type="button"
class="admin-select__trigger"
:aria-label="ariaLabel"
:aria-expanded="open"
aria-haspopup="listbox"
@click="toggle"
>
<span class="admin-select__value">{{ selectedLabel }}</span>
<span class="admin-select__chevron" aria-hidden="true">
<AdminIcon name="chevron-down" :size="16" />
</span>
</button>
<ul
v-if="open"
class="admin-select__menu"
role="listbox"
:aria-label="ariaLabel"
>
<li v-for="option in options" :key="option.value" role="presentation">
<button
type="button"
class="admin-select__option"
:class="{ 'admin-select__option--active': option.value === modelValue }"
role="option"
:aria-selected="option.value === modelValue"
@click="pick(option.value)"
>
<span>{{ option.label }}</span>
<AdminIcon
v-if="option.value === modelValue"
name="check"
:size="16"
class="admin-select__check"
/>
</button>
</li>
</ul>
</div>
</template>
<style scoped>
.admin-select {
position: relative;
min-width: 200px;
max-width: 100%;
}
.admin-select--block {
width: 100%;
min-width: 0;
}
.admin-select__trigger {
width: 100%;
min-height: var(--control-height, 40px);
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 0 12px 0 10px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-text);
font-family: inherit;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition:
border-color var(--transition-fast),
box-shadow var(--transition-fast),
background var(--transition-fast);
}
.admin-select__trigger:hover {
border-color: var(--color-primary);
background: var(--color-elevated, var(--color-surface));
}
.admin-select--open .admin-select__trigger,
.admin-select__trigger:focus-visible {
outline: none;
border-color: var(--color-primary);
box-shadow: var(--focus-ring);
}
.admin-select__value {
flex: 1;
min-width: 0;
text-align: start;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-select__chevron {
display: inline-flex;
flex-shrink: 0;
color: var(--color-text-muted);
transition: transform var(--transition-fast);
}
.admin-select--open .admin-select__chevron {
transform: rotate(180deg);
color: var(--color-primary);
}
.admin-select__menu {
position: absolute;
inset-inline-start: 0;
top: calc(100% + 6px);
z-index: 40;
width: max(100%, 220px);
margin: 0;
padding: 8px;
list-style: none;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-card, 0 12px 28px rgba(15, 23, 42, 0.12));
display: flex;
flex-direction: column;
gap: 4px;
}
.admin-select__option {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px 12px;
border: 1px solid transparent;
border-radius: 12px;
background: transparent;
color: var(--color-text);
font-family: inherit;
font-size: 0.875rem;
font-weight: 500;
text-align: start;
cursor: pointer;
transition:
background var(--transition-fast),
border-color var(--transition-fast),
color var(--transition-fast);
}
.admin-select__option:hover {
background: var(--color-bg);
border-color: var(--color-border);
}
.admin-select__option--active {
background: var(--color-primary-soft);
border-color: transparent;
color: var(--color-primary);
font-weight: 600;
}
.admin-select__option--active:hover {
background: var(--color-primary-soft);
border-color: transparent;
}
.admin-select__check {
flex-shrink: 0;
color: var(--color-primary);
}
</style>
+21 -7
View File
@@ -2,6 +2,7 @@
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import AdminIcon from '@/components/icons/AdminIcon.vue'
import AdminSelect from '@/components/ui/AdminSelect.vue'
import { cashPaymentsApi } from '@/services/api'
import { ApiError } from '@/services/http'
import {
@@ -19,6 +20,14 @@ const statusFilter = ref<'all' | CashPayment['status']>('all')
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') {
@@ -93,13 +102,12 @@ async function rejectPayment(payment: CashPayment) {
placeholder="جستجو در شماره سفارش، مشتری، پیگیری..."
/>
</div>
<select v-model="statusFilter" class="admin-input">
<option value="all">همه وضعیت‌ها</option>
<option value="pending_review">در انتظار تأیید</option>
<option value="approved">تأیید شده</option>
<option value="rejected">رد شده</option>
<option value="expired">منقضی‌شده</option>
</select>
<AdminSelect
v-model="statusFilter"
class="cash-payments-page__status-select"
aria-label="فیلتر وضعیت پرداخت"
:options="statusFilterOptions"
/>
</div>
<div class="cash-payments-page__table admin-card desktop-only">
@@ -221,6 +229,12 @@ async function rejectPayment(payment: CashPayment) {
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 {
+24 -12
View File
@@ -5,6 +5,7 @@ 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'
@@ -20,6 +21,15 @@ 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: '',
@@ -173,12 +183,12 @@ function clearFilters() {
placeholder="جستجو بر اساس کد یا نام..."
/>
</div>
<select v-model="companyFilter" class="admin-input catalogs-page__company-filter">
<option value="all">همه شرکت‌ها</option>
<option v-for="company in companies" :key="company.id" :value="company.id">
{{ company.name }}
</option>
</select>
<AdminSelect
v-model="companyFilter"
class="catalogs-page__company-filter"
aria-label="فیلتر شرکت"
:options="companyFilterOptions"
/>
</div>
<BaseEmptyState
@@ -271,12 +281,14 @@ function clearFilters() {
<p v-if="formError" class="admin-form__error" role="alert">{{ formError }}</p>
<div class="admin-form__field">
<label for="catalog-company">شرکت <span class="admin-form__required">*</span></label>
<select id="catalog-company" v-model="form.companyId" class="admin-input">
<option v-for="company in companies" :key="company.id" :value="company.id">
{{ company.name }}
</option>
</select>
<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">
+20 -6
View File
@@ -2,6 +2,7 @@
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import AdminIcon from '@/components/icons/AdminIcon.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'
@@ -17,6 +18,13 @@ 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') {
@@ -125,12 +133,12 @@ async function updateCheckStatus(check: Check, status: Check['status']) {
placeholder="جستجو در شماره سفارش، مشتری، بانک، صیاد..."
/>
</div>
<select v-model="statusFilter" class="admin-input">
<option value="all">همه وضعیت‌ها</option>
<option value="pending_review">در انتظار بررسی</option>
<option value="approved">تأیید شده</option>
<option value="rejected">رد شده</option>
</select>
<AdminSelect
v-model="statusFilter"
class="checks-list-page__status-select"
aria-label="فیلتر وضعیت چک"
:options="statusFilterOptions"
/>
</div>
<div class="checks-list-page__table admin-card desktop-only">
@@ -293,6 +301,12 @@ async function updateCheckStatus(check: Check, status: Check['status']) {
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 {
+42 -19
View File
@@ -5,6 +5,7 @@ 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 { customersApi } from '@/services/api'
import { ApiError } from '@/services/http'
import { useToast } from '@/composables/useToast'
@@ -20,6 +21,24 @@ 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 emptyForm = () => ({
name: '',
phone: '',
@@ -154,7 +173,7 @@ function clearSearch() {
v-if="!filteredCustomers.length"
class="admin-card"
icon="customers"
:title="searchQuery.trim() ? 'نتیجه‌ای یافت نشد' : 'مشتری‌ای ثبت نشده'"
:title="searchQuery.trim() ? 'نتیجه‌ای یافت نشد' : 'مشتریان ثبت شده'"
:description="
searchQuery.trim()
? 'عبارت جستجو را تغییر دهید یا فیلترها را پاک کنید.'
@@ -188,13 +207,13 @@ function clearSearch() {
</thead>
<tbody>
<tr v-for="customer in filteredCustomers" :key="customer.id">
<td>{{ customer.name }}</td>
<td>{{ customer.phone }}</td>
<td>{{ customer.nationalId }}</td>
<td>{{ CUSTOMER_TYPE_LABELS[customer.customerType] }}</td>
<td>{{ customer.companyName }}</td>
<td class="numeric">{{ customer.orderCount }}</td>
<td class="numeric">{{ formatMeterage(customer.totalMeterage) }}</td>
<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.companyName) }}</td>
<td class="numeric">{{ displayCell(customer.orderCount) }}</td>
<td class="numeric">{{ displayMeterage(customer.totalMeterage) }}</td>
<td class="status">
<ActiveSwitchCell
:id="customer.id"
@@ -204,7 +223,7 @@ function clearSearch() {
:persist="persistActive"
/>
</td>
<td>{{ formatDateTime(customer.registeredAt) }}</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)">
ویرایش
@@ -219,7 +238,7 @@ function clearSearch() {
<div v-if="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">{{ customer.name }}</p>
<p class="admin-card-item__title">{{ displayCell(customer.name) }}</p>
<div class="admin-card-item__header-actions">
<ActiveSwitchCell
:id="customer.id"
@@ -234,10 +253,12 @@ function clearSearch() {
</div>
</div>
<div class="admin-card-item__meta">
<span>{{ customer.phone }}</span>
<span>{{ CUSTOMER_TYPE_LABELS[customer.customerType] }}</span>
<span>{{ customer.companyName }}</span>
<span>{{ customer.orderCount }} سفارش · {{ formatMeterage(customer.totalMeterage) }}</span>
<span>{{ displayCell(customer.phone) }}</span>
<span>{{ displayCell(CUSTOMER_TYPE_LABELS[customer.customerType]) }}</span>
<span>{{ displayCell(customer.companyName) }}</span>
<span>
{{ displayCell(customer.orderCount) }} سفارش · {{ displayMeterage(customer.totalMeterage) }}
</span>
</div>
</article>
</div>
@@ -264,11 +285,13 @@ function clearSearch() {
<div class="admin-form__row">
<div class="admin-form__field">
<label for="customer-type">نوع مشتری</label>
<select id="customer-type" v-model="form.customerType" class="admin-input">
<option value="wholesale">عمده‌فروش</option>
<option value="retail">خرده‌فروش</option>
</select>
<label>نوع مشتری</label>
<AdminSelect
v-model="form.customerType"
block
aria-label="نوع مشتری"
:options="customerTypeOptions"
/>
</div>
<div class="admin-form__field">
<label for="customer-company">شرکت مرتبط</label>
@@ -92,6 +92,18 @@ function handleCashReceipt() {
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
@@ -284,7 +296,11 @@ function runMenuAction(action: 'detail' | 'pricing' | 'checks' | 'cash') {
<AdminIcon name="sms" :size="16" />
ارسال پیامک
</button>
<button type="button" class="admin-btn admin-btn--outline admin-btn--sm">
<button
type="button"
class="admin-btn admin-btn--outline admin-btn--sm"
@click="printOrderDetail"
>
<AdminIcon name="print" :size="16" />
چاپ
</button>
+26 -11
View File
@@ -1,5 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import BaseModal from '@/components/ui/BaseModal.vue'
import AdminSelect from '@/components/ui/AdminSelect.vue'
import { useOrdersPageContext } from '@/composables/useOrdersPage'
const {
@@ -11,6 +13,15 @@ const {
closeAddModal,
submitAddOrder,
} = useOrdersPageContext()
const companyOptions = computed(() =>
companies.value.map((company) => ({ value: company.id, label: company.name })),
)
const settlementOptions = [
{ value: 'check', label: 'چکی' },
{ value: 'cash', label: 'نقدی' },
]
</script>
<template>
@@ -31,19 +42,23 @@ const {
<div class="admin-form__row">
<div class="admin-form__field">
<label for="order-company">شرکت <span class="admin-form__required">*</span></label>
<select id="order-company" v-model="form.companyId" class="admin-input">
<option v-for="company in companies" :key="company.id" :value="company.id">
{{ company.name }}
</option>
</select>
<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 for="order-settlement">روش تسویه</label>
<select id="order-settlement" v-model="form.settlementMethod" class="admin-input">
<option value="check">چکی</option>
<option value="cash">نقدی</option>
</select>
<label>روش تسویه</label>
<AdminSelect
v-model="form.settlementMethod"
block
aria-label="روش تسویه"
:options="settlementOptions"
/>
</div>
</div>
@@ -1,6 +1,7 @@
<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 { RAW_ORDER_STATUS_LABELS, rawStatusLabel } from '@/utils/orderStatus'
@@ -45,6 +46,14 @@ const statusOptions = computed<RawOrderStatus[]>(() =>
: [...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),
@@ -83,7 +92,7 @@ function submitStatus() {
<p class="order-detail__raw-status">{{ rawStatusLabel(order.rawStatus) }}</p>
</div>
<div class="order-detail__actions">
<div class="order-detail__actions no-print">
<button type="button" class="admin-btn admin-btn--primary admin-btn--sm" @click="emit('pricing')">
قیمت‌گذاری سفارش
</button>
@@ -204,7 +213,7 @@ function submitStatus() {
</ul>
</section>
<section class="order-detail__section order-detail__status">
<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">
@@ -222,13 +231,14 @@ function submitStatus() {
<div v-if="statusOptions.length" class="order-detail__status-form">
<div class="admin-form__field">
<label for="next-status">وضعیت بعدی</label>
<select id="next-status" v-model="nextStatus" class="admin-input">
<option value="">انتخاب کنید…</option>
<option v-for="status in statusOptions" :key="status" :value="status">
{{ rawStatusLabel(status) }}
</option>
</select>
<label>وضعیت بعدی</label>
<AdminSelect
v-model="nextStatus"
block
aria-label="وضعیت بعدی"
placeholder="انتخاب کنید…"
:options="statusSelectOptions"
/>
</div>
<div class="admin-form__field">
+30 -12
View File
@@ -5,6 +5,7 @@ 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'
@@ -22,6 +23,21 @@ 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: '',
@@ -198,12 +214,12 @@ function clearFilters() {
placeholder="جستجو در نام، رنگ، کد..."
/>
</div>
<select v-model="catalogFilter" class="admin-input products-page__catalog-filter">
<option value="all">همه کالیته‌ها</option>
<option v-for="catalog in catalogs" :key="catalog.id" :value="catalog.id">
{{ catalog.code }} — {{ catalog.name }}
</option>
</select>
<AdminSelect
v-model="catalogFilter"
class="products-page__catalog-filter"
aria-label="فیلتر کالیته"
:options="catalogFilterOptions"
/>
</div>
<BaseEmptyState
@@ -305,12 +321,14 @@ function clearFilters() {
<p v-if="formError" class="admin-form__error" role="alert">{{ formError }}</p>
<div class="admin-form__field">
<label for="product-catalog">کالیته <span class="admin-form__required">*</span></label>
<select id="product-catalog" v-model="form.catalogId" class="admin-input">
<option v-for="catalog in catalogs" :key="catalog.id" :value="catalog.id">
{{ catalog.code }} — {{ catalog.name }} ({{ catalog.companyName }})
</option>
</select>
<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">
+51 -21
View File
@@ -2,6 +2,7 @@
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
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'
@@ -20,6 +21,21 @@ 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: '',
@@ -55,6 +71,13 @@ const availableOrders = computed(() => {
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() {
try {
const [shipmentsData, ordersData] = await Promise.all([
@@ -156,13 +179,12 @@ async function advanceStatus(shipment: Shipment) {
placeholder="جستجو در سفارش، مشتری، باربری..."
/>
</div>
<select v-model="statusFilter" class="admin-input">
<option value="all">همه وضعیت‌ها</option>
<option value="preparing">در حال آماده‌سازی</option>
<option value="ready">آماده ارسال</option>
<option value="shipped">ارسال شده</option>
<option value="delivered">تحویل شده</option>
</select>
<AdminSelect
v-model="statusFilter"
class="shipping-page__status-select"
aria-label="فیلتر وضعیت ارسال"
:options="statusFilterOptions"
/>
</div>
<div class="shipping-page__table admin-card desktop-only">
@@ -239,13 +261,15 @@ async function advanceStatus(shipment: Shipment) {
<p v-if="formError" class="admin-form__error" role="alert">{{ formError }}</p>
<div class="admin-form__field">
<label for="ship-order">سفارش <span class="admin-form__required">*</span></label>
<select id="ship-order" v-model="form.orderId" class="admin-input" @change="onOrderChange">
<option value="" disabled>انتخاب سفارش</option>
<option v-for="order in availableOrders" :key="order.id" :value="order.id">
{{ order.orderNumber }} — {{ order.customerName }}
</option>
</select>
<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">
@@ -265,13 +289,13 @@ async function advanceStatus(shipment: Shipment) {
<input id="ship-meterage" v-model.number="form.meterage" type="number" min="1" class="admin-input" />
</div>
<div class="admin-form__field">
<label for="ship-status">وضعیت</label>
<select id="ship-status" v-model="form.status" class="admin-input">
<option value="preparing">در حال آماده‌سازی</option>
<option value="ready">آماده ارسال</option>
<option value="shipped">ارسال شده</option>
<option value="delivered">تحویل شده</option>
</select>
<label id="ship-status-label">وضعیت</label>
<AdminSelect
v-model="form.status"
block
aria-label="وضعیت ارسال"
:options="shipmentStatusOptions"
/>
</div>
</div>
</form>
@@ -316,6 +340,12 @@ async function advanceStatus(shipment: Shipment) {
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 {
+15 -7
View File
@@ -5,6 +5,7 @@ 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'
@@ -20,6 +21,13 @@ 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: '',
@@ -254,13 +262,13 @@ function clearSearch() {
</div>
<div class="admin-form__field">
<label for="user-role">نقش</label>
<select id="user-role" v-model="form.role" class="admin-input">
<option value="admin">مدیر سیستم</option>
<option value="sales">واحد فروش</option>
<option value="finance">واحد مالی</option>
<option value="warehouse">انبار</option>
</select>
<label>نقش</label>
<AdminSelect
v-model="form.role"
block
aria-label="نقش کاربر"
:options="roleOptions"
/>
</div>
<div class="admin-form__field">