Initialize the user app and load home banners from the API.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-09 17:08:45 +03:30
co-authored by Cursor
commit a7f9c66514
190 changed files with 36160 additions and 0 deletions
+141
View File
@@ -0,0 +1,141 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { User } from '@/types'
import {
getCurrentUser,
logout as authLogout,
sendOtp,
verifyOtp,
updateProfile as authUpdateProfile,
fetchMe,
} from '@/services/authService'
import {
getPendingRegister,
setPendingRegister,
type PendingRegister,
} from '@/utils/pendingRegister'
import { useCartStore } from './cart'
import { useOrderStore } from './order'
import { useCatalogStore } from './catalog'
import { useNotificationStore } from './notification'
import { useFavoritesStore } from './favorites'
export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(getCurrentUser())
const loading = ref(false)
const error = ref<string | null>(null)
const pendingRegister = ref<PendingRegister | null>(getPendingRegister())
const isAuthenticated = computed(() => !!user.value)
const fullName = computed(() =>
user.value ? `${user.value.firstName} ${user.value.lastName}` : '',
)
const isAgent = computed(() => user.value?.appRole === 'agent')
const isCustomerOnly = computed(
() => !!user.value && user.value.appRole !== 'agent',
)
function clearError() {
error.value = null
}
function clearPendingRegister() {
pendingRegister.value = null
setPendingRegister(null)
}
async function requestOtp(
phone: string,
options: { firstName?: string; lastName?: string; isRegister: boolean },
) {
loading.value = true
error.value = null
try {
// Re-read storage on every call (survives HMR / refresh / separate tabs).
const stored = getPendingRegister()
pendingRegister.value = stored
const firstName = options.firstName?.trim() || stored?.firstName
const lastName = options.lastName?.trim() || stored?.lastName
if (options.isRegister && firstName && lastName) {
pendingRegister.value = { firstName, lastName }
setPendingRegister(pendingRegister.value)
}
const result = await sendOtp(phone, {
isRegister: options.isRegister,
firstName,
lastName,
})
if (!result.success) error.value = result.message
return result
} finally {
loading.value = false
}
}
async function confirmOtp(phone: string, code: string) {
loading.value = true
error.value = null
const result = await verifyOtp(phone, code)
loading.value = false
if (result.success && result.user) {
user.value = result.user
clearPendingRegister()
await Promise.all([
useCatalogStore().loadCompanies(),
useCartStore().hydrate(),
useOrderStore().hydrate(),
useNotificationStore().hydrate(),
useFavoritesStore().hydrate(),
])
} else {
error.value = result.message
}
return result
}
function logout() {
authLogout()
user.value = null
clearPendingRegister()
useCartStore().resetLocal()
useOrderStore().resetLocal()
useNotificationStore().resetLocal()
useFavoritesStore().resetLocal()
}
async function updateProfile(
data: Partial<Pick<User, 'firstName' | 'lastName' | 'nationalId' | 'avatarUrl'>>,
) {
if (!user.value) return null
const updated = await authUpdateProfile(data)
if (updated) user.value = updated
return updated
}
async function hydrate() {
user.value = getCurrentUser()
if (!user.value) return
const me = await fetchMe()
user.value = me
}
return {
user,
loading,
error,
isAuthenticated,
fullName,
isAgent,
isCustomerOnly,
clearError,
requestOtp,
confirmOtp,
logout,
updateProfile,
hydrate,
}
})
+407
View File
@@ -0,0 +1,407 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { Cart, CartItem, Company, Product } from '@/types'
import { validateMeterage } from '@/utils/validation'
import { apiRequest, ApiError, getAccessToken } from '@/services/http'
import { useCatalogStore } from './catalog'
const DEFAULT_MAX_METERAGE = 5000
type ApiCart = Cart & { totalMeterage?: number; itemCount?: number }
function emptyCart(): Cart {
return { id: 'local', companyId: '', companyName: '', items: [] }
}
function useLocalCart(): boolean {
return false
}
function uniqueCompanyNames(items: Cart['items']): string {
const names = [...new Set(items.map((item) => item.companyName).filter(Boolean))]
return names.join('، ')
}
function companyMeterage(cart: Cart, companyId: string): number {
return cart.items
.filter((item) => item.companyId === companyId)
.reduce((sum, item) => sum + item.meterage, 0)
}
export const useCartStore = defineStore('cart', () => {
const carts = ref<Cart[]>([emptyCart()])
const activeCartId = ref<string | null>('local')
const selectedCompanyId = ref<string | null>(null)
const syncing = ref(false)
const activeCart = computed(
() => carts.value.find((c) => c.id === activeCartId.value) ?? carts.value[0] ?? null,
)
const totalMeterage = computed(
() => activeCart.value?.items.reduce((sum, i) => sum + i.meterage, 0) ?? 0,
)
const itemCount = computed(() => activeCart.value?.items.length ?? 0)
const catalogStore = useCatalogStore()
const companyNamesLabel = computed(() => {
if (!activeCart.value?.items.length) return ''
return uniqueCompanyNames(activeCart.value.items)
})
const companySummaries = computed(() => {
const cart = activeCart.value
if (!cart?.items.length) return [] as {
companyId: string
companyName: string
meterage: number
maxMeterage: number
remaining: number
}[]
const byCompany = new Map<string, { companyName: string; meterage: number }>()
for (const item of cart.items) {
const current = byCompany.get(item.companyId)
if (current) {
current.meterage += item.meterage
} else {
byCompany.set(item.companyId, {
companyName: item.companyName || 'شرکت',
meterage: item.meterage,
})
}
}
return [...byCompany.entries()].map(([companyId, info]) => {
const max =
catalogStore.getCompany(companyId)?.maxMeteragePerInvoice ?? DEFAULT_MAX_METERAGE
return {
companyId,
companyName: info.companyName,
meterage: info.meterage,
maxMeterage: max,
remaining: Math.max(0, max - info.meterage),
}
})
})
/** سقف نمایشی: مجموع سقف شرکت‌های داخل سبد (هر شرکت جداگانه محدود می‌شود). */
const maxMeterage = computed(() => {
if (!companySummaries.value.length) {
const selected = selectedCompanyId.value
? catalogStore.getCompany(selectedCompanyId.value)
: null
return selected?.maxMeteragePerInvoice ?? DEFAULT_MAX_METERAGE
}
return companySummaries.value.reduce((sum, row) => sum + row.maxMeterage, 0)
})
const remainingCapacity = computed(() =>
companySummaries.value.length
? companySummaries.value.reduce((sum, row) => sum + row.remaining, 0)
: Math.max(0, maxMeterage.value - totalMeterage.value),
)
/** وقتی حداقل یک شرکت به سقف خودش رسیده باشد. */
const isFull = computed(() =>
companySummaries.value.some((row) => row.meterage >= row.maxMeterage),
)
function applyServerCart(serverCart: ApiCart) {
const cart: Cart = {
id: serverCart.id,
companyId: serverCart.companyId || '',
companyName: serverCart.companyName || uniqueCompanyNames(serverCart.items),
items: serverCart.items ?? [],
notes: serverCart.notes,
}
carts.value = [cart]
activeCartId.value = cart.id
if (cart.companyId) selectedCompanyId.value = cart.companyId
}
function replaceActiveCart(cart: Cart) {
carts.value = [{ ...cart, items: cart.items.map((item) => ({ ...item })) }]
activeCartId.value = carts.value[0]!.id
}
async function hydrate() {
if (!getAccessToken()) {
resetLocal()
return
}
if (useLocalCart()) {
// Keep whatever local cart already exists for mock sessions.
syncing.value = false
return
}
syncing.value = true
try {
const serverCart = await apiRequest<ApiCart>('/carts')
applyServerCart(serverCart)
} catch {
resetLocal()
} finally {
syncing.value = false
}
}
function resetLocal() {
carts.value = [emptyCart()]
activeCartId.value = carts.value[0]!.id
selectedCompanyId.value = null
}
function selectCompany(company: Company): void {
selectedCompanyId.value = company.id
if (activeCart.value && !activeCart.value.items.length) {
activeCart.value.companyId = company.id
activeCart.value.companyName = company.name
}
}
function initCartForCompany(company: Company): Cart {
selectCompany(company)
return activeCart.value ?? emptyCart()
}
function getOrCreateCart(company?: Company): Cart {
if (company) selectCompany(company)
return activeCart.value ?? emptyCart()
}
async function createNewCartForSameCompany(): Promise<Cart | null> {
if (useLocalCart()) {
resetLocal()
return activeCart.value
}
try {
const serverCart = await apiRequest<ApiCart>('/carts', { method: 'DELETE' })
applyServerCart(serverCart)
return activeCart.value
} catch (e) {
console.error(e)
return null
}
}
function addItemLocal(
product: Product,
catalogCode: string,
meterage: number,
company: Company,
): { success: boolean; message?: string } {
const cart = getOrCreateCart(company)
const existing = cart.items.find((i) => i.productId === product.id)
const items = [...cart.items]
if (existing) {
const index = items.findIndex((i) => i.id === existing.id)
items[index] = { ...existing, meterage }
} else {
const item: CartItem = {
id: `local-item-${product.id}-${Date.now()}`,
productId: product.id,
catalogCode,
productName: product.name,
productCode: product.code,
color: product.color,
meterage,
companyId: company.id,
companyName: company.name,
}
items.push(item)
}
replaceActiveCart({
id: cart.id || 'local',
companyId: items[0]?.companyId || company.id,
companyName: uniqueCompanyNames(items) || company.name,
items,
notes: cart.notes,
})
selectedCompanyId.value = company.id
return { success: true }
}
async function addItem(
product: Product,
catalogCode: string,
meterage: number,
): Promise<{ success: boolean; message?: string }> {
const company = catalogStore.getCompany(product.companyId)
if (!company) {
return { success: false, message: 'شرکت محصول یافت نشد.' }
}
const cart = getOrCreateCart(company)
const companyMax = company.maxMeteragePerInvoice
const currentCompanyMeterage = companyMeterage(cart, company.id)
const existing = cart.items.find((i) => i.productId === product.id)
const previousMeterage = existing?.meterage ?? 0
const validation = validateMeterage(
meterage,
product,
currentCompanyMeterage,
companyMax,
previousMeterage,
)
if (!validation.valid) {
return { success: false, message: validation.message }
}
if (useLocalCart()) {
return addItemLocal(product, catalogCode, meterage, company)
}
try {
let serverCart: ApiCart
if (existing) {
serverCart = await apiRequest<ApiCart>(`/carts/items/${existing.id}`, {
method: 'PATCH',
body: { meterage },
})
} else {
serverCart = await apiRequest<ApiCart>('/carts/items', {
method: 'POST',
body: { productId: product.id, meterage },
})
}
applyServerCart(serverCart)
selectedCompanyId.value = company.id
return { success: true }
} catch (e) {
return {
success: false,
message: e instanceof ApiError ? e.message : 'افزودن به سبد ناموفق بود.',
}
}
}
async function updateItemMeterage(
itemId: string,
meterage: number,
product: Product,
): Promise<{ success: boolean; message?: string }> {
if (!activeCart.value) return { success: false, message: 'سبد خرید یافت نشد.' }
const item = activeCart.value.items.find((i) => i.id === itemId)
if (!item) return { success: false, message: 'آیتم یافت نشد.' }
const company = catalogStore.getCompany(item.companyId || product.companyId)
const companyMax = company?.maxMeteragePerInvoice ?? DEFAULT_MAX_METERAGE
const currentCompanyMeterage = companyMeterage(activeCart.value, item.companyId)
const validation = validateMeterage(
meterage,
product,
currentCompanyMeterage,
companyMax,
item.meterage,
)
if (!validation.valid) {
return { success: false, message: validation.message }
}
if (useLocalCart()) {
const items = activeCart.value.items.map((entry) =>
entry.id === itemId ? { ...entry, meterage } : entry,
)
replaceActiveCart({ ...activeCart.value, items })
return { success: true }
}
try {
const serverCart = await apiRequest<ApiCart>(`/carts/items/${itemId}`, {
method: 'PATCH',
body: { meterage },
})
applyServerCart(serverCart)
return { success: true }
} catch (e) {
return {
success: false,
message: e instanceof ApiError ? e.message : 'به‌روزرسانی متراژ ناموفق بود.',
}
}
}
async function removeItem(itemId: string) {
if (useLocalCart()) {
if (!activeCart.value) return
const items = activeCart.value.items.filter((entry) => entry.id !== itemId)
replaceActiveCart({ ...activeCart.value, items })
return
}
try {
const serverCart = await apiRequest<ApiCart>(`/carts/items/${itemId}`, {
method: 'DELETE',
})
applyServerCart(serverCart)
} catch (e) {
console.error(e)
}
}
async function setNotes(notes: string) {
if (useLocalCart()) {
if (!activeCart.value) return
replaceActiveCart({ ...activeCart.value, notes })
return
}
try {
const serverCart = await apiRequest<ApiCart>('/carts/notes', {
method: 'PATCH',
body: { notes },
})
applyServerCart(serverCart)
} catch (e) {
console.error(e)
}
}
async function clearActiveCart() {
if (useLocalCart()) {
resetLocal()
return
}
try {
const serverCart = await apiRequest<ApiCart>('/carts', { method: 'DELETE' })
applyServerCart(serverCart)
} catch {
resetLocal()
}
}
function ensureActiveCartForCartPage() {
// server keeps a single cart
}
return {
carts,
activeCartId,
selectedCompanyId,
syncing,
activeCart,
totalMeterage,
itemCount,
companyNamesLabel,
companySummaries,
maxMeterage,
remainingCapacity,
isFull,
initCartForCompany,
selectCompany,
createNewCartForSameCompany,
addItem,
updateItemMeterage,
removeItem,
setNotes,
clearActiveCart,
ensureActiveCartForCartPage,
hydrate,
resetLocal,
}
})
+129
View File
@@ -0,0 +1,129 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { apiRequest } from '@/services/http'
import type { BankAccount, Catalog, Company, Product } from '@/types'
export const useCatalogStore = defineStore('catalog', () => {
const companies = ref<Company[]>([])
const catalogs = ref<Catalog[]>([])
const products = ref<Product[]>([])
const bankAccounts = ref<BankAccount[]>([])
const searchQuery = ref('')
const loading = ref(false)
const loaded = ref(false)
async function loadCompanies(force = false) {
if (loaded.value && !force) return companies.value
loading.value = true
try {
const list = await apiRequest<Company[]>('/companies', {
query: { activeOnly: true },
})
companies.value = list
.filter((c) => c.isActive)
.sort((a, b) => a.displayOrder - b.displayOrder)
loaded.value = true
} finally {
loading.value = false
}
return companies.value
}
function getActiveCompanies(): Company[] {
return [...companies.value].sort((a, b) => a.displayOrder - b.displayOrder)
}
function getCompany(id: string): Company | undefined {
return companies.value.find((c) => c.id === id)
}
async function loadCatalogs(companyId: string) {
const list = await apiRequest<Catalog[]>('/catalogs', {
query: { companyId, activeOnly: true },
})
catalogs.value = [
...catalogs.value.filter((c) => c.companyId !== companyId),
...list,
]
return list
}
async function loadProducts(catalogId: string, color?: string) {
const list = await apiRequest<Product[]>('/products', {
query: {
catalogId,
color: color && color !== 'all' ? color : undefined,
activeOnly: true,
},
})
const others = products.value.filter((p) => p.catalogId !== catalogId)
products.value = [...others, ...list]
return list
}
async function loadBankAccounts(companyId: string) {
const list = await apiRequest<BankAccount[]>(`/companies/${companyId}/bank-accounts`)
bankAccounts.value = [
...bankAccounts.value.filter((b) => b.companyId !== companyId),
...list.filter((b) => b.isActive),
]
return bankAccounts.value.filter((b) => b.companyId === companyId)
}
function fetchCatalogs(companyId: string, query = ''): Catalog[] {
let list = catalogs.value.filter((c) => c.companyId === companyId && c.isActive)
if (query.trim()) {
const q = query.trim()
list = list.filter((c) => c.code.includes(q) || c.name.includes(q))
}
return [...list].sort((a, b) => a.displayOrder - b.displayOrder)
}
function fetchProducts(catalogId: string, colorFilter?: string): Product[] {
let list = products.value.filter((p) => p.catalogId === catalogId && p.isActive)
if (colorFilter && colorFilter !== 'all') {
list = list.filter((p) => p.color.includes(colorFilter))
}
return list
}
function getCatalog(id: string) {
return catalogs.value.find((c) => c.id === id)
}
function getProduct(id: string) {
return products.value.find((p) => p.id === id)
}
function getUniqueColors(catalogId: string): string[] {
return [...new Set(fetchProducts(catalogId).map((p) => p.color))]
}
function getBankAccountsByCompany(companyId: string): BankAccount[] {
return bankAccounts.value
.filter((b) => b.companyId === companyId && b.isActive)
.sort((a, b) => a.displayOrder - b.displayOrder)
}
return {
companies,
catalogs,
products,
bankAccounts,
searchQuery,
loading,
loaded,
loadCompanies,
loadCatalogs,
loadProducts,
loadBankAccounts,
getActiveCompanies,
getCompany,
fetchCatalogs,
fetchProducts,
getCatalog,
getProduct,
getUniqueColors,
getBankAccountsByCompany,
}
})
+206
View File
@@ -0,0 +1,206 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { apiRequest } from '@/services/http'
const STORAGE_KEY = 'ft_catalog_requests'
export type CatalogRequestItem = {
productId: string
productName: string
productCode: string
quantity: number
}
/** UI statuses (legacy) + server statuses mapped in */
export type CatalogRequestStatus =
| 'submitted'
| 'reviewing'
| 'preparing'
| 'shipped'
| 'delivered'
| 'new'
| 'approved'
| 'ready_to_ship'
| 'returned'
| 'cancelled'
export type CatalogRequest = {
id: string
createdAt: string
status: CatalogRequestStatus
note?: string
items: CatalogRequestItem[]
}
const SERVER_TO_UI: Record<string, CatalogRequestStatus> = {
new: 'submitted',
approved: 'reviewing',
ready_to_ship: 'preparing',
delivered: 'delivered',
returned: 'delivered',
cancelled: 'submitted',
}
function readAll(): CatalogRequest[] {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return []
const parsed = JSON.parse(raw) as unknown
return Array.isArray(parsed) ? (parsed as CatalogRequest[]) : []
} catch {
return []
}
}
function writeAll(list: CatalogRequest[]) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(list))
}
const STATUS_LABELS: Record<string, string> = {
submitted: 'ثبت‌شده',
reviewing: 'در حال بررسی',
preparing: 'آماده‌سازی',
shipped: 'ارسال‌شده',
delivered: 'تحویل‌شده',
new: 'درخواست جدید',
approved: 'تأیید شده',
ready_to_ship: 'آماده ارسال',
returned: 'بازگردانده شده',
cancelled: 'لغو شده',
}
function mapServerRow(row: {
id: string
createdAt: string
status: string
note?: string
items: Array<{
productId: string
productName: string
productCode: string
quantity: number
}>
}): CatalogRequest {
return {
id: row.id,
createdAt: row.createdAt,
status: SERVER_TO_UI[row.status] ?? (row.status as CatalogRequestStatus),
note: row.note,
items: row.items.map((i) => ({
productId: i.productId,
productName: i.productName,
productCode: i.productCode,
quantity: i.quantity,
})),
}
}
export const useCatalogRequestsStore = defineStore('catalogRequests', () => {
const requests = ref<CatalogRequest[]>(readAll())
const useApi = ref(true)
const sorted = computed(() =>
[...requests.value].sort((a, b) => b.createdAt.localeCompare(a.createdAt)),
)
function statusLabel(status: CatalogRequest['status']) {
return STATUS_LABELS[status] ?? status
}
async function hydrate() {
if (!useApi.value) {
requests.value = readAll()
return
}
try {
const rows = await apiRequest<
Array<{
id: string
createdAt: string
status: string
note?: string
items: Array<{
productId: string
productName: string
productCode: string
quantity: number
}>
}>
>('/catalog-requests')
requests.value = rows.map(mapServerRow)
writeAll(requests.value)
} catch {
useApi.value = false
requests.value = readAll()
}
}
async function create(input: { items: CatalogRequestItem[]; note?: string }) {
const items = input.items
.filter((i) => i.productId && i.quantity > 0)
.map((i) => ({
...i,
quantity: Math.max(1, Math.floor(i.quantity)),
}))
if (!items.length) {
return { success: false as const, message: 'حداقل یک قلم با تعداد معتبر لازم است.' }
}
if (useApi.value) {
try {
const row = await apiRequest<{
id: string
createdAt: string
status: string
note?: string
items: Array<{
productId: string
productName: string
productCode: string
quantity: number
}>
}>('/catalog-requests', {
method: 'POST',
body: {
note: input.note?.trim() || undefined,
items: items.map((i) => ({
productId: i.productId,
quantity: i.quantity,
})),
},
})
const mapped = mapServerRow(row)
requests.value = [mapped, ...requests.value.filter((r) => r.id !== mapped.id)]
writeAll(requests.value)
return { success: true as const, request: mapped }
} catch (err) {
useApi.value = false
// fall through to local
}
}
const row: CatalogRequest = {
id: `cr-${Date.now()}`,
createdAt: new Date().toISOString(),
status: 'submitted',
note: input.note?.trim() || undefined,
items,
}
requests.value = [row, ...requests.value]
writeAll(requests.value)
return { success: true as const, request: row }
}
function getById(id: string) {
return requests.value.find((r) => r.id === id) ?? null
}
return {
requests,
sorted,
statusLabel,
create,
getById,
hydrate,
}
})
+199
View File
@@ -0,0 +1,199 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { CrmCustomer, CrmMeeting, CrmOrderSummary, CrmReminder } from '@/types/crm'
import type { CatalogProductCard, ProductCategory, SeasonCategory } from '@/types/crm'
import type { AgentProfileDto } from '@/services/agentApi'
import {
completeReminder as apiCompleteReminder,
createAgentCustomer,
createMeetingReport,
createReminder as apiCreateReminder,
fetchAgentCustomerDetail,
fetchAgentCustomers,
fetchAgentProfile,
fetchBrowseProducts,
fetchCatalogCategories,
fetchReminderDetail,
fetchReminders,
} from '@/services/agentApi'
import { apiRequest } from '@/services/http'
function mapProduct(p: {
id: string
name: string
code: string
companyId?: string
catalogId?: string
catalogCode?: string
color?: string
minMeterage?: number
orderMultiplier?: number
basePrice?: number | null
categoryId?: string
usageCategory?: string
season?: string
widthCm?: number
composition?: string
weightGrams?: number
weaveType?: string
colors?: string[]
tags?: string[]
isNew?: boolean
subtitle?: string
rollMeters?: number
imageUrl?: string
imageUrls?: string[]
}): CatalogProductCard {
return {
id: p.id,
name: p.name,
code: p.code,
categoryId: p.categoryId || p.usageCategory || 'other',
season: (p.season as CatalogProductCard['season']) || 'all_season',
widthCm: p.widthCm ?? 150,
composition: p.composition ?? '',
weightGrams: p.weightGrams,
weaveType: p.weaveType,
colors: p.colors?.length ? p.colors : ['#6b7280'],
tags: p.tags ?? [],
isNew: !!p.isNew,
imageUrl: p.imageUrl || p.imageUrls?.[0],
subtitle: p.subtitle,
rollMeters: p.rollMeters ?? 50,
companyId: p.companyId,
catalogId: p.catalogId,
catalogCode: p.catalogCode,
color: p.color,
minMeterage: p.minMeterage ?? 30,
orderMultiplier: p.orderMultiplier ?? 10,
basePrice: p.basePrice ?? undefined,
}
}
export const useCrmStore = defineStore('crm', () => {
const loading = ref(false)
const error = ref<string | null>(null)
const agentProfile = ref<AgentProfileDto | null>(null)
const customers = ref<CrmCustomer[]>([])
const reminders = ref<CrmReminder[]>([])
const usageCategories = ref<ProductCategory[]>([])
const seasonCategories = ref<SeasonCategory[]>([])
const browseProducts = ref<CatalogProductCard[]>([])
async function loadCategories() {
const data = await fetchCatalogCategories()
usageCategories.value = data.usage
seasonCategories.value = data.seasons as SeasonCategory[]
return data
}
async function loadBrowseProducts(query?: { categoryId?: string; season?: string }) {
const list = await fetchBrowseProducts(query)
browseProducts.value = list.map(mapProduct)
return browseProducts.value
}
async function loadProductById(id: string) {
const p = await apiRequest<{
id: string
name: string
code: string
companyId?: string
catalogId?: string
catalogCode?: string
color?: string
minMeterage?: number
orderMultiplier?: number
basePrice?: number | null
categoryId?: string
usageCategory?: string
season?: string
widthCm?: number
composition?: string
weightGrams?: number
weaveType?: string
colors?: string[]
tags?: string[]
isNew?: boolean
subtitle?: string
rollMeters?: number
imageUrl?: string
imageUrls?: string[]
}>(`/products/${id}`)
return mapProduct(p)
}
async function loadAgentProfile() {
agentProfile.value = await fetchAgentProfile()
return agentProfile.value
}
async function loadCustomers(query?: { q?: string; status?: string }) {
customers.value = (await fetchAgentCustomers(query)) as CrmCustomer[]
return customers.value
}
async function loadCustomerDetail(id: string) {
return (await fetchAgentCustomerDetail(id)) as {
customer: CrmCustomer
orders: CrmOrderSummary[]
meetings: CrmMeeting[]
}
}
async function submitMeeting(body: Parameters<typeof createMeetingReport>[0]) {
return createMeetingReport(body)
}
async function registerCustomer(body: Parameters<typeof createAgentCustomer>[0]) {
return createAgentCustomer(body)
}
async function loadReminders(filter?: string) {
reminders.value = (await fetchReminders(filter)) as CrmReminder[]
return reminders.value
}
async function loadReminderDetail(id: string) {
return fetchReminderDetail(id)
}
async function markReminderComplete(id: string) {
return apiCompleteReminder(id, true)
}
async function createReminder(body: {
customerId: string
title: string
date: string
time: string
description?: string
note?: string
interactionType?: string
}) {
return apiCreateReminder(body)
}
return {
loading,
error,
agentProfile,
customers,
reminders,
usageCategories,
seasonCategories,
browseProducts,
loadCategories,
loadBrowseProducts,
loadProductById,
loadAgentProfile,
loadCustomers,
loadCustomerDetail,
submitMeeting,
registerCustomer,
loadReminders,
loadReminderDetail,
markReminderComplete,
createReminder,
}
})
+95
View File
@@ -0,0 +1,95 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { apiRequest, ApiError } from '@/services/http'
const LOCAL_KEY = 'ft_favorites'
function readLocal(): string[] {
try {
const raw = localStorage.getItem(LOCAL_KEY)
if (!raw) return []
const parsed = JSON.parse(raw) as unknown
return Array.isArray(parsed) ? parsed.filter((x) => typeof x === 'string') : []
} catch {
return []
}
}
function writeLocal(ids: string[]) {
localStorage.setItem(LOCAL_KEY, JSON.stringify(ids))
}
export const useFavoritesStore = defineStore('favorites', () => {
const ids = ref<string[]>(readLocal())
const loaded = ref(false)
const pending = ref<Record<string, boolean>>({})
const idSet = computed(() => new Set(ids.value))
function isFavorite(productId: string) {
return idSet.value.has(productId)
}
async function hydrate() {
try {
const list = await apiRequest<string[]>('/products/favorites/ids')
ids.value = list
writeLocal(list)
} catch {
ids.value = readLocal()
} finally {
loaded.value = true
}
}
async function toggle(productId: string) {
if (!productId || pending.value[productId]) return
pending.value = { ...pending.value, [productId]: true }
const wasFavorite = isFavorite(productId)
ids.value = wasFavorite
? ids.value.filter((id) => id !== productId)
: [...ids.value, productId]
writeLocal(ids.value)
try {
const res = await apiRequest<{ productId: string; favorited: boolean }>(
`/products/${productId}/favorite`,
{ method: 'POST' },
)
ids.value = res.favorited
? [...new Set([...ids.value.filter((id) => id !== productId), productId])]
: ids.value.filter((id) => id !== productId)
writeLocal(ids.value)
} catch (e) {
// اگر endpoint روی سرور نبود، حالت محلی حفظ می‌شود
if (!(e instanceof ApiError && e.status === 404)) {
ids.value = wasFavorite
? [...new Set([...ids.value, productId])]
: ids.value.filter((id) => id !== productId)
writeLocal(ids.value)
throw e
}
} finally {
const next = { ...pending.value }
delete next[productId]
pending.value = next
}
}
function resetLocal() {
ids.value = []
writeLocal([])
loaded.value = false
}
return {
ids,
loaded,
pending,
isFavorite,
hydrate,
toggle,
resetLocal,
}
})
+84
View File
@@ -0,0 +1,84 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { apiRequest, getAccessToken } from '@/services/http'
import type { AppNotification } from '@/types'
export const useNotificationStore = defineStore('notification', () => {
const items = ref<AppNotification[]>([])
const unreadCount = ref(0)
const loading = ref(false)
const hasUnread = computed(() => unreadCount.value > 0)
async function hydrate() {
if (!getAccessToken()) {
items.value = []
unreadCount.value = 0
return
}
loading.value = true
try {
items.value = await apiRequest<AppNotification[]>('/notifications')
unreadCount.value = items.value.filter((n) => !n.isRead).length
} catch {
items.value = []
unreadCount.value = 0
} finally {
loading.value = false
}
}
/** فقط شمارنده؛ برای زنگ هدر که نیازی به کل فهرست ندارد. */
async function refreshUnreadCount() {
if (!getAccessToken()) {
unreadCount.value = 0
return
}
try {
const res = await apiRequest<{ count: number }>('/notifications/unread-count')
unreadCount.value = res.count
} catch {
/* شمارنده حیاتی نیست */
}
}
async function markRead(id: string) {
const item = items.value.find((n) => n.id === id)
if (!item || item.isRead) return
item.isRead = true
unreadCount.value = Math.max(0, unreadCount.value - 1)
try {
await apiRequest(`/notifications/${id}/read`, { method: 'PATCH' })
} catch {
await hydrate()
}
}
async function markAllRead() {
if (!unreadCount.value) return
items.value = items.value.map((n) => ({ ...n, isRead: true }))
unreadCount.value = 0
try {
await apiRequest('/notifications/read-all', { method: 'PATCH' })
} catch {
await hydrate()
}
}
function resetLocal() {
items.value = []
unreadCount.value = 0
}
return {
items,
unreadCount,
loading,
hasUnread,
hydrate,
refreshUnreadCount,
markRead,
markAllRead,
resetLocal,
}
})
+292
View File
@@ -0,0 +1,292 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type {
Cart,
CheckData,
CashPaymentReceipt,
Order,
SettlementType,
CheckRegistrationMode,
} from '@/types'
import { generateId } from '@/utils/format'
import { calculateCheckRass } from '@/utils/checkRass'
import { apiRequest, ApiError, getAccessToken } from '@/services/http'
import { useCatalogStore } from './catalog'
export const useOrderStore = defineStore('order', () => {
const orders = ref<Order[]>([])
const draftChecks = ref<CheckData[]>([])
const draftSettlementType = ref<SettlementType | null>(null)
const draftCheckMode = ref<CheckRegistrationMode | null>(null)
const draftOrderId = ref<string | null>(null)
const loading = ref(false)
const userOrders = computed(
() => (_userId: string) =>
[...orders.value].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
),
)
function getOrderById(id: string): Order | undefined {
return orders.value.find((o) => o.id === id)
}
/** شروع مرحله ثبت چک روی سفارشی که فاکتورش صادر شده است. */
function startCheckDraft(orderId: string, mode?: CheckRegistrationMode) {
draftOrderId.value = orderId
draftSettlementType.value = 'check'
draftChecks.value = []
draftCheckMode.value = mode ?? null
}
/** برای اصلاح چک، چک‌های ثبت‌شده سفارش به فرم برگردانده می‌شوند. */
function loadChecksIntoDraft(order: Order) {
draftOrderId.value = order.id
draftSettlementType.value = 'check'
draftCheckMode.value = order.checkMode ?? 'full'
draftChecks.value = (order.checks ?? []).map((c) => ({ ...c }))
}
function clearCheckDraft() {
draftOrderId.value = null
draftChecks.value = []
draftCheckMode.value = null
draftSettlementType.value = null
}
function setCheckMode(mode: CheckRegistrationMode) {
draftCheckMode.value = mode
}
function addDraftCheck(check: Omit<CheckData, 'id'>) {
draftChecks.value.push({ ...check, id: generateId() })
}
function updateDraftCheck(id: string, data: Partial<CheckData>) {
const index = draftChecks.value.findIndex((c) => c.id === id)
if (index >= 0) {
draftChecks.value[index] = { ...draftChecks.value[index]!, ...data }
}
}
function removeDraftCheck(id: string) {
draftChecks.value = draftChecks.value.filter((c) => c.id !== id)
}
function getDraftRass(baseDate?: Date) {
return calculateCheckRass(draftChecks.value, baseDate ?? new Date())
}
async function hydrate() {
if (!getAccessToken()) {
orders.value = []
return
}
loading.value = true
try {
orders.value = await apiRequest<Order[]>('/orders')
} catch {
orders.value = []
} finally {
loading.value = false
}
}
function resetLocal() {
orders.value = []
clearCheckDraft()
}
function upsertOrder(order: Order) {
const idx = orders.value.findIndex((o) => o.id === order.id)
if (idx >= 0) orders.value[idx] = order
else orders.value.unshift(order)
}
async function fetchOrder(id: string): Promise<Order | null> {
try {
const order = await apiRequest<Order>(`/orders/${id}`)
upsertOrder(order)
return order
} catch {
return null
}
}
/**
* سفارش بدون روش تسویه ثبت می‌شود؛ مشتری بعد از قیمت‌گذاری مدیریت
* روش تسویه را انتخاب می‌کند.
* اقلام همه شرکت‌ها در یک فاکتور واحد ثبت می‌شوند.
*/
async function submitOrder(cart: Cart): Promise<Order> {
if (!cart.items.length) {
throw new Error('سبد خرید خالی است.')
}
if (cart.id === 'local' || cart.items.some((item) => item.id.startsWith('local-item-'))) {
throw new Error('سبد خرید با سرور همگام نیست. لطفاً دوباره محصولات را به سبد اضافه کنید.')
}
const companyIds = [
...new Set(cart.items.map((item) => item.companyId).filter(Boolean)),
]
if (!companyIds.length) {
throw new Error('شرکت سبد مشخص نیست. سبد را خالی کنید و دوباره اضافه کنید.')
}
try {
const order = await apiRequest<Order>('/orders', {
method: 'POST',
body: {
companyId: companyIds[0],
notes: cart.notes,
items: cart.items.map((item) => ({
productId: item.productId,
meterage: Number(item.meterage),
notes: item.notes,
})),
},
})
orders.value.unshift(order)
clearCheckDraft()
return order
} catch (e) {
throw new Error(e instanceof ApiError ? e.message : 'ثبت سفارش ناموفق بود.')
}
}
/** انتخاب روش تسویه پس از صدور فاکتور. */
async function chooseSettlement(
orderId: string,
settlementType: SettlementType,
checkMode?: CheckRegistrationMode,
): Promise<Order> {
try {
const order = await apiRequest<Order>(`/orders/${orderId}/settlement`, {
method: 'POST',
body: {
settlementType,
checkMode: settlementType === 'check' ? (checkMode ?? 'full') : undefined,
},
})
upsertOrder(order)
return order
} catch (e) {
throw new Error(e instanceof ApiError ? e.message : 'ثبت روش تسویه ناموفق بود.')
}
}
async function submitCashReceipt(
orderId: string,
receipt: Omit<CashPaymentReceipt, 'id' | 'submittedAt'>,
) {
try {
await apiRequest(`/orders/${orderId}/cash-receipt`, {
method: 'POST',
body: {
amount: receipt.amount,
transferDate: receipt.transferDate,
transferTime: receipt.transferTime,
trackingNumber: receipt.trackingNumber,
destinationAccountId: receipt.destinationAccountId,
receiptImage: receipt.receiptImage,
notes: receipt.notes,
},
})
const order = await fetchOrder(orderId)
if (!order) {
throw new Error('رسید ثبت شد ولی دریافت سفارش ناموفق بود.')
}
return order
} catch (e) {
throw new Error(e instanceof ApiError ? e.message : 'ثبت رسید ناموفق بود.')
}
}
async function updateOrderChecks(
orderId: string,
checks: CheckData[],
checkMode?: CheckRegistrationMode,
): Promise<Order> {
try {
const order = await apiRequest<Order>(`/orders/${orderId}/checks`, {
method: 'PATCH',
body: {
checkMode,
checks: checks.map((c) => ({
amount: c.amount,
dueDate: c.dueDate,
sayadId: c.sayadId,
checkNumber: c.checkNumber,
ownerNationalId: c.ownerNationalId,
ownerFirstName: c.ownerFirstName,
ownerLastName: c.ownerLastName,
bankName: c.bankName,
branchName: c.branchName,
frontImage: c.frontImage,
backImage: c.backImage,
notes: c.notes,
mode: c.mode,
})),
},
})
upsertOrder(order)
clearCheckDraft()
return order
} catch (e) {
throw new Error(e instanceof ApiError ? e.message : 'ثبت چک‌ها ناموفق بود.')
}
}
async function getBankAccountsForOrder(order: Order) {
const companyIds = [
...new Set(
[
order.companyId,
...order.items.map((item) => item.companyId).filter(Boolean),
].filter(Boolean) as string[],
),
]
const catalog = useCatalogStore()
const lists = await Promise.all(companyIds.map((id) => catalog.loadBankAccounts(id)))
const byId = new Map<string, (typeof lists)[number][number]>()
for (const list of lists) {
for (const account of list) {
byId.set(account.id, account)
}
}
return [...byId.values()]
}
async function getBankAccountsForCompany(companyId: string) {
return useCatalogStore().loadBankAccounts(companyId)
}
return {
orders,
draftChecks,
draftSettlementType,
draftCheckMode,
draftOrderId,
loading,
userOrders,
getOrderById,
startCheckDraft,
loadChecksIntoDraft,
clearCheckDraft,
setCheckMode,
addDraftCheck,
updateDraftCheck,
removeDraftCheck,
getDraftRass,
submitOrder,
chooseSettlement,
submitCashReceipt,
updateOrderChecks,
hydrate,
resetLocal,
fetchOrder,
getBankAccountsForOrder,
getBankAccountsForCompany,
}
})
+47
View File
@@ -0,0 +1,47 @@
import { defineStore } from 'pinia'
import { computed, watchEffect } from 'vue'
import { usePreferredDark, useStorage } from '@vueuse/core'
export type ThemePreference = 'light' | 'dark' | 'system'
export const useThemeStore = defineStore('theme', () => {
const preference = useStorage<ThemePreference>('ft_theme', 'system')
const prefersDark = usePreferredDark()
const resolved = computed<'light' | 'dark'>(() => {
if (preference.value === 'system') return prefersDark.value ? 'dark' : 'light'
return preference.value
})
const isDark = computed(() => resolved.value === 'dark')
watchEffect(() => {
const root = document.documentElement
root.classList.toggle('dark', isDark.value)
root.style.colorScheme = resolved.value
})
function setPreference(value: ThemePreference) {
preference.value = value
}
function cyclePreference() {
const order: ThemePreference[] = ['light', 'dark', 'system']
const index = order.indexOf(preference.value)
preference.value = order[(index + 1) % order.length]!
}
/** Explicit light ↔ dark toggle (skips system). */
function toggleLightDark() {
preference.value = isDark.value ? 'light' : 'dark'
}
return {
preference,
resolved,
isDark,
setPreference,
cyclePreference,
toggleLightDark,
}
})