Add AdminSelect, polish tables, and enable order detail print.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user