Initial commit: fabric-trade-admin panel with responsive layouts and UI refinements.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-03 12:14:12 +02:00
co-authored by Cursor
commit a8501f4604
75 changed files with 14787 additions and 0 deletions
+56
View File
@@ -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 }
}
+61
View File
@@ -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,
}
}
+56
View File
@@ -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
}
+87
View File
@@ -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,
}
}
+166
View File
@@ -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
}
+34
View File
@@ -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'),
}
}