Initial commit: FunZone accounting frontend with Sepidar-inspired RTL modules

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Shayan Azadi
2026-07-01 00:29:25 +03:30
commit ce6c110e06
52 changed files with 8257 additions and 0 deletions

57
src/App.tsx Normal file
View File

@@ -0,0 +1,57 @@
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
import { AppStoreProvider } from './store/AppStore'
import { ConnectionProvider, useConnection } from './connection/ConnectionContext'
import { Layout } from './components/layout/Layout'
import { Login } from './pages/Login'
import { Dashboard } from './pages/Dashboard'
import { Accounts } from './pages/Accounts'
import { Vouchers } from './pages/Vouchers'
import { Sales } from './pages/Sales'
import { Purchases } from './pages/Purchases'
import { Inventory } from './pages/Inventory'
import { Treasury } from './pages/Treasury'
import { Payroll } from './pages/Payroll'
import { Owners } from './pages/Owners'
import { Customers } from './pages/Customers'
import { Reports } from './pages/Reports'
import { Settings } from './pages/Settings'
function AppRoutes() {
const { connected } = useConnection()
if (!connected) {
return <Login />
}
return (
<BrowserRouter>
<Routes>
<Route element={<Layout />}>
<Route path="/" element={<Dashboard />} />
<Route path="/accounts" element={<Accounts />} />
<Route path="/vouchers" element={<Vouchers />} />
<Route path="/sales" element={<Sales />} />
<Route path="/purchases" element={<Purchases />} />
<Route path="/inventory" element={<Inventory />} />
<Route path="/treasury" element={<Treasury />} />
<Route path="/payroll" element={<Payroll />} />
<Route path="/owners" element={<Owners />} />
<Route path="/customers" element={<Customers />} />
<Route path="/reports" element={<Reports />} />
<Route path="/settings" element={<Settings />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
</BrowserRouter>
)
}
export default function App() {
return (
<ConnectionProvider>
<AppStoreProvider>
<AppRoutes />
</AppStoreProvider>
</ConnectionProvider>
)
}

View File

@@ -0,0 +1,218 @@
import { useCallback } from 'react'
import type { Voucher, VoucherAccountMap, VoucherLine } from '../types'
import type { ApiOwnerTransaction, ApiPaymentOrRefund } from '../api/types'
import { useStore } from '../store/AppStore'
import { createId, nextNumber } from '../utils/id'
export interface SyncResult {
created: number
skipped: number
}
const MAPPING_LABELS: Record<keyof VoucherAccountMap, string> = {
bankAccountId: 'حساب نقد/بانک',
ownerPayableAccountId: 'حساب بدهی به مالکان',
customerPayableAccountId: 'حساب بدهی به مشتریان',
ownerShareAccountId: 'حساب سهم مالکان از فروش',
salesIncomeAccountId: 'حساب درآمد فروش',
}
function assertMapping(map: VoucherAccountMap): void {
const missing = (Object.keys(MAPPING_LABELS) as Array<keyof VoucherAccountMap>).filter(
(key) => !map[key],
)
if (missing.length > 0) {
const names = missing.map((key) => MAPPING_LABELS[key]).join('، ')
throw new Error(`نگاشت حساب‌های صدور سند کامل نیست. ابتدا در تنظیمات تعیین کنید: ${names}`)
}
}
function makeLine(accountId: string, debit: number, credit: number, description: string): VoucherLine {
return { id: createId('vl-'), accountId, debit, credit, description }
}
function makeVoucher(
number: number,
isoDate: string,
description: string,
lines: VoucherLine[],
source: string,
): Voucher {
return {
id: createId('v-'),
number,
date: isoDate.slice(0, 10),
description,
status: 'posted',
source,
lines,
}
}
function buildOwnerVouchers(
records: ApiOwnerTransaction[],
map: VoucherAccountMap,
existingSources: Set<string>,
startNumber: number,
): { vouchers: Voucher[]; skipped: number } {
const vouchers: Voucher[] = []
let skipped = 0
let number = startNumber
for (const txn of records) {
if (txn.status !== 'completed') continue
const amount = Math.abs(txn.amount)
if (amount <= 0) continue
const source = `funzone:owner-txn:${txn.id}`
if (existingSources.has(source)) {
skipped += 1
continue
}
let lines: VoucherLine[]
let description: string
switch (txn.type) {
case 'deposit':
lines = [
makeLine(map.bankAccountId, amount, 0, 'واریز کیف پول مالک'),
makeLine(map.ownerPayableAccountId, 0, amount, 'بدهی به مالک'),
]
description = 'واریز به کیف پول مالک'
break
case 'withdraw':
lines = [
makeLine(map.ownerPayableAccountId, amount, 0, 'تسویه با مالک'),
makeLine(map.bankAccountId, 0, amount, 'پرداخت از بانک'),
]
description = 'برداشت مالک از کیف پول'
break
case 'booking_payment':
lines = [
makeLine(map.ownerShareAccountId, amount, 0, 'سهم مالک از رزرو'),
makeLine(map.ownerPayableAccountId, 0, amount, 'بدهی به مالک'),
]
description = 'سهم مالک از فروش رزرو'
break
case 'refund':
lines = [
makeLine(map.ownerPayableAccountId, amount, 0, 'بازپرداخت به مالک'),
makeLine(map.bankAccountId, 0, amount, 'پرداخت از بانک'),
]
description = 'بازپرداخت به مالک'
break
default:
continue
}
vouchers.push(makeVoucher(number, txn.created_at, description, lines, source))
number += 1
}
return { vouchers, skipped }
}
function buildCustomerVouchers(
records: ApiPaymentOrRefund[],
map: VoucherAccountMap,
existingSources: Set<string>,
startNumber: number,
): { vouchers: Voucher[]; skipped: number } {
const vouchers: Voucher[] = []
let skipped = 0
let number = startNumber
for (const item of records) {
const amount = Math.abs(item.amount)
if (amount <= 0) continue
const source = `funzone:cust:${item.id}`
if (existingSources.has(source)) {
skipped += 1
continue
}
const isWallet = (item.event_name || '').toLowerCase() === 'wallet'
const label = item.customer_name || 'مشتری'
let lines: VoucherLine[]
let description: string
if (item.type === 'payment') {
if (isWallet) {
lines = [
makeLine(map.bankAccountId, amount, 0, 'شارژ کیف پول'),
makeLine(map.customerPayableAccountId, 0, amount, 'بدهی به مشتری'),
]
description = `شارژ کیف پول - ${label}`
} else {
lines = [
makeLine(map.bankAccountId, amount, 0, 'دریافت بابت رزرو'),
makeLine(map.salesIncomeAccountId, 0, amount, `درآمد - ${item.event_name}`),
]
description = `فروش/رزرو - ${label}`
}
} else if (isWallet) {
lines = [
makeLine(map.customerPayableAccountId, amount, 0, 'برداشت کیف پول'),
makeLine(map.bankAccountId, 0, amount, 'پرداخت از بانک'),
]
description = `برداشت کیف پول - ${label}`
} else {
lines = [
makeLine(map.salesIncomeAccountId, amount, 0, `کاهش درآمد - ${item.event_name}`),
makeLine(map.bankAccountId, 0, amount, 'بازپرداخت'),
]
description = `بازپرداخت رزرو - ${label}`
}
vouchers.push(makeVoucher(number, item.created_at, description, lines, source))
number += 1
}
return { vouchers, skipped }
}
/**
* Generates journal vouchers from live FunZone records using the configurable
* account mapping in company settings, persisting them via the accounting API.
* Existing imports are detected via the voucher `source` tag and skipped.
*/
export function useVoucherSync() {
const { data, createVouchers } = useStore()
const run = useCallback(
async (
build: (
map: VoucherAccountMap,
existingSources: Set<string>,
startNumber: number,
) => { vouchers: Voucher[]; skipped: number },
): Promise<SyncResult> => {
const map = data.settings.voucherAccounts
assertMapping(map)
const existingSources = new Set(
data.vouchers.map((v) => v.source).filter((s): s is string => Boolean(s)),
)
const startNumber = nextNumber(data.vouchers)
const { vouchers, skipped } = build(map, existingSources, startNumber)
const created = await createVouchers(vouchers)
return { created, skipped }
},
[data.settings.voucherAccounts, data.vouchers, createVouchers],
)
const syncOwnerTransactions = useCallback(
(records: ApiOwnerTransaction[]): Promise<SyncResult> =>
run((map, sources, start) => buildOwnerVouchers(records, map, sources, start)),
[run],
)
const syncCustomerActivity = useCallback(
(records: ApiPaymentOrRefund[]): Promise<SyncResult> =>
run((map, sources, start) => buildCustomerVouchers(records, map, sources, start)),
[run],
)
return { syncOwnerTransactions, syncCustomerActivity }
}

109
src/api/client.ts Normal file
View File

@@ -0,0 +1,109 @@
import { ENDPOINTS, getAccountingBaseUrl, getApiBaseUrl } from './config'
const TOKEN_KEY = 'funzone-accounting:token'
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY)
}
export function setToken(token: string): void {
localStorage.setItem(TOKEN_KEY, token)
}
export function clearToken(): void {
localStorage.removeItem(TOKEN_KEY)
}
export class ApiError extends Error {
constructor(message: string, public readonly status: number) {
super(message)
this.name = 'ApiError'
}
}
async function request<T>(path: string, init?: RequestInit, baseUrl: string = getApiBaseUrl()): Promise<T> {
const token = getToken()
let response: Response
try {
response = await fetch(`${baseUrl}${path}`, {
...init,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(init?.headers ?? {}),
},
})
} catch {
throw new ApiError('عدم دسترسی به سرور. آدرس سرور و اتصال شبکه را بررسی کنید.', 0)
}
if (response.status === 401) {
throw new ApiError('نشست شما منقضی شده است. دوباره وارد شوید.', 401)
}
if (response.status === 403) {
throw new ApiError('دسترسی غیرمجاز. با حساب مدیر وارد شوید.', 403)
}
if (!response.ok) {
const data = await response.json().catch(() => ({}))
const message = data?.error || data?.detail || `خطای سرور (${response.status})`
throw new ApiError(message, response.status)
}
if (response.status === 204) return undefined as T
const text = await response.text()
return (text ? JSON.parse(text) : undefined) as T
}
export const apiGet = <T>(path: string): Promise<T> => request<T>(path)
export const apiPatch = <T>(path: string, body: unknown): Promise<T> =>
request<T>(path, { method: 'PATCH', body: JSON.stringify(body) })
/** Client bound to the standalone accounting backend (shares the admin token). */
export const acct = {
get: <T>(path: string): Promise<T> => request<T>(path, undefined, getAccountingBaseUrl()),
post: <T>(path: string, body: unknown): Promise<T> =>
request<T>(path, { method: 'POST', body: JSON.stringify(body) }, getAccountingBaseUrl()),
patch: <T>(path: string, body: unknown): Promise<T> =>
request<T>(path, { method: 'PATCH', body: JSON.stringify(body) }, getAccountingBaseUrl()),
put: <T>(path: string, body: unknown): Promise<T> =>
request<T>(path, { method: 'PUT', body: JSON.stringify(body) }, getAccountingBaseUrl()),
delete: (path: string): Promise<void> =>
request<void>(path, { method: 'DELETE' }, getAccountingBaseUrl()),
}
/** Authenticates against the admin token endpoint and returns the access token. */
export async function login(username: string, password: string): Promise<string> {
let response: Response
try {
response = await fetch(`${getApiBaseUrl()}${ENDPOINTS.ADMIN_LOGIN}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
})
} catch {
throw new ApiError('عدم دسترسی به سرور. آدرس سرور را بررسی کنید.', 0)
}
if (!response.ok) {
const data = await response.json().catch(() => ({}))
throw new ApiError(data?.error || data?.detail || 'نام کاربری یا رمز عبور نادرست است.', response.status)
}
const data = (await response.json()) as { access?: string }
if (!data.access) {
throw new ApiError('پاسخ نامعتبر از سرور دریافت شد.', 500)
}
return data.access
}
/** Normalizes a DRF response that may be a bare array or a paginated object. */
export function asList<T>(data: unknown): T[] {
if (Array.isArray(data)) return data as T[]
if (data && typeof data === 'object') {
const record = data as Record<string, unknown>
if (Array.isArray(record.results)) return record.results as T[]
if (Array.isArray(record.transactions)) return record.transactions as T[]
}
return []
}

58
src/api/config.ts Normal file
View File

@@ -0,0 +1,58 @@
const BASE_URL_KEY = 'funzone-accounting:apiBaseUrl'
const DEFAULT_BASE_URL = 'http://localhost:8000/api'
const ACCOUNTING_BASE_URL_KEY = 'funzone-accounting:acctBaseUrl'
const DEFAULT_ACCOUNTING_BASE_URL = 'http://localhost:8010/api'
/** Resolves the FunZone backend base URL: stored override → build env → default. */
export function getApiBaseUrl(): string {
const stored = localStorage.getItem(BASE_URL_KEY)
if (stored) return stored
const env = import.meta.env.VITE_API_BASE_URL as string | undefined
return (env || DEFAULT_BASE_URL).replace(/\/$/, '')
}
export function setApiBaseUrl(url: string): void {
localStorage.setItem(BASE_URL_KEY, url.trim().replace(/\/$/, ''))
}
/** Resolves the standalone accounting service base URL. */
export function getAccountingBaseUrl(): string {
const stored = localStorage.getItem(ACCOUNTING_BASE_URL_KEY)
if (stored) return stored
const env = import.meta.env.VITE_ACCOUNTING_BASE_URL as string | undefined
return (env || DEFAULT_ACCOUNTING_BASE_URL).replace(/\/$/, '')
}
export function setAccountingBaseUrl(url: string): void {
localStorage.setItem(ACCOUNTING_BASE_URL_KEY, url.trim().replace(/\/$/, ''))
}
/** Admin/back-office endpoints reused from the FunZone backend. */
export const ENDPOINTS = {
ADMIN_LOGIN: '/auth/token/admin/',
OWNERS: '/owners/',
CUSTOMERS: '/customers/',
OWNER_TRANSACTIONS: (ownerId: string) =>
`/owners/wallet/admin/transactions/?owner_id=${ownerId}`,
ALL_OWNER_TRANSACTIONS: '/owners/wallet/admin/transactions/',
CUSTOMER_PAYMENTS_REFUNDS: (customerId?: string) =>
customerId
? `/payments/admin/customer-payments-refunds/?customer_id=${customerId}`
: '/payments/admin/customer-payments-refunds/',
WITHDRAWALS: '/payments/admin/withdrawals/',
} as const
/** Endpoints served by the standalone accounting backend (relative to its base URL). */
export const ACCOUNTING_ENDPOINTS = {
ACCOUNTS: '/accounting/accounts/',
PARTIES: '/accounting/parties/',
PRODUCTS: '/accounting/products/',
VOUCHERS: '/accounting/vouchers/',
INVOICES: '/accounting/invoices/',
TREASURY: '/accounting/treasury/',
EMPLOYEES: '/accounting/employees/',
PAYSLIPS: '/accounting/payslips/',
SETTINGS: '/accounting/settings/',
RESET: '/accounting/reset/',
} as const

71
src/api/types.ts Normal file
View File

@@ -0,0 +1,71 @@
export interface ApiOwner {
id: string
f_name: string | null
l_name: string | null
username: string | null
mobile_number: string | null
email: string | null
address: string | null
balance: number
credit_number: string | null
is_active: boolean
created_at: string
social_hubs?: string[]
}
export interface ApiCustomer {
id: string
f_name: string | null
l_name: string | null
username: string | null
mobile_number: string | null
email: string | null
address: string | null
balance: number
iban: string | null
is_active: boolean
created_at: string
}
export type WalletTxnType = 'deposit' | 'withdraw' | 'booking_payment' | 'refund'
export type WalletTxnStatus = 'pending' | 'completed' | 'failed'
export interface ApiOwnerTransaction {
id: string
owner_id: string
type: WalletTxnType
amount: number
status: WalletTxnStatus
description: string
created_at: string
updated_at: string
}
export interface ApiPaymentOrRefund {
id: string
type: 'payment' | 'cancellation'
customer_id: string
customer_name: string
trace_no: string
ref_num: string
amount: number
event_name: string
event_date: string | null
event_time: string | null
created_at: string
reservation_id: string | null
refund_status: WalletTxnStatus | null
}
export interface ApiWithdrawal {
id: string
user_type: 'owner' | 'customer'
user_id: string
user_name: string
username: string
amount: number
status: WalletTxnStatus
description: string
iban: string
created_at: string
}

View File

@@ -0,0 +1,60 @@
import { useState } from 'react'
import { useConnection } from '../../connection/ConnectionContext'
import { Button, Card, Field, Input } from '../ui'
/**
* Shown on live-data pages when no backend session is active.
* Lets the user authenticate inline without navigating to Settings.
*/
export function ConnectionRequired() {
const { baseUrl, busy, error, connect } = useConnection()
const [url, setUrl] = useState(baseUrl)
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const handleConnect = () => {
if (!username || !password) return
void connect(url, username, password)
}
return (
<Card className="mx-auto max-w-md p-6">
<div className="mb-4 text-center">
<div className="mb-2 text-4xl">🔌</div>
<h2 className="text-lg font-bold text-slate-800">اتصال به سرور فانزون</h2>
<p className="mt-1 text-sm text-slate-500">
برای مشاهده اطلاعات واقعی، با حساب مدیر (admin) وارد شوید.
</p>
</div>
{error && (
<div className="mb-4 rounded-lg border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
{error}
</div>
)}
<div className="space-y-4">
<Field label="آدرس سرور" hint="مثال: http://localhost:8000/api">
<Input value={url} onChange={(e) => setUrl(e.target.value)} dir="ltr" placeholder="http://localhost:8000/api" />
</Field>
<Field label="نام کاربری مدیر">
<Input value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" placeholder="Admin" />
</Field>
<Field label="رمز عبور">
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
onKeyDown={(e) => {
if (e.key === 'Enter') handleConnect()
}}
/>
</Field>
<Button className="w-full" onClick={handleConnect} disabled={busy || !username || !password}>
{busy ? 'در حال اتصال…' : 'اتصال و ورود'}
</Button>
</div>
</Card>
)
}

View File

@@ -0,0 +1,500 @@
import { useMemo, useState, type ReactNode } from 'react'
import { useStore } from '../../store/AppStore'
import {
Badge,
Button,
Card,
ConfirmDialog,
DataTable,
Field,
Input,
Modal,
PageHeader,
Select,
Textarea,
type Column,
} from '../ui'
import type { Invoice, InvoiceKind, InvoiceLine, InvoiceStatus, Party } from '../../types'
import { createId, nextNumber } from '../../utils/id'
import { formatDate, formatMoney, toFa } from '../../utils/format'
import { invoiceTotals, lineTotals } from '../../utils/accounting'
interface TradeConfig {
kind: InvoiceKind
title: string
subtitle: string
partyLabel: string
partyTabLabel: string
}
const statusLabels: Record<InvoiceStatus, string> = {
draft: 'پیش‌نویس',
confirmed: 'قطعی',
paid: 'تسویه‌شده',
}
const statusTones: Record<InvoiceStatus, 'amber' | 'blue' | 'green'> = {
draft: 'amber',
confirmed: 'blue',
paid: 'green',
}
export function TradeModule({ config }: { config: TradeConfig }) {
const [tab, setTab] = useState<'invoices' | 'parties'>('invoices')
return (
<div className="space-y-6">
<PageHeader title={config.title} subtitle={config.subtitle} />
<div className="inline-flex rounded-xl bg-slate-200/60 p-1">
<TabButton active={tab === 'invoices'} onClick={() => setTab('invoices')}>
فاکتورها
</TabButton>
<TabButton active={tab === 'parties'} onClick={() => setTab('parties')}>
{config.partyTabLabel}
</TabButton>
</div>
{tab === 'invoices' ? <InvoiceSection config={config} /> : <PartySection config={config} />}
</div>
)
}
function TabButton({
active,
onClick,
children,
}: {
active: boolean
onClick: () => void
children: ReactNode
}) {
return (
<button
onClick={onClick}
className={`rounded-lg px-5 py-2 text-sm font-medium transition ${
active ? 'bg-white text-brand-700 shadow-sm' : 'text-slate-500 hover:text-slate-700'
}`}
>
{children}
</button>
)
}
/* --------------------------------- Invoices -------------------------------- */
const newInvoiceLine = (): InvoiceLine => ({
id: createId('il-'),
productId: '',
quantity: 1,
unitPrice: 0,
discount: 0,
taxRate: 0,
})
function InvoiceSection({ config }: { config: TradeConfig }) {
const { data, upsertInvoice, removeInvoice } = useStore()
const [draft, setDraft] = useState<Invoice | null>(null)
const [toDelete, setToDelete] = useState<Invoice | null>(null)
const parties = useMemo(
() => data.parties.filter((party) => party.kind === (config.kind === 'sale' ? 'customer' : 'supplier')),
[data.parties, config.kind],
)
const invoices = useMemo(
() =>
data.invoices
.filter((invoice) => invoice.kind === config.kind)
.sort((a, b) => b.date.localeCompare(a.date) || b.number - a.number),
[data.invoices, config.kind],
)
const partyName = (id: string) => data.parties.find((p) => p.id === id)?.name ?? '—'
const createDraft = (): Invoice => ({
id: '',
kind: config.kind,
number: nextNumber(data.invoices.filter((i) => i.kind === config.kind)),
partyId: parties[0]?.id ?? '',
date: new Date().toISOString().slice(0, 10),
status: 'draft',
note: '',
lines: [newInvoiceLine()],
})
const updateLine = (lineId: string, patch: Partial<InvoiceLine>) => {
if (!draft) return
setDraft({
...draft,
lines: draft.lines.map((line) => (line.id === lineId ? { ...line, ...patch } : line)),
})
}
const onSelectProduct = (lineId: string, productId: string) => {
const product = data.products.find((p) => p.id === productId)
updateLine(lineId, {
productId,
unitPrice: product ? (config.kind === 'sale' ? product.salePrice : product.purchasePrice) : 0,
taxRate: data.settings.taxRate,
})
}
const handleSave = () => {
if (!draft || !draft.partyId) return
const cleanLines = draft.lines.filter((line) => line.productId && line.quantity > 0)
if (cleanLines.length === 0) return
upsertInvoice({ ...draft, lines: cleanLines, id: draft.id || createId('inv-') })
setDraft(null)
}
const totals = draft ? invoiceTotals(draft) : null
const columns: Array<Column<Invoice>> = [
{ key: 'number', header: 'شماره', render: (i) => <span className="font-mono">{toFa(i.number)}</span> },
{ key: 'date', header: 'تاریخ', render: (i) => formatDate(i.date) },
{ key: 'party', header: config.partyLabel, render: (i) => partyName(i.partyId) },
{
key: 'count',
header: 'اقلام',
align: 'center',
render: (i) => toFa(i.lines.length),
},
{
key: 'net',
header: 'مبلغ کل',
align: 'end',
render: (i) => <span className="tabular-nums">{formatMoney(invoiceTotals(i).net)}</span>,
},
{ key: 'status', header: 'وضعیت', render: (i) => <Badge tone={statusTones[i.status]}>{statusLabels[i.status]}</Badge> },
{
key: 'actions',
header: '',
align: 'end',
render: (i) => (
<div className="flex justify-end gap-1">
<Button size="sm" variant="ghost" onClick={() => setDraft({ ...i, lines: i.lines.map((l) => ({ ...l })) })}>
ویرایش
</Button>
<Button size="sm" variant="ghost" className="text-rose-600" onClick={() => setToDelete(i)}>
حذف
</Button>
</div>
),
},
]
return (
<>
<Card className="p-4">
<div className="mb-4 flex justify-end">
<Button icon="" onClick={() => setDraft(createDraft())} disabled={parties.length === 0}>
فاکتور جدید
</Button>
</div>
{parties.length === 0 ? (
<p className="py-10 text-center text-sm text-slate-400">
ابتدا حداقل یک {config.partyLabel} تعریف کنید.
</p>
) : (
<DataTable
columns={columns}
rows={invoices}
rowKey={(i) => i.id}
emptyTitle="فاکتوری ثبت نشده است"
/>
)}
</Card>
<Modal
open={draft !== null}
title={draft?.id ? `ویرایش فاکتور #${toFa(draft.number)}` : 'فاکتور جدید'}
onClose={() => setDraft(null)}
size="xl"
footer={
<>
{totals && (
<div className="me-auto flex flex-wrap items-center gap-4 text-sm text-slate-500">
<span>تخفیف: <span className="tabular-nums text-slate-700">{formatMoney(totals.discount)}</span></span>
<span>مالیات: <span className="tabular-nums text-slate-700">{formatMoney(totals.tax)}</span></span>
<span className="font-semibold text-slate-800">
قابل پرداخت: <span className="tabular-nums text-brand-700">{formatMoney(totals.net)}</span>
</span>
</div>
)}
<Button variant="secondary" onClick={() => setDraft(null)}>
انصراف
</Button>
<Button onClick={handleSave}>ذخیره فاکتور</Button>
</>
}
>
{draft && (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
<Field label="شماره فاکتور">
<Input
type="number"
value={draft.number}
onChange={(e) => setDraft({ ...draft, number: Number(e.target.value) || 0 })}
/>
</Field>
<Field label="تاریخ">
<Input type="date" value={draft.date} onChange={(e) => setDraft({ ...draft, date: e.target.value })} />
</Field>
<Field label={config.partyLabel}>
<Select value={draft.partyId} onChange={(e) => setDraft({ ...draft, partyId: e.target.value })}>
{parties.map((party) => (
<option key={party.id} value={party.id}>
{party.name}
</option>
))}
</Select>
</Field>
<Field label="وضعیت">
<Select
value={draft.status}
onChange={(e) => setDraft({ ...draft, status: e.target.value as InvoiceStatus })}
>
{Object.entries(statusLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</Select>
</Field>
</div>
<div className="overflow-x-auto rounded-xl border border-slate-200">
<table className="w-full text-sm">
<thead className="bg-slate-50 text-slate-500">
<tr>
<th className="px-3 py-2 text-start font-medium">کالا</th>
<th className="px-3 py-2 text-end font-medium">تعداد</th>
<th className="px-3 py-2 text-end font-medium">قیمت واحد</th>
<th className="px-3 py-2 text-end font-medium">تخفیف</th>
<th className="px-3 py-2 text-end font-medium">مالیات٪</th>
<th className="px-3 py-2 text-end font-medium">جمع</th>
<th className="px-3 py-2" />
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{draft.lines.map((line) => (
<tr key={line.id}>
<td className="px-2 py-1.5 min-w-[180px]">
<Select value={line.productId} onChange={(e) => onSelectProduct(line.id, e.target.value)}>
<option value="">انتخاب کالا</option>
{data.products.map((product) => (
<option key={product.id} value={product.id}>
{product.name}
</option>
))}
</Select>
</td>
<td className="px-2 py-1.5 w-24">
<Input
type="number"
className="text-end"
value={line.quantity}
onChange={(e) => updateLine(line.id, { quantity: Number(e.target.value) || 0 })}
/>
</td>
<td className="px-2 py-1.5 w-32">
<Input
type="number"
className="text-end"
value={line.unitPrice}
onChange={(e) => updateLine(line.id, { unitPrice: Number(e.target.value) || 0 })}
/>
</td>
<td className="px-2 py-1.5 w-28">
<Input
type="number"
className="text-end"
value={line.discount}
onChange={(e) => updateLine(line.id, { discount: Number(e.target.value) || 0 })}
/>
</td>
<td className="px-2 py-1.5 w-20">
<Input
type="number"
className="text-end"
value={line.taxRate}
onChange={(e) => updateLine(line.id, { taxRate: Number(e.target.value) || 0 })}
/>
</td>
<td className="px-3 py-1.5 text-end tabular-nums text-slate-700">
{formatMoney(lineTotals(line).net)}
</td>
<td className="px-2 py-1.5 text-center">
<button
onClick={() => setDraft({ ...draft, lines: draft.lines.filter((l) => l.id !== line.id) })}
className="text-slate-400 transition hover:text-rose-600"
aria-label="حذف ردیف"
disabled={draft.lines.length <= 1}
>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex items-center justify-between">
<Button
size="sm"
variant="secondary"
icon=""
onClick={() => setDraft({ ...draft, lines: [...draft.lines, newInvoiceLine()] })}
>
افزودن ردیف
</Button>
</div>
<Field label="توضیحات">
<Textarea
rows={2}
value={draft.note}
onChange={(e) => setDraft({ ...draft, note: e.target.value })}
placeholder="توضیحات فاکتور…"
/>
</Field>
</div>
)}
</Modal>
<ConfirmDialog
open={toDelete !== null}
title="حذف فاکتور"
message={`فاکتور شماره ${toFa(toDelete?.number ?? 0)} حذف شود؟`}
onCancel={() => setToDelete(null)}
onConfirm={() => {
if (toDelete) removeInvoice(toDelete.id)
setToDelete(null)
}}
/>
</>
)
}
/* --------------------------------- Parties --------------------------------- */
function PartySection({ config }: { config: TradeConfig }) {
const { data, upsertParty, removeParty } = useStore()
const [draft, setDraft] = useState<Party | null>(null)
const [toDelete, setToDelete] = useState<Party | null>(null)
const partyKind = config.kind === 'sale' ? 'customer' : 'supplier'
const parties = useMemo(
() => data.parties.filter((party) => party.kind === partyKind),
[data.parties, partyKind],
)
const createDraft = (): Party => ({
id: '',
kind: partyKind,
name: '',
phone: '',
economicCode: '',
address: '',
openingBalance: 0,
})
const handleSave = () => {
if (!draft || !draft.name.trim()) return
upsertParty({ ...draft, id: draft.id || createId('party-') })
setDraft(null)
}
const columns: Array<Column<Party>> = [
{ key: 'name', header: 'نام', render: (p) => <span className="font-medium text-slate-800">{p.name}</span> },
{ key: 'phone', header: 'تلفن', render: (p) => <span className="tabular-nums">{toFa(p.phone) || '—'}</span> },
{ key: 'eco', header: 'کد اقتصادی', render: (p) => <span className="font-mono text-slate-500">{toFa(p.economicCode) || '—'}</span> },
{
key: 'opening',
header: 'مانده اولیه',
align: 'end',
render: (p) => <span className="tabular-nums">{formatMoney(p.openingBalance)}</span>,
},
{
key: 'actions',
header: '',
align: 'end',
render: (p) => (
<div className="flex justify-end gap-1">
<Button size="sm" variant="ghost" onClick={() => setDraft({ ...p })}>
ویرایش
</Button>
<Button size="sm" variant="ghost" className="text-rose-600" onClick={() => setToDelete(p)}>
حذف
</Button>
</div>
),
},
]
return (
<>
<Card className="p-4">
<div className="mb-4 flex justify-end">
<Button icon="" onClick={() => setDraft(createDraft())}>
{config.partyLabel} جدید
</Button>
</div>
<DataTable columns={columns} rows={parties} rowKey={(p) => p.id} emptyTitle={`${config.partyLabel}ی ثبت نشده است`} />
</Card>
<Modal
open={draft !== null}
title={draft?.id ? `ویرایش ${config.partyLabel}` : `${config.partyLabel} جدید`}
onClose={() => setDraft(null)}
footer={
<>
<Button variant="secondary" onClick={() => setDraft(null)}>
انصراف
</Button>
<Button onClick={handleSave}>ذخیره</Button>
</>
}
>
{draft && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field label="نام">
<Input value={draft.name} onChange={(e) => setDraft({ ...draft, name: e.target.value })} />
</Field>
<Field label="تلفن">
<Input value={draft.phone} onChange={(e) => setDraft({ ...draft, phone: e.target.value })} />
</Field>
<Field label="کد اقتصادی">
<Input value={draft.economicCode} onChange={(e) => setDraft({ ...draft, economicCode: e.target.value })} />
</Field>
<Field label="مانده اولیه (تومان)">
<Input
type="number"
value={draft.openingBalance}
onChange={(e) => setDraft({ ...draft, openingBalance: Number(e.target.value) || 0 })}
/>
</Field>
<div className="sm:col-span-2">
<Field label="آدرس">
<Textarea rows={2} value={draft.address} onChange={(e) => setDraft({ ...draft, address: e.target.value })} />
</Field>
</div>
</div>
)}
</Modal>
<ConfirmDialog
open={toDelete !== null}
title={`حذف ${config.partyLabel}`}
message={`«${toDelete?.name}» حذف شود؟`}
onCancel={() => setToDelete(null)}
onConfirm={() => {
if (toDelete) removeParty(toDelete.id)
setToDelete(null)
}}
/>
</>
)
}

View File

@@ -0,0 +1,109 @@
import { useState } from 'react'
import { Outlet, useLocation } from 'react-router-dom'
import { Sidebar } from './Sidebar'
import { navItems } from './navigation'
import { useConnection } from '../../connection/ConnectionContext'
import { useStore } from '../../store/AppStore'
import { ConnectionRequired } from '../business/ConnectionRequired'
import { Button, Card } from '../ui'
function DataGate({ children }: { children: React.ReactNode }) {
const { connected, disconnect } = useConnection()
const { ready, loading, error, reload } = useStore()
if (!connected) {
return (
<div className="mx-auto max-w-md py-10">
<ConnectionRequired />
</div>
)
}
if (!ready) {
if (error) {
return (
<Card className="mx-auto max-w-md p-6 text-center">
<div className="mb-2 text-4xl"></div>
<h2 className="mb-1 text-lg font-bold text-slate-800">خطا در بارگذاری اطلاعات</h2>
<p className="mb-4 text-sm text-rose-600">{error}</p>
<p className="mb-4 text-xs text-slate-500">
مطمئن شوید سرویس حسابداری در دسترس است (پیشفرض پورت ۸۰۱۰).
</p>
<div className="flex items-center justify-center gap-2">
<Button onClick={reload} disabled={loading}>
{loading ? 'در حال تلاش…' : 'تلاش مجدد'}
</Button>
<Button variant="secondary" onClick={disconnect}>
خروج و ورود مجدد
</Button>
</div>
</Card>
)
}
return (
<div className="flex flex-col items-center justify-center py-20 text-slate-400">
<div className="mb-3 h-8 w-8 animate-spin rounded-full border-2 border-slate-300 border-t-brand-500" />
<p className="text-sm">در حال بارگذاری اطلاعات حسابداری</p>
</div>
)
}
return <>{children}</>
}
export function Layout() {
const [sidebarOpen, setSidebarOpen] = useState(false)
const location = useLocation()
const current = navItems.find(
(item) => item.to === location.pathname || (item.to !== '/' && location.pathname.startsWith(item.to)),
)
return (
<div className="flex min-h-screen bg-slate-100 text-slate-800">
<Sidebar open={sidebarOpen} onNavigate={() => setSidebarOpen(false)} />
{sidebarOpen && (
<div
className="fixed inset-0 z-30 bg-slate-900/30 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
<div className="flex min-w-0 flex-1 flex-col">
<header className="sticky top-0 z-20 flex items-center justify-between gap-3 border-b border-slate-200 bg-white/80 px-4 py-3 backdrop-blur lg:px-8">
<div className="flex items-center gap-3">
<button
onClick={() => setSidebarOpen((value) => !value)}
className="flex h-9 w-9 items-center justify-center rounded-lg border border-slate-200 text-slate-600 lg:hidden"
aria-label="منو"
>
</button>
<div className="flex items-center gap-2 text-sm text-slate-500">
<span className="text-lg">{current?.icon ?? '🏠'}</span>
<span className="font-semibold text-slate-700">{current?.label ?? 'داشبورد'}</span>
</div>
</div>
<div className="flex items-center gap-2">
<div className="hidden text-end sm:block">
<p className="text-sm font-semibold text-slate-700">مدیر سیستم</p>
<p className="text-xs text-slate-400">دسترسی کامل</p>
</div>
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-brand-100 font-bold text-brand-700">
م
</div>
</div>
</header>
<main className="mx-auto w-full max-w-7xl flex-1 px-4 py-6 lg:px-8">
<div className="animate-fade-in">
<DataGate>
<Outlet />
</DataGate>
</div>
</main>
</div>
</div>
)
}

View File

@@ -0,0 +1,68 @@
import { NavLink } from 'react-router-dom'
import { navItems, type NavItem } from './navigation'
import { useStore } from '../../store/AppStore'
function groupItems(items: NavItem[]): Array<[string, NavItem[]]> {
const map = new Map<string, NavItem[]>()
for (const item of items) {
const existing = map.get(item.group) ?? []
existing.push(item)
map.set(item.group, existing)
}
return Array.from(map.entries())
}
export function Sidebar({ open, onNavigate }: { open: boolean; onNavigate: () => void }) {
const { data } = useStore()
const groups = groupItems(navItems)
return (
<aside
className={`fixed inset-y-0 right-0 z-40 flex w-72 flex-col border-l border-slate-200 bg-white transition-transform duration-300 lg:static lg:translate-x-0 ${
open ? 'translate-x-0' : 'translate-x-full'
}`}
>
<div className="flex items-center gap-3 border-b border-slate-100 px-5 py-5">
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-gradient-to-br from-brand-500 to-brand-700 text-xl text-white shadow-sm">
</div>
<div className="min-w-0">
<p className="truncate text-sm font-bold text-slate-800">سپیدار فانزون</p>
<p className="truncate text-xs text-slate-400">{data.settings.name}</p>
</div>
</div>
<nav className="flex-1 space-y-5 overflow-y-auto px-3 py-4">
{groups.map(([group, items]) => (
<div key={group}>
<p className="px-3 pb-2 text-xs font-semibold uppercase tracking-wide text-slate-400">{group}</p>
<div className="space-y-1">
{items.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
onClick={onNavigate}
className={({ isActive }) =>
`flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition-colors ${
isActive
? 'bg-brand-50 text-brand-700'
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900'
}`
}
>
<span className="text-lg">{item.icon}</span>
<span>{item.label}</span>
</NavLink>
))}
</div>
</div>
))}
</nav>
<div className="border-t border-slate-100 px-5 py-4 text-xs text-slate-400">
سال مالی {data.settings.fiscalYear} · نسخه ۰.۱
</div>
</aside>
)
}

View File

@@ -0,0 +1,26 @@
export interface NavItem {
to: string
label: string
icon: string
group: string
}
/** Sidebar navigation mirroring Sepidar's system/module structure. */
export const navItems: NavItem[] = [
{ to: '/', label: 'داشبورد', icon: '🏠', group: 'اصلی' },
{ to: '/accounts', label: 'حساب‌ها (کدینگ)', icon: '🗂️', group: 'حسابداری' },
{ to: '/vouchers', label: 'اسناد حسابداری', icon: '📑', group: 'حسابداری' },
{ to: '/sales', label: 'مشتریان و فروش', icon: '🧾', group: 'عملیات' },
{ to: '/purchases', label: 'تأمین‌کنندگان و خرید', icon: '🛒', group: 'عملیات' },
{ to: '/inventory', label: 'انبارداری', icon: '📦', group: 'عملیات' },
{ to: '/treasury', label: 'دریافت و پرداخت', icon: '💰', group: 'عملیات' },
{ to: '/payroll', label: 'حقوق و دستمزد', icon: '👥', group: 'عملیات' },
{ to: '/owners', label: 'مالکان مجموعه‌ها', icon: '🏢', group: 'مدیریت فان‌زون' },
{ to: '/customers', label: 'کاربران', icon: '🧑‍🤝‍🧑', group: 'مدیریت فان‌زون' },
{ to: '/reports', label: 'گزارش‌ها', icon: '📊', group: 'تحلیل' },
{ to: '/settings', label: 'تنظیمات', icon: '⚙️', group: 'سیستم' },
]

View File

@@ -0,0 +1,42 @@
import { Modal } from './Modal'
import { Button } from './primitives'
interface ConfirmDialogProps {
open: boolean
title: string
message: string
confirmLabel?: string
cancelLabel?: string
onConfirm: () => void
onCancel: () => void
}
export function ConfirmDialog({
open,
title,
message,
confirmLabel = 'حذف',
cancelLabel = 'انصراف',
onConfirm,
onCancel,
}: ConfirmDialogProps) {
return (
<Modal
open={open}
title={title}
onClose={onCancel}
footer={
<>
<Button variant="secondary" onClick={onCancel}>
{cancelLabel}
</Button>
<Button variant="danger" onClick={onConfirm}>
{confirmLabel}
</Button>
</>
}
>
<p className="text-sm leading-7 text-slate-600">{message}</p>
</Modal>
)
}

View File

@@ -0,0 +1,78 @@
import type { ReactNode } from 'react'
import { EmptyState } from './primitives'
export interface Column<T> {
key: string
header: ReactNode
render: (row: T) => ReactNode
align?: 'start' | 'center' | 'end'
className?: string
}
interface DataTableProps<T> {
columns: Array<Column<T>>
rows: T[]
rowKey: (row: T) => string
onRowClick?: (row: T) => void
emptyTitle?: string
emptyDescription?: string
footer?: ReactNode
}
const alignClass = {
start: 'text-start',
center: 'text-center',
end: 'text-end',
} as const
export function DataTable<T>({
columns,
rows,
rowKey,
onRowClick,
emptyTitle = 'موردی یافت نشد',
emptyDescription,
footer,
}: DataTableProps<T>) {
if (rows.length === 0) {
return <EmptyState title={emptyTitle} description={emptyDescription} />
}
return (
<div className="overflow-x-auto">
<table className="w-full border-collapse text-sm">
<thead>
<tr className="border-b border-slate-200 bg-slate-50/70">
{columns.map((column) => (
<th
key={column.key}
className={`whitespace-nowrap px-4 py-3 font-semibold text-slate-500 ${alignClass[column.align ?? 'start']}`}
>
{column.header}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{rows.map((row) => (
<tr
key={rowKey(row)}
onClick={onRowClick ? () => onRowClick(row) : undefined}
className={`transition-colors ${onRowClick ? 'cursor-pointer hover:bg-brand-50/50' : 'hover:bg-slate-50'}`}
>
{columns.map((column) => (
<td
key={column.key}
className={`px-4 py-3 text-slate-700 ${alignClass[column.align ?? 'start']} ${column.className ?? ''}`}
>
{column.render(row)}
</td>
))}
</tr>
))}
</tbody>
{footer && <tfoot className="border-t-2 border-slate-200 bg-slate-50 font-semibold text-slate-800">{footer}</tfoot>}
</table>
</div>
)
}

View File

@@ -0,0 +1,57 @@
import { useEffect, type ReactNode } from 'react'
interface ModalProps {
open: boolean
title: string
onClose: () => void
children: ReactNode
footer?: ReactNode
size?: 'md' | 'lg' | 'xl'
}
const sizes = {
md: 'max-w-lg',
lg: 'max-w-2xl',
xl: 'max-w-4xl',
} as const
export function Modal({ open, title, onClose, children, footer, size = 'md' }: ModalProps) {
useEffect(() => {
if (!open) return
const handler = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose()
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [open, onClose])
if (!open) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-slate-900/40 backdrop-blur-sm" onClick={onClose} />
<div
className={`relative z-10 w-full ${sizes[size]} animate-fade-in overflow-hidden rounded-2xl bg-white shadow-xl`}
role="dialog"
aria-modal="true"
>
<div className="flex items-center justify-between border-b border-slate-100 px-5 py-4">
<h2 className="text-lg font-bold text-slate-800">{title}</h2>
<button
onClick={onClose}
className="flex h-8 w-8 items-center justify-center rounded-lg text-slate-400 transition hover:bg-slate-100 hover:text-slate-600"
aria-label="بستن"
>
</button>
</div>
<div className="max-h-[70vh] overflow-y-auto px-5 py-4">{children}</div>
{footer && (
<div className="flex items-center justify-end gap-2 border-t border-slate-100 bg-slate-50 px-5 py-3">
{footer}
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,15 @@
export {
Button,
Badge,
Card,
StatCard,
PageHeader,
EmptyState,
Field,
Input,
Textarea,
Select,
} from './primitives'
export { Modal } from './Modal'
export { DataTable, type Column } from './DataTable'
export { ConfirmDialog } from './ConfirmDialog'

View File

@@ -0,0 +1,197 @@
import type {
ButtonHTMLAttributes,
InputHTMLAttributes,
ReactNode,
SelectHTMLAttributes,
TextareaHTMLAttributes,
} from 'react'
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger' | 'success'
type ButtonSize = 'sm' | 'md'
const buttonVariants: Record<ButtonVariant, string> = {
primary: 'bg-brand-600 hover:bg-brand-700 text-white shadow-sm',
secondary: 'bg-white hover:bg-slate-50 text-slate-700 border border-slate-200',
ghost: 'bg-transparent hover:bg-slate-100 text-slate-600',
danger: 'bg-rose-600 hover:bg-rose-700 text-white shadow-sm',
success: 'bg-emerald-600 hover:bg-emerald-700 text-white shadow-sm',
}
const buttonSizes: Record<ButtonSize, string> = {
sm: 'px-3 py-1.5 text-xs',
md: 'px-4 py-2 text-sm',
}
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant
size?: ButtonSize
icon?: ReactNode
}
export function Button({
variant = 'primary',
size = 'md',
icon,
className = '',
children,
...rest
}: ButtonProps) {
return (
<button
className={`inline-flex items-center justify-center gap-2 rounded-lg font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${buttonVariants[variant]} ${buttonSizes[size]} ${className}`}
{...rest}
>
{icon}
{children}
</button>
)
}
type BadgeTone = 'slate' | 'green' | 'amber' | 'blue' | 'rose' | 'indigo'
const badgeTones: Record<BadgeTone, string> = {
slate: 'bg-slate-100 text-slate-700',
green: 'bg-emerald-100 text-emerald-700',
amber: 'bg-amber-100 text-amber-700',
blue: 'bg-sky-100 text-sky-700',
rose: 'bg-rose-100 text-rose-700',
indigo: 'bg-indigo-100 text-indigo-700',
}
export function Badge({ tone = 'slate', children }: { tone?: BadgeTone; children: ReactNode }) {
return (
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${badgeTones[tone]}`}>
{children}
</span>
)
}
export function Card({
children,
className = '',
}: {
children: ReactNode
className?: string
}) {
return (
<div className={`rounded-2xl border border-slate-200 bg-white shadow-card ${className}`}>
{children}
</div>
)
}
export function StatCard({
label,
value,
hint,
icon,
tone = 'brand',
}: {
label: string
value: ReactNode
hint?: ReactNode
icon?: ReactNode
tone?: 'brand' | 'green' | 'amber' | 'rose'
}) {
const tones = {
brand: 'from-brand-500 to-brand-700',
green: 'from-emerald-500 to-emerald-700',
amber: 'from-amber-500 to-amber-600',
rose: 'from-rose-500 to-rose-700',
} as const
return (
<Card className="p-5">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-sm text-slate-500">{label}</p>
<p className="mt-2 truncate text-2xl font-bold text-slate-800 tabular-nums">{value}</p>
{hint && <p className="mt-1 text-xs text-slate-400">{hint}</p>}
</div>
{icon && (
<div className={`flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br text-white ${tones[tone]}`}>
{icon}
</div>
)}
</div>
</Card>
)
}
export function PageHeader({
title,
subtitle,
actions,
}: {
title: string
subtitle?: string
actions?: ReactNode
}) {
return (
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-2xl font-bold text-slate-800">{title}</h1>
{subtitle && <p className="mt-1 text-sm text-slate-500">{subtitle}</p>}
</div>
{actions && <div className="flex flex-wrap items-center gap-2">{actions}</div>}
</div>
)
}
export function EmptyState({
icon = '📭',
title,
description,
action,
}: {
icon?: ReactNode
title: string
description?: string
action?: ReactNode
}) {
return (
<div className="flex flex-col items-center justify-center gap-2 py-14 text-center">
<div className="text-4xl">{icon}</div>
<p className="text-base font-semibold text-slate-700">{title}</p>
{description && <p className="max-w-sm text-sm text-slate-400">{description}</p>}
{action && <div className="mt-2">{action}</div>}
</div>
)
}
export function Field({
label,
children,
hint,
}: {
label: string
children: ReactNode
hint?: string
}) {
return (
<label className="block">
<span className="mb-1.5 block text-sm font-medium text-slate-600">{label}</span>
{children}
{hint && <span className="mt-1 block text-xs text-slate-400">{hint}</span>}
</label>
)
}
const fieldBase =
'w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800 outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-100'
export function Input({ className = '', ...rest }: InputHTMLAttributes<HTMLInputElement>) {
return <input className={`${fieldBase} ${className}`} {...rest} />
}
export function Textarea({ className = '', ...rest }: TextareaHTMLAttributes<HTMLTextAreaElement>) {
return <textarea className={`${fieldBase} ${className}`} {...rest} />
}
export function Select({ className = '', children, ...rest }: SelectHTMLAttributes<HTMLSelectElement>) {
return (
<select className={`${fieldBase} ${className}`} {...rest}>
{children}
</select>
)
}

View File

@@ -0,0 +1,72 @@
import {
createContext,
useCallback,
useContext,
useMemo,
useState,
type ReactNode,
} from 'react'
import { clearToken, getToken, login, setToken } from '../api/client'
import { getApiBaseUrl, setApiBaseUrl } from '../api/config'
interface ConnectionValue {
baseUrl: string
connected: boolean
busy: boolean
error: string | null
/** Sets the base URL, authenticates, and stores the access token. */
connect: (baseUrl: string, username: string, password: string) => Promise<boolean>
disconnect: () => void
}
const ConnectionContext = createContext<ConnectionValue | null>(null)
export function ConnectionProvider({ children }: { children: ReactNode }) {
const [baseUrl, setBaseUrl] = useState<string>(() => getApiBaseUrl())
const [connected, setConnected] = useState<boolean>(() => Boolean(getToken()))
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const connect = useCallback(
async (url: string, username: string, password: string): Promise<boolean> => {
setBusy(true)
setError(null)
try {
setApiBaseUrl(url)
setBaseUrl(getApiBaseUrl())
const token = await login(username, password)
setToken(token)
setConnected(true)
return true
} catch (err) {
setError(err instanceof Error ? err.message : 'اتصال ناموفق بود.')
setConnected(false)
return false
} finally {
setBusy(false)
}
},
[],
)
const disconnect = useCallback(() => {
clearToken()
setConnected(false)
setError(null)
}, [])
const value = useMemo<ConnectionValue>(
() => ({ baseUrl, connected, busy, error, connect, disconnect }),
[baseUrl, connected, busy, error, connect, disconnect],
)
return <ConnectionContext.Provider value={value}>{children}</ConnectionContext.Provider>
}
export function useConnection(): ConnectionValue {
const ctx = useContext(ConnectionContext)
if (!ctx) {
throw new Error('useConnection must be used within a ConnectionProvider')
}
return ctx
}

View File

@@ -0,0 +1,42 @@
import { useCallback, useEffect, useState } from 'react'
interface ApiResourceState<T> {
data: T | null
loading: boolean
error: string | null
reload: () => void
}
/**
* Fetches a remote resource and tracks loading/error state.
* Re-runs whenever `deps` change and only when `enabled` is true.
*/
export function useApiResource<T>(
fetcher: () => Promise<T>,
deps: ReadonlyArray<unknown>,
enabled = true,
): ApiResourceState<T> {
const [data, setData] = useState<T | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const run = useCallback(async () => {
if (!enabled) return
setLoading(true)
setError(null)
try {
setData(await fetcher())
} catch (err) {
setError(err instanceof Error ? err.message : 'خطای ناشناخته رخ داد.')
} finally {
setLoading(false)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled, ...deps])
useEffect(() => {
run()
}, [run])
return { data, loading, error, reload: run }
}

62
src/index.css Normal file
View File

@@ -0,0 +1,62 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
color-scheme: light;
}
html,
body {
margin: 0;
padding: 0;
background-color: #f1f5f9;
font-family: 'Vazirmatn', 'Shabnam', 'Inter', 'Segoe UI', 'Tahoma', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
}
/* Persian digits feel: tabular numbers for tables */
.tabular-nums {
font-variant-numeric: tabular-nums;
}
/* Custom slim scrollbar */
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 9999px;
border: 2px solid transparent;
background-clip: content-box;
}
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
background-clip: content-box;
}
@layer utilities {
.animate-fade-in {
animation: fadeIn 0.25s ease-out;
}
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

10
src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

242
src/pages/Accounts.tsx Normal file
View File

@@ -0,0 +1,242 @@
import { useMemo, useState } from 'react'
import { useStore } from '../store/AppStore'
import {
Badge,
Button,
Card,
ConfirmDialog,
DataTable,
Field,
Input,
Modal,
PageHeader,
Select,
type Column,
} from '../components/ui'
import type { Account, AccountType } from '../types'
import { createId } from '../utils/id'
import { formatMoney, toFa } from '../utils/format'
const typeLabels: Record<AccountType, string> = {
asset: 'دارایی',
liability: 'بدهی',
equity: 'سرمایه',
income: 'درآمد',
expense: 'هزینه',
}
const typeTones: Record<AccountType, 'blue' | 'rose' | 'indigo' | 'green' | 'amber'> = {
asset: 'blue',
liability: 'rose',
equity: 'indigo',
income: 'green',
expense: 'amber',
}
const emptyDraft = (): Account => ({
id: '',
code: '',
name: '',
type: 'asset',
parentId: null,
isGroup: false,
openingBalance: 0,
})
export function Accounts() {
const { data, upsertAccount, removeAccount } = useStore()
const [search, setSearch] = useState('')
const [draft, setDraft] = useState<Account | null>(null)
const [toDelete, setToDelete] = useState<Account | null>(null)
const groups = useMemo(
() => data.accounts.filter((account) => account.isGroup),
[data.accounts],
)
const filtered = useMemo(() => {
const query = search.trim().toLowerCase()
if (!query) return data.accounts
return data.accounts.filter(
(account) =>
account.name.toLowerCase().includes(query) || account.code.includes(query),
)
}, [data.accounts, search])
const handleSave = () => {
if (!draft) return
if (!draft.code.trim() || !draft.name.trim()) return
upsertAccount({ ...draft, id: draft.id || createId('acc-') })
setDraft(null)
}
const confirmDelete = () => {
if (!toDelete) return
removeAccount(toDelete.id)
setToDelete(null)
}
const columns: Array<Column<Account>> = [
{
key: 'code',
header: 'کد حساب',
render: (a) => <span className="font-mono text-slate-500">{toFa(a.code)}</span>,
},
{
key: 'name',
header: 'عنوان حساب',
render: (a) => (
<span className={a.isGroup ? 'font-bold text-slate-800' : 'text-slate-700'}>
{a.isGroup && <span className="ms-1 text-slate-400"></span>} {a.name}
</span>
),
},
{
key: 'type',
header: 'ماهیت',
render: (a) => <Badge tone={typeTones[a.type]}>{typeLabels[a.type]}</Badge>,
},
{
key: 'kind',
header: 'نوع',
render: (a) => (a.isGroup ? <Badge tone="slate">گروه</Badge> : <Badge tone="indigo">معین</Badge>),
},
{
key: 'opening',
header: 'مانده افتتاحیه',
align: 'end',
render: (a) => (a.isGroup ? '—' : <span className="tabular-nums">{formatMoney(a.openingBalance)}</span>),
},
{
key: 'actions',
header: '',
align: 'end',
render: (a) => (
<div className="flex justify-end gap-1">
<Button size="sm" variant="ghost" onClick={() => setDraft({ ...a })}>
ویرایش
</Button>
<Button size="sm" variant="ghost" className="text-rose-600" onClick={() => setToDelete(a)}>
حذف
</Button>
</div>
),
},
]
return (
<div className="space-y-6">
<PageHeader
title="حساب‌ها (کدینگ)"
subtitle="کدینگ استاندارد حساب‌ها مطابق ساختار دارایی، بدهی، سرمایه، درآمد و هزینه"
actions={
<Button icon="" onClick={() => setDraft(emptyDraft())}>
حساب جدید
</Button>
}
/>
<Card className="p-4">
<Input
placeholder="جستجو بر اساس کد یا عنوان حساب…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="mb-4 max-w-sm"
/>
<DataTable
columns={columns}
rows={filtered}
rowKey={(a) => a.id}
emptyTitle="حسابی یافت نشد"
emptyDescription="برای شروع یک حساب جدید تعریف کنید."
/>
</Card>
<Modal
open={draft !== null}
title={draft?.id ? 'ویرایش حساب' : 'تعریف حساب جدید'}
onClose={() => setDraft(null)}
footer={
<>
<Button variant="secondary" onClick={() => setDraft(null)}>
انصراف
</Button>
<Button onClick={handleSave}>ذخیره</Button>
</>
}
>
{draft && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field label="کد حساب">
<Input
value={draft.code}
onChange={(e) => setDraft({ ...draft, code: e.target.value })}
placeholder="مثال: 1001"
/>
</Field>
<Field label="عنوان حساب">
<Input
value={draft.name}
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
placeholder="مثال: صندوق"
/>
</Field>
<Field label="ماهیت حساب">
<Select
value={draft.type}
onChange={(e) => setDraft({ ...draft, type: e.target.value as AccountType })}
>
{Object.entries(typeLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</Select>
</Field>
<Field label="حساب گروه (سرفصل)">
<Select
value={draft.parentId ?? ''}
onChange={(e) => setDraft({ ...draft, parentId: e.target.value || null })}
>
<option value=""> بدون گروه </option>
{groups
.filter((group) => group.id !== draft.id)
.map((group) => (
<option key={group.id} value={group.id}>
{group.name}
</option>
))}
</Select>
</Field>
<Field label="نوع حساب">
<Select
value={draft.isGroup ? 'group' : 'leaf'}
onChange={(e) => setDraft({ ...draft, isGroup: e.target.value === 'group' })}
>
<option value="leaf">حساب معین</option>
<option value="group">گروه / سرفصل</option>
</Select>
</Field>
{!draft.isGroup && (
<Field label="مانده افتتاحیه (تومان)">
<Input
type="number"
value={draft.openingBalance}
onChange={(e) => setDraft({ ...draft, openingBalance: Number(e.target.value) || 0 })}
/>
</Field>
)}
</div>
)}
</Modal>
<ConfirmDialog
open={toDelete !== null}
title="حذف حساب"
message={`آیا از حذف حساب «${toDelete?.name}» مطمئن هستید؟ این عمل قابل بازگشت نیست.`}
onCancel={() => setToDelete(null)}
onConfirm={confirmDelete}
/>
</div>
)
}

296
src/pages/Customers.tsx Normal file
View File

@@ -0,0 +1,296 @@
import { useMemo, useState } from 'react'
import { useConnection } from '../connection/ConnectionContext'
import { useApiResource } from '../hooks/useApiResource'
import { apiGet } from '../api/client'
import { ENDPOINTS } from '../api/config'
import type { ApiCustomer, ApiPaymentOrRefund, ApiWithdrawal } from '../api/types'
import {
Badge,
Button,
Card,
DataTable,
Input,
PageHeader,
StatCard,
type Column,
} from '../components/ui'
import { ConnectionRequired } from '../components/business/ConnectionRequired'
import { useVoucherSync } from '../accounting/voucherSync'
import { formatDateTime, formatMoney, toFa } from '../utils/format'
const customerFullName = (customer: ApiCustomer): string =>
`${customer.f_name ?? ''} ${customer.l_name ?? ''}`.trim() || customer.username || 'بدون نام'
export function Customers() {
const { connected } = useConnection()
const { syncCustomerActivity } = useVoucherSync()
const [search, setSearch] = useState('')
const [selected, setSelected] = useState<ApiCustomer | null>(null)
const [syncBusy, setSyncBusy] = useState(false)
const [syncMsg, setSyncMsg] = useState<string | null>(null)
const customers = useApiResource(
() =>
apiGet<unknown>(ENDPOINTS.CUSTOMERS).then((data) => {
if (Array.isArray(data)) return data as ApiCustomer[]
const record = data as { results?: ApiCustomer[] }
return record.results ?? []
}),
[connected],
connected,
)
const withdrawals = useApiResource(
() =>
apiGet<{ withdrawals: ApiWithdrawal[] }>(ENDPOINTS.WITHDRAWALS).then((res) => res.withdrawals ?? []),
[connected],
connected,
)
const list = customers.data ?? []
const customerWithdrawals = useMemo(
() => (withdrawals.data ?? []).filter((w) => w.user_type === 'customer'),
[withdrawals.data],
)
const summary = useMemo(() => {
const totalBalance = list.reduce((sum, c) => sum + (c.balance || 0), 0)
const pending = customerWithdrawals.filter((w) => w.status === 'pending').length
return { count: list.length, totalBalance, pending }
}, [list, customerWithdrawals])
const filtered = useMemo(() => {
const query = search.trim().toLowerCase()
if (!query) return list
return list.filter((c) => {
const name = customerFullName(c).toLowerCase()
return (
name.includes(query) ||
(c.username ?? '').toLowerCase().includes(query) ||
(c.mobile_number ?? '').includes(query)
)
})
}, [list, search])
const handleBulkSync = async () => {
setSyncBusy(true)
setSyncMsg(null)
try {
const all = await apiGet<{ payments_and_refunds: ApiPaymentOrRefund[] }>(
ENDPOINTS.CUSTOMER_PAYMENTS_REFUNDS(),
).then((res) => res.payments_and_refunds ?? [])
const res = await syncCustomerActivity(all)
setSyncMsg(`صدور سند انجام شد: ${toFa(res.created)} سند جدید ثبت شد، ${toFa(res.skipped)} مورد قبلاً ثبت شده بود.`)
} catch (err) {
setSyncMsg(err instanceof Error ? err.message : 'خطا در صدور سند')
} finally {
setSyncBusy(false)
}
}
if (!connected) {
return (
<div className="space-y-6">
<PageHeader title="کاربران (مشتریان)" subtitle="اطلاعات مالی کاربران از اپلیکیشن مشتریان" />
<ConnectionRequired />
</div>
)
}
if (selected) {
return <CustomerDetail customer={selected} withdrawals={customerWithdrawals} onBack={() => setSelected(null)} />
}
const columns: Array<Column<ApiCustomer>> = [
{ key: 'name', header: 'نام کاربر', render: (c) => <span className="font-medium text-slate-800">{customerFullName(c)}</span> },
{ key: 'username', header: 'نام کاربری', render: (c) => c.username || '—' },
{ key: 'mobile', header: 'موبایل', render: (c) => <span className="tabular-nums">{toFa(c.mobile_number ?? '') || '—'}</span> },
{ key: 'iban', header: 'شبا', render: (c) => <span className="font-mono text-xs text-slate-500">{c.iban || '—'}</span> },
{
key: 'balance',
header: 'موجودی کیف پول',
align: 'end',
render: (c) => <span className="tabular-nums font-semibold text-slate-800">{formatMoney(c.balance || 0)}</span>,
},
{
key: 'actions',
header: '',
align: 'end',
render: (c) => (
<Button size="sm" variant="ghost" onClick={() => setSelected(c)}>
مشاهده پرداختها
</Button>
),
},
]
return (
<div className="space-y-6">
<PageHeader
title="کاربران (مشتریان)"
subtitle="اطلاعات مالی کاربران از اپلیکیشن مشتریان"
actions={
<div className="flex items-center gap-2">
<Button variant="primary" icon="📑" disabled={syncBusy} onClick={handleBulkSync}>
{syncBusy ? 'در حال صدور…' : 'صدور سند همه پرداخت‌ها'}
</Button>
<Button
variant="secondary"
icon="↻"
onClick={() => {
customers.reload()
withdrawals.reload()
}}
>
بروزرسانی
</Button>
</div>
}
/>
{syncMsg && (
<Card className="border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">{syncMsg}</Card>
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<StatCard label="تعداد کاربران" value={toFa(summary.count)} icon="👥" tone="brand" />
<StatCard label="مجموع موجودی کیف پول‌ها" value={formatMoney(summary.totalBalance)} icon="💳" tone="green" />
<StatCard label="برداشت‌های در انتظار" value={toFa(summary.pending)} icon="⏳" tone={summary.pending > 0 ? 'amber' : 'green'} />
</div>
{customers.error && (
<Card className="border-rose-200 bg-rose-50 p-4 text-sm text-rose-700">{customers.error}</Card>
)}
<Card className="p-4">
<Input
placeholder="جستجوی کاربر بر اساس نام، نام کاربری یا موبایل…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="mb-4 max-w-sm"
/>
{customers.loading ? (
<p className="py-10 text-center text-sm text-slate-400">در حال بارگذاری</p>
) : (
<DataTable columns={columns} rows={filtered} rowKey={(c) => c.id} onRowClick={(c) => setSelected(c)} emptyTitle="کاربری یافت نشد" />
)}
</Card>
</div>
)
}
function CustomerDetail({
customer,
withdrawals,
onBack,
}: {
customer: ApiCustomer
withdrawals: ApiWithdrawal[]
onBack: () => void
}) {
const { syncCustomerActivity } = useVoucherSync()
const [syncMsg, setSyncMsg] = useState<string | null>(null)
const [syncBusy, setSyncBusy] = useState(false)
const activity = useApiResource(
() =>
apiGet<{ payments_and_refunds: ApiPaymentOrRefund[] }>(
ENDPOINTS.CUSTOMER_PAYMENTS_REFUNDS(customer.id),
).then((res) => res.payments_and_refunds ?? []),
[customer.id],
)
const rows = activity.data ?? []
const customerWithdrawals = withdrawals.filter((w) => w.user_id === customer.id)
const handleSync = async () => {
setSyncBusy(true)
try {
const res = await syncCustomerActivity(rows)
setSyncMsg(`صدور سند انجام شد: ${toFa(res.created)} سند جدید ثبت شد، ${toFa(res.skipped)} مورد قبلاً ثبت شده بود.`)
} catch (err) {
setSyncMsg(err instanceof Error ? err.message : 'خطا در صدور سند')
} finally {
setSyncBusy(false)
}
}
const totals = useMemo(() => {
const payments = rows.filter((r) => r.type === 'payment').reduce((s, r) => s + r.amount, 0)
const refunds = rows.filter((r) => r.type === 'cancellation').reduce((s, r) => s + r.amount, 0)
return { payments, refunds }
}, [rows])
const columns: Array<Column<ApiPaymentOrRefund>> = [
{ key: 'date', header: 'تاریخ و زمان', render: (r) => formatDateTime(r.created_at) },
{
key: 'type',
header: 'نوع',
render: (r) => (
<Badge tone={r.type === 'payment' ? 'blue' : 'amber'}>
{r.type === 'payment' ? 'پرداخت' : 'لغو/بازپرداخت'}
</Badge>
),
},
{ key: 'event', header: 'رویداد/کیف پول', render: (r) => (r.event_name === 'wallet' ? 'کیف پول' : r.event_name) },
{ key: 'trace', header: 'شماره پیگیری', render: (r) => <span className="font-mono text-xs text-slate-500">{toFa(r.trace_no) || '—'}</span> },
{ key: 'ref', header: 'شماره مرجع', render: (r) => <span className="font-mono text-xs text-slate-500">{toFa(r.ref_num) || '—'}</span> },
{
key: 'amount',
header: 'مبلغ',
align: 'end',
render: (r) => (
<span className={`tabular-nums font-semibold ${r.type === 'payment' ? 'text-emerald-600' : 'text-amber-600'}`}>
{formatMoney(r.amount)}
</span>
),
},
]
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<Button variant="secondary" icon="→" onClick={onBack}>
بازگشت
</Button>
<div>
<h1 className="text-2xl font-bold text-slate-800">{customerFullName(customer)}</h1>
<p className="text-sm text-slate-500">{toFa(customer.mobile_number ?? '')}</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="primary" icon="📑" disabled={syncBusy || activity.loading || rows.length === 0} onClick={handleSync}>
{syncBusy ? 'در حال صدور…' : 'صدور سند پرداخت‌ها'}
</Button>
<Button variant="secondary" icon="↻" onClick={activity.reload}>
بروزرسانی
</Button>
</div>
</div>
{syncMsg && (
<Card className="border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">{syncMsg}</Card>
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
<StatCard label="موجودی کیف پول" value={formatMoney(customer.balance || 0)} icon="💳" tone="brand" />
<StatCard label="جمع پرداخت‌ها" value={formatMoney(totals.payments)} icon="↓" tone="green" />
<StatCard label="جمع بازپرداخت‌ها" value={formatMoney(totals.refunds)} icon="↑" tone="amber" />
<StatCard label="برداشت‌ها" value={toFa(customerWithdrawals.length)} icon="🏧" tone="rose" />
</div>
{activity.error && (
<Card className="border-rose-200 bg-rose-50 p-4 text-sm text-rose-700">{activity.error}</Card>
)}
<Card className="p-4">
<h2 className="mb-3 text-lg font-bold text-slate-800">پرداختها و بازپرداختها</h2>
{activity.loading ? (
<p className="py-10 text-center text-sm text-slate-400">در حال بارگذاری</p>
) : (
<DataTable columns={columns} rows={rows} rowKey={(r) => r.id} emptyTitle="پرداختی ثبت نشده است" />
)}
</Card>
</div>
)
}

232
src/pages/Dashboard.tsx Normal file
View File

@@ -0,0 +1,232 @@
import { useMemo } from 'react'
import { Link } from 'react-router-dom'
import { useStore } from '../store/AppStore'
import { Badge, Card, PageHeader, StatCard } from '../components/ui'
import { formatMoney, formatMoneyWithUnit, toFa } from '../utils/format'
import {
balanceSheet,
incomeStatement,
invoiceTotals,
trialBalance,
} from '../utils/accounting'
interface MonthBucket {
label: string
sales: number
purchases: number
}
function monthlyBuckets(
invoices: ReturnType<typeof useStore>['data']['invoices'],
): MonthBucket[] {
const formatter = new Intl.DateTimeFormat('fa-IR-u-ca-persian', { month: 'short' })
const buckets: MonthBucket[] = []
const now = new Date()
for (let offset = 5; offset >= 0; offset -= 1) {
const date = new Date(now.getFullYear(), now.getMonth() - offset, 1)
const key = `${date.getFullYear()}-${date.getMonth()}`
const inMonth = invoices.filter((invoice) => {
const d = new Date(invoice.date)
return `${d.getFullYear()}-${d.getMonth()}` === key
})
buckets.push({
label: formatter.format(date),
sales: inMonth
.filter((i) => i.kind === 'sale')
.reduce((sum, i) => sum + invoiceTotals(i).net, 0),
purchases: inMonth
.filter((i) => i.kind === 'purchase')
.reduce((sum, i) => sum + invoiceTotals(i).net, 0),
})
}
return buckets
}
export function Dashboard() {
const { data } = useStore()
const metrics = useMemo(() => {
const balances = trialBalance(data.accounts, data.vouchers)
const liquidity = balances
.filter((b) => ['1001', '1002', '1003'].includes(b.account.code))
.reduce((sum, b) => sum + b.balance, 0)
const sales = data.invoices
.filter((i) => i.kind === 'sale')
.reduce((sum, i) => sum + invoiceTotals(i).net, 0)
const purchases = data.invoices
.filter((i) => i.kind === 'purchase')
.reduce((sum, i) => sum + invoiceTotals(i).net, 0)
const income = incomeStatement(balances)
const sheet = balanceSheet(balances)
const lowStock = data.products.filter((p) => p.stock <= p.reorderLevel)
const unpaidSales = data.invoices.filter((i) => i.kind === 'sale' && i.status !== 'paid')
return { liquidity, sales, purchases, income, sheet, lowStock, unpaidSales }
}, [data])
const buckets = useMemo(() => monthlyBuckets(data.invoices), [data.invoices])
const chartMax = Math.max(1, ...buckets.flatMap((b) => [b.sales, b.purchases]))
return (
<div className="space-y-6">
<PageHeader
title="داشبورد مدیریتی"
subtitle="نمای کلی از وضعیت مالی مجموعه"
/>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
<StatCard
label="موجودی نقد و بانک"
value={formatMoney(metrics.liquidity)}
hint="صندوق و حساب‌های بانکی"
icon="💰"
tone="brand"
/>
<StatCard
label="فروش کل"
value={formatMoney(metrics.sales)}
hint={`${toFa(data.invoices.filter((i) => i.kind === 'sale').length)} فاکتور فروش`}
icon="🧾"
tone="green"
/>
<StatCard
label="خرید کل"
value={formatMoney(metrics.purchases)}
hint={`${toFa(data.invoices.filter((i) => i.kind === 'purchase').length)} فاکتور خرید`}
icon="🛒"
tone="amber"
/>
<StatCard
label="سود (زیان) دوره"
value={formatMoney(metrics.income.profit)}
hint="درآمد منهای هزینه"
icon={metrics.income.profit >= 0 ? '📈' : '📉'}
tone={metrics.income.profit >= 0 ? 'green' : 'rose'}
/>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<Card className="p-5 lg:col-span-2">
<div className="mb-5 flex items-center justify-between">
<h2 className="text-lg font-bold text-slate-800">روند فروش و خرید (۶ ماه اخیر)</h2>
<div className="flex items-center gap-4 text-xs text-slate-500">
<span className="flex items-center gap-1.5">
<span className="h-2.5 w-2.5 rounded-full bg-brand-500" /> فروش
</span>
<span className="flex items-center gap-1.5">
<span className="h-2.5 w-2.5 rounded-full bg-amber-400" /> خرید
</span>
</div>
</div>
<div className="flex h-56 items-end justify-between gap-3">
{buckets.map((bucket) => (
<div key={bucket.label} className="flex flex-1 flex-col items-center gap-2">
<div className="flex h-full w-full items-end justify-center gap-1">
<div
className="w-1/2 rounded-t-md bg-brand-500 transition-all"
style={{ height: `${(bucket.sales / chartMax) * 100}%` }}
title={formatMoney(bucket.sales)}
/>
<div
className="w-1/2 rounded-t-md bg-amber-400 transition-all"
style={{ height: `${(bucket.purchases / chartMax) * 100}%` }}
title={formatMoney(bucket.purchases)}
/>
</div>
<span className="text-xs text-slate-500">{bucket.label}</span>
</div>
))}
</div>
</Card>
<Card className="p-5">
<h2 className="mb-4 text-lg font-bold text-slate-800">ترازنامه فشرده</h2>
<dl className="space-y-3 text-sm">
<Row label="دارایی‌ها" value={metrics.sheet.assets} tone="text-slate-800" />
<Row label="بدهی‌ها" value={metrics.sheet.liabilities} tone="text-rose-600" />
<Row label="حقوق صاحبان سهام" value={metrics.sheet.equity} tone="text-slate-800" />
<Row label="سود انباشته دوره" value={metrics.sheet.retainedEarnings} tone="text-emerald-600" />
<div className="border-t border-slate-100 pt-3">
<Row
label="جمع بدهی و سرمایه"
value={metrics.sheet.liabilities + metrics.sheet.equity + metrics.sheet.retainedEarnings}
tone="text-brand-700 font-bold"
/>
</div>
</dl>
</Card>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<Card className="p-5">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-bold text-slate-800">هشدار موجودی انبار</h2>
<Link to="/inventory" className="text-sm font-medium text-brand-600 hover:underline">
مدیریت انبار
</Link>
</div>
{metrics.lowStock.length === 0 ? (
<p className="py-6 text-center text-sm text-slate-400">موجودی همه کالاها در حد مطلوب است.</p>
) : (
<ul className="space-y-2">
{metrics.lowStock.map((product) => (
<li
key={product.id}
className="flex items-center justify-between rounded-lg bg-rose-50 px-3 py-2 text-sm"
>
<span className="font-medium text-slate-700">{product.name}</span>
<Badge tone="rose">
موجودی: {toFa(product.stock)} {product.unit}
</Badge>
</li>
))}
</ul>
)}
</Card>
<Card className="p-5">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-bold text-slate-800">فاکتورهای فروش تسویهنشده</h2>
<Link to="/sales" className="text-sm font-medium text-brand-600 hover:underline">
مشاهده فروش
</Link>
</div>
{metrics.unpaidSales.length === 0 ? (
<p className="py-6 text-center text-sm text-slate-400">همه فاکتورها تسویه شدهاند.</p>
) : (
<ul className="space-y-2">
{metrics.unpaidSales.slice(0, 5).map((invoice) => {
const party = data.parties.find((p) => p.id === invoice.partyId)
return (
<li
key={invoice.id}
className="flex items-center justify-between rounded-lg bg-slate-50 px-3 py-2 text-sm"
>
<span className="font-medium text-slate-700">
فاکتور #{toFa(invoice.number)} · {party?.name ?? '—'}
</span>
<span className="tabular-nums text-slate-600">
{formatMoneyWithUnit(invoiceTotals(invoice).net)}
</span>
</li>
)
})}
</ul>
)}
</Card>
</div>
</div>
)
}
function Row({ label, value, tone }: { label: string; value: number; tone: string }) {
return (
<div className="flex items-center justify-between">
<dt className="text-slate-500">{label}</dt>
<dd className={`tabular-nums ${tone}`}>{formatMoney(value)}</dd>
</div>
)
}

219
src/pages/Inventory.tsx Normal file
View File

@@ -0,0 +1,219 @@
import { useMemo, useState } from 'react'
import { useStore } from '../store/AppStore'
import {
Badge,
Button,
Card,
ConfirmDialog,
DataTable,
Field,
Input,
Modal,
PageHeader,
StatCard,
type Column,
} from '../components/ui'
import type { Product } from '../types'
import { createId } from '../utils/id'
import { formatMoney, toFa } from '../utils/format'
const emptyDraft = (): Product => ({
id: '',
code: '',
name: '',
unit: 'عدد',
salePrice: 0,
purchasePrice: 0,
stock: 0,
reorderLevel: 0,
})
export function Inventory() {
const { data, upsertProduct, removeProduct } = useStore()
const [search, setSearch] = useState('')
const [draft, setDraft] = useState<Product | null>(null)
const [adjust, setAdjust] = useState<{ product: Product; delta: number } | null>(null)
const [toDelete, setToDelete] = useState<Product | null>(null)
const summary = useMemo(() => {
const value = data.products.reduce((sum, p) => sum + p.stock * p.purchasePrice, 0)
const lowStock = data.products.filter((p) => p.stock <= p.reorderLevel).length
return { value, lowStock, count: data.products.length }
}, [data.products])
const filtered = useMemo(() => {
const query = search.trim().toLowerCase()
if (!query) return data.products
return data.products.filter(
(p) => p.name.toLowerCase().includes(query) || p.code.toLowerCase().includes(query),
)
}, [data.products, search])
const handleSave = () => {
if (!draft || !draft.name.trim() || !draft.code.trim()) return
upsertProduct({ ...draft, id: draft.id || createId('prod-') })
setDraft(null)
}
const applyAdjust = () => {
if (!adjust) return
const nextStock = Math.max(0, adjust.product.stock + adjust.delta)
upsertProduct({ ...adjust.product, stock: nextStock })
setAdjust(null)
}
const columns: Array<Column<Product>> = [
{ key: 'code', header: 'کد', render: (p) => <span className="font-mono text-slate-500">{p.code}</span> },
{ key: 'name', header: 'نام کالا', render: (p) => <span className="font-medium text-slate-800">{p.name}</span> },
{ key: 'unit', header: 'واحد', render: (p) => p.unit },
{ key: 'purchase', header: 'قیمت خرید', align: 'end', render: (p) => <span className="tabular-nums">{formatMoney(p.purchasePrice)}</span> },
{ key: 'sale', header: 'قیمت فروش', align: 'end', render: (p) => <span className="tabular-nums">{formatMoney(p.salePrice)}</span> },
{
key: 'stock',
header: 'موجودی',
align: 'center',
render: (p) =>
p.stock <= p.reorderLevel ? (
<Badge tone="rose">{toFa(p.stock)}</Badge>
) : (
<span className="tabular-nums font-semibold text-slate-700">{toFa(p.stock)}</span>
),
},
{
key: 'actions',
header: '',
align: 'end',
render: (p) => (
<div className="flex justify-end gap-1">
<Button size="sm" variant="ghost" onClick={() => setAdjust({ product: p, delta: 0 })}>
تعدیل موجودی
</Button>
<Button size="sm" variant="ghost" onClick={() => setDraft({ ...p })}>
ویرایش
</Button>
<Button size="sm" variant="ghost" className="text-rose-600" onClick={() => setToDelete(p)}>
حذف
</Button>
</div>
),
},
]
return (
<div className="space-y-6">
<PageHeader
title="انبارداری"
subtitle="مدیریت کالاها، قیمت‌گذاری و کنترل موجودی"
actions={
<Button icon="" onClick={() => setDraft(emptyDraft())}>
کالای جدید
</Button>
}
/>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<StatCard label="تعداد کالاها" value={toFa(summary.count)} icon="📦" tone="brand" />
<StatCard label="ارزش موجودی انبار" value={formatMoney(summary.value)} hint="بر اساس قیمت خرید" icon="🏷️" tone="green" />
<StatCard label="کالاهای کمتر از حد سفارش" value={toFa(summary.lowStock)} icon="⚠️" tone={summary.lowStock > 0 ? 'rose' : 'green'} />
</div>
<Card className="p-4">
<Input
placeholder="جستجوی کالا…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="mb-4 max-w-sm"
/>
<DataTable columns={columns} rows={filtered} rowKey={(p) => p.id} emptyTitle="کالایی یافت نشد" />
</Card>
<Modal
open={draft !== null}
title={draft?.id ? 'ویرایش کالا' : 'تعریف کالای جدید'}
onClose={() => setDraft(null)}
footer={
<>
<Button variant="secondary" onClick={() => setDraft(null)}>
انصراف
</Button>
<Button onClick={handleSave}>ذخیره</Button>
</>
}
>
{draft && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field label="کد کالا">
<Input value={draft.code} onChange={(e) => setDraft({ ...draft, code: e.target.value })} />
</Field>
<Field label="نام کالا">
<Input value={draft.name} onChange={(e) => setDraft({ ...draft, name: e.target.value })} />
</Field>
<Field label="واحد شمارش">
<Input value={draft.unit} onChange={(e) => setDraft({ ...draft, unit: e.target.value })} />
</Field>
<Field label="موجودی اولیه">
<Input type="number" value={draft.stock} onChange={(e) => setDraft({ ...draft, stock: Number(e.target.value) || 0 })} />
</Field>
<Field label="قیمت خرید (تومان)">
<Input type="number" value={draft.purchasePrice} onChange={(e) => setDraft({ ...draft, purchasePrice: Number(e.target.value) || 0 })} />
</Field>
<Field label="قیمت فروش (تومان)">
<Input type="number" value={draft.salePrice} onChange={(e) => setDraft({ ...draft, salePrice: Number(e.target.value) || 0 })} />
</Field>
<Field label="حد سفارش مجدد">
<Input type="number" value={draft.reorderLevel} onChange={(e) => setDraft({ ...draft, reorderLevel: Number(e.target.value) || 0 })} />
</Field>
</div>
)}
</Modal>
<Modal
open={adjust !== null}
title="تعدیل موجودی انبار"
onClose={() => setAdjust(null)}
footer={
<>
<Button variant="secondary" onClick={() => setAdjust(null)}>
انصراف
</Button>
<Button onClick={applyAdjust}>اعمال</Button>
</>
}
>
{adjust && (
<div className="space-y-4">
<p className="text-sm text-slate-600">
کالا: <span className="font-semibold text-slate-800">{adjust.product.name}</span>
<br />
موجودی فعلی: <span className="tabular-nums font-semibold">{toFa(adjust.product.stock)}</span> {adjust.product.unit}
</p>
<Field label="مقدار تعدیل (مثبت برای ورود، منفی برای خروج)">
<Input
type="number"
value={adjust.delta}
onChange={(e) => setAdjust({ ...adjust, delta: Number(e.target.value) || 0 })}
/>
</Field>
<p className="text-sm text-slate-500">
موجودی پس از تعدیل:{' '}
<span className="tabular-nums font-semibold text-brand-700">
{toFa(Math.max(0, adjust.product.stock + adjust.delta))}
</span>
</p>
</div>
)}
</Modal>
<ConfirmDialog
open={toDelete !== null}
title="حذف کالا"
message={`«${toDelete?.name}» حذف شود؟`}
onCancel={() => setToDelete(null)}
onConfirm={() => {
if (toDelete) removeProduct(toDelete.id)
setToDelete(null)
}}
/>
</div>
)
}

113
src/pages/Login.tsx Normal file
View File

@@ -0,0 +1,113 @@
import { useState } from 'react'
import { useConnection } from '../connection/ConnectionContext'
import { Button, Card, Field, Input } from '../components/ui'
import {
getAccountingBaseUrl,
getApiBaseUrl,
setAccountingBaseUrl,
} from '../api/config'
/**
* Full-screen login gate for the accounting app. Authenticates against the
* FunZone admin endpoint; the returned token is reused for the accounting
* service. Server URLs are configurable under "تنظیمات سرور".
*/
export function Login() {
const { busy, error, connect } = useConnection()
const [username, setUsername] = useState('Admin')
const [password, setPassword] = useState('1234')
const [funzoneUrl, setFunzoneUrl] = useState(getApiBaseUrl())
const [acctUrl, setAcctUrl] = useState(getAccountingBaseUrl())
const [showServers, setShowServers] = useState(false)
const handleSubmit = async () => {
if (!username || !password || busy) return
setAccountingBaseUrl(acctUrl)
await connect(funzoneUrl, username, password)
}
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-slate-900 via-slate-800 to-brand-900 p-4">
<div className="w-full max-w-md">
<div className="mb-6 text-center text-white">
<div className="mx-auto mb-3 flex h-16 w-16 items-center justify-center rounded-2xl bg-white/10 text-3xl backdrop-blur">
🧮
</div>
<h1 className="text-2xl font-extrabold">حسابداری فانزون</h1>
<p className="mt-1 text-sm text-slate-300">برای ورود، با حساب مدیر وارد شوید</p>
</div>
<Card className="p-6 shadow-2xl">
{error && (
<div className="mb-4 rounded-lg border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
{error}
</div>
)}
<div className="space-y-4">
<Field label="نام کاربری">
<Input
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
placeholder="Admin"
onKeyDown={(e) => {
if (e.key === 'Enter') void handleSubmit()
}}
/>
</Field>
<Field label="رمز عبور">
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
placeholder="••••"
onKeyDown={(e) => {
if (e.key === 'Enter') void handleSubmit()
}}
/>
</Field>
<Button className="w-full" onClick={handleSubmit} disabled={busy || !username || !password}>
{busy ? 'در حال ورود…' : 'ورود به سیستم'}
</Button>
<button
type="button"
className="w-full text-center text-xs text-slate-400 transition hover:text-slate-600"
onClick={() => setShowServers((v) => !v)}
>
{showServers ? 'بستن تنظیمات سرور ▲' : 'تنظیمات سرور ▼'}
</button>
{showServers && (
<div className="space-y-4 rounded-lg bg-slate-50 p-4">
<Field label="آدرس سرور فان‌زون" hint="برای ورود و داده‌های مالکان/مشتریان">
<Input
value={funzoneUrl}
onChange={(e) => setFunzoneUrl(e.target.value)}
dir="ltr"
placeholder="http://localhost:8000/api"
/>
</Field>
<Field label="آدرس سرویس حسابداری" hint="محل ذخیره اسناد، حساب‌ها و گزارش‌ها">
<Input
value={acctUrl}
onChange={(e) => setAcctUrl(e.target.value)}
dir="ltr"
placeholder="http://localhost:8010/api"
/>
</Field>
</div>
)}
</div>
</Card>
<p className="mt-6 text-center text-xs text-slate-400">
سامانه حسابداری یکپارچه فانزون
</p>
</div>
</div>
)
}

291
src/pages/Owners.tsx Normal file
View File

@@ -0,0 +1,291 @@
import { useMemo, useState } from 'react'
import { useConnection } from '../connection/ConnectionContext'
import { useApiResource } from '../hooks/useApiResource'
import { apiGet, asList } from '../api/client'
import { ENDPOINTS } from '../api/config'
import type { ApiOwner, ApiOwnerTransaction, ApiWithdrawal, WalletTxnType } from '../api/types'
import {
Badge,
Button,
Card,
DataTable,
Input,
PageHeader,
StatCard,
type Column,
} from '../components/ui'
import { ConnectionRequired } from '../components/business/ConnectionRequired'
import { useVoucherSync } from '../accounting/voucherSync'
import { formatDateTime, formatMoney, toFa } from '../utils/format'
const txnTypeLabels: Record<WalletTxnType, string> = {
deposit: 'واریز',
withdraw: 'برداشت',
booking_payment: 'پرداخت رزرو',
refund: 'بازپرداخت',
}
const txnTypeTones: Record<WalletTxnType, 'green' | 'rose' | 'blue' | 'amber'> = {
deposit: 'green',
withdraw: 'rose',
booking_payment: 'blue',
refund: 'amber',
}
const ownerFullName = (owner: ApiOwner): string =>
`${owner.f_name ?? ''} ${owner.l_name ?? ''}`.trim() || owner.username || 'بدون نام'
export function Owners() {
const { connected } = useConnection()
const { syncOwnerTransactions } = useVoucherSync()
const [search, setSearch] = useState('')
const [selected, setSelected] = useState<ApiOwner | null>(null)
const [syncBusy, setSyncBusy] = useState(false)
const [syncMsg, setSyncMsg] = useState<string | null>(null)
const owners = useApiResource(
() => apiGet<unknown>(ENDPOINTS.OWNERS).then(asList<ApiOwner>),
[connected],
connected,
)
const withdrawals = useApiResource(
() =>
apiGet<{ withdrawals: ApiWithdrawal[] }>(ENDPOINTS.WITHDRAWALS).then(
(res) => res.withdrawals ?? [],
),
[connected],
connected,
)
const list = owners.data ?? []
const ownerWithdrawals = useMemo(
() => (withdrawals.data ?? []).filter((w) => w.user_type === 'owner'),
[withdrawals.data],
)
const summary = useMemo(() => {
const totalBalance = list.reduce((sum, o) => sum + (o.balance || 0), 0)
const pending = ownerWithdrawals.filter((w) => w.status === 'pending').length
return { count: list.length, totalBalance, pending }
}, [list, ownerWithdrawals])
const filtered = useMemo(() => {
const query = search.trim().toLowerCase()
if (!query) return list
return list.filter((o) => {
const name = ownerFullName(o).toLowerCase()
return (
name.includes(query) ||
(o.username ?? '').toLowerCase().includes(query) ||
(o.mobile_number ?? '').includes(query)
)
})
}, [list, search])
const handleBulkSync = async () => {
setSyncBusy(true)
setSyncMsg(null)
try {
const all = await apiGet<unknown>(ENDPOINTS.ALL_OWNER_TRANSACTIONS).then(asList<ApiOwnerTransaction>)
const res = await syncOwnerTransactions(all)
setSyncMsg(`صدور سند انجام شد: ${toFa(res.created)} سند جدید ثبت شد، ${toFa(res.skipped)} مورد قبلاً ثبت شده بود.`)
} catch (err) {
setSyncMsg(err instanceof Error ? err.message : 'خطا در صدور سند')
} finally {
setSyncBusy(false)
}
}
if (!connected) {
return (
<div className="space-y-6">
<PageHeader title="مالکان مجموعه‌ها" subtitle="اطلاعات مالی صاحبان مجموعه‌ها از اپلیکیشن مالکان" />
<ConnectionRequired />
</div>
)
}
if (selected) {
return <OwnerDetail owner={selected} withdrawals={ownerWithdrawals} onBack={() => setSelected(null)} />
}
const columns: Array<Column<ApiOwner>> = [
{ key: 'name', header: 'نام مالک', render: (o) => <span className="font-medium text-slate-800">{ownerFullName(o)}</span> },
{ key: 'username', header: 'نام کاربری', render: (o) => o.username || '—' },
{ key: 'mobile', header: 'موبایل', render: (o) => <span className="tabular-nums">{toFa(o.mobile_number ?? '') || '—'}</span> },
{ key: 'venues', header: 'مجموعه‌ها', render: (o) => toFa(o.social_hubs?.length ?? 0) },
{ key: 'iban', header: 'شبا', render: (o) => <span className="font-mono text-xs text-slate-500">{o.credit_number || '—'}</span> },
{
key: 'balance',
header: 'موجودی کیف پول',
align: 'end',
render: (o) => <span className="tabular-nums font-semibold text-slate-800">{formatMoney(o.balance || 0)}</span>,
},
{
key: 'actions',
header: '',
align: 'end',
render: (o) => (
<Button size="sm" variant="ghost" onClick={() => setSelected(o)}>
مشاهده تراکنشها
</Button>
),
},
]
return (
<div className="space-y-6">
<PageHeader
title="مالکان مجموعه‌ها"
subtitle="اطلاعات مالی صاحبان مجموعه‌ها از اپلیکیشن مالکان"
actions={
<div className="flex items-center gap-2">
<Button variant="primary" icon="📑" disabled={syncBusy} onClick={handleBulkSync}>
{syncBusy ? 'در حال صدور…' : 'صدور سند همه تراکنش‌ها'}
</Button>
<Button
variant="secondary"
icon="↻"
onClick={() => {
owners.reload()
withdrawals.reload()
}}
>
بروزرسانی
</Button>
</div>
}
/>
{syncMsg && (
<Card className="border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">{syncMsg}</Card>
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<StatCard label="تعداد مالکان" value={toFa(summary.count)} icon="👤" tone="brand" />
<StatCard label="مجموع موجودی کیف پول‌ها" value={formatMoney(summary.totalBalance)} icon="💼" tone="green" />
<StatCard label="برداشت‌های در انتظار" value={toFa(summary.pending)} icon="⏳" tone={summary.pending > 0 ? 'amber' : 'green'} />
</div>
{owners.error && (
<Card className="border-rose-200 bg-rose-50 p-4 text-sm text-rose-700">{owners.error}</Card>
)}
<Card className="p-4">
<Input
placeholder="جستجوی مالک بر اساس نام، نام کاربری یا موبایل…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="mb-4 max-w-sm"
/>
{owners.loading ? (
<p className="py-10 text-center text-sm text-slate-400">در حال بارگذاری</p>
) : (
<DataTable columns={columns} rows={filtered} rowKey={(o) => o.id} onRowClick={(o) => setSelected(o)} emptyTitle="مالکی یافت نشد" />
)}
</Card>
</div>
)
}
function OwnerDetail({
owner,
withdrawals,
onBack,
}: {
owner: ApiOwner
withdrawals: ApiWithdrawal[]
onBack: () => void
}) {
const { syncOwnerTransactions } = useVoucherSync()
const [syncMsg, setSyncMsg] = useState<string | null>(null)
const [syncBusy, setSyncBusy] = useState(false)
const transactions = useApiResource(
() => apiGet<unknown>(ENDPOINTS.OWNER_TRANSACTIONS(owner.id)).then(asList<ApiOwnerTransaction>),
[owner.id],
)
const rows = transactions.data ?? []
const ownerWithdrawals = withdrawals.filter((w) => w.user_id === owner.id)
const handleSync = async () => {
setSyncBusy(true)
try {
const res = await syncOwnerTransactions(rows)
setSyncMsg(`صدور سند انجام شد: ${toFa(res.created)} سند جدید ثبت شد، ${toFa(res.skipped)} مورد قبلاً ثبت شده بود.`)
} catch (err) {
setSyncMsg(err instanceof Error ? err.message : 'خطا در صدور سند')
} finally {
setSyncBusy(false)
}
}
const columns: Array<Column<ApiOwnerTransaction>> = [
{ key: 'date', header: 'تاریخ و زمان', render: (t) => formatDateTime(t.created_at) },
{ key: 'type', header: 'نوع', render: (t) => <Badge tone={txnTypeTones[t.type]}>{txnTypeLabels[t.type]}</Badge> },
{
key: 'amount',
header: 'مبلغ',
align: 'end',
render: (t) => <span className="tabular-nums font-semibold text-slate-800">{formatMoney(t.amount)}</span>,
},
{
key: 'status',
header: 'وضعیت',
render: (t) => (
<Badge tone={t.status === 'completed' ? 'green' : t.status === 'pending' ? 'amber' : 'rose'}>
{t.status === 'completed' ? 'انجام‌شده' : t.status === 'pending' ? 'در انتظار' : 'ناموفق'}
</Badge>
),
},
{ key: 'desc', header: 'توضیحات', render: (t) => <span className="text-slate-600">{t.description || '—'}</span> },
]
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<Button variant="secondary" icon="→" onClick={onBack}>
بازگشت
</Button>
<div>
<h1 className="text-2xl font-bold text-slate-800">{ownerFullName(owner)}</h1>
<p className="text-sm text-slate-500">{toFa(owner.mobile_number ?? '')}</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="primary" icon="📑" disabled={syncBusy || transactions.loading || rows.length === 0} onClick={handleSync}>
{syncBusy ? 'در حال صدور…' : 'صدور سند تراکنش‌ها'}
</Button>
<Button variant="secondary" icon="↻" onClick={transactions.reload}>
بروزرسانی
</Button>
</div>
</div>
{syncMsg && (
<Card className="border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">{syncMsg}</Card>
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<StatCard label="موجودی کیف پول" value={formatMoney(owner.balance || 0)} icon="💼" tone="brand" />
<StatCard label="تعداد تراکنش‌ها" value={toFa(rows.length)} icon="🔁" tone="green" />
<StatCard label="برداشت‌ها" value={toFa(ownerWithdrawals.length)} icon="🏧" tone="amber" />
</div>
{transactions.error && (
<Card className="border-rose-200 bg-rose-50 p-4 text-sm text-rose-700">{transactions.error}</Card>
)}
<Card className="p-4">
<h2 className="mb-3 text-lg font-bold text-slate-800">گردش حساب کیف پول</h2>
{transactions.loading ? (
<p className="py-10 text-center text-sm text-slate-400">در حال بارگذاری</p>
) : (
<DataTable columns={columns} rows={rows} rowKey={(t) => t.id} emptyTitle="تراکنشی ثبت نشده است" />
)}
</Card>
</div>
)
}

323
src/pages/Payroll.tsx Normal file
View File

@@ -0,0 +1,323 @@
import { useMemo, useState } from 'react'
import { useStore } from '../store/AppStore'
import {
Badge,
Button,
Card,
ConfirmDialog,
DataTable,
Field,
Input,
Modal,
PageHeader,
Select,
StatCard,
type Column,
} from '../components/ui'
import type { Employee, Payslip } from '../types'
import { createId } from '../utils/id'
import { formatMoney, toFa } from '../utils/format'
import { payslipNet } from '../utils/accounting'
export function Payroll() {
const [tab, setTab] = useState<'employees' | 'payslips'>('employees')
return (
<div className="space-y-6">
<PageHeader title="حقوق و دستمزد" subtitle="مدیریت کارکنان و صدور فیش حقوقی" />
<div className="inline-flex rounded-xl bg-slate-200/60 p-1">
<button
onClick={() => setTab('employees')}
className={`rounded-lg px-5 py-2 text-sm font-medium transition ${
tab === 'employees' ? 'bg-white text-brand-700 shadow-sm' : 'text-slate-500'
}`}
>
کارکنان
</button>
<button
onClick={() => setTab('payslips')}
className={`rounded-lg px-5 py-2 text-sm font-medium transition ${
tab === 'payslips' ? 'bg-white text-brand-700 shadow-sm' : 'text-slate-500'
}`}
>
فیشهای حقوقی
</button>
</div>
{tab === 'employees' ? <EmployeeSection /> : <PayslipSection />}
</div>
)
}
const emptyEmployee = (): Employee => ({
id: '',
code: '',
name: '',
position: '',
baseSalary: 0,
insuranceNo: '',
hireDate: new Date().toISOString().slice(0, 10),
active: true,
})
function EmployeeSection() {
const { data, upsertEmployee, removeEmployee } = useStore()
const [draft, setDraft] = useState<Employee | null>(null)
const [toDelete, setToDelete] = useState<Employee | null>(null)
const summary = useMemo(() => {
const active = data.employees.filter((e) => e.active)
const totalSalary = active.reduce((s, e) => s + e.baseSalary, 0)
return { count: active.length, totalSalary }
}, [data.employees])
const handleSave = () => {
if (!draft || !draft.name.trim()) return
upsertEmployee({ ...draft, id: draft.id || createId('emp-') })
setDraft(null)
}
const columns: Array<Column<Employee>> = [
{ key: 'code', header: 'کد', render: (e) => <span className="font-mono text-slate-500">{e.code}</span> },
{ key: 'name', header: 'نام', render: (e) => <span className="font-medium text-slate-800">{e.name}</span> },
{ key: 'position', header: 'سمت', render: (e) => e.position },
{ key: 'salary', header: 'حقوق پایه', align: 'end', render: (e) => <span className="tabular-nums">{formatMoney(e.baseSalary)}</span> },
{
key: 'status',
header: 'وضعیت',
render: (e) => (e.active ? <Badge tone="green">شاغل</Badge> : <Badge tone="slate">غیرفعال</Badge>),
},
{
key: 'actions',
header: '',
align: 'end',
render: (e) => (
<div className="flex justify-end gap-1">
<Button size="sm" variant="ghost" onClick={() => setDraft({ ...e })}>
ویرایش
</Button>
<Button size="sm" variant="ghost" className="text-rose-600" onClick={() => setToDelete(e)}>
حذف
</Button>
</div>
),
},
]
return (
<>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<StatCard label="کارکنان شاغل" value={toFa(summary.count)} icon="👥" tone="brand" />
<StatCard label="جمع حقوق پایه ماهانه" value={formatMoney(summary.totalSalary)} icon="💵" tone="green" />
</div>
<Card className="p-4">
<div className="mb-4 flex justify-end">
<Button icon="" onClick={() => setDraft(emptyEmployee())}>
کارمند جدید
</Button>
</div>
<DataTable columns={columns} rows={data.employees} rowKey={(e) => e.id} emptyTitle="کارمندی ثبت نشده است" />
</Card>
<Modal
open={draft !== null}
title={draft?.id ? 'ویرایش کارمند' : 'کارمند جدید'}
onClose={() => setDraft(null)}
footer={
<>
<Button variant="secondary" onClick={() => setDraft(null)}>
انصراف
</Button>
<Button onClick={handleSave}>ذخیره</Button>
</>
}
>
{draft && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field label="کد پرسنلی">
<Input value={draft.code} onChange={(e) => setDraft({ ...draft, code: e.target.value })} />
</Field>
<Field label="نام و نام خانوادگی">
<Input value={draft.name} onChange={(e) => setDraft({ ...draft, name: e.target.value })} />
</Field>
<Field label="سمت">
<Input value={draft.position} onChange={(e) => setDraft({ ...draft, position: e.target.value })} />
</Field>
<Field label="حقوق پایه (تومان)">
<Input type="number" value={draft.baseSalary} onChange={(e) => setDraft({ ...draft, baseSalary: Number(e.target.value) || 0 })} />
</Field>
<Field label="شماره بیمه">
<Input value={draft.insuranceNo} onChange={(e) => setDraft({ ...draft, insuranceNo: e.target.value })} />
</Field>
<Field label="تاریخ استخدام">
<Input type="date" value={draft.hireDate} onChange={(e) => setDraft({ ...draft, hireDate: e.target.value })} />
</Field>
<Field label="وضعیت">
<Select value={draft.active ? 'active' : 'inactive'} onChange={(e) => setDraft({ ...draft, active: e.target.value === 'active' })}>
<option value="active">شاغل</option>
<option value="inactive">غیرفعال</option>
</Select>
</Field>
</div>
)}
</Modal>
<ConfirmDialog
open={toDelete !== null}
title="حذف کارمند"
message={`«${toDelete?.name}» حذف شود؟`}
onCancel={() => setToDelete(null)}
onConfirm={() => {
if (toDelete) removeEmployee(toDelete.id)
setToDelete(null)
}}
/>
</>
)
}
const emptyPayslip = (employeeId: string, baseSalary: number): Payslip => ({
id: '',
employeeId,
period: '',
baseSalary,
overtime: 0,
bonus: 0,
deductions: 0,
insurance: 0,
tax: 0,
})
function PayslipSection() {
const { data, upsertPayslip, removePayslip } = useStore()
const [draft, setDraft] = useState<Payslip | null>(null)
const [toDelete, setToDelete] = useState<Payslip | null>(null)
const employeeName = (id: string) => data.employees.find((e) => e.id === id)?.name ?? '—'
const createDraft = (): Payslip => {
const first = data.employees[0]
return emptyPayslip(first?.id ?? '', first?.baseSalary ?? 0)
}
const onSelectEmployee = (employeeId: string) => {
if (!draft) return
const employee = data.employees.find((e) => e.id === employeeId)
setDraft({ ...draft, employeeId, baseSalary: employee?.baseSalary ?? draft.baseSalary })
}
const handleSave = () => {
if (!draft || !draft.employeeId || !draft.period.trim()) return
upsertPayslip({ ...draft, id: draft.id || createId('ps-') })
setDraft(null)
}
const columns: Array<Column<Payslip>> = [
{ key: 'employee', header: 'کارمند', render: (p) => <span className="font-medium text-slate-800">{employeeName(p.employeeId)}</span> },
{ key: 'period', header: 'دوره', render: (p) => toFa(p.period) },
{ key: 'base', header: 'حقوق پایه', align: 'end', render: (p) => <span className="tabular-nums">{formatMoney(p.baseSalary)}</span> },
{ key: 'additions', header: 'اضافات', align: 'end', render: (p) => <span className="tabular-nums text-emerald-600">{formatMoney(p.overtime + p.bonus)}</span> },
{ key: 'deductions', header: 'کسورات', align: 'end', render: (p) => <span className="tabular-nums text-rose-600">{formatMoney(p.deductions + p.insurance + p.tax)}</span> },
{ key: 'net', header: 'خالص پرداختی', align: 'end', render: (p) => <span className="tabular-nums font-semibold text-brand-700">{formatMoney(payslipNet(p))}</span> },
{
key: 'actions',
header: '',
align: 'end',
render: (p) => (
<div className="flex justify-end gap-1">
<Button size="sm" variant="ghost" onClick={() => setDraft({ ...p })}>
ویرایش
</Button>
<Button size="sm" variant="ghost" className="text-rose-600" onClick={() => setToDelete(p)}>
حذف
</Button>
</div>
),
},
]
return (
<>
<Card className="p-4">
<div className="mb-4 flex justify-end">
<Button icon="" onClick={() => setDraft(createDraft())} disabled={data.employees.length === 0}>
فیش حقوقی جدید
</Button>
</div>
{data.employees.length === 0 ? (
<p className="py-10 text-center text-sm text-slate-400">ابتدا کارکنان را تعریف کنید.</p>
) : (
<DataTable columns={columns} rows={data.payslips} rowKey={(p) => p.id} emptyTitle="فیشی صادر نشده است" />
)}
</Card>
<Modal
open={draft !== null}
title={draft?.id ? 'ویرایش فیش حقوقی' : 'صدور فیش حقوقی'}
onClose={() => setDraft(null)}
footer={
<>
{draft && (
<span className="me-auto text-sm text-slate-500">
خالص پرداختی:{' '}
<span className="tabular-nums font-bold text-brand-700">{formatMoney(payslipNet(draft))}</span>
</span>
)}
<Button variant="secondary" onClick={() => setDraft(null)}>
انصراف
</Button>
<Button onClick={handleSave}>ذخیره</Button>
</>
}
>
{draft && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field label="کارمند">
<Select value={draft.employeeId} onChange={(e) => onSelectEmployee(e.target.value)}>
{data.employees.map((employee) => (
<option key={employee.id} value={employee.id}>
{employee.name}
</option>
))}
</Select>
</Field>
<Field label="دوره (مثال: 1404/03)">
<Input value={draft.period} onChange={(e) => setDraft({ ...draft, period: e.target.value })} />
</Field>
<Field label="حقوق پایه">
<Input type="number" value={draft.baseSalary} onChange={(e) => setDraft({ ...draft, baseSalary: Number(e.target.value) || 0 })} />
</Field>
<Field label="اضافه‌کاری">
<Input type="number" value={draft.overtime} onChange={(e) => setDraft({ ...draft, overtime: Number(e.target.value) || 0 })} />
</Field>
<Field label="پاداش">
<Input type="number" value={draft.bonus} onChange={(e) => setDraft({ ...draft, bonus: Number(e.target.value) || 0 })} />
</Field>
<Field label="کسورات سایر">
<Input type="number" value={draft.deductions} onChange={(e) => setDraft({ ...draft, deductions: Number(e.target.value) || 0 })} />
</Field>
<Field label="بیمه">
<Input type="number" value={draft.insurance} onChange={(e) => setDraft({ ...draft, insurance: Number(e.target.value) || 0 })} />
</Field>
<Field label="مالیات">
<Input type="number" value={draft.tax} onChange={(e) => setDraft({ ...draft, tax: Number(e.target.value) || 0 })} />
</Field>
</div>
)}
</Modal>
<ConfirmDialog
open={toDelete !== null}
title="حذف فیش حقوقی"
message="این فیش حقوقی حذف شود؟"
onCancel={() => setToDelete(null)}
onConfirm={() => {
if (toDelete) removePayslip(toDelete.id)
setToDelete(null)
}}
/>
</>
)
}

15
src/pages/Purchases.tsx Normal file
View File

@@ -0,0 +1,15 @@
import { TradeModule } from '../components/business/TradeModule'
export function Purchases() {
return (
<TradeModule
config={{
kind: 'purchase',
title: 'تأمین‌کنندگان و خرید',
subtitle: 'مدیریت تأمین‌کنندگان و ثبت فاکتورهای خرید',
partyLabel: 'تأمین‌کننده',
partyTabLabel: 'تأمین‌کنندگان',
}}
/>
)
}

241
src/pages/Reports.tsx Normal file
View File

@@ -0,0 +1,241 @@
import { useMemo, useState } from 'react'
import { useStore } from '../store/AppStore'
import { Card, DataTable, PageHeader, Select, StatCard, type Column } from '../components/ui'
import { formatDate, formatMoney } from '../utils/format'
import {
accountLedger,
balanceSheet,
incomeStatement,
trialBalance,
type AccountBalance,
type LedgerRow,
} from '../utils/accounting'
type ReportTab = 'trial' | 'ledger' | 'income' | 'balance'
const tabs: Array<{ id: ReportTab; label: string }> = [
{ id: 'trial', label: 'تراز آزمایشی' },
{ id: 'ledger', label: 'دفتر کل' },
{ id: 'income', label: 'سود و زیان' },
{ id: 'balance', label: 'ترازنامه' },
]
export function Reports() {
const { data } = useStore()
const [tab, setTab] = useState<ReportTab>('trial')
const balances = useMemo(
() => trialBalance(data.accounts, data.vouchers),
[data.accounts, data.vouchers],
)
return (
<div className="space-y-6">
<PageHeader title="گزارش‌های مالی" subtitle="تراز آزمایشی، دفتر کل، صورت سود و زیان و ترازنامه" />
<div className="inline-flex flex-wrap gap-1 rounded-xl bg-slate-200/60 p-1">
{tabs.map((item) => (
<button
key={item.id}
onClick={() => setTab(item.id)}
className={`rounded-lg px-5 py-2 text-sm font-medium transition ${
tab === item.id ? 'bg-white text-brand-700 shadow-sm' : 'text-slate-500 hover:text-slate-700'
}`}
>
{item.label}
</button>
))}
</div>
{tab === 'trial' && <TrialBalanceReport balances={balances} />}
{tab === 'ledger' && <LedgerReport />}
{tab === 'income' && <IncomeReport balances={balances} />}
{tab === 'balance' && <BalanceReport balances={balances} />}
</div>
)
}
function TrialBalanceReport({ balances }: { balances: AccountBalance[] }) {
const rows = balances.filter((b) => b.debit !== 0 || b.credit !== 0)
const totalDebit = rows.reduce((s, b) => s + b.debit, 0)
const totalCredit = rows.reduce((s, b) => s + b.credit, 0)
const columns: Array<Column<AccountBalance>> = [
{ key: 'code', header: 'کد', render: (b) => <span className="font-mono text-slate-500">{b.account.code}</span> },
{ key: 'name', header: 'عنوان حساب', render: (b) => b.account.name },
{ key: 'debit', header: 'گردش بدهکار', align: 'end', render: (b) => <span className="tabular-nums">{formatMoney(b.debit)}</span> },
{ key: 'credit', header: 'گردش بستانکار', align: 'end', render: (b) => <span className="tabular-nums">{formatMoney(b.credit)}</span> },
{
key: 'balance',
header: 'مانده',
align: 'end',
render: (b) => <span className="tabular-nums font-semibold text-slate-800">{formatMoney(Math.abs(b.balance))}</span>,
},
]
return (
<Card className="p-4">
<DataTable
columns={columns}
rows={rows}
rowKey={(b) => b.account.id}
emptyTitle="گردشی برای نمایش وجود ندارد"
footer={
<tr>
<td className="px-4 py-3" colSpan={2}>
جمع کل
</td>
<td className="px-4 py-3 text-end tabular-nums">{formatMoney(totalDebit)}</td>
<td className="px-4 py-3 text-end tabular-nums">{formatMoney(totalCredit)}</td>
<td className="px-4 py-3 text-end">
{Math.abs(totalDebit - totalCredit) < 0.001 ? '✓ تراز' : '✗ نامتوازن'}
</td>
</tr>
}
/>
</Card>
)
}
function LedgerReport() {
const { data } = useStore()
const leafAccounts = useMemo(() => data.accounts.filter((a) => !a.isGroup), [data.accounts])
const [accountId, setAccountId] = useState(leafAccounts[0]?.id ?? '')
const account = leafAccounts.find((a) => a.id === accountId)
const rows = useMemo(
() => accountLedger(accountId, account, data.vouchers),
[accountId, account, data.vouchers],
)
const columns: Array<Column<LedgerRow>> = [
{ key: 'number', header: 'سند', render: (r) => <span className="font-mono">{r.voucherNumber}</span> },
{ key: 'date', header: 'تاریخ', render: (r) => formatDate(r.date) },
{ key: 'desc', header: 'شرح', render: (r) => r.description },
{ key: 'debit', header: 'بدهکار', align: 'end', render: (r) => <span className="tabular-nums">{r.debit ? formatMoney(r.debit) : '—'}</span> },
{ key: 'credit', header: 'بستانکار', align: 'end', render: (r) => <span className="tabular-nums">{r.credit ? formatMoney(r.credit) : '—'}</span> },
{ key: 'running', header: 'مانده', align: 'end', render: (r) => <span className="tabular-nums font-semibold text-slate-800">{formatMoney(r.running)}</span> },
]
return (
<div className="space-y-4">
<Card className="p-4">
<div className="max-w-sm">
<Select value={accountId} onChange={(e) => setAccountId(e.target.value)}>
{leafAccounts.map((acc) => (
<option key={acc.id} value={acc.id}>
{acc.code} - {acc.name}
</option>
))}
</Select>
</div>
</Card>
<Card className="p-4">
<DataTable
columns={columns}
rows={rows}
rowKey={(r) => `${r.voucherId}-${r.running}-${r.debit}-${r.credit}`}
emptyTitle="گردشی برای این حساب ثبت نشده است"
/>
</Card>
</div>
)
}
function IncomeReport({ balances }: { balances: AccountBalance[] }) {
const result = incomeStatement(balances)
const incomeRows = balances.filter((b) => b.account.type === 'income' && b.balance !== 0)
const expenseRows = balances.filter((b) => b.account.type === 'expense' && b.balance !== 0)
return (
<div className="space-y-6">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<StatCard label="جمع درآمدها" value={formatMoney(result.income)} tone="green" icon="📈" />
<StatCard label="جمع هزینه‌ها" value={formatMoney(result.expense)} tone="amber" icon="📉" />
<StatCard
label={result.profit >= 0 ? 'سود خالص' : 'زیان خالص'}
value={formatMoney(Math.abs(result.profit))}
tone={result.profit >= 0 ? 'green' : 'rose'}
icon="🧮"
/>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<Card className="p-4">
<h3 className="mb-3 font-bold text-slate-800">درآمدها</h3>
<BreakdownList rows={incomeRows} />
</Card>
<Card className="p-4">
<h3 className="mb-3 font-bold text-slate-800">هزینهها</h3>
<BreakdownList rows={expenseRows} />
</Card>
</div>
</div>
)
}
function BreakdownList({ rows }: { rows: AccountBalance[] }) {
if (rows.length === 0) {
return <p className="py-6 text-center text-sm text-slate-400">موردی ثبت نشده است.</p>
}
return (
<ul className="divide-y divide-slate-100">
{rows.map((row) => (
<li key={row.account.id} className="flex items-center justify-between py-2.5 text-sm">
<span className="text-slate-600">{row.account.name}</span>
<span className="tabular-nums font-semibold text-slate-800">{formatMoney(Math.abs(row.balance))}</span>
</li>
))}
</ul>
)
}
function BalanceReport({ balances }: { balances: AccountBalance[] }) {
const sheet = balanceSheet(balances)
const byType = (type: AccountBalance['account']['type']) =>
balances.filter((b) => b.account.type === type && b.balance !== 0)
const totalEquity = sheet.equity + sheet.retainedEarnings
const totalLiabEquity = sheet.liabilities + totalEquity
return (
<div className="space-y-6">
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<Card className="p-4">
<h3 className="mb-3 font-bold text-slate-800">داراییها</h3>
<BreakdownList rows={byType('asset')} />
<div className="mt-3 flex items-center justify-between border-t border-slate-200 pt-3 text-sm font-bold">
<span>جمع داراییها</span>
<span className="tabular-nums text-brand-700">{formatMoney(sheet.assets)}</span>
</div>
</Card>
<Card className="p-4">
<h3 className="mb-3 font-bold text-slate-800">بدهیها و حقوق صاحبان سهام</h3>
<BreakdownList rows={byType('liability')} />
<BreakdownList rows={byType('equity')} />
<div className="flex items-center justify-between py-2.5 text-sm">
<span className="text-slate-600">سود (زیان) انباشته دوره</span>
<span className="tabular-nums font-semibold text-slate-800">{formatMoney(sheet.retainedEarnings)}</span>
</div>
<div className="mt-3 flex items-center justify-between border-t border-slate-200 pt-3 text-sm font-bold">
<span>جمع بدهی و سرمایه</span>
<span className="tabular-nums text-brand-700">{formatMoney(totalLiabEquity)}</span>
</div>
</Card>
</div>
<Card className="p-4">
<p className="text-center text-sm text-slate-500">
{Math.abs(sheet.assets - totalLiabEquity) < 1 ? (
<span className="font-semibold text-emerald-600"> ترازنامه متوازن است</span>
) : (
<span className="font-semibold text-rose-600">
اختلاف ترازنامه: {formatMoney(Math.abs(sheet.assets - totalLiabEquity))}
</span>
)}
</p>
</Card>
</div>
)
}

15
src/pages/Sales.tsx Normal file
View File

@@ -0,0 +1,15 @@
import { TradeModule } from '../components/business/TradeModule'
export function Sales() {
return (
<TradeModule
config={{
kind: 'sale',
title: 'مشتریان و فروش',
subtitle: 'مدیریت مشتریان و صدور فاکتورهای فروش',
partyLabel: 'مشتری',
partyTabLabel: 'مشتریان',
}}
/>
)
}

212
src/pages/Settings.tsx Normal file
View File

@@ -0,0 +1,212 @@
import { useEffect, useMemo, useState } from 'react'
import { useStore } from '../store/AppStore'
import { Badge, Button, Card, ConfirmDialog, Field, Input, PageHeader, Select, Textarea } from '../components/ui'
import type { Account, CompanySettings, VoucherAccountMap } from '../types'
import { useConnection } from '../connection/ConnectionContext'
import { getAccountingBaseUrl, setAccountingBaseUrl } from '../api/config'
const VOUCHER_ACCOUNT_FIELDS: Array<{ key: keyof VoucherAccountMap; label: string; hint: string }> = [
{ key: 'bankAccountId', label: 'حساب نقد/بانک', hint: 'برای دریافت و پرداخت‌ها' },
{ key: 'salesIncomeAccountId', label: 'حساب درآمد فروش', hint: 'درآمد رزرو و بلیت' },
{ key: 'ownerPayableAccountId', label: 'حساب بدهی به مالکان', hint: 'موجودی کیف پول مالکان' },
{ key: 'customerPayableAccountId', label: 'حساب بدهی به مشتریان', hint: 'موجودی کیف پول مشتریان' },
{ key: 'ownerShareAccountId', label: 'حساب سهم مالکان از فروش', hint: 'هزینه سهم مالک از رزرو' },
]
export function Settings() {
const { data, updateSettings, resetAll } = useStore()
const [form, setForm] = useState<CompanySettings>(data.settings)
const [saved, setSaved] = useState(false)
const [confirmReset, setConfirmReset] = useState(false)
useEffect(() => {
setForm(data.settings)
}, [data.settings])
const leafAccounts = useMemo<Account[]>(
() => data.accounts.filter((a) => !a.isGroup),
[data.accounts],
)
const handleSave = () => {
updateSettings(form)
setSaved(true)
window.setTimeout(() => setSaved(false), 2500)
}
const setMapping = (key: keyof VoucherAccountMap, accountId: string) =>
setForm((prev) => ({
...prev,
voucherAccounts: { ...prev.voucherAccounts, [key]: accountId },
}))
return (
<div className="space-y-6">
<PageHeader
title="تنظیمات"
subtitle="اطلاعات شرکت، سال مالی و پیکربندی سیستم"
actions={
<>
{saved && <span className="text-sm font-medium text-emerald-600"> ذخیره شد</span>}
<Button onClick={handleSave}>ذخیره تغییرات</Button>
</>
}
/>
<Card className="p-6">
<h2 className="mb-4 text-lg font-bold text-slate-800">اطلاعات شرکت</h2>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field label="نام شرکت">
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</Field>
<Field label="کد اقتصادی">
<Input value={form.economicCode} onChange={(e) => setForm({ ...form, economicCode: e.target.value })} />
</Field>
<Field label="سال مالی">
<Input value={form.fiscalYear} onChange={(e) => setForm({ ...form, fiscalYear: e.target.value })} />
</Field>
<Field label="واحد پول پایه">
<Input value={form.baseCurrency} onChange={(e) => setForm({ ...form, baseCurrency: e.target.value })} />
</Field>
<Field label="نرخ مالیات بر ارزش افزوده (٪)" hint="به‌صورت پیش‌فرض در فاکتورها اعمال می‌شود">
<Input type="number" value={form.taxRate} onChange={(e) => setForm({ ...form, taxRate: Number(e.target.value) || 0 })} />
</Field>
<Field label="تلفن">
<Input value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} />
</Field>
<div className="sm:col-span-2">
<Field label="آدرس">
<Textarea rows={2} value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} />
</Field>
</div>
</div>
</Card>
<Card className="p-6">
<h2 className="mb-1 text-lg font-bold text-slate-800">نگاشت حسابهای صدور سند خودکار</h2>
<p className="mb-4 text-sm text-slate-500">
تعیین کنید تراکنشهای واقعی مالکان و مشتریان هنگام «صدور سند» به کدام حسابهای دفتر کل ثبت شوند.
</p>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{VOUCHER_ACCOUNT_FIELDS.map(({ key, label, hint }) => (
<Field key={key} label={label} hint={hint}>
<Select value={form.voucherAccounts[key]} onChange={(e) => setMapping(key, e.target.value)}>
{leafAccounts.map((acc) => (
<option key={acc.id} value={acc.id}>
{acc.code} - {acc.name}
</option>
))}
</Select>
</Field>
))}
</div>
</Card>
<ConnectionPanel />
<Card className="border-rose-200 p-6">
<h2 className="mb-2 text-lg font-bold text-rose-700">منطقه خطر</h2>
<p className="mb-4 text-sm text-slate-500">
بازنشانی، تمام اسناد، فاکتورها و دادههای ثبتشده را روی سرور حذف و دفترها را با حسابهای پیشفرض (با مانده صفر) از نو میسازد. این عمل قابل بازگشت نیست.
</p>
<Button variant="danger" onClick={() => setConfirmReset(true)}>
بازنشانی کامل اطلاعات
</Button>
</Card>
<ConfirmDialog
open={confirmReset}
title="بازنشانی اطلاعات"
message="تمام داده‌های حسابداری روی سرور حذف و دفترها از نو ساخته می‌شود. ادامه می‌دهید؟"
confirmLabel="بله، بازنشانی کن"
onCancel={() => setConfirmReset(false)}
onConfirm={() => {
resetAll()
setConfirmReset(false)
}}
/>
</div>
)
}
function ConnectionPanel() {
const { baseUrl, connected, busy, error, connect, disconnect } = useConnection()
const [url, setUrl] = useState(baseUrl)
const [acctUrl, setAcctUrl] = useState(getAccountingBaseUrl())
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const persistAcctUrl = (value: string) => {
setAcctUrl(value)
setAccountingBaseUrl(value)
}
const handleConnect = async () => {
const ok = await connect(url, username, password)
if (ok) setPassword('')
}
return (
<Card className="p-6">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-bold text-slate-800">اتصال به سرور فانزون</h2>
{connected ? <Badge tone="green">متصل</Badge> : <Badge tone="rose">قطع</Badge>}
</div>
<p className="mb-4 text-sm text-slate-500">
برای نمایش اطلاعات واقعی مالکان و کاربران، با حساب مدیر (admin) به بکاند متصل شوید.
</p>
{error && (
<div className="mb-4 rounded-lg border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
{error}
</div>
)}
{connected ? (
<div className="space-y-4">
<Field label="آدرس سرور فان‌زون">
<Input value={baseUrl} readOnly className="bg-slate-50" dir="ltr" />
</Field>
<Field label="آدرس سرویس حسابداری" hint="محل ذخیره اسناد، حساب‌ها و گزارش‌ها">
<Input value={acctUrl} onChange={(e) => persistAcctUrl(e.target.value)} dir="ltr" placeholder="http://localhost:8010/api" />
</Field>
<Button variant="danger" onClick={disconnect}>
قطع اتصال
</Button>
</div>
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<Field label="آدرس سرور فان‌زون (API Base URL)" hint="مثال: http://localhost:8000/api">
<Input value={url} onChange={(e) => setUrl(e.target.value)} dir="ltr" placeholder="http://localhost:8000/api" />
</Field>
</div>
<div className="sm:col-span-2">
<Field label="آدرس سرویس حسابداری" hint="مثال: http://localhost:8010/api">
<Input value={acctUrl} onChange={(e) => persistAcctUrl(e.target.value)} dir="ltr" placeholder="http://localhost:8010/api" />
</Field>
</div>
<Field label="نام کاربری مدیر">
<Input value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" />
</Field>
<Field label="رمز عبور">
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
onKeyDown={(e) => {
if (e.key === 'Enter') handleConnect()
}}
/>
</Field>
<div className="sm:col-span-2">
<Button onClick={handleConnect} disabled={busy || !username || !password}>
{busy ? 'در حال اتصال…' : 'اتصال و ورود'}
</Button>
</div>
</div>
)}
</Card>
)
}

198
src/pages/Treasury.tsx Normal file
View File

@@ -0,0 +1,198 @@
import { useMemo, useState } from 'react'
import { useStore } from '../store/AppStore'
import {
Badge,
Button,
Card,
ConfirmDialog,
DataTable,
Field,
Input,
Modal,
PageHeader,
Select,
StatCard,
Textarea,
type Column,
} from '../components/ui'
import type { TreasuryKind, TreasuryMethod, TreasuryTxn } from '../types'
import { createId, nextNumber } from '../utils/id'
import { formatDate, formatMoney } from '../utils/format'
const kindLabels: Record<TreasuryKind, string> = { receipt: 'دریافت', payment: 'پرداخت' }
const methodLabels: Record<TreasuryMethod, string> = { cash: 'نقدی', bank: 'بانکی', cheque: 'چک' }
export function Treasury() {
const { data, upsertTreasury, removeTreasury } = useStore()
const [draft, setDraft] = useState<TreasuryTxn | null>(null)
const [toDelete, setToDelete] = useState<TreasuryTxn | null>(null)
const partyName = (id: string | null) =>
id ? data.parties.find((p) => p.id === id)?.name ?? '—' : '—'
const summary = useMemo(() => {
const receipts = data.treasury.filter((t) => t.kind === 'receipt').reduce((s, t) => s + t.amount, 0)
const payments = data.treasury.filter((t) => t.kind === 'payment').reduce((s, t) => s + t.amount, 0)
return { receipts, payments, net: receipts - payments }
}, [data.treasury])
const sorted = useMemo(
() => [...data.treasury].sort((a, b) => b.date.localeCompare(a.date)),
[data.treasury],
)
const createDraft = (kind: TreasuryKind): TreasuryTxn => ({
id: '',
number: nextNumber(data.treasury.filter((t) => t.kind === kind)),
kind,
method: 'bank',
date: new Date().toISOString().slice(0, 10),
partyId: null,
amount: 0,
reference: '',
description: '',
})
const handleSave = () => {
if (!draft || draft.amount <= 0) return
upsertTreasury({ ...draft, id: draft.id || createId('tr-') })
setDraft(null)
}
const columns: Array<Column<TreasuryTxn>> = [
{ key: 'date', header: 'تاریخ', render: (t) => formatDate(t.date) },
{
key: 'kind',
header: 'نوع',
render: (t) => <Badge tone={t.kind === 'receipt' ? 'green' : 'rose'}>{kindLabels[t.kind]}</Badge>,
},
{ key: 'method', header: 'روش', render: (t) => <Badge tone="slate">{methodLabels[t.method]}</Badge> },
{ key: 'party', header: 'طرف حساب', render: (t) => partyName(t.partyId) },
{ key: 'ref', header: 'مرجع', render: (t) => <span className="font-mono text-slate-500">{t.reference || '—'}</span> },
{
key: 'amount',
header: 'مبلغ',
align: 'end',
render: (t) => (
<span className={`tabular-nums font-semibold ${t.kind === 'receipt' ? 'text-emerald-600' : 'text-rose-600'}`}>
{t.kind === 'receipt' ? '+' : ''} {formatMoney(t.amount)}
</span>
),
},
{
key: 'actions',
header: '',
align: 'end',
render: (t) => (
<div className="flex justify-end gap-1">
<Button size="sm" variant="ghost" onClick={() => setDraft({ ...t })}>
ویرایش
</Button>
<Button size="sm" variant="ghost" className="text-rose-600" onClick={() => setToDelete(t)}>
حذف
</Button>
</div>
),
},
]
return (
<div className="space-y-6">
<PageHeader
title="دریافت و پرداخت (خزانه‌داری)"
subtitle="ثبت دریافت‌ها و پرداخت‌های نقدی، بانکی و چک"
actions={
<>
<Button variant="success" icon="↓" onClick={() => setDraft(createDraft('receipt'))}>
دریافت جدید
</Button>
<Button variant="danger" icon="↑" onClick={() => setDraft(createDraft('payment'))}>
پرداخت جدید
</Button>
</>
}
/>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<StatCard label="جمع دریافت‌ها" value={formatMoney(summary.receipts)} icon="↓" tone="green" />
<StatCard label="جمع پرداخت‌ها" value={formatMoney(summary.payments)} icon="↑" tone="rose" />
<StatCard
label="مانده خالص"
value={formatMoney(summary.net)}
icon="💰"
tone={summary.net >= 0 ? 'brand' : 'rose'}
/>
</div>
<Card className="p-4">
<DataTable columns={columns} rows={sorted} rowKey={(t) => t.id} emptyTitle="تراکنشی ثبت نشده است" />
</Card>
<Modal
open={draft !== null}
title={draft ? `${kindLabels[draft.kind]} جدید` : ''}
onClose={() => setDraft(null)}
footer={
<>
<Button variant="secondary" onClick={() => setDraft(null)}>
انصراف
</Button>
<Button onClick={handleSave}>ذخیره</Button>
</>
}
>
{draft && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field label="تاریخ">
<Input type="date" value={draft.date} onChange={(e) => setDraft({ ...draft, date: e.target.value })} />
</Field>
<Field label="روش">
<Select value={draft.method} onChange={(e) => setDraft({ ...draft, method: e.target.value as TreasuryMethod })}>
{Object.entries(methodLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</Select>
</Field>
<Field label="طرف حساب">
<Select
value={draft.partyId ?? ''}
onChange={(e) => setDraft({ ...draft, partyId: e.target.value || null })}
>
<option value=""> بدون طرف حساب </option>
{data.parties.map((party) => (
<option key={party.id} value={party.id}>
{party.name} ({party.kind === 'customer' ? 'مشتری' : 'تأمین‌کننده'})
</option>
))}
</Select>
</Field>
<Field label="مبلغ (تومان)">
<Input type="number" value={draft.amount} onChange={(e) => setDraft({ ...draft, amount: Number(e.target.value) || 0 })} />
</Field>
<Field label="شماره مرجع / چک">
<Input value={draft.reference} onChange={(e) => setDraft({ ...draft, reference: e.target.value })} />
</Field>
<div className="sm:col-span-2">
<Field label="توضیحات">
<Textarea rows={2} value={draft.description} onChange={(e) => setDraft({ ...draft, description: e.target.value })} />
</Field>
</div>
</div>
)}
</Modal>
<ConfirmDialog
open={toDelete !== null}
title="حذف تراکنش"
message="این تراکنش خزانه حذف شود؟"
onCancel={() => setToDelete(null)}
onConfirm={() => {
if (toDelete) removeTreasury(toDelete.id)
setToDelete(null)
}}
/>
</div>
)
}

283
src/pages/Vouchers.tsx Normal file
View File

@@ -0,0 +1,283 @@
import { useMemo, useState } from 'react'
import { useStore } from '../store/AppStore'
import {
Badge,
Button,
Card,
ConfirmDialog,
DataTable,
Field,
Input,
Modal,
PageHeader,
Select,
type Column,
} from '../components/ui'
import type { Voucher, VoucherLine } from '../types'
import { createId, nextNumber } from '../utils/id'
import { formatDate, formatMoney, toFa } from '../utils/format'
import { voucherBalance } from '../utils/accounting'
const newLine = (): VoucherLine => ({
id: createId('vl-'),
accountId: '',
description: '',
debit: 0,
credit: 0,
})
function createDraft(items: Voucher[]): Voucher {
return {
id: '',
number: nextNumber(items),
date: new Date().toISOString().slice(0, 10),
description: '',
status: 'draft',
lines: [newLine(), newLine()],
}
}
export function Vouchers() {
const { data, upsertVoucher, removeVoucher } = useStore()
const [draft, setDraft] = useState<Voucher | null>(null)
const [toDelete, setToDelete] = useState<Voucher | null>(null)
const leafAccounts = useMemo(
() => data.accounts.filter((account) => !account.isGroup),
[data.accounts],
)
const accountName = (id: string) => leafAccounts.find((a) => a.id === id)?.name ?? '—'
const sorted = useMemo(
() => [...data.vouchers].sort((a, b) => b.date.localeCompare(a.date) || b.number - a.number),
[data.vouchers],
)
const balance = draft ? voucherBalance(draft) : null
const updateLine = (lineId: string, patch: Partial<VoucherLine>) => {
if (!draft) return
setDraft({
...draft,
lines: draft.lines.map((line) => (line.id === lineId ? { ...line, ...patch } : line)),
})
}
const handleSave = (status: Voucher['status']) => {
if (!draft) return
const cleanLines = draft.lines.filter((line) => line.accountId && (line.debit > 0 || line.credit > 0))
if (cleanLines.length < 2) return
const candidate: Voucher = { ...draft, lines: cleanLines, status }
if (status === 'posted' && !voucherBalance(candidate).balanced) return
upsertVoucher({ ...candidate, id: candidate.id || createId('v-') })
setDraft(null)
}
const columns: Array<Column<Voucher>> = [
{ key: 'number', header: 'شماره', render: (v) => <span className="font-mono">{toFa(v.number)}</span> },
{ key: 'date', header: 'تاریخ', render: (v) => formatDate(v.date) },
{ key: 'desc', header: 'شرح', render: (v) => <span className="text-slate-700">{v.description || '—'}</span> },
{
key: 'amount',
header: 'مبلغ',
align: 'end',
render: (v) => <span className="tabular-nums">{formatMoney(voucherBalance(v).debit)}</span>,
},
{
key: 'status',
header: 'وضعیت',
render: (v) =>
v.status === 'posted' ? <Badge tone="green">ثبت قطعی</Badge> : <Badge tone="amber">پیشنویس</Badge>,
},
{
key: 'actions',
header: '',
align: 'end',
render: (v) => (
<div className="flex justify-end gap-1">
<Button size="sm" variant="ghost" onClick={() => setDraft({ ...v, lines: v.lines.map((l) => ({ ...l })) })}>
ویرایش
</Button>
<Button size="sm" variant="ghost" className="text-rose-600" onClick={() => setToDelete(v)}>
حذف
</Button>
</div>
),
},
]
return (
<div className="space-y-6">
<PageHeader
title="اسناد حسابداری"
subtitle="ثبت اسناد دوطرفه با کنترل تراز بدهکار و بستانکار"
actions={
<Button icon="" onClick={() => setDraft(createDraft(data.vouchers))}>
سند جدید
</Button>
}
/>
<Card className="p-4">
<DataTable
columns={columns}
rows={sorted}
rowKey={(v) => v.id}
emptyTitle="سندی ثبت نشده است"
emptyDescription="اولین سند حسابداری خود را ایجاد کنید."
/>
</Card>
<Modal
open={draft !== null}
title={draft?.id ? `ویرایش سند #${toFa(draft.number)}` : 'سند حسابداری جدید'}
onClose={() => setDraft(null)}
size="xl"
footer={
<>
<div className="me-auto flex items-center gap-4 text-sm">
<span className="text-slate-500">
بدهکار: <span className="tabular-nums font-semibold text-slate-700">{formatMoney(balance?.debit ?? 0)}</span>
</span>
<span className="text-slate-500">
بستانکار: <span className="tabular-nums font-semibold text-slate-700">{formatMoney(balance?.credit ?? 0)}</span>
</span>
{balance?.balanced ? (
<Badge tone="green">تراز است</Badge>
) : (
<Badge tone="rose">اختلاف {formatMoney(Math.abs((balance?.debit ?? 0) - (balance?.credit ?? 0)))}</Badge>
)}
</div>
<Button variant="secondary" onClick={() => setDraft(null)}>
انصراف
</Button>
<Button variant="secondary" onClick={() => handleSave('draft')}>
ذخیره پیشنویس
</Button>
<Button variant="success" disabled={!balance?.balanced} onClick={() => handleSave('posted')}>
ثبت قطعی
</Button>
</>
}
>
{draft && (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<Field label="شماره سند">
<Input
type="number"
value={draft.number}
onChange={(e) => setDraft({ ...draft, number: Number(e.target.value) || 0 })}
/>
</Field>
<Field label="تاریخ">
<Input type="date" value={draft.date} onChange={(e) => setDraft({ ...draft, date: e.target.value })} />
</Field>
<Field label="شرح سند">
<Input value={draft.description} onChange={(e) => setDraft({ ...draft, description: e.target.value })} />
</Field>
</div>
<div className="overflow-x-auto rounded-xl border border-slate-200">
<table className="w-full text-sm">
<thead className="bg-slate-50 text-slate-500">
<tr>
<th className="px-3 py-2 text-start font-medium">حساب</th>
<th className="px-3 py-2 text-start font-medium">شرح ردیف</th>
<th className="px-3 py-2 text-end font-medium">بدهکار</th>
<th className="px-3 py-2 text-end font-medium">بستانکار</th>
<th className="px-3 py-2" />
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{draft.lines.map((line) => (
<tr key={line.id}>
<td className="px-2 py-1.5">
<Select
value={line.accountId}
onChange={(e) => updateLine(line.id, { accountId: e.target.value })}
>
<option value="">انتخاب حساب</option>
{leafAccounts.map((account) => (
<option key={account.id} value={account.id}>
{account.code} - {account.name}
</option>
))}
</Select>
</td>
<td className="px-2 py-1.5">
<Input
value={line.description}
onChange={(e) => updateLine(line.id, { description: e.target.value })}
placeholder="شرح"
/>
</td>
<td className="px-2 py-1.5">
<Input
type="number"
className="text-end"
value={line.debit || ''}
onChange={(e) =>
updateLine(line.id, { debit: Number(e.target.value) || 0, credit: 0 })
}
/>
</td>
<td className="px-2 py-1.5">
<Input
type="number"
className="text-end"
value={line.credit || ''}
onChange={(e) =>
updateLine(line.id, { credit: Number(e.target.value) || 0, debit: 0 })
}
/>
</td>
<td className="px-2 py-1.5 text-center">
<button
onClick={() =>
setDraft({ ...draft, lines: draft.lines.filter((l) => l.id !== line.id) })
}
className="text-slate-400 transition hover:text-rose-600"
aria-label="حذف ردیف"
disabled={draft.lines.length <= 2}
>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<Button
size="sm"
variant="secondary"
icon=""
onClick={() => setDraft({ ...draft, lines: [...draft.lines, newLine()] })}
>
افزودن ردیف
</Button>
{draft.lines.some((l) => l.accountId) && (
<p className="text-xs text-slate-400">
حسابهای انتخابشده: {draft.lines.filter((l) => l.accountId).map((l) => accountName(l.accountId)).join('، ')}
</p>
)}
</div>
)}
</Modal>
<ConfirmDialog
open={toDelete !== null}
title="حذف سند"
message={`سند شماره ${toFa(toDelete?.number ?? 0)} حذف شود؟`}
onCancel={() => setToDelete(null)}
onConfirm={() => {
if (toDelete) removeVoucher(toDelete.id)
setToDelete(null)
}}
/>
</div>
)
}

302
src/store/AppStore.tsx Normal file
View File

@@ -0,0 +1,302 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react'
import type {
Account,
AppData,
CompanySettings,
Employee,
Invoice,
Party,
Payslip,
Product,
TreasuryTxn,
Voucher,
} from '../types'
import { acct, ApiError, asList } from '../api/client'
import { ACCOUNTING_ENDPOINTS } from '../api/config'
import { useConnection } from '../connection/ConnectionContext'
type Identifiable = { id: string }
/** Collections of AppData that follow the standard id-based CRUD pattern. */
type CollectionKey =
| 'accounts'
| 'vouchers'
| 'parties'
| 'products'
| 'invoices'
| 'treasury'
| 'employees'
| 'payslips'
const ENDPOINT: Record<CollectionKey, string> = {
accounts: ACCOUNTING_ENDPOINTS.ACCOUNTS,
vouchers: ACCOUNTING_ENDPOINTS.VOUCHERS,
parties: ACCOUNTING_ENDPOINTS.PARTIES,
products: ACCOUNTING_ENDPOINTS.PRODUCTS,
invoices: ACCOUNTING_ENDPOINTS.INVOICES,
treasury: ACCOUNTING_ENDPOINTS.TREASURY,
employees: ACCOUNTING_ENDPOINTS.EMPLOYEES,
payslips: ACCOUNTING_ENDPOINTS.PAYSLIPS,
}
const EMPTY_SETTINGS: CompanySettings = {
name: '',
economicCode: '',
fiscalYear: '',
baseCurrency: 'تومان',
taxRate: 0,
address: '',
phone: '',
voucherAccounts: {
bankAccountId: '',
ownerPayableAccountId: '',
customerPayableAccountId: '',
ownerShareAccountId: '',
salesIncomeAccountId: '',
},
}
const EMPTY_DATA: AppData = {
accounts: [],
vouchers: [],
parties: [],
products: [],
invoices: [],
treasury: [],
employees: [],
payslips: [],
settings: EMPTY_SETTINGS,
}
interface AppStoreValue {
data: AppData
loading: boolean
error: string | null
ready: boolean
reload: () => void
upsertAccount: (item: Account) => void
removeAccount: (id: string) => void
upsertVoucher: (item: Voucher) => void
removeVoucher: (id: string) => void
/** Bulk-creates vouchers (used by the live-data "صدور سند" sync); returns how many were created. */
createVouchers: (items: Voucher[]) => Promise<number>
upsertParty: (item: Party) => void
removeParty: (id: string) => void
upsertProduct: (item: Product) => void
removeProduct: (id: string) => void
upsertInvoice: (item: Invoice) => void
removeInvoice: (id: string) => void
upsertTreasury: (item: TreasuryTxn) => void
removeTreasury: (id: string) => void
upsertEmployee: (item: Employee) => void
removeEmployee: (id: string) => void
upsertPayslip: (item: Payslip) => void
removePayslip: (id: string) => void
updateSettings: (settings: CompanySettings) => void
resetAll: () => void
}
const AppStoreContext = createContext<AppStoreValue | null>(null)
const errorMessage = (err: unknown): string =>
err instanceof Error ? err.message : 'خطا در ارتباط با سرور حسابداری'
/** Strips client-generated ids; nested lines are recreated server-side. */
function serializePayload(key: CollectionKey, item: Identifiable): Record<string, unknown> {
const { id: _id, ...rest } = item as Record<string, unknown> & Identifiable
if (key === 'vouchers' || key === 'invoices') {
const lines = (rest as { lines?: Identifiable[] }).lines ?? []
return {
...rest,
lines: lines.map((line) => {
const { id: _lineId, ...lineRest } = line as Record<string, unknown> & Identifiable
return lineRest
}),
}
}
return rest
}
export function AppStoreProvider({ children }: { children: ReactNode }) {
const { connected, disconnect } = useConnection()
const [data, setData] = useState<AppData>(EMPTY_DATA)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [ready, setReady] = useState(false)
const dataRef = useRef(data)
useEffect(() => {
dataRef.current = data
}, [data])
/** On an expired/invalid session, clear the token so the app returns to login. */
const handleApiError = useCallback(
(err: unknown) => {
if (err instanceof ApiError && err.status === 401) {
disconnect()
return
}
setError(errorMessage(err))
},
[disconnect],
)
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
const [accounts, vouchers, parties, products, invoices, treasury, employees, payslips, settings] =
await Promise.all([
acct.get(ENDPOINT.accounts).then(asList<Account>),
acct.get(ENDPOINT.vouchers).then(asList<Voucher>),
acct.get(ENDPOINT.parties).then(asList<Party>),
acct.get(ENDPOINT.products).then(asList<Product>),
acct.get(ENDPOINT.invoices).then(asList<Invoice>),
acct.get(ENDPOINT.treasury).then(asList<TreasuryTxn>),
acct.get(ENDPOINT.employees).then(asList<Employee>),
acct.get(ENDPOINT.payslips).then(asList<Payslip>),
acct.get<CompanySettings>(ACCOUNTING_ENDPOINTS.SETTINGS),
])
setData({ accounts, vouchers, parties, products, invoices, treasury, employees, payslips, settings })
setReady(true)
} catch (err) {
setReady(false)
handleApiError(err)
} finally {
setLoading(false)
}
}, [handleApiError])
useEffect(() => {
if (connected) {
void load()
} else {
setData(EMPTY_DATA)
setReady(false)
setError(null)
}
}, [connected, load])
const upsert = useCallback(
async <K extends CollectionKey>(key: K, item: AppData[K][number]) => {
const entity = item as Identifiable
const exists = (dataRef.current[key] as Identifiable[]).some((x) => x.id === entity.id)
const payload = serializePayload(key, entity)
try {
if (exists) {
const saved = await acct.patch<AppData[K][number]>(`${ENDPOINT[key]}${entity.id}/`, payload)
setData((prev) => ({
...prev,
[key]: (prev[key] as Identifiable[]).map((x) =>
x.id === entity.id ? (saved as Identifiable) : x,
),
}))
} else {
const saved = await acct.post<AppData[K][number]>(ENDPOINT[key], payload)
setData((prev) => ({ ...prev, [key]: [...(prev[key] as Identifiable[]), saved as Identifiable] }))
}
} catch (err) {
handleApiError(err)
throw err
}
},
[handleApiError],
)
const remove = useCallback(
async (key: CollectionKey, id: string) => {
try {
await acct.delete(`${ENDPOINT[key]}${id}/`)
setData((prev) => ({ ...prev, [key]: (prev[key] as Identifiable[]).filter((x) => x.id !== id) }))
} catch (err) {
handleApiError(err)
throw err
}
},
[handleApiError],
)
const createVouchers = useCallback(async (items: Voucher[]): Promise<number> => {
if (items.length === 0) return 0
try {
const saved = await Promise.all(
items.map((v) => acct.post<Voucher>(ENDPOINT.vouchers, serializePayload('vouchers', v))),
)
setData((prev) => ({ ...prev, vouchers: [...prev.vouchers, ...saved] }))
return saved.length
} catch (err) {
handleApiError(err)
throw err
}
}, [handleApiError])
const updateSettings = useCallback(async (settings: CompanySettings) => {
try {
const saved = await acct.patch<CompanySettings>(ACCOUNTING_ENDPOINTS.SETTINGS, settings)
setData((prev) => ({ ...prev, settings: saved }))
} catch (err) {
handleApiError(err)
throw err
}
}, [handleApiError])
const resetAll = useCallback(async () => {
try {
await acct.post(ACCOUNTING_ENDPOINTS.RESET, {})
await load()
} catch (err) {
handleApiError(err)
}
}, [load, handleApiError])
const value = useMemo<AppStoreValue>(
() => ({
data,
loading,
error,
ready,
reload: () => {
void load()
},
upsertAccount: (item) => void upsert('accounts', item),
removeAccount: (id) => void remove('accounts', id),
upsertVoucher: (item) => void upsert('vouchers', item),
removeVoucher: (id) => void remove('vouchers', id),
createVouchers,
upsertParty: (item) => void upsert('parties', item),
removeParty: (id) => void remove('parties', id),
upsertProduct: (item) => void upsert('products', item),
removeProduct: (id) => void remove('products', id),
upsertInvoice: (item) => void upsert('invoices', item),
removeInvoice: (id) => void remove('invoices', id),
upsertTreasury: (item) => void upsert('treasury', item),
removeTreasury: (id) => void remove('treasury', id),
upsertEmployee: (item) => void upsert('employees', item),
removeEmployee: (id) => void remove('employees', id),
upsertPayslip: (item) => void upsert('payslips', item),
removePayslip: (id) => void remove('payslips', id),
updateSettings,
resetAll,
}),
[data, loading, error, ready, load, upsert, remove, createVouchers, updateSettings, resetAll],
)
return <AppStoreContext.Provider value={value}>{children}</AppStoreContext.Provider>
}
export function useStore(): AppStoreValue {
const ctx = useContext(AppStoreContext)
if (!ctx) {
throw new Error('useStore must be used within an AppStoreProvider')
}
return ctx
}

155
src/types/index.ts Normal file
View File

@@ -0,0 +1,155 @@
export type ID = string
export type AccountType = 'asset' | 'liability' | 'equity' | 'income' | 'expense'
export interface Account {
id: ID
code: string
name: string
type: AccountType
parentId: ID | null
isGroup: boolean
openingBalance: number
}
export interface VoucherLine {
id: ID
accountId: ID
description: string
debit: number
credit: number
}
export type VoucherStatus = 'draft' | 'posted'
export interface Voucher {
id: ID
number: number
date: string
description: string
status: VoucherStatus
lines: VoucherLine[]
/** Set when the voucher was generated automatically by another module. */
source?: string
}
export type PartyKind = 'customer' | 'supplier'
export interface Party {
id: ID
kind: PartyKind
name: string
phone: string
economicCode: string
address: string
openingBalance: number
}
export interface Product {
id: ID
code: string
name: string
unit: string
salePrice: number
purchasePrice: number
stock: number
reorderLevel: number
}
export interface InvoiceLine {
id: ID
productId: ID
quantity: number
unitPrice: number
discount: number
taxRate: number
}
export type InvoiceKind = 'sale' | 'purchase'
export type InvoiceStatus = 'draft' | 'confirmed' | 'paid'
export interface Invoice {
id: ID
kind: InvoiceKind
number: number
partyId: ID
date: string
status: InvoiceStatus
note: string
lines: InvoiceLine[]
}
export type TreasuryKind = 'receipt' | 'payment'
export type TreasuryMethod = 'cash' | 'bank' | 'cheque'
export interface TreasuryTxn {
id: ID
number: number
kind: TreasuryKind
method: TreasuryMethod
date: string
partyId: ID | null
amount: number
reference: string
description: string
}
export interface Employee {
id: ID
code: string
name: string
position: string
baseSalary: number
insuranceNo: string
hireDate: string
active: boolean
}
export interface Payslip {
id: ID
employeeId: ID
period: string
baseSalary: number
overtime: number
bonus: number
deductions: number
insurance: number
tax: number
}
/** Account mapping used to auto-generate journal vouchers from live FunZone data. */
export interface VoucherAccountMap {
/** Cash/bank settlement account (debit on inflow, credit on outflow). */
bankAccountId: ID
/** Liability owed to venue owners (their wallet balance). */
ownerPayableAccountId: ID
/** Liability owed to customers (their wallet balance). */
customerPayableAccountId: ID
/** Expense recognising the owner's share of a booking. */
ownerShareAccountId: ID
/** Income account for reservation/ticket sales. */
salesIncomeAccountId: ID
}
export interface CompanySettings {
name: string
economicCode: string
fiscalYear: string
baseCurrency: string
taxRate: number
address: string
phone: string
voucherAccounts: VoucherAccountMap
}
export interface AppData {
accounts: Account[]
vouchers: Voucher[]
parties: Party[]
products: Product[]
invoices: Invoice[]
treasury: TreasuryTxn[]
employees: Employee[]
payslips: Payslip[]
settings: CompanySettings
}

210
src/utils/accounting.ts Normal file
View File

@@ -0,0 +1,210 @@
import type {
Account,
AccountType,
Invoice,
InvoiceLine,
Payslip,
Voucher,
} from '../types'
export interface LineTotals {
gross: number
discount: number
taxable: number
tax: number
net: number
}
/** Computes the monetary breakdown of a single invoice line. */
export function lineTotals(line: InvoiceLine): LineTotals {
const gross = line.quantity * line.unitPrice
const discount = Math.min(line.discount, gross)
const taxable = gross - discount
const tax = (taxable * line.taxRate) / 100
return { gross, discount, taxable, tax, net: taxable + tax }
}
export interface InvoiceTotals {
gross: number
discount: number
taxable: number
tax: number
net: number
}
/** Aggregates all line totals of an invoice. */
export function invoiceTotals(invoice: Invoice): InvoiceTotals {
return invoice.lines.reduce<InvoiceTotals>(
(acc, line) => {
const t = lineTotals(line)
return {
gross: acc.gross + t.gross,
discount: acc.discount + t.discount,
taxable: acc.taxable + t.taxable,
tax: acc.tax + t.tax,
net: acc.net + t.net,
}
},
{ gross: 0, discount: 0, taxable: 0, tax: 0, net: 0 },
)
}
export interface VoucherBalance {
debit: number
credit: number
balanced: boolean
}
/** Sums debit/credit of a voucher and reports whether it balances. */
export function voucherBalance(voucher: Voucher): VoucherBalance {
const debit = voucher.lines.reduce((sum, l) => sum + l.debit, 0)
const credit = voucher.lines.reduce((sum, l) => sum + l.credit, 0)
return { debit, credit, balanced: Math.abs(debit - credit) < 0.001 && debit > 0 }
}
export interface AccountBalance {
account: Account
debit: number
credit: number
balance: number
}
const isDebitNature = (type: AccountType): boolean =>
type === 'asset' || type === 'expense'
/**
* Builds a trial balance from posted vouchers and account opening balances.
* Only leaf (non-group) accounts carry balances.
*/
export function trialBalance(
accounts: Account[],
vouchers: Voucher[],
): AccountBalance[] {
const totals = new Map<string, { debit: number; credit: number }>()
for (const account of accounts) {
if (account.isGroup) continue
const opening = account.openingBalance || 0
totals.set(account.id, {
debit: isDebitNature(account.type) ? Math.max(opening, 0) : Math.max(-opening, 0),
credit: isDebitNature(account.type) ? Math.max(-opening, 0) : Math.max(opening, 0),
})
}
for (const voucher of vouchers) {
if (voucher.status !== 'posted') continue
for (const line of voucher.lines) {
const entry = totals.get(line.accountId)
if (!entry) continue
entry.debit += line.debit
entry.credit += line.credit
}
}
return accounts
.filter((account) => !account.isGroup)
.map((account) => {
const entry = totals.get(account.id) ?? { debit: 0, credit: 0 }
const net = entry.debit - entry.credit
const balance = isDebitNature(account.type) ? net : -net
return { account, debit: entry.debit, credit: entry.credit, balance }
})
}
export interface LedgerRow {
voucherId: string
voucherNumber: number
date: string
description: string
debit: number
credit: number
running: number
}
/** Produces the running general-ledger movements for a single account. */
export function accountLedger(
accountId: string,
account: Account | undefined,
vouchers: Voucher[],
): LedgerRow[] {
let running = account?.openingBalance ?? 0
const rows: LedgerRow[] = []
const sorted = [...vouchers]
.filter((v) => v.status === 'posted')
.sort((a, b) => a.date.localeCompare(b.date) || a.number - b.number)
// Debit-nature accounts (asset, expense) increase with debits.
// Credit-nature accounts (liability, equity, income) increase with credits.
const debitNature = account ? isDebitNature(account.type) : true
for (const voucher of sorted) {
for (const line of voucher.lines) {
if (line.accountId !== accountId) continue
running += debitNature ? line.debit - line.credit : line.credit - line.debit
rows.push({
voucherId: voucher.id,
voucherNumber: voucher.number,
date: voucher.date,
description: line.description || voucher.description,
debit: line.debit,
credit: line.credit,
running,
})
}
}
return rows
}
export interface IncomeStatement {
income: number
expense: number
profit: number
}
export function incomeStatement(balances: AccountBalance[]): IncomeStatement {
const income = balances
.filter((b) => b.account.type === 'income')
.reduce((sum, b) => sum + b.balance, 0)
const expense = balances
.filter((b) => b.account.type === 'expense')
.reduce((sum, b) => sum + b.balance, 0)
return { income, expense, profit: income - expense }
}
export interface BalanceSheet {
assets: number
liabilities: number
equity: number
retainedEarnings: number
}
export function balanceSheet(balances: AccountBalance[]): BalanceSheet {
const sumByType = (type: AccountType): number =>
balances
.filter((b) => b.account.type === type)
.reduce((sum, b) => sum + b.balance, 0)
const income = sumByType('income')
const expense = sumByType('expense')
return {
assets: sumByType('asset'),
liabilities: sumByType('liability'),
equity: sumByType('equity'),
retainedEarnings: income - expense,
}
}
/** Net pay of a payslip: earnings minus all deductions. */
export function payslipNet(payslip: Payslip): number {
return (
payslip.baseSalary +
payslip.overtime +
payslip.bonus -
payslip.deductions -
payslip.insurance -
payslip.tax
)
}

59
src/utils/format.ts Normal file
View File

@@ -0,0 +1,59 @@
const currencyFormatter = new Intl.NumberFormat('fa-IR', {
maximumFractionDigits: 0,
})
const numberFormatter = new Intl.NumberFormat('fa-IR', {
maximumFractionDigits: 2,
})
const jalaliDateFormatter = new Intl.DateTimeFormat('fa-IR-u-ca-persian', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
const jalaliDateTimeFormatter = new Intl.DateTimeFormat('fa-IR-u-ca-persian', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
/** Formats an amount as Toman with Persian digits and grouping. */
export function formatMoney(amount: number): string {
return currencyFormatter.format(Math.round(amount))
}
export function formatMoneyWithUnit(amount: number, unit = 'تومان'): string {
return `${formatMoney(amount)} ${unit}`
}
export function formatNumber(value: number): string {
return numberFormatter.format(value)
}
/** Formats an ISO date string to a Jalali (Persian) date. */
export function formatDate(iso: string): string {
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return '-'
return jalaliDateFormatter.format(date)
}
export function formatDateTime(iso: string): string {
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return '-'
return jalaliDateTimeFormatter.format(date)
}
/** Returns today's date as an ISO yyyy-mm-dd string for date inputs. */
export function todayISO(): string {
return new Date().toISOString().slice(0, 10)
}
const toPersianDigits = (input: string): string =>
input.replace(/[0-9]/g, (digit) => '۰۱۲۳۴۵۶۷۸۹'[Number(digit)])
export function toFa(value: string | number): string {
return toPersianDigits(String(value))
}

11
src/utils/id.ts Normal file
View File

@@ -0,0 +1,11 @@
/** Generates a collision-resistant identifier without external dependencies. */
export function createId(prefix = ''): string {
const random = Math.random().toString(36).slice(2, 10)
const time = Date.now().toString(36)
return `${prefix}${time}${random}`
}
/** Returns the next sequential document number for a list of numbered documents. */
export function nextNumber(items: ReadonlyArray<{ number: number }>): number {
return items.reduce((max, item) => Math.max(max, item.number), 0) + 1
}

9
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}