Initialize the admin app with banner management for app sections.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,628 @@
|
||||
import { apiRequest } from '@/services/http'
|
||||
import type {
|
||||
AdminNotification,
|
||||
AdminUser,
|
||||
BankAccount,
|
||||
CashPayment,
|
||||
Catalog,
|
||||
Check,
|
||||
CheckDeadline,
|
||||
Company,
|
||||
Customer,
|
||||
InternalMessage,
|
||||
Order,
|
||||
OrderItem,
|
||||
OrderStatus,
|
||||
OrderStatusHistoryItem,
|
||||
Product,
|
||||
RawOrderStatus,
|
||||
SettlementMethod,
|
||||
Shipment,
|
||||
ShipmentStatus,
|
||||
UserRole,
|
||||
} from '@/types'
|
||||
|
||||
/** Normalize admin list order status badges */
|
||||
export function mapAdminOrder<
|
||||
T extends {
|
||||
status: string
|
||||
adminStatus?: string
|
||||
allowedTransitions?: string[]
|
||||
settlementMethod?: string | null
|
||||
settlementType?: string | null
|
||||
meterage?: number
|
||||
totalMeterage?: number
|
||||
},
|
||||
>(
|
||||
order: T,
|
||||
): T & {
|
||||
status: OrderStatus
|
||||
rawStatus: RawOrderStatus
|
||||
allowedTransitions: RawOrderStatus[]
|
||||
settlementMethod: SettlementMethod | null
|
||||
meterage: number
|
||||
} {
|
||||
return {
|
||||
...order,
|
||||
status: (order.adminStatus ?? order.status) as OrderStatus,
|
||||
rawStatus: order.status as RawOrderStatus,
|
||||
allowedTransitions: (order.allowedTransitions ?? []) as RawOrderStatus[],
|
||||
settlementMethod: (order.settlementMethod ??
|
||||
order.settlementType ??
|
||||
null) as SettlementMethod | null,
|
||||
meterage: order.meterage ?? order.totalMeterage ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
export function slugifyCompanyName(name: string): string {
|
||||
const slug = name
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^a-z0-9-]/g, '')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
return slug || `c-${Date.now()}`
|
||||
}
|
||||
|
||||
/* ── Companies ─────────────────────────────────────────────── */
|
||||
|
||||
export type CreateCompanyPayload = {
|
||||
name: string
|
||||
slug: string
|
||||
description?: string
|
||||
logoUrl?: string | null
|
||||
displayOrder?: number
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
export type UpdateCompanyPayload = Partial<Omit<CreateCompanyPayload, 'slug'>> & {
|
||||
slug?: string
|
||||
}
|
||||
|
||||
export const companiesApi = {
|
||||
list: (activeOnly?: boolean) =>
|
||||
apiRequest<Company[]>('/companies', { query: { activeOnly: activeOnly ? 'true' : undefined } }),
|
||||
get: (id: string) => apiRequest<Company>(`/companies/${id}`),
|
||||
create: (body: CreateCompanyPayload) => apiRequest<Company>('/companies', { body }),
|
||||
update: (id: string, body: UpdateCompanyPayload) =>
|
||||
apiRequest<Company>(`/companies/${id}`, { method: 'PATCH', body }),
|
||||
setActive: (id: string, isActive: boolean) =>
|
||||
apiRequest<Company>(`/companies/${id}/active`, { method: 'PATCH', body: { isActive } }),
|
||||
}
|
||||
|
||||
/* ── Bank accounts ─────────────────────────────────────────── */
|
||||
|
||||
export type CreateBankAccountPayload = {
|
||||
bankName: string
|
||||
accountHolder: string
|
||||
accountNumber: string
|
||||
cardNumber: string
|
||||
iban: string
|
||||
displayOrder?: number
|
||||
isActive?: boolean
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export type UpdateBankAccountPayload = Partial<CreateBankAccountPayload>
|
||||
|
||||
export const bankAccountsApi = {
|
||||
listByCompany: (companyId: string) =>
|
||||
apiRequest<BankAccount[]>(`/companies/${companyId}/bank-accounts`),
|
||||
create: (companyId: string, body: CreateBankAccountPayload) =>
|
||||
apiRequest<BankAccount>(`/companies/${companyId}/bank-accounts`, { body }),
|
||||
update: (id: string, body: UpdateBankAccountPayload) =>
|
||||
apiRequest<BankAccount>(`/bank-accounts/${id}`, { method: 'PATCH', body }),
|
||||
setActive: (id: string, isActive: boolean) =>
|
||||
apiRequest<BankAccount>(`/bank-accounts/${id}/active`, { method: 'PATCH', body: { isActive } }),
|
||||
}
|
||||
|
||||
/* ── Catalogs ──────────────────────────────────────────────── */
|
||||
|
||||
export type CreateCatalogPayload = {
|
||||
companyId: string
|
||||
name: string
|
||||
code: string
|
||||
description?: string
|
||||
displayOrder?: number
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
export type UpdateCatalogPayload = Partial<Omit<CreateCatalogPayload, 'companyId'>>
|
||||
|
||||
export const catalogsApi = {
|
||||
list: (params?: { companyId?: string; activeOnly?: boolean }) =>
|
||||
apiRequest<Catalog[]>('/catalogs', {
|
||||
query: {
|
||||
companyId: params?.companyId,
|
||||
activeOnly: params?.activeOnly ? 'true' : undefined,
|
||||
},
|
||||
}),
|
||||
create: (body: CreateCatalogPayload) => apiRequest<Catalog>('/catalogs', { body }),
|
||||
update: (id: string, body: UpdateCatalogPayload) =>
|
||||
apiRequest<Catalog>(`/catalogs/${id}`, { method: 'PATCH', body }),
|
||||
setActive: (id: string, isActive: boolean) =>
|
||||
apiRequest<Catalog>(`/catalogs/${id}/active`, { method: 'PATCH', body: { isActive } }),
|
||||
}
|
||||
|
||||
/* ── Products ──────────────────────────────────────────────── */
|
||||
|
||||
export type CreateProductPayload = {
|
||||
companyId: string
|
||||
catalogId: string
|
||||
name: string
|
||||
code?: string
|
||||
productCode?: string
|
||||
color: string
|
||||
minMeterage?: number
|
||||
orderStep?: number
|
||||
orderMultiplier?: number
|
||||
price?: number | null
|
||||
basePrice?: number | null
|
||||
internalStock?: number | null
|
||||
stock?: number | null
|
||||
isActive?: boolean
|
||||
description?: string
|
||||
usageCategory?: string
|
||||
season?: string
|
||||
composition?: string | null
|
||||
weaveType?: string | null
|
||||
subtitle?: string | null
|
||||
rollMeters?: number
|
||||
widthCm?: number | null
|
||||
weightGrams?: number | null
|
||||
isNew?: boolean
|
||||
colorsJson?: string[]
|
||||
imageUrlsJson?: string[]
|
||||
tagsJson?: string[]
|
||||
}
|
||||
|
||||
export type UpdateProductPayload = Partial<Omit<CreateProductPayload, 'companyId'>>
|
||||
|
||||
export const productsApi = {
|
||||
list: (params?: { catalogId?: string; companyId?: string; activeOnly?: boolean }) =>
|
||||
apiRequest<Product[]>('/products', {
|
||||
query: {
|
||||
catalogId: params?.catalogId,
|
||||
companyId: params?.companyId,
|
||||
activeOnly: params?.activeOnly ? 'true' : undefined,
|
||||
},
|
||||
}),
|
||||
create: (body: CreateProductPayload) => apiRequest<Product>('/products', { body }),
|
||||
update: (id: string, body: UpdateProductPayload) =>
|
||||
apiRequest<Product>(`/products/${id}`, { method: 'PATCH', body }),
|
||||
setActive: (id: string, isActive: boolean) =>
|
||||
apiRequest<Product>(`/products/${id}/active`, { method: 'PATCH', body: { isActive } }),
|
||||
}
|
||||
|
||||
/* ── Customers ─────────────────────────────────────────────── */
|
||||
|
||||
export type CreateCustomerPayload = {
|
||||
name?: string
|
||||
firstName?: string
|
||||
lastName?: string
|
||||
phone: string
|
||||
nationalId?: string
|
||||
customerType?: Customer['customerType']
|
||||
companyId?: string | null
|
||||
isActive?: boolean
|
||||
levelDefId?: string | null
|
||||
assignedAgentId?: string | null
|
||||
appRole?: 'customer' | 'agent'
|
||||
agentCode?: string | null
|
||||
brandIds?: string[]
|
||||
}
|
||||
|
||||
export type UpdateCustomerPayload = Partial<CreateCustomerPayload>
|
||||
|
||||
export const customersApi = {
|
||||
list: (q?: string, appRole?: 'customer' | 'agent') =>
|
||||
apiRequest<Customer[]>('/admin/customers', { query: { q, appRole } }),
|
||||
create: (body: CreateCustomerPayload) => apiRequest<Customer>('/admin/customers', { body }),
|
||||
update: (id: string, body: UpdateCustomerPayload) =>
|
||||
apiRequest<Customer>(`/admin/customers/${id}`, { method: 'PATCH', body }),
|
||||
setActive: (id: string, isActive: boolean) =>
|
||||
apiRequest<Customer>(`/admin/customers/${id}/active`, { method: 'PATCH', body: { isActive } }),
|
||||
}
|
||||
|
||||
/* ── Orders ────────────────────────────────────────────────── */
|
||||
|
||||
export type AdminCreateOrderPayload = {
|
||||
companyId: string
|
||||
customerId?: string
|
||||
customerName?: string
|
||||
customerPhone?: string
|
||||
userId?: string
|
||||
settlementMethod?: SettlementMethod
|
||||
settlementType?: SettlementMethod
|
||||
meterage?: number
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export type OrderItemPricingPayload = {
|
||||
id: string
|
||||
confirmedMeterage?: number
|
||||
unitPrice?: number
|
||||
supplyPrice?: number
|
||||
}
|
||||
|
||||
export type UpdateOrderPricingPayload = {
|
||||
items: OrderItemPricingPayload[]
|
||||
shippingCost?: number
|
||||
note?: string
|
||||
}
|
||||
|
||||
export type ApiOrder = Order & {
|
||||
adminStatus?: string
|
||||
settlementType?: SettlementMethod | null
|
||||
totalMeterage?: number
|
||||
shippingCost?: number | null
|
||||
finalAmount?: number | null
|
||||
checkRassDays?: number | null
|
||||
checkRassDate?: string
|
||||
items?: OrderItem[]
|
||||
checks?: Check[]
|
||||
statusHistory?: OrderStatusHistoryItem[]
|
||||
/** وضعیتهای مجاز بعدی از دید ماشین وضعیت سرور */
|
||||
allowedTransitions?: string[]
|
||||
cashReceipt?: { id: string } | null
|
||||
}
|
||||
|
||||
export type UpdateOrderStatusPayload = {
|
||||
status: string
|
||||
note?: string
|
||||
reason?: string
|
||||
/** گذر خارج از ماشین وضعیت؛ سرور reason را اجباری میکند */
|
||||
force?: boolean
|
||||
}
|
||||
|
||||
export const ordersApi = {
|
||||
list: (params?: { status?: string; q?: string }) =>
|
||||
apiRequest<ApiOrder[]>('/admin/orders', { query: { status: params?.status, q: params?.q } }),
|
||||
get: (id: string) => apiRequest<ApiOrder>(`/admin/orders/${id}`),
|
||||
create: (body: AdminCreateOrderPayload) => apiRequest<ApiOrder>('/admin/orders', { body }),
|
||||
updatePricing: (id: string, body: UpdateOrderPricingPayload) =>
|
||||
apiRequest<ApiOrder>(`/admin/orders/${id}/pricing`, { method: 'PATCH', body }),
|
||||
updateStatus: (id: string, body: UpdateOrderStatusPayload) =>
|
||||
apiRequest<ApiOrder>(`/admin/orders/${id}/status`, { method: 'PATCH', body }),
|
||||
}
|
||||
|
||||
/* ── Checks ────────────────────────────────────────────────── */
|
||||
|
||||
export const checksApi = {
|
||||
list: (status?: Check['status']) =>
|
||||
apiRequest<Check[]>('/admin/checks', { query: { status } }),
|
||||
listByOrder: (orderId: string) =>
|
||||
apiRequest<Check[]>(`/admin/orders/${orderId}/checks`),
|
||||
updateStatus: (id: string, body: { status: Check['status']; note?: string }) =>
|
||||
apiRequest<Check>(`/admin/checks/${id}/status`, { method: 'PATCH', body }),
|
||||
}
|
||||
|
||||
/* ── Cash payments ─────────────────────────────────────────── */
|
||||
|
||||
export const cashPaymentsApi = {
|
||||
list: (status?: CashPayment['status']) =>
|
||||
apiRequest<CashPayment[]>('/admin/cash-payments', { query: { status } }),
|
||||
updateStatus: (id: string, body: { status: CashPayment['status']; note?: string }) =>
|
||||
apiRequest<CashPayment>(`/admin/cash-payments/${id}/status`, { method: 'PATCH', body }),
|
||||
}
|
||||
|
||||
/* ── Shipments ─────────────────────────────────────────────── */
|
||||
|
||||
export type CreateShipmentPayload = {
|
||||
orderId: string
|
||||
carrier: string
|
||||
trackingNumber?: string
|
||||
meterage?: number
|
||||
status?: ShipmentStatus
|
||||
}
|
||||
|
||||
export const shipmentsApi = {
|
||||
list: (status?: ShipmentStatus) =>
|
||||
apiRequest<Shipment[]>('/admin/shipments', { query: { status } }),
|
||||
create: (body: CreateShipmentPayload) => apiRequest<Shipment>('/admin/shipments', { body }),
|
||||
update: (id: string, body: Partial<CreateShipmentPayload>) =>
|
||||
apiRequest<Shipment>(`/admin/shipments/${id}`, { method: 'PATCH', body }),
|
||||
advanceStatus: (id: string, body?: { status?: ShipmentStatus; trackingNumber?: string }) =>
|
||||
apiRequest<Shipment>(`/admin/shipments/${id}/status`, { method: 'PATCH', body: body ?? {} }),
|
||||
}
|
||||
|
||||
/* ── Admin users ───────────────────────────────────────────── */
|
||||
|
||||
export type CreateAdminUserPayload = {
|
||||
fullName: string
|
||||
phone: string
|
||||
email?: string
|
||||
password?: string
|
||||
role?: UserRole
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
export type UpdateAdminUserPayload = Partial<CreateAdminUserPayload>
|
||||
|
||||
export const adminUsersApi = {
|
||||
list: () => apiRequest<AdminUser[]>('/admin/users'),
|
||||
create: (body: CreateAdminUserPayload) => apiRequest<AdminUser>('/admin/users', { body }),
|
||||
update: (id: string, body: UpdateAdminUserPayload) =>
|
||||
apiRequest<AdminUser>(`/admin/users/${id}`, { method: 'PATCH', body }),
|
||||
setActive: (id: string, isActive: boolean) =>
|
||||
apiRequest<AdminUser>(`/admin/users/${id}/active`, { method: 'PATCH', body: { isActive } }),
|
||||
}
|
||||
|
||||
/* ── Dashboard & reports ───────────────────────────────────── */
|
||||
|
||||
export type DashboardStats = {
|
||||
newOrders: number
|
||||
pendingReview: number
|
||||
pendingCheck: number
|
||||
finalApproval: number
|
||||
todaySales: number
|
||||
todaySalesGrowth: number
|
||||
monthSales: number
|
||||
}
|
||||
|
||||
export type DashboardResponse = {
|
||||
stats: DashboardStats
|
||||
recentOrders: ApiOrder[]
|
||||
salesChartData: number[]
|
||||
chartAxisLabels: string[]
|
||||
checkDeadlines: CheckDeadline[]
|
||||
notifications?: AdminNotification[]
|
||||
messages?: InternalMessage[]
|
||||
}
|
||||
|
||||
export type ReportsResponse = {
|
||||
reportSalesByCompany: {
|
||||
companyName: string
|
||||
orderCount: number
|
||||
totalAmount: number
|
||||
meterage: number
|
||||
}[]
|
||||
reportOrdersByStatus: { status: string; count: number }[]
|
||||
reportSalesByMonth?: {
|
||||
month: string
|
||||
orderCount: number
|
||||
totalAmount: number
|
||||
meterage: number
|
||||
}[]
|
||||
reportSalesByProduct?: {
|
||||
productId: string
|
||||
productName: string
|
||||
productCode: string
|
||||
orderCount: number
|
||||
meterage: number
|
||||
totalAmount: number
|
||||
}[]
|
||||
reportSalesByCustomer?: {
|
||||
customerName: string
|
||||
orderCount: number
|
||||
totalAmount: number
|
||||
meterage: number
|
||||
}[]
|
||||
reportCustomers?: {
|
||||
active: number
|
||||
inactive: number
|
||||
newLast30Days: number
|
||||
noPurchase: number
|
||||
noPurchase90Days: number
|
||||
withAgent: number
|
||||
}
|
||||
}
|
||||
|
||||
export const dashboardApi = {
|
||||
get: () => apiRequest<DashboardResponse>('/admin/dashboard'),
|
||||
reports: () => apiRequest<ReportsResponse>('/admin/reports'),
|
||||
}
|
||||
|
||||
/* ── System settings ──────────────────────────────────────── */
|
||||
|
||||
export type SystemSettings = {
|
||||
id: string
|
||||
maxMeteragePerInvoice: number
|
||||
cashInvoiceValidityHours: number
|
||||
defaultSettlementCheck: boolean
|
||||
defaultSettlementCash: boolean
|
||||
minOrderRollsPerProduct?: number
|
||||
showCatalogPublicPrice?: boolean
|
||||
allowAgentPricing?: boolean
|
||||
agentMinUnitPrice?: number | null
|
||||
smsOrderRegistered: boolean
|
||||
smsPricingReady: boolean
|
||||
smsPaymentReminder: boolean
|
||||
smsCheckReview: boolean
|
||||
smsShippingUpdate: boolean
|
||||
supportPhone: string | null
|
||||
supportEmail: string | null
|
||||
categoryImagesJson?: Record<string, string>
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type UpdateSettingsPayload = Partial<Omit<SystemSettings, 'id' | 'updatedAt'>>
|
||||
|
||||
export const settingsApi = {
|
||||
get: () => apiRequest<SystemSettings>('/admin/settings'),
|
||||
update: (body: UpdateSettingsPayload) =>
|
||||
apiRequest<SystemSettings>('/admin/settings', { method: 'PATCH', body }),
|
||||
}
|
||||
|
||||
/* ── Admin notifications ──────────────────────────────────── */
|
||||
|
||||
export const notificationsApi = {
|
||||
markRead: (id: string) =>
|
||||
apiRequest<{ ok: boolean }>(`/admin/notifications/${id}/read`, { method: 'PATCH' }),
|
||||
markAllRead: () =>
|
||||
apiRequest<{ ok: boolean }>('/admin/notifications/read-all', { method: 'PATCH' }),
|
||||
broadcast: (body: { title: string; body: string; customerIds?: string[]; tone?: string }) =>
|
||||
apiRequest<{ sent: number }>('/admin/notifications/broadcast', { method: 'POST', body }),
|
||||
}
|
||||
|
||||
/* ── Catalog requests (physical) ──────────────────────────── */
|
||||
|
||||
export type CatalogRequestRow = {
|
||||
id: string
|
||||
customerId: string
|
||||
customerName: string
|
||||
customerPhone: string
|
||||
status: string
|
||||
note?: string
|
||||
createdAt: string
|
||||
items: {
|
||||
id: string
|
||||
productId: string
|
||||
productName: string
|
||||
productCode: string
|
||||
quantity: number
|
||||
holderType: string
|
||||
holderCustomerId?: string | null
|
||||
movements?: {
|
||||
id: string
|
||||
fromHolder: string
|
||||
toHolder: string
|
||||
note?: string
|
||||
movedAt: string
|
||||
}[]
|
||||
}[]
|
||||
}
|
||||
|
||||
export const catalogRequestsApi = {
|
||||
list: (status?: string) =>
|
||||
apiRequest<CatalogRequestRow[]>('/admin/catalog-requests', {
|
||||
query: { status },
|
||||
}),
|
||||
updateStatus: (id: string, status: string) =>
|
||||
apiRequest<CatalogRequestRow>(`/admin/catalog-requests/${id}/status`, {
|
||||
method: 'PATCH',
|
||||
body: { status },
|
||||
}),
|
||||
updateHolder: (
|
||||
itemId: string,
|
||||
body: { holderType: string; holderCustomerId?: string | null; note?: string },
|
||||
) =>
|
||||
apiRequest(`/admin/catalog-request-items/${itemId}/holder`, {
|
||||
method: 'PATCH',
|
||||
body,
|
||||
}),
|
||||
}
|
||||
|
||||
/* ── Brands ───────────────────────────────────────────────── */
|
||||
|
||||
export type Brand = {
|
||||
id: string
|
||||
name: string
|
||||
slug?: string
|
||||
description?: string
|
||||
isActive: boolean
|
||||
displayOrder: number
|
||||
customersCount?: number
|
||||
}
|
||||
|
||||
export type AppBanner = {
|
||||
id: string
|
||||
slot: string
|
||||
title: string
|
||||
subtitle: string
|
||||
ctaLabel: string
|
||||
ctaLink: string
|
||||
imageUrl: string
|
||||
displayOrder: number
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
export type BannerPayload = {
|
||||
slot: string
|
||||
title: string
|
||||
subtitle?: string | null
|
||||
ctaLabel?: string | null
|
||||
ctaLink?: string | null
|
||||
imageUrl?: string | null
|
||||
displayOrder?: number
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
export const bannersApi = {
|
||||
list: (slot?: string) =>
|
||||
apiRequest<AppBanner[]>('/admin/banners', { query: { slot } }),
|
||||
create: (body: BannerPayload) =>
|
||||
apiRequest<AppBanner>('/admin/banners', { body }),
|
||||
update: (id: string, body: Partial<BannerPayload>) =>
|
||||
apiRequest<AppBanner>(`/admin/banners/${id}`, { method: 'PATCH', body }),
|
||||
remove: (id: string) =>
|
||||
apiRequest<{ ok: boolean }>(`/admin/banners/${id}`, { method: 'DELETE' }),
|
||||
}
|
||||
|
||||
export const brandsApi = {
|
||||
list: () => apiRequest<Brand[]>('/admin/brands'),
|
||||
create: (body: Partial<Brand> & { name: string }) =>
|
||||
apiRequest<Brand>('/admin/brands', { body }),
|
||||
update: (id: string, body: Partial<Brand>) =>
|
||||
apiRequest<Brand>(`/admin/brands/${id}`, { method: 'PATCH', body }),
|
||||
}
|
||||
|
||||
/* ── Levels ───────────────────────────────────────────────── */
|
||||
|
||||
export type CustomerLevel = {
|
||||
id: string
|
||||
code: string
|
||||
label: string
|
||||
sortOrder: number
|
||||
isActive: boolean
|
||||
customersCount: number
|
||||
benefits: { id?: string; title: string; description?: string }[]
|
||||
}
|
||||
|
||||
export const levelsApi = {
|
||||
list: () => apiRequest<CustomerLevel[]>('/admin/levels'),
|
||||
create: (body: {
|
||||
code: string
|
||||
label: string
|
||||
sortOrder?: number
|
||||
benefits?: { title: string; description?: string }[]
|
||||
}) => apiRequest<CustomerLevel>('/admin/levels', { body }),
|
||||
update: (id: string, body: Partial<CustomerLevel> & { benefits?: { title: string; description?: string }[] }) =>
|
||||
apiRequest<CustomerLevel>(`/admin/levels/${id}`, { method: 'PATCH', body }),
|
||||
}
|
||||
|
||||
/* ── Suggestions ──────────────────────────────────────────── */
|
||||
|
||||
export type ProductSuggestion = {
|
||||
id: string
|
||||
customerId: string
|
||||
customerName: string
|
||||
productId: string
|
||||
productName: string
|
||||
productCode: string
|
||||
note?: string
|
||||
isActive: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export const suggestionsApi = {
|
||||
list: (customerId?: string) =>
|
||||
apiRequest<ProductSuggestion[]>('/admin/suggestions', { query: { customerId } }),
|
||||
create: (body: { customerId: string; productId: string; note?: string }) =>
|
||||
apiRequest<ProductSuggestion>('/admin/suggestions', { body }),
|
||||
setActive: (id: string, isActive: boolean) =>
|
||||
apiRequest<ProductSuggestion>(`/admin/suggestions/${id}/active`, {
|
||||
method: 'PATCH',
|
||||
body: { isActive },
|
||||
}),
|
||||
}
|
||||
|
||||
/* ── Stock alerts ─────────────────────────────────────────── */
|
||||
|
||||
export type StockAlert = {
|
||||
id: string
|
||||
customerName: string
|
||||
customerPhone: string
|
||||
productName: string
|
||||
productCode: string
|
||||
stock: number | null
|
||||
active: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export const stockAlertsApi = {
|
||||
list: () => apiRequest<StockAlert[]>('/admin/stock-alerts'),
|
||||
notify: (id: string) =>
|
||||
apiRequest<StockAlert>(`/admin/stock-alerts/${id}/notify`, { method: 'PATCH' }),
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { AdminLoginPayload, AdminLoginResult, AdminSession } from '@/types/auth'
|
||||
import { ApiError, apiRequest, getAccessToken, setAccessToken } from '@/services/http'
|
||||
|
||||
const STORAGE_KEY = 'fabric-admin-session'
|
||||
const TOKEN_KEY = 'fabric-admin-token'
|
||||
|
||||
const USE_MOCK_AUTH = import.meta.env.VITE_USE_MOCK_AUTH === 'true'
|
||||
|
||||
/** حساب موک توسعه — فقط وقتی VITE_USE_MOCK_AUTH=true */
|
||||
export const MOCK_ADMIN = {
|
||||
phone: '09128081392',
|
||||
password: '123456',
|
||||
session: {
|
||||
id: 'mock-admin-1',
|
||||
fullName: 'مدیر سیستم (موک)',
|
||||
phone: '09128081392',
|
||||
email: 'admin@parche.ir',
|
||||
role: 'admin' as const,
|
||||
} satisfies AdminSession,
|
||||
}
|
||||
|
||||
function readSession(): AdminSession | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY) ?? sessionStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return null
|
||||
return JSON.parse(raw) as AdminSession
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function writeSession(session: AdminSession, remember: boolean) {
|
||||
const payload = JSON.stringify(session)
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
sessionStorage.removeItem(STORAGE_KEY)
|
||||
if (remember) {
|
||||
localStorage.setItem(STORAGE_KEY, payload)
|
||||
} else {
|
||||
sessionStorage.setItem(STORAGE_KEY, payload)
|
||||
}
|
||||
}
|
||||
|
||||
function clearSession() {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
sessionStorage.removeItem(STORAGE_KEY)
|
||||
}
|
||||
|
||||
function isRememberedToken(): boolean {
|
||||
return !!localStorage.getItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
function toSession(user: AdminSession): AdminSession {
|
||||
return {
|
||||
id: user.id,
|
||||
fullName: user.fullName,
|
||||
phone: user.phone,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
}
|
||||
}
|
||||
|
||||
export function getStoredSession(): AdminSession | null {
|
||||
return readSession()
|
||||
}
|
||||
|
||||
export function isMockAuthEnabled() {
|
||||
return USE_MOCK_AUTH
|
||||
}
|
||||
|
||||
export async function loginAdmin(payload: AdminLoginPayload): Promise<AdminLoginResult> {
|
||||
const phone = payload.phone.trim()
|
||||
const password = payload.password
|
||||
|
||||
if (!phone || !password) {
|
||||
return { success: false, message: 'شماره موبایل و رمز عبور الزامی است.' }
|
||||
}
|
||||
|
||||
if (USE_MOCK_AUTH) {
|
||||
if (phone === MOCK_ADMIN.phone && password === MOCK_ADMIN.password) {
|
||||
setAccessToken('mock-admin-token', payload.remember)
|
||||
writeSession(MOCK_ADMIN.session, payload.remember)
|
||||
return { success: true }
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
message: `حالت موک: ${MOCK_ADMIN.phone} / ${MOCK_ADMIN.password}`,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await apiRequest<{ accessToken: string; user: AdminSession }>(
|
||||
'/admin/auth/login',
|
||||
{
|
||||
method: 'POST',
|
||||
body: { phone, password },
|
||||
auth: false,
|
||||
},
|
||||
)
|
||||
|
||||
setAccessToken(data.accessToken, payload.remember)
|
||||
writeSession(toSession(data.user), payload.remember)
|
||||
return { success: true }
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
return { success: false, message: err.message }
|
||||
}
|
||||
return { success: false, message: 'ورود انجام نشد.' }
|
||||
}
|
||||
}
|
||||
|
||||
/** Refresh session from GET /auth/me when a token exists. */
|
||||
export async function refreshAdminSession(): Promise<AdminSession | null> {
|
||||
const token = getAccessToken()
|
||||
if (!token) return readSession()
|
||||
|
||||
if (USE_MOCK_AUTH || token === 'mock-admin-token') {
|
||||
const session = readSession() ?? MOCK_ADMIN.session
|
||||
writeSession(session, isRememberedToken())
|
||||
return session
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await apiRequest<AdminSession>('/auth/me')
|
||||
const session = toSession(user)
|
||||
writeSession(session, isRememberedToken())
|
||||
return session
|
||||
} catch {
|
||||
return readSession()
|
||||
}
|
||||
}
|
||||
|
||||
export function logoutAdmin() {
|
||||
setAccessToken(null)
|
||||
clearSession()
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
const TOKEN_KEY = 'fabric-admin-token'
|
||||
|
||||
export function getApiBaseUrl(): string {
|
||||
return (import.meta.env.VITE_API_BASE_URL as string | undefined)?.replace(/\/$/, '')
|
||||
|| 'https://parche.montest.ir/server/api'
|
||||
}
|
||||
|
||||
export function getAccessToken(): string | null {
|
||||
return localStorage.getItem(TOKEN_KEY) ?? sessionStorage.getItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
export function setAccessToken(token: string | null, remember = true): void {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
sessionStorage.removeItem(TOKEN_KEY)
|
||||
if (!token) return
|
||||
if (remember) localStorage.setItem(TOKEN_KEY, token)
|
||||
else sessionStorage.setItem(TOKEN_KEY, token)
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number
|
||||
constructor(status: number, message: string) {
|
||||
super(message)
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
type RequestOptions = {
|
||||
method?: string
|
||||
body?: unknown
|
||||
auth?: boolean
|
||||
query?: Record<string, string | number | boolean | undefined | null>
|
||||
}
|
||||
|
||||
function buildUrl(path: string, query?: RequestOptions['query']): string {
|
||||
const base = getApiBaseUrl()
|
||||
const joined = path.startsWith('http')
|
||||
? path
|
||||
: `${base.replace(/\/$/, '')}/${path.replace(/^\//, '')}`
|
||||
|
||||
const url = joined.startsWith('http')
|
||||
? new URL(joined)
|
||||
: new URL(joined, typeof window !== 'undefined' ? window.location.origin : 'http://127.0.0.1')
|
||||
|
||||
if (query) {
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value === undefined || value === null || value === '') continue
|
||||
url.searchParams.set(key, String(value))
|
||||
}
|
||||
}
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
function errorMessageFromBody(data: unknown, status: number): string {
|
||||
if (typeof data === 'object' && data && 'message' in data) {
|
||||
const message = (data as { message: unknown }).message
|
||||
if (Array.isArray(message)) return message.join('، ')
|
||||
return String(message)
|
||||
}
|
||||
if (typeof data === 'string') {
|
||||
if (/upstream error/i.test(data)) {
|
||||
return 'سرور API در دسترس نیست. کمی بعد دوباره تلاش کنید.'
|
||||
}
|
||||
if (data.trim() && !data.trimStart().startsWith('<')) {
|
||||
return data.trim().slice(0, 200)
|
||||
}
|
||||
}
|
||||
return `خطای سرور (${status})`
|
||||
}
|
||||
|
||||
export async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/json',
|
||||
}
|
||||
if (options.body !== undefined) {
|
||||
headers['Content-Type'] = 'application/json'
|
||||
}
|
||||
if (options.auth !== false) {
|
||||
const token = getAccessToken()
|
||||
if (token) headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(buildUrl(path, options.query), {
|
||||
method: options.method ?? (options.body !== undefined ? 'POST' : 'GET'),
|
||||
headers,
|
||||
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
|
||||
signal: AbortSignal.timeout(20_000),
|
||||
})
|
||||
} catch (err) {
|
||||
const timedOut =
|
||||
(err instanceof DOMException && err.name === 'TimeoutError') ||
|
||||
(err instanceof Error && err.name === 'TimeoutError')
|
||||
throw new ApiError(
|
||||
0,
|
||||
timedOut
|
||||
? 'پاسخ سرور طول کشید. دوباره تلاش کنید.'
|
||||
: 'اتصال به سرور برقرار نشد. اینترنت یا آدرس API را بررسی کنید.',
|
||||
)
|
||||
}
|
||||
|
||||
const text = await res.text()
|
||||
let data: unknown = null
|
||||
if (text) {
|
||||
try {
|
||||
data = JSON.parse(text)
|
||||
} catch {
|
||||
data = text
|
||||
}
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new ApiError(res.status, errorMessageFromBody(data, res.status))
|
||||
}
|
||||
|
||||
return data as T
|
||||
}
|
||||
Reference in New Issue
Block a user