diff --git a/src/assets/styles/admin.css b/src/assets/styles/admin.css index 3b7de25..2e6a9c7 100644 --- a/src/assets/styles/admin.css +++ b/src/assets/styles/admin.css @@ -320,6 +320,15 @@ color: var(--color-text); } +.admin-input[type='number'], +.admin-input[type='tel'], +.admin-input[inputmode='numeric'], +.admin-input[inputmode='decimal'], +.admin-input[inputmode='tel'], +.admin-input[dir='ltr'] { + text-align: right; +} + 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"); diff --git a/src/assets/styles/responsive.css b/src/assets/styles/responsive.css index ebef51c..8744db9 100644 --- a/src/assets/styles/responsive.css +++ b/src/assets/styles/responsive.css @@ -181,7 +181,8 @@ .admin-detail-panel__header { flex-shrink: 0; - display: flex; + display: grid; + grid-template-columns: 40px 1fr 40px; align-items: center; gap: 12px; padding: 12px var(--page-padding); @@ -200,12 +201,23 @@ justify-content: center; color: var(--color-text); flex-shrink: 0; + grid-column: 1; + transition: + background var(--transition-fast), + border-color var(--transition-fast); +} + +.admin-detail-panel__back:hover { + background: var(--color-border-light); + border-color: var(--color-border); } .admin-detail-panel__title { margin: 0; font-size: 1rem; font-weight: 700; + text-align: center; + grid-column: 2; } .admin-detail-panel__body { diff --git a/src/components/icons/AdminIcon.vue b/src/components/icons/AdminIcon.vue index f499a58..cf6558d 100644 --- a/src/components/icons/AdminIcon.vue +++ b/src/components/icons/AdminIcon.vue @@ -39,6 +39,9 @@ const ICON_MAP: Record = { filter: 'vuesax-linear:filter', more: 'vuesax-linear:more', 'chevron-left': 'vuesax-linear:arrow-left-1', + 'chevron-right': 'vuesax-linear:arrow-right-1', + /** RTL back — same direction as user app IconChevronBack */ + 'chevron-back': 'vuesax-linear:arrow-right-1', 'chevron-down': 'vuesax-linear:arrow-down', print: 'vuesax-linear:printer', sms: 'vuesax-linear:sms', diff --git a/src/components/ui/AdminCurrencyInput.vue b/src/components/ui/AdminCurrencyInput.vue index 940c27f..37569a3 100644 --- a/src/components/ui/AdminCurrencyInput.vue +++ b/src/components/ui/AdminCurrencyInput.vue @@ -49,6 +49,7 @@ function onInput(event: Event) { type="text" inputmode="numeric" dir="ltr" + class="admin-currency-input" :class="inputClass" :value="displayValue" :placeholder="placeholder" @@ -56,3 +57,9 @@ function onInput(event: Event) { @input="onInput" /> + + diff --git a/src/components/ui/BaseModal.vue b/src/components/ui/BaseModal.vue index c44b4e8..f9f03a0 100644 --- a/src/components/ui/BaseModal.vue +++ b/src/components/ui/BaseModal.vue @@ -61,7 +61,9 @@ function onBackdropClick(event: MouseEvent) { align-items: center; justify-content: center; padding: 16px; - background: rgba(15, 23, 42, 0.45); + background: rgba(15, 23, 42, 0.52); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); } .admin-modal { diff --git a/src/components/ui/StatusBadge.vue b/src/components/ui/StatusBadge.vue index 5036b08..dc98966 100644 --- a/src/components/ui/StatusBadge.vue +++ b/src/components/ui/StatusBadge.vue @@ -3,7 +3,7 @@ import type { OrderStatus } from '@/types' import { ORDER_STATUS_META } from '@/utils/orderStatus' defineProps<{ - status: OrderStatus | 'active' | 'inactive' | 'pending_check_review' + status: OrderStatus | 'active' | 'inactive' | 'pending_check_review' | 'check_approved' | 'check_rejected' }>() /** فقط وضعیت‌هایی که سفارش نیستند */ @@ -11,6 +11,8 @@ const extraMeta: Record = { active: { label: 'فعال', tone: 'success' }, inactive: { label: 'غیرفعال', tone: 'neutral' }, pending_check_review: { label: 'در انتظار بررسی', tone: 'warning' }, + check_approved: { label: 'تأیید شده', tone: 'success' }, + check_rejected: { label: 'رد شده', tone: 'danger' }, } diff --git a/src/composables/useOrdersPage.ts b/src/composables/useOrdersPage.ts index 99fc9c1..b2336bc 100644 --- a/src/composables/useOrdersPage.ts +++ b/src/composables/useOrdersPage.ts @@ -1,8 +1,8 @@ import { computed, inject, onMounted, provide, reactive, ref, watch, type InjectionKey } from 'vue' import { useRoute, useRouter } from 'vue-router' -import { companiesApi, mapAdminOrder, ordersApi } from '@/services/api' +import { companiesApi, customersApi, mapAdminOrder, ordersApi } from '@/services/api' import { ApiError } from '@/services/http' -import type { Company, Order, RawOrderStatus, SettlementMethod } from '@/types' +import type { Company, Customer, Order, RawOrderStatus, SettlementMethod } from '@/types' import { rawStatusLabel } from '@/utils/orderStatus' import { useToast } from '@/composables/useToast' @@ -13,6 +13,7 @@ export function useOrdersPage() { const toast = useToast() const companies = ref([]) + const customers = ref([]) const orderList = ref([]) const activeTab = ref('all') const selectedOrderId = ref('') @@ -24,6 +25,7 @@ export function useOrdersPage() { const saving = ref(false) const emptyForm = () => ({ + customerId: '', customerName: '', companyId: companies.value[0]?.id ?? '', settlementMethod: 'check' as SettlementMethod, @@ -34,9 +36,18 @@ export function useOrdersPage() { const form = reactive(emptyForm()) const isFormValid = computed( - () => form.customerName.trim().length > 0 && form.companyId.length > 0, + () => form.customerId.length > 0 && form.companyId.length > 0, ) + const activeCustomers = computed(() => customers.value.filter((c) => c.isActive)) + + function applyCustomer(customerId: string) { + const customer = customers.value.find((c) => c.id === customerId) + form.customerId = customerId + form.customerName = customer?.name ?? '' + form.customerPhone = customer?.phone ?? '' + } + const filteredOrders = computed(() => { let list = orderList.value if (activeTab.value !== 'all') { @@ -89,12 +100,14 @@ export function useOrdersPage() { async function loadData() { try { - const [ordersData, companiesData] = await Promise.all([ + const [ordersData, companiesData, customersData] = await Promise.all([ ordersApi.list(), companiesApi.list(), + customersApi.list(), ]) orderList.value = ordersData.map((o) => mapAdminOrder(o) as Order) companies.value = companiesData + customers.value = customersData if (!form.companyId && companiesData[0]) { form.companyId = companiesData[0].id } @@ -196,7 +209,7 @@ export function useOrdersPage() { async function submitAddOrder() { if (!isFormValid.value) { - formError.value = 'نام مشتری و شرکت الزامی هستند.' + formError.value = 'انتخاب مشتری و شرکت الزامی است.' return } @@ -204,6 +217,8 @@ export function useOrdersPage() { try { const created = await ordersApi.create({ companyId: form.companyId, + customerId: form.customerId || undefined, + userId: form.customerId || undefined, customerName: form.customerName.trim(), customerPhone: form.customerPhone.trim() || undefined, settlementMethod: form.settlementMethod, @@ -223,6 +238,9 @@ export function useOrdersPage() { return { companies, + customers, + activeCustomers, + applyCustomer, orderList, activeTab, selectedOrderId, diff --git a/src/services/api.ts b/src/services/api.ts index 429e3f0..6911d03 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -211,6 +211,7 @@ export const customersApi = { export type AdminCreateOrderPayload = { companyId: string + customerId?: string customerName?: string customerPhone?: string userId?: string diff --git a/src/utils/orderStatus.ts b/src/utils/orderStatus.ts index 2d0f1e8..1da7c83 100644 --- a/src/utils/orderStatus.ts +++ b/src/utils/orderStatus.ts @@ -55,6 +55,25 @@ export function rawStatusLabel(status?: string): string { return RAW_ORDER_STATUS_LABELS[status as RawOrderStatus] ?? status } +/** وضعیت‌هایی که هنوز فاکتور صادر نشده و قیمت‌گذاری اقدام اصلی است. */ +export const PRE_INVOICE_RAW_STATUSES: RawOrderStatus[] = [ + 'cart', + 'submitted', + 'awaiting_review', + 'pricing', + 'needs_correction', +] + +const PRE_INVOICE_ADMIN_STATUSES: OrderStatus[] = ['pending_review', 'needs_correction'] + +export function canPriceOrder(order: { + status: OrderStatus + rawStatus?: RawOrderStatus +}): boolean { + if (order.rawStatus) return PRE_INVOICE_RAW_STATUSES.includes(order.rawStatus) + return PRE_INVOICE_ADMIN_STATUSES.includes(order.status) +} + export const ORDER_TABS = [ { key: 'all', label: 'همه' }, { key: 'pending_review', label: 'در انتظار بررسی' }, diff --git a/src/views/auth/LoginView.vue b/src/views/auth/LoginView.vue index 63a4bc0..ecf3403 100644 --- a/src/views/auth/LoginView.vue +++ b/src/views/auth/LoginView.vue @@ -61,6 +61,8 @@ async function submit() { class="admin-input login-page__input" placeholder="۰۹۱۲۱۱۱۱۲۲۲۲" autocomplete="username" + inputmode="tel" + dir="ltr" @input="auth.clearError()" /> diff --git a/src/views/checks/CheckManagementView.vue b/src/views/checks/CheckManagementView.vue index efd40ac..8beaac1 100644 --- a/src/views/checks/CheckManagementView.vue +++ b/src/views/checks/CheckManagementView.vue @@ -29,6 +29,24 @@ 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([ @@ -87,8 +105,8 @@ function goBack() { } function checkBadgeStatus(status: Check['status']) { - if (status === 'approved') return 'final_approval' - if (status === 'rejected') return 'needs_correction' + if (status === 'approved') return 'check_approved' + if (status === 'rejected') return 'check_rejected' return 'pending_check_review' } @@ -177,7 +195,7 @@ async function updateCheckStatus(check: Check, status: Check['status']) { } async function approveAll() { - const pending = orderChecks.value.filter((c) => c.status === 'pending_review') + const pending = pendingChecks.value if (!pending.length) { toast.error('چکی برای تأیید وجود ندارد.') return @@ -198,6 +216,34 @@ async function approveAll() { } 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 + } +} diff --git a/src/views/orders/components/DesktopOrdersPage.vue b/src/views/orders/components/DesktopOrdersPage.vue index 151ac0b..a5ae6f1 100644 --- a/src/views/orders/components/DesktopOrdersPage.vue +++ b/src/views/orders/components/DesktopOrdersPage.vue @@ -1,15 +1,18 @@ + + diff --git a/src/views/orders/components/OrderDetailContent.vue b/src/views/orders/components/OrderDetailContent.vue index 5f85860..7b4da96 100644 --- a/src/views/orders/components/OrderDetailContent.vue +++ b/src/views/orders/components/OrderDetailContent.vue @@ -4,8 +4,9 @@ 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' -import type { Order, RawOrderStatus } from '@/types' +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<{ @@ -61,6 +62,34 @@ const canSubmitStatus = computed( 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 = '' @@ -89,25 +118,35 @@ function submitStatus() { {{ order.orderNumber }} -

{{ rawStatusLabel(order.rawStatus) }}

+

+ {{ distinctRawStatusLabel }} +

-