Initial commit: fabric-trade-admin panel with responsive layouts and UI refinements.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
import { ref } from 'vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
export function mockPersistToggle(delayMs = 480): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
window.setTimeout(resolve, delayMs)
|
||||
})
|
||||
}
|
||||
|
||||
export function useAsyncToggle(options: {
|
||||
onPersist: (value: boolean) => Promise<void>
|
||||
successMessage?: (value: boolean) => string
|
||||
errorMessage?: string
|
||||
confirmOff?: () => Promise<boolean>
|
||||
}) {
|
||||
const loadingId = ref<string | null>(null)
|
||||
const errorId = ref<string | null>(null)
|
||||
const toast = useToast()
|
||||
|
||||
async function toggle(id: string, current: boolean): Promise<boolean> {
|
||||
if (loadingId.value) return current
|
||||
|
||||
const next = !current
|
||||
|
||||
if (!next && options.confirmOff) {
|
||||
const confirmed = await options.confirmOff()
|
||||
if (!confirmed) return current
|
||||
}
|
||||
|
||||
loadingId.value = id
|
||||
errorId.value = null
|
||||
const previous = current
|
||||
|
||||
try {
|
||||
await options.onPersist(next)
|
||||
toast.success(options.successMessage?.(next) ?? (next ? 'وضعیت فعال شد.' : 'وضعیت غیرفعال شد.'))
|
||||
return next
|
||||
} catch {
|
||||
errorId.value = id
|
||||
toast.error(options.errorMessage ?? 'بهروزرسانی وضعیت انجام نشد.')
|
||||
return previous
|
||||
} finally {
|
||||
loadingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function isLoading(id: string) {
|
||||
return loadingId.value === id
|
||||
}
|
||||
|
||||
function hasError(id: string) {
|
||||
return errorId.value === id
|
||||
}
|
||||
|
||||
return { toggle, isLoading, hasError }
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
export type BreakpointName =
|
||||
| 'xs'
|
||||
| 'sm'
|
||||
| 'md'
|
||||
| 'lg'
|
||||
| 'xl'
|
||||
| '2xl'
|
||||
|
||||
const BREAKPOINTS = {
|
||||
xs: 0,
|
||||
sm: 480,
|
||||
md: 768,
|
||||
lg: 1024,
|
||||
xl: 1280,
|
||||
'2xl': 1440,
|
||||
} as const
|
||||
|
||||
function getBreakpoint(width: number): BreakpointName {
|
||||
if (width >= BREAKPOINTS['2xl']) return '2xl'
|
||||
if (width >= BREAKPOINTS.xl) return 'xl'
|
||||
if (width >= BREAKPOINTS.lg) return 'lg'
|
||||
if (width >= BREAKPOINTS.md) return 'md'
|
||||
if (width >= BREAKPOINTS.sm) return 'sm'
|
||||
return 'xs'
|
||||
}
|
||||
|
||||
export function useBreakpoint() {
|
||||
const width = ref(typeof window !== 'undefined' ? window.innerWidth : 1280)
|
||||
const breakpoint = ref<BreakpointName>(getBreakpoint(width.value))
|
||||
|
||||
function update() {
|
||||
width.value = window.innerWidth
|
||||
breakpoint.value = getBreakpoint(width.value)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
update()
|
||||
window.addEventListener('resize', update, { passive: true })
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', update)
|
||||
})
|
||||
|
||||
const isDesktop = () => breakpoint.value === 'xl' || breakpoint.value === '2xl'
|
||||
const isTablet = () => breakpoint.value === 'md' || breakpoint.value === 'lg'
|
||||
const isMobile = () => breakpoint.value === 'xs' || breakpoint.value === 'sm'
|
||||
const isCompact = () => !isDesktop()
|
||||
|
||||
return {
|
||||
width,
|
||||
breakpoint,
|
||||
isDesktop,
|
||||
isTablet,
|
||||
isMobile,
|
||||
isCompact,
|
||||
BREAKPOINTS,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { reactive } from 'vue'
|
||||
|
||||
export interface ConfirmOptions {
|
||||
title: string
|
||||
message: string
|
||||
confirmLabel?: string
|
||||
cancelLabel?: string
|
||||
tone?: 'danger' | 'primary'
|
||||
}
|
||||
|
||||
interface ConfirmState extends ConfirmOptions {
|
||||
open: boolean
|
||||
loading: boolean
|
||||
resolve: ((value: boolean) => void) | null
|
||||
}
|
||||
|
||||
const state = reactive<ConfirmState>({
|
||||
open: false,
|
||||
loading: false,
|
||||
title: '',
|
||||
message: '',
|
||||
confirmLabel: 'تأیید',
|
||||
cancelLabel: 'انصراف',
|
||||
tone: 'primary',
|
||||
resolve: null,
|
||||
})
|
||||
|
||||
export function useConfirmState() {
|
||||
return state
|
||||
}
|
||||
|
||||
export function confirm(options: ConfirmOptions): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
Object.assign(state, {
|
||||
open: true,
|
||||
loading: false,
|
||||
title: options.title,
|
||||
message: options.message,
|
||||
confirmLabel: options.confirmLabel ?? 'تأیید',
|
||||
cancelLabel: options.cancelLabel ?? 'انصراف',
|
||||
tone: options.tone ?? 'primary',
|
||||
resolve,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function resolveConfirm(value: boolean) {
|
||||
state.resolve?.(value)
|
||||
state.open = false
|
||||
state.loading = false
|
||||
state.resolve = null
|
||||
}
|
||||
|
||||
export function setConfirmLoading(loading: boolean) {
|
||||
state.loading = loading
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
checkDeadlines,
|
||||
dashboardStats,
|
||||
orders,
|
||||
salesChartData,
|
||||
} from '@/data/mockData'
|
||||
|
||||
export const statCards = [
|
||||
{
|
||||
label: 'سفارشهای جدید',
|
||||
value: dashboardStats.newOrders,
|
||||
icon: 'cart',
|
||||
tone: 'info',
|
||||
link: '/orders',
|
||||
},
|
||||
{
|
||||
label: 'در انتظار بررسی',
|
||||
value: dashboardStats.pendingReview,
|
||||
icon: 'file',
|
||||
tone: 'warning',
|
||||
link: '/orders?tab=pending_review',
|
||||
},
|
||||
{
|
||||
label: 'در انتظار چک',
|
||||
value: dashboardStats.pendingCheck,
|
||||
icon: 'wallet',
|
||||
tone: 'purple',
|
||||
link: '/orders?tab=pending_check',
|
||||
},
|
||||
{
|
||||
label: 'تأیید نهایی شده',
|
||||
value: dashboardStats.finalApproval,
|
||||
icon: 'check-circle',
|
||||
tone: 'success',
|
||||
link: '/orders?tab=final_approval',
|
||||
},
|
||||
] as const
|
||||
|
||||
export const iconBg: Record<string, string> = {
|
||||
info: '#eef3ff',
|
||||
warning: '#fff6e5',
|
||||
purple: '#ede9fe',
|
||||
success: '#eaf8f1',
|
||||
}
|
||||
|
||||
export const iconColor: Record<string, string> = {
|
||||
info: '#17379f',
|
||||
warning: '#d97706',
|
||||
purple: '#7c3aed',
|
||||
success: '#159660',
|
||||
}
|
||||
|
||||
export function useDashboard() {
|
||||
const router = useRouter()
|
||||
|
||||
const recentOrders = computed(() => orders.slice(0, 5))
|
||||
|
||||
const chartPoints = computed(() => {
|
||||
const w = 280
|
||||
const h = 120
|
||||
const max = Math.max(...salesChartData)
|
||||
const step = w / (salesChartData.length - 1)
|
||||
return salesChartData
|
||||
.map((v, i) => {
|
||||
const x = i * step
|
||||
const y = h - (v / max) * (h - 10) - 5
|
||||
return `${x},${y}`
|
||||
})
|
||||
.join(' ')
|
||||
})
|
||||
|
||||
function goOrders() {
|
||||
router.push('/orders')
|
||||
}
|
||||
|
||||
return {
|
||||
checkDeadlines,
|
||||
dashboardStats,
|
||||
recentOrders,
|
||||
chartPoints,
|
||||
salesChartData,
|
||||
chartAxisLabels: ['۱', '۷', '۱۴', '۲۱', '۲۸'],
|
||||
goOrders,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { computed, inject, provide, reactive, ref, type InjectionKey } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { companies, orders as initialOrders } from '@/data/mockData'
|
||||
import { generateOrderNumber } from '@/utils/format'
|
||||
import type { Order, SettlementMethod } from '@/types'
|
||||
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
export function useOrdersPage() {
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
|
||||
const orderList = ref<Order[]>([...initialOrders])
|
||||
const activeTab = ref('all')
|
||||
const selectedOrderId = ref<string>('')
|
||||
const searchQuery = ref('')
|
||||
const showAddModal = ref(false)
|
||||
const showDetailPanel = ref(false)
|
||||
const showDetailModal = ref(false)
|
||||
const formError = ref('')
|
||||
|
||||
const emptyForm = () => ({
|
||||
customerName: '',
|
||||
companyId: companies[0]?.id ?? '',
|
||||
settlementMethod: 'check' as SettlementMethod,
|
||||
meterage: 1000,
|
||||
customerPhone: '',
|
||||
})
|
||||
|
||||
const form = reactive(emptyForm())
|
||||
|
||||
const isFormValid = computed(
|
||||
() => form.customerName.trim().length > 0 && form.companyId.length > 0,
|
||||
)
|
||||
|
||||
const filteredOrders = computed(() => {
|
||||
let list = orderList.value
|
||||
if (activeTab.value !== 'all') {
|
||||
list = list.filter((o) => o.status === activeTab.value)
|
||||
}
|
||||
if (searchQuery.value.trim()) {
|
||||
const q = searchQuery.value.trim()
|
||||
list = list.filter(
|
||||
(o) => o.orderNumber.includes(q) || o.customerName.includes(q),
|
||||
)
|
||||
}
|
||||
return list
|
||||
})
|
||||
|
||||
const selectedOrder = computed(() =>
|
||||
orderList.value.find((o) => o.id === selectedOrderId.value) ?? null,
|
||||
)
|
||||
|
||||
function selectOrder(order: Order, openDetail = false) {
|
||||
selectedOrderId.value = order.id
|
||||
if (openDetail) {
|
||||
showDetailPanel.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function openOrderDetail(order: Order) {
|
||||
selectedOrderId.value = order.id
|
||||
showDetailModal.value = true
|
||||
}
|
||||
|
||||
function closeDetailPanel() {
|
||||
showDetailPanel.value = false
|
||||
}
|
||||
|
||||
function closeDetailModal() {
|
||||
showDetailModal.value = false
|
||||
}
|
||||
|
||||
function settlementLabel(method: string) {
|
||||
return method === 'check' ? 'چکی' : 'نقدی'
|
||||
}
|
||||
|
||||
function goPricing(order: Order) {
|
||||
router.push(`/orders/${order.id}/pricing`)
|
||||
}
|
||||
|
||||
function goChecks(order: Order) {
|
||||
router.push(`/orders/${order.id}/checks`)
|
||||
}
|
||||
|
||||
function openAddModal() {
|
||||
Object.assign(form, emptyForm())
|
||||
formError.value = ''
|
||||
showAddModal.value = true
|
||||
}
|
||||
|
||||
function closeAddModal() {
|
||||
showAddModal.value = false
|
||||
formError.value = ''
|
||||
}
|
||||
|
||||
function submitAddOrder() {
|
||||
if (!isFormValid.value) {
|
||||
formError.value = 'نام مشتری و شرکت الزامی هستند.'
|
||||
return
|
||||
}
|
||||
|
||||
const company = companies.find((c) => c.id === form.companyId)
|
||||
const newOrder: Order = {
|
||||
id: `ord-${crypto.randomUUID().slice(0, 8)}`,
|
||||
orderNumber: generateOrderNumber(),
|
||||
customerName: form.customerName.trim(),
|
||||
companyName: company?.name ?? '—',
|
||||
companyId: form.companyId,
|
||||
settlementMethod: form.settlementMethod,
|
||||
totalAmount: null,
|
||||
meterage: form.meterage,
|
||||
status: 'pending_review',
|
||||
createdAt: new Date().toISOString(),
|
||||
customerPhone: form.customerPhone.trim() || undefined,
|
||||
items: [],
|
||||
}
|
||||
|
||||
orderList.value.unshift(newOrder)
|
||||
selectedOrderId.value = newOrder.id
|
||||
toast.success('سفارش جدید ثبت شد.')
|
||||
closeAddModal()
|
||||
}
|
||||
|
||||
return {
|
||||
companies,
|
||||
orderList,
|
||||
activeTab,
|
||||
selectedOrderId,
|
||||
searchQuery,
|
||||
showAddModal,
|
||||
showDetailPanel,
|
||||
showDetailModal,
|
||||
formError,
|
||||
form,
|
||||
isFormValid,
|
||||
filteredOrders,
|
||||
selectedOrder,
|
||||
selectOrder,
|
||||
openOrderDetail,
|
||||
closeDetailPanel,
|
||||
closeDetailModal,
|
||||
settlementLabel,
|
||||
goPricing,
|
||||
goChecks,
|
||||
openAddModal,
|
||||
closeAddModal,
|
||||
submitAddOrder,
|
||||
}
|
||||
}
|
||||
|
||||
export type OrdersPageContext = ReturnType<typeof useOrdersPage>
|
||||
|
||||
export const ORDERS_PAGE_KEY: InjectionKey<OrdersPageContext> = Symbol('orders-page')
|
||||
|
||||
export function provideOrdersPage() {
|
||||
const ctx = useOrdersPage()
|
||||
provide(ORDERS_PAGE_KEY, ctx)
|
||||
return ctx
|
||||
}
|
||||
|
||||
export function useOrdersPageContext() {
|
||||
const ctx = inject(ORDERS_PAGE_KEY)
|
||||
if (!ctx) throw new Error('Orders page context is missing')
|
||||
return ctx
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { reactive } from 'vue'
|
||||
|
||||
export type ToastTone = 'success' | 'error' | 'info' | 'warning'
|
||||
|
||||
export interface ToastItem {
|
||||
id: string
|
||||
message: string
|
||||
tone: ToastTone
|
||||
}
|
||||
|
||||
const state = reactive<{ items: ToastItem[] }>({
|
||||
items: [],
|
||||
})
|
||||
|
||||
function push(message: string, tone: ToastTone) {
|
||||
const id = crypto.randomUUID()
|
||||
state.items.push({ id, message, tone })
|
||||
window.setTimeout(() => dismiss(id), 3200)
|
||||
}
|
||||
|
||||
function dismiss(id: string) {
|
||||
state.items = state.items.filter((item) => item.id !== id)
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
return {
|
||||
toasts: state.items,
|
||||
dismiss,
|
||||
success: (message: string) => push(message, 'success'),
|
||||
error: (message: string) => push(message, 'error'),
|
||||
info: (message: string) => push(message, 'info'),
|
||||
warning: (message: string) => push(message, 'warning'),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user