Initial commit: FunZone accounting frontend with Sepidar-inspired RTL modules
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.DS_Store
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
79
README.md
Normal file
79
README.md
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# سپیدار فانزون — سیستم حسابداری (FunZone Accounting)
|
||||||
|
|
||||||
|
یک سیستم حسابداری یکپارچه با الهام از نرمافزار **سپیدار**، طراحیشده برای مجموعه FunZone.
|
||||||
|
رابط کاربری کاملاً فارسی و راستبهچپ (RTL) با تجربه کاربری مدرن و ساده.
|
||||||
|
|
||||||
|
## امکانات (ماژولها)
|
||||||
|
|
||||||
|
مطابق ساختار «سیستم»های سپیدار:
|
||||||
|
|
||||||
|
| ماژول | توضیح |
|
||||||
|
|-------|-------|
|
||||||
|
| **داشبورد** | شاخصهای کلیدی، نمودار روند فروش/خرید، هشدار موجودی، ترازنامه فشرده |
|
||||||
|
| **حسابها (کدینگ)** | کدینگ استاندارد حسابها (دارایی، بدهی، سرمایه، درآمد، هزینه) با گروه و معین |
|
||||||
|
| **اسناد حسابداری** | ثبت سند دوطرفه با کنترل تراز بدهکار/بستانکار و وضعیت پیشنویس/قطعی |
|
||||||
|
| **مشتریان و فروش** | مدیریت مشتریان و صدور فاکتور فروش با محاسبه تخفیف و مالیات |
|
||||||
|
| **تأمینکنندگان و خرید** | مدیریت تأمینکنندگان و ثبت فاکتور خرید |
|
||||||
|
| **انبارداری** | تعریف کالا، قیمتگذاری، کنترل موجودی و تعدیل انبار |
|
||||||
|
| **دریافت و پرداخت** | خزانهداری نقدی، بانکی و چک |
|
||||||
|
| **حقوق و دستمزد** | مدیریت کارکنان و صدور فیش حقوقی |
|
||||||
|
| **مالکان مجموعهها** | اطلاعات مالی واقعی صاحبان مجموعهها (از اپ مالکان): موجودی کیف پول، گردش حساب، برداشتها |
|
||||||
|
| **کاربران** | اطلاعات مالی واقعی مشتریان (از اپ مشتریان): موجودی، پرداختها و بازپرداختها |
|
||||||
|
| **گزارشها** | تراز آزمایشی، دفتر کل، صورت سود و زیان، ترازنامه |
|
||||||
|
| **تنظیمات** | اطلاعات شرکت، سال مالی، نرخ مالیات، اتصال به سرور |
|
||||||
|
|
||||||
|
## اتصال به دادههای واقعی (بکاند فانزون)
|
||||||
|
|
||||||
|
دو بخش «مالکان مجموعهها» و «کاربران» دادهها را بهصورت زنده از بکاند `funzone-backend` میخوانند
|
||||||
|
(از همان APIهای مدیریتی پنل ادمین). برای اتصال:
|
||||||
|
|
||||||
|
1. مطمئن شوید بکاند Django در حال اجراست (مثلاً روی `http://localhost:8000`).
|
||||||
|
2. در برنامه به **تنظیمات ← اتصال به سرور فانزون** بروید.
|
||||||
|
3. آدرس سرور (پیشفرض `http://localhost:8000/api`) و نام کاربری/رمز عبور **مدیر (admin)** را وارد و «اتصال» را بزنید.
|
||||||
|
|
||||||
|
بهصورت اختیاری میتوانید آدرس سرور را در فایل `.env` نیز تنظیم کنید:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
VITE_API_BASE_URL=http://localhost:8000/api
|
||||||
|
```
|
||||||
|
|
||||||
|
> توکن دسترسی و آدرس سرور در مرورگر ذخیره میشوند. APIهای مورد استفاده فقط خواندنی هستند
|
||||||
|
> (لیست مالکان/کاربران، تراکنشها، پرداختها و برداشتها) و نیاز به دسترسی مدیر دارند.
|
||||||
|
|
||||||
|
## پشته فناوری
|
||||||
|
|
||||||
|
- React 18 + TypeScript (strict)
|
||||||
|
- Vite 5
|
||||||
|
- Tailwind CSS 3
|
||||||
|
- React Router 6
|
||||||
|
- ذخیرهسازی محلی (localStorage) — بدون نیاز به بکاند
|
||||||
|
|
||||||
|
## اجرا
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
برنامه روی پورت **5176** اجرا میشود: <http://localhost:5176>
|
||||||
|
|
||||||
|
دستورهای دیگر:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build # بیلد تولیدی
|
||||||
|
npm run preview # پیشنمایش بیلد روی پورت 5176
|
||||||
|
npm run lint # بررسی نوعها (type-check)
|
||||||
|
```
|
||||||
|
|
||||||
|
## معماری
|
||||||
|
|
||||||
|
- `src/types` — مدلهای دامنه با تایپ قوی
|
||||||
|
- `src/data` — لایه ذخیرهسازی و دادههای نمونه
|
||||||
|
- `src/store` — مدیریت وضعیت سراسری (Context) با CRUD تایپشده و ذخیره خودکار
|
||||||
|
- `src/utils` — محاسبات حسابداری (تراز، دفتر کل، سود و زیان)، قالببندی اعداد و تاریخ شمسی
|
||||||
|
- `src/components/ui` — کیت رابط کاربری قابل استفاده مجدد
|
||||||
|
- `src/components/layout` — چیدمان، نوار کناری و نوار بالا
|
||||||
|
- `src/components/business` — ماژول مشترک فروش/خرید
|
||||||
|
- `src/pages` — صفحات هر ماژول
|
||||||
|
|
||||||
|
> دادهها در مرورگر ذخیره میشوند. برای بازگشت به دادههای نمونه از «تنظیمات ← بازنشانی» استفاده کنید.
|
||||||
18
index.html
Normal file
18
index.html
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="fa" dir="rtl">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>سپیدار فانزون | سیستم حسابداری</title>
|
||||||
|
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://cdn.jsdelivr.net/gh/rastikerdar/vazirmatn@v33.003/Vazirmatn-font-face.css"
|
||||||
|
rel="stylesheet"
|
||||||
|
type="text/css"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2740
package-lock.json
generated
Normal file
2740
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
package.json
Normal file
30
package.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "funzone-accounting",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"description": "Sepidar-inspired accounting system for FunZone",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"lint": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"caniuse-lite": "^1.0.30001799",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.26.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.7.4",
|
||||||
|
"@types/react": "^18.3.11",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.2",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.4.47",
|
||||||
|
"tailwindcss": "^3.4.13",
|
||||||
|
"typescript": "^5.6.3",
|
||||||
|
"vite": "^5.4.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
57
src/App.tsx
Normal file
57
src/App.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
218
src/accounting/voucherSync.ts
Normal file
218
src/accounting/voucherSync.ts
Normal 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
109
src/api/client.ts
Normal 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
58
src/api/config.ts
Normal 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
71
src/api/types.ts
Normal 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
|
||||||
|
}
|
||||||
60
src/components/business/ConnectionRequired.tsx
Normal file
60
src/components/business/ConnectionRequired.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
500
src/components/business/TradeModule.tsx
Normal file
500
src/components/business/TradeModule.tsx
Normal 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)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
109
src/components/layout/Layout.tsx
Normal file
109
src/components/layout/Layout.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
68
src/components/layout/Sidebar.tsx
Normal file
68
src/components/layout/Sidebar.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
26
src/components/layout/navigation.ts
Normal file
26
src/components/layout/navigation.ts
Normal 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: 'سیستم' },
|
||||||
|
]
|
||||||
42
src/components/ui/ConfirmDialog.tsx
Normal file
42
src/components/ui/ConfirmDialog.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
78
src/components/ui/DataTable.tsx
Normal file
78
src/components/ui/DataTable.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
57
src/components/ui/Modal.tsx
Normal file
57
src/components/ui/Modal.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
15
src/components/ui/index.ts
Normal file
15
src/components/ui/index.ts
Normal 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'
|
||||||
197
src/components/ui/primitives.tsx
Normal file
197
src/components/ui/primitives.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
72
src/connection/ConnectionContext.tsx
Normal file
72
src/connection/ConnectionContext.tsx
Normal 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
|
||||||
|
}
|
||||||
42
src/hooks/useApiResource.ts
Normal file
42
src/hooks/useApiResource.ts
Normal 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
62
src/index.css
Normal 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
10
src/main.tsx
Normal 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
242
src/pages/Accounts.tsx
Normal 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
296
src/pages/Customers.tsx
Normal 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
232
src/pages/Dashboard.tsx
Normal 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
219
src/pages/Inventory.tsx
Normal 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
113
src/pages/Login.tsx
Normal 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
291
src/pages/Owners.tsx
Normal 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
323
src/pages/Payroll.tsx
Normal 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
15
src/pages/Purchases.tsx
Normal 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
241
src/pages/Reports.tsx
Normal 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
15
src/pages/Sales.tsx
Normal 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
212
src/pages/Settings.tsx
Normal 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
198
src/pages/Treasury.tsx
Normal 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
283
src/pages/Vouchers.tsx
Normal 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
302
src/store/AppStore.tsx
Normal 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
155
src/types/index.ts
Normal 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
210
src/utils/accounting.ts
Normal 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
59
src/utils/format.ts
Normal 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
11
src/utils/id.ts
Normal 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
9
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_API_BASE_URL?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv
|
||||||
|
}
|
||||||
29
tailwind.config.js
Normal file
29
tailwind.config.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['Vazirmatn', 'Shabnam', 'Inter', 'Segoe UI', 'Tahoma', 'sans-serif'],
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
brand: {
|
||||||
|
50: '#eef4ff',
|
||||||
|
100: '#d9e6ff',
|
||||||
|
200: '#bcd2ff',
|
||||||
|
300: '#8eb4ff',
|
||||||
|
400: '#598bff',
|
||||||
|
500: '#3563ff',
|
||||||
|
600: '#1d40f5',
|
||||||
|
700: '#162fe1',
|
||||||
|
800: '#1829b6',
|
||||||
|
900: '#1a298f',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
card: '0 1px 2px 0 rgba(15, 23, 42, 0.04), 0 4px 16px -2px rgba(15, 23, 42, 0.08)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
22
tsconfig.json
Normal file
22
tsconfig.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"forceConsistentCasingInFileNames": true
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
11
tsconfig.node.json
Normal file
11
tsconfig.node.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
1
tsconfig.node.tsbuildinfo
Normal file
1
tsconfig.node.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
1
tsconfig.tsbuildinfo
Normal file
1
tsconfig.tsbuildinfo
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/accounting/vouchersync.ts","./src/api/client.ts","./src/api/config.ts","./src/api/types.ts","./src/components/business/connectionrequired.tsx","./src/components/business/trademodule.tsx","./src/components/layout/layout.tsx","./src/components/layout/sidebar.tsx","./src/components/layout/navigation.ts","./src/components/ui/confirmdialog.tsx","./src/components/ui/datatable.tsx","./src/components/ui/modal.tsx","./src/components/ui/index.ts","./src/components/ui/primitives.tsx","./src/connection/connectioncontext.tsx","./src/hooks/useapiresource.ts","./src/pages/accounts.tsx","./src/pages/customers.tsx","./src/pages/dashboard.tsx","./src/pages/inventory.tsx","./src/pages/login.tsx","./src/pages/owners.tsx","./src/pages/payroll.tsx","./src/pages/purchases.tsx","./src/pages/reports.tsx","./src/pages/sales.tsx","./src/pages/settings.tsx","./src/pages/treasury.tsx","./src/pages/vouchers.tsx","./src/store/appstore.tsx","./src/types/index.ts","./src/utils/accounting.ts","./src/utils/format.ts","./src/utils/id.ts"],"version":"5.6.3"}
|
||||||
2
vite.config.d.ts
vendored
Normal file
2
vite.config.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
declare const _default: import("vite").UserConfig;
|
||||||
|
export default _default;
|
||||||
15
vite.config.js
Normal file
15
vite.config.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
export default defineConfig({
|
||||||
|
base: process.env.VITE_BASE_PATH || '/',
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 5176,
|
||||||
|
strictPort: true,
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
port: 5176,
|
||||||
|
strictPort: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
16
vite.config.ts
Normal file
16
vite.config.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
base: process.env.VITE_BASE_PATH || '/',
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 5176,
|
||||||
|
strictPort: true,
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
port: 5176,
|
||||||
|
strictPort: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user