Connect Accounting frontend to domain APIs for phases 5.1-5.11.

Wire sales/purchase posting, settlements, compliance, payroll employees, and ops edit flows; document the integration report.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Mortezakoohjani 2026-07-25 22:17:41 +03:30
parent 735f343e0d
commit 067b499193
22 changed files with 1383 additions and 142 deletions

View File

@ -184,6 +184,36 @@ async def reject_request(
return {"id": str(request.id), "status": request.status.value} return {"id": str(request.id), "status": request.status.value}
@router.get("/risks")
async def list_risks(
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
from app.models.compliance import RiskRecord
from sqlalchemy import select
stmt = (
select(RiskRecord)
.where(RiskRecord.tenant_id == tenant_id)
.order_by(RiskRecord.created_at.desc())
.limit(200)
)
rows = list((await db.execute(stmt)).scalars().all())
return [
{
"id": str(r.id),
"code": r.code,
"title": r.title,
"risk_category": r.risk_category,
"risk_level": r.risk_level.value,
"risk_score": r.risk_score,
"is_resolved": r.is_resolved,
}
for r in rows
]
@router.post("/risks", status_code=status.HTTP_201_CREATED) @router.post("/risks", status_code=status.HTTP_201_CREATED)
async def create_risk( async def create_risk(
body: RiskCreate, body: RiskCreate,

View File

@ -59,6 +59,16 @@ async def create_purchase_profile(
return {"id": str(entity.id), "name": entity.name} return {"id": str(entity.id), "name": entity.name}
@router.get("/posting-profiles")
async def list_purchase_profiles(
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
rows = await PurchaseProfileRepo(db).list_by_tenant(tenant_id, limit=100)
return [{"id": str(r.id), "name": r.name, "is_default": r.is_default, "is_active": r.is_active} for r in rows]
@router.post("/preview/goods-receipt") @router.post("/preview/goods-receipt")
async def preview_goods_receipt( async def preview_goods_receipt(
body: GoodsReceiptRequest, body: GoodsReceiptRequest,

View File

@ -216,6 +216,36 @@ async def create_receivable_invoice(
return {"id": str(entity.id)} return {"id": str(entity.id)}
@router.get("/settlements")
async def list_settlements(
party_type: str | None = None,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
from app.models.receivable_payable import Settlement
from sqlalchemy import select
stmt = select(Settlement).where(Settlement.tenant_id == tenant_id)
if party_type:
stmt = stmt.where(Settlement.party_type == party_type)
stmt = stmt.order_by(Settlement.settlement_date.desc()).limit(200)
rows = list((await db.execute(stmt)).scalars().all())
return [
{
"id": str(s.id),
"settlement_number": s.settlement_number,
"settlement_date": s.settlement_date.isoformat(),
"settlement_type": s.settlement_type,
"party_type": s.party_type,
"party_id": str(s.party_id),
"total_amount": str(s.total_amount),
"status": s.status.value,
}
for s in rows
]
@router.post("/settlements") @router.post("/settlements")
async def create_settlement( async def create_settlement(
body: SettlementCreate, body: SettlementCreate,

View File

@ -0,0 +1,28 @@
"""Smoke: new accounting integration list routes import + OpenAPI presence."""
from __future__ import annotations
from fastapi.routing import APIRoute
from app.main import app
def test_settlements_list_route_registered():
paths = {r.path for r in app.routes if isinstance(r, APIRoute)}
assert "/api/v1/ar-ap/settlements" in paths
def test_compliance_risks_list_route_registered():
paths = {r.path for r in app.routes if isinstance(r, APIRoute)}
assert "/api/v1/compliance/risks" in paths
def test_purchase_profiles_list_route_registered():
paths = {r.path for r in app.routes if isinstance(r, APIRoute)}
assert "/api/v1/purchase-inventory/posting-profiles" in paths
def test_ops_and_sales_accounting_present():
paths = {r.path for r in app.routes if isinstance(r, APIRoute)}
assert "/api/v1/ops/documents" in paths
assert "/api/v1/sales-accounting/post" in paths
assert "/api/v1/purchase-inventory/post/goods-receipt" in paths

View File

@ -0,0 +1,116 @@
# Accounting Frontend Integration Report
> Phase: Enterprise Accounting Frontend Integration (connect FE ↔ existing BE 5.15.11)
> Date: 2026-07-25
> Scope: Accounting module only — no new product features beyond wiring existing APIs.
## Summary
Scanned ~118 Accounting FE routes against `/api/v1` routers. Connected previously disconnected domain screens to real backend APIs. Kept Phase 5.12 AI blocked. Ops document scaffolding remains for areas without domain routers (budget, DMS, integration connectors) with full list/create/edit/confirm against `/api/v1/ops`.
## Connected Pages (domain APIs)
| Area | Routes | Backend |
|------|--------|---------|
| Dashboard / Setup | `/accounting`, `/setup`, `/settings` | setup, health, charts, vouchers |
| COA / dimensions | chart-of-accounts, cost-centers, projects, currencies | `/accounts` |
| Fiscal | `/fiscal` | `/fiscal` |
| Vouchers | list (paginated), new, `[id]` | `/posting` |
| Ledger | `/ledger` | `/ledger` |
| Reports (TB/BS/IS) | `/reports` | `/reporting` |
| Treasury | hub, receipts-payments, transfers, reconciliation, cheques | `/treasury` |
| Customers / Suppliers | `/customers`, `/suppliers` | `/ar-ap` |
| Settlements | `/sales/settlements`, `/purchase/settlements` | `/ar-ap/settlements` |
| Sales invoice accounting | `/sales/invoices` | ops docs + `/sales-accounting` preview/post |
| Purchase GR | `/purchase/goods-receipts` | ops + `/purchase-inventory` |
| Inventory valuation | `/inventory/valuation` | `/purchase-inventory/valuation` + ops items |
| Assets hub + schedule | `/assets`, `/assets/schedule` | `/assets` |
| Payroll hub + employees | `/payroll`, `/payroll/employees` | `/payroll` |
| Compliance policies/risks | `/compliance/policies`, `/compliance/risks` | `/compliance` |
| Audit | `/audit` | compliance audit + posting audit |
| Monitoring health | `/monitoring/health` | `/health` + setup status |
| Inventory items/warehouses | `/inventory/items`, `/inventory/settings` | `/ops/items`, `/ops/warehouses` |
## Fixed Pages
| Page | Before | After |
|------|--------|-------|
| `/payroll/employees` | Ops stub documents | Domain payroll employees CRUD list/create |
| `/compliance/policies` | Ops stub | Domain policies list/create |
| `/compliance/risks` | Ops stub | Domain risks list/create |
| `/sales/settlements`, `/purchase/settlements` | Ops stub | Domain settlements list/create |
| `/sales/invoices` | Ops-only | Ops docs + sales-accounting preview/post |
| `/purchase/goods-receipts` | Ops-only | Ops docs + purchase-inventory preview/post |
| `/inventory/valuation` | Ops stub | Real valuation API |
| `/monitoring/health` | Ops stub | Live health + setup |
| `/assets/schedule` | Ops stub | Asset depreciation schedule from API |
| Ops BusinessDocsPage | Create/confirm only | + detail + edit (PATCH) |
| Vouchers list | First page only | Pagination (`page` / `page_size`) |
| Receipts/payments | Missing cash/bank ids | Cash box / bank account selects |
## Connected APIs (FE client additions)
- `settlements.list` / `settlements.create`
- `salesAccounting.listProfiles|createProfile|preview|post`
- `purchaseInventory.listProfiles|createProfile|previewGoodsReceipt|postGoodsReceipt|valuation`
- `compliance.requestApproval|approve|reject|listRisks|createRisk`
- `ops.updateDocument` (PATCH)
### Backend list endpoints added (integration completeness only)
- `GET /api/v1/ar-ap/settlements?party_type=`
- `GET /api/v1/compliance/risks`
- `GET /api/v1/purchase-inventory/posting-profiles`
## Modified Components
- `frontend/lib/accounting-api.ts`
- `frontend/components/accounting/DomainScreens.tsx` (new)
- `frontend/components/accounting/BusinessDocsPage.tsx`
- `frontend/app/accounting/vouchers/page.tsx`
- Route pages listed under Fixed Pages
- `backend/services/accounting/app/api/v1/{receivable_payable,compliance,purchase_inventory}.py`
## Validation Results
| Check | Result |
|-------|--------|
| BE module imports | Pass |
| Architecture / accounting pytest | See CI/local run in this session |
| Tenant header `X-Tenant-ID` on all authenticated FE calls | Pass (via `request()`) |
| JWT + refresh before accounting calls | Pass (`getValidAccessToken` + force refresh on 401) |
| ADR-010 (no direct journal writes from UI) | Pass — vouchers via Posting Engine only; sales/purchase post via accounting services |
| AI pages | Remain blocked |
| Fake/mock data | None introduced |
## Automatic Posting / Journal / Approval
| Workflow | How verified |
|----------|----------------|
| Automatic voucher generation | Sales `POST /sales-accounting/post`, Purchase `POST /purchase-inventory/post/goods-receipt`, Treasury cash-receipt, Payroll post → return `voucher_id` |
| Journal workflow | FE vouchers: create → validate → post → reverse/cancel on `/posting` |
| Approval workflow | FE client wired to `/compliance/approvals` (+ approve/reject); UI for request uses domain screens where applicable |
| Accounting events | Unchanged producers in BE services; UI consumes resulting vouchers/reports |
## Known Issues
1. **Ops scaffolding** remains for budget, documents DMS, integration connectors, monitoring KPIs/alerts, voucher recurring/templates, guarantees/facilities — no dedicated domain routers yet; CRUD is against `/ops/documents`.
2. **Suppliers** still list+create only (BE has no PATCH).
3. **Treasury** cheques/transfers/receipts mostly list+create (status transitions not exposed).
4. **Assets** dispose/transfer lack domain HTTP surfaces — ops stubs remain for those submenus.
5. **Reporting** cash-flow / equity / analytical still ops-backed; TB/BS/IS use Report Engine.
6. **Gitea push** may still be denied for this repo (local commits only unless permissions fixed).
7. Phase **5.12 AI** intentionally blocked.
## Next Recommendations
1. Promote high-traffic ops modules (sales orders, purchase orders) to first-class domain models when product prioritizes them.
2. Add supplier PATCH/archive + payable invoice GET/PATCH.
3. Cheque status transition APIs + UI.
4. Server-side voucher status filter + total count for pagination.
5. Wire compliance approval list UI once workflow list endpoint exists.
6. Unblock AI only after AI Provider Active (phase 5.12).
## Scoreboard note
Module scoreboard should treat ops-heavy areas as `partial` where domain engines are incomplete; domain hubs remain `complete`.

View File

@ -3,30 +3,31 @@
> Source of truth for enterprise completion. Updated after every module gate. > Source of truth for enterprise completion. Updated after every module gate.
> States: **Complete** | **Partial** | **Missing** | **Blocked** > States: **Complete** | **Partial** | **Missing** | **Blocked**
Last updated: 2026-07-24 Last updated: 2026-07-25 (Frontend Integration)
## Overall ## Overall
| Module | % | Gate | | Module | % | Gate |
|--------|---|------| |--------|---|------|
| Dashboard | 90% | Complete | | Dashboard | 95% | Complete |
| Chart of Accounts | 98% | Complete | | Chart of Accounts | 98% | Complete |
| Fiscal | 98% | Complete | | Fiscal | 98% | Complete |
| Currencies | 98% | Complete | | Currencies | 98% | Complete |
| Cost Centers | 95% | Complete | | Cost Centers | 95% | Complete |
| Projects | 95% | Complete | | Projects | 95% | Complete |
| Vouchers | 95% | Complete | | Vouchers | 96% | Complete |
| Ledger | 95% | Complete | | Ledger | 95% | Complete |
| Treasury | 90% | Complete | | Treasury | 88% | Partial |
| Customers / AR | 92% | Complete | | Customers / AR | 92% | Complete |
| Suppliers / AP | 85% | Complete | | Suppliers / AP | 80% | Partial |
| Assets | 90% | Complete | | Sales accounting | 85% | Partial |
| Payroll | 88% | Complete | | Purchase / Inventory | 85% | Partial |
| Reports | 92% | Complete | | Assets | 88% | Partial |
| Compliance / Audit | 88% | Complete | | Payroll | 90% | Complete |
| Settings | 85% | Complete (IAM deep-links) | | Reports | 90% | Partial |
| Compliance / Audit | 8290% | Partial / Complete |
| Settings | 80% | Partial |
| Onboarding / Setup | 95% | Complete | | Onboarding / Setup | 95% | Complete |
| Mobile / Nav polish | 90% | Complete |
| AI | 0% | Blocked (provider Planned) | | AI | 0% | Blocked (provider Planned) |
## Category legend ## Category legend
@ -36,15 +37,20 @@ API · CRUD · Routing · Nav · Dashboard · Forms · Dialogs · Drawers · Tab
## Module notes ## Module notes
- **Foundation masters** (COA, fiscal, currencies, cost centers, projects): create/edit/archive via PATCH; DatePicker on date fields. - **Foundation masters** (COA, fiscal, currencies, cost centers, projects): create/edit/archive via PATCH; DatePicker on date fields.
- **Vouchers**: draft edit via `PATCH /posting/vouchers/{id}`; lines support cost center + project. - **Vouchers**: draft edit via `PATCH /posting/vouchers/{id}`; list pagination; lines support cost center + project.
- **Ledger**: balances, trial balance, recalculate — real `/api/v1/ledger/*`. - **Ledger**: balances, trial balance, recalculate — real `/api/v1/ledger/*`.
- **Treasury**: cash boxes, banks, bank accounts, cash receipts. - **Treasury**: cash boxes, banks, receipts/payments (cash/bank binding), transfers, recon, cheques; guarantees/facilities still ops.
- **Customers**: edit drawer, aging, receivable invoices; suppliers list/create. - **Customers**: edit drawer, aging, receivable invoices; settlements via `/ar-ap/settlements`.
- **Assets**: categories, activate, depreciate, schedule. - **Sales**: invoice docs + `/sales-accounting` preview/post; other sales nav still ops.
- **Payroll**: departments, periods, calculate, post. - **Purchase/Inventory**: goods receipt preview/post + valuation; item/warehouse masters via `/ops`.
- **Reports**: trial balance / BS / IS from reporting API; structured display of `data`. - **Assets**: categories, activate, depreciate, schedule page; transfer/dispose ops.
- **Setup**: `/api/v1/setup/status`, skippable tour, COA template import. - **Payroll**: departments, employees page, periods, calculate, post.
- **Reports**: trial balance / BS / IS from reporting API; other report types ops.
- **Compliance**: policies + risks domain; audit lists; SoD/controls still ops.
- **Setup**: `/api/v1/setup/status`, COA template import via BFF proxy.
- **AI**: Blocked until provider registry marks AI Model Provider Active. - **AI**: Blocked until provider registry marks AI Model Provider Active.
- **IAM / White-label / Custom domain**: Deep-link to SuperApp settings — not rebuilt inside Accounting. - **IAM / White-label / Custom domain**: Deep-link to SuperApp settings — not rebuilt inside Accounting.
Integration report: [../accounting-frontend-integration-report.md](../accounting-frontend-integration-report.md).
See also: [component-library.md](component-library.md), [frontend-architecture.md](frontend-architecture.md). See also: [component-library.md](component-library.md), [frontend-architecture.md](frontend-architecture.md).

View File

@ -103,6 +103,18 @@ Remaining: Phase 5.12 AI UI blocked on AI provider; optional richer PDF export f
--- ---
## Accounting Frontend Integration (FE ↔ BE 5.15.11) ✅
- [x] Full route/menu scan vs `/api/v1` routers; gap matrix executed
- [x] Domain rewires: payroll employees, compliance policies/risks, settlements, sales-accounting post, purchase GR + valuation, asset schedule, monitoring health
- [x] FE client: settlements, salesAccounting, purchaseInventory, compliance approvals/risks, ops PATCH
- [x] Ops documents: detail + edit; treasury receipt/payment cash/bank binding; voucher list pagination
- [x] BE list endpoints for settlements, risks, purchase posting-profiles (integration completeness)
- [x] Report: [accounting-frontend-integration-report.md](accounting-frontend-integration-report.md)
- [x] Scoreboard honesty for partial/ops-backed modules; AI remains blocked
---
## Phase 6.0 — CRM Service Foundation ✅ ## Phase 6.0 — CRM Service Foundation ✅
- [x] `backend/services/crm` service scaffold (API / services / repositories / models) - [x] `backend/services/crm` service scaffold (API / services / repositories / models)
@ -170,6 +182,118 @@ Remaining: Phase 5.12 AI UI blocked on AI provider; optional richer PDF export f
--- ---
## Phase 7.0 — Loyalty Service Foundation ✅
- [x] `backend/services/loyalty` service scaffold (API / services / repositories / models)
- [x] Foundation aggregates: LoyaltyProgram, MembershipTier, Member, PointAccount, Reward, Campaign, LoyaltyAuditLog, OutboxEvent
- [x] No mutable balance on PointAccount — direct balance mutation forbidden (`extra=forbid`)
- [x] Optimistic locking on Program / Member / PointAccount; campaign `rule_version`
- [x] Publish-only events via transactional outbox (`loyalty.program|tier|member|point_account|reward|campaign.*`)
- [x] Loyalty APIs only (`/api/v1/programs|tiers|members|point-accounts|rewards|campaigns|audit`)
- [x] Platform provider contracts only (Notification, Analytics, Customer360, AI, ModuleIntegration, CRM, Communication, FileStorage)
- [x] Permissions `loyalty.*` trees with manage/view inheritance; tenant isolation via `X-Tenant-ID`
- [x] Alembic `0001_initial` + `loyalty_db` wiring (compose port 8004)
- [x] Soft-delete unique reclaim; IntegrityError → 409; null required-field → 422; production config guards
- [x] Enterprise validation & self-heal (incl. removal of incomplete 7.1 residue) — **52 tests passed**
- [x] Docs: [loyalty-phase-7-0.md](loyalty-phase-7-0.md), [loyalty-phase-7-0-audit.md](loyalty-phase-7-0-audit.md), ADR-011, module registry, phase area README
---
## Phase 8.08.10 — Enterprise Communication Platform ✅
- [x] Independent `communication-service` / `communication_db` (port 8005) — not owned by CRM/Loyalty/Restaurant
- [x] Provider framework + registry (mock, payamak; stubs for email/push/whatsapp/telegram/rubika/voice)
- [x] Communication router with priority, automatic failover, retry, circuit breaker
- [x] SMS platform: sender numbers, balance, rate limits, provider logs
- [x] Template engine: variables, locale, versioning, approval, preview, rendering
- [x] Dynamic contact sources (manual, CSV, CRM/Loyalty/Restaurant/Marketplace/Accounting/Website/External REST) — resolve only, no duplication
- [x] Queue engine: async queue, scheduling, priority, backoff retry, DLQ, batch
- [x] Delivery tracking: queued/sent/delivered/failed/expired/cancelled + timeline
- [x] OTP platform: generate/verify/expire, rate limit, brute-force lock, multi-channel path
- [x] Incoming provider webhooks with signature verification
- [x] Health `/health`, Capability `/capabilities`, providers status, monitoring stats
- [x] Permissions `communication.*`; events `communication.*`; audit on every send
- [x] Alembic `0001_initial`; version `0.8.10.0`
- [x] Tests: architecture, health/security, providers/SMS/failover/queue, templates/contacts/OTP/webhooks, validators
- [x] Docs: [communication-phase-8.md](communication-phase-8.md), ADR-012, module/provider registry, progress
---
## Phase AF — AI Development Framework ✅
> Documentation-only. No business functionality. No modifications to existing services, schemas, or APIs.
- [x] Permanent area [`docs/ai-framework/`](ai-framework/README.md)
- [x] Master prompt, development loop, phase handover, quality gates
- [x] Templates: phase, service, module, entity, repository, service-layer, API, event, testing, documentation
- [x] Manifests: [phase-manifest.yaml](ai-framework/phase-manifest.yaml), [service-manifest.yaml](ai-framework/service-manifest.yaml)
- [x] Prompt rules + Cursor guidelines
- [x] Docs index / module registry / progress integration
- [x] ADR-013 Accepted
- [x] Architecture, documentation, cross-reference, link, template, and manifest validation
---
## Phase SC-Reg — Sports Center Platform Registration ✅
> Documentation-only registration. No business code, models, APIs, migrations, or repositories.
- [x] Phase roadmap 9.09.10 registered in [phase-manifest.yaml](ai-framework/phase-manifest.yaml)
- [x] Service registered in [service-manifest.yaml](ai-framework/service-manifest.yaml)
- [x] Planned modules registered in [module-registry.md](module-registry.md#sports_center)
- [x] Sports terminology added to [glossary.md](glossary.md#sports-center)
- [x] Roadmap doc: [sports-center-roadmap.md](sports-center-roadmap.md)
- [x] ADR-014 Accepted — Independent Sports Center Platform
- [x] Module boundaries + phase area README updated
- [x] Manifest / cross-reference / documentation / module validation passed
---
## Phase 9.0 — Sports Center Platform Foundation ✅
- [x] `backend/services/sports_center` service scaffold (API / services / repositories / models)
- [x] Foundation aggregates: SportsCenter, Branch, Sport, MembershipType, Membership, Coach, SportsRole, SportsPermission, Facility, Court, Room, LockerRoom, Locker, DeviceProvider, Device, AttendanceGateway, SportsConfiguration, SportsEvent, SportsSetting, SportsAuditLog
- [x] Adapter-based connector framework (QR/RFID/Barcode/Fingerprint/Face/Turnstile/Door/Payment/Attendance) — interfaces only
- [x] Platform provider contracts only (Accounting, CRM, Loyalty, Communication, Notification, Storage, AI, Identity, Customer360)
- [x] Publish-only events (`sports_center.member|membership|coach|facility|device|attendance|locker.*`)
- [x] Sports APIs under `/api/v1` + device connect/disconnect + locker assign/release
- [x] Permissions `sports_center.*` trees; tenant isolation via `X-Tenant-ID`
- [x] Alembic `0001_initial` + `sports_center_db` wiring (compose port 8006)
- [x] Architecture / API / permissions / migration / dependency / connectors / docs tests
- [x] Docs: [sports-center-phase-9-0.md](sports-center-phase-9-0.md), [phase-handover/phase-9-0.md](phase-handover/phase-9-0.md), ADR-014, module registry
- [x] No business workflow engines (booking/attendance lifecycle deferred)
---
## Phase 9.1 — Sports Center Membership Catalog ✅
- [x] Catalog aggregates: SportCategory, AgeGroup, PricingModel, MembershipPackage, MembershipPlan, MembershipRule, RenewalPolicy, FreezingRule, ExpirationPolicy
- [x] MembershipType extended with catalog refs (package/plan/pricing/age/category/policies), transferability, sort order
- [x] Validators for age range, pricing/billing/rule kinds, money amounts, catalog ref integrity
- [x] Publish-only catalog events; permissions trees for catalog modules
- [x] APIs under `/api/v1/sport-categories|age-groups|pricing-models|membership-packages|membership-plans|membership-rules|renewal-policies|freezing-rules|expiration-policies`
- [x] `/capabilities` endpoint; version `0.9.1.0`
- [x] Alembic `0002_phase_91_membership_catalog`
- [x] Tests: catalog API, tenant isolation, age validation, architecture, docs
- [x] Docs: [sports-center-phase-9-1.md](sports-center-phase-9-1.md), [phase-handover/phase-9-1.md](phase-handover/phase-9-1.md)
- [x] No member enrollment / coach / attendance / booking engines
---
## Phase 9.2 — Sports Center Member Management ✅
- [x] Members aggregate + family members, emergency contacts, medical information shells
- [x] Membership assignment (`member_id`) consuming Phase 9.1 catalog types
- [x] Status management: activate / suspend / cancel / freeze / unfreeze
- [x] Membership cards (QR/physical/digital), digital membership passes, waivers (sign), member documents (Storage refs)
- [x] Validators, permissions (`sports_center.members.*` trees), publish-only events
- [x] Alembic `0003_phase_92_member_management`; version `0.9.2.0`
- [x] Tests: members API, tenant isolation, transitions, architecture, migration, docs
- [x] Docs: [sports-center-phase-9-2.md](sports-center-phase-9-2.md), [phase-handover/phase-9-2.md](phase-handover/phase-9-2.md)
- [x] No coach / attendance / booking engines
---
## Related Documents ## Related Documents
- [Next Steps](next-steps.md) - [Next Steps](next-steps.md)
@ -180,3 +304,15 @@ Remaining: Phase 5.12 AI UI blocked on AI provider; optional richer PDF export f
- [CRM Phase 6.1](crm-phase-6-1.md) - [CRM Phase 6.1](crm-phase-6-1.md)
- [CRM Phase 6.2](crm-phase-6-2.md) - [CRM Phase 6.2](crm-phase-6-2.md)
- [CRM Phase 6.3](crm-phase-6-3.md) - [CRM Phase 6.3](crm-phase-6-3.md)
- [Loyalty Phase 7.0](loyalty-phase-7-0.md)
- [Communication Phase 8](communication-phase-8.md)
- [Sports Center Phase 9.0](sports-center-phase-9-0.md)
- [Sports Center Phase 9.1](sports-center-phase-9-1.md)
- [Sports Center Phase 9.2](sports-center-phase-9-2.md)
- [Phase Handover 9.0](phase-handover/phase-9-0.md)
- [Phase Handover 9.1](phase-handover/phase-9-1.md)
- [Phase Handover 9.2](phase-handover/phase-9-2.md)
- [Sports Center Roadmap](sports-center-roadmap.md)
- [AI Development Framework](ai-framework/README.md)
- [ADR-013](architecture/adr/ADR-013.md)
- [ADR-014](architecture/adr/ADR-014.md)

View File

@ -1,13 +1,5 @@
"use client"; import { AssetSchedulePage } from "@/components/accounting/DomainScreens";
import { BusinessDocsPage } from "@/components/accounting/BusinessDocsPage";
export default function Page() { export default function Page() {
return ( return <AssetSchedulePage />;
<BusinessDocsPage
title="برنامه استهلاک"
module="assets"
docType="schedule"
/>
);
} }

View File

@ -1,13 +1,5 @@
"use client"; import { CompliancePoliciesPage } from "@/components/accounting/DomainScreens";
import { BusinessDocsPage } from "@/components/accounting/BusinessDocsPage";
export default function Page() { export default function Page() {
return ( return <CompliancePoliciesPage />;
<BusinessDocsPage
title="سیاست‌ها و قوانین"
module="compliance"
docType="policy"
/>
);
} }

View File

@ -1,13 +1,5 @@
"use client"; import { ComplianceRisksPage } from "@/components/accounting/DomainScreens";
import { BusinessDocsPage } from "@/components/accounting/BusinessDocsPage";
export default function Page() { export default function Page() {
return ( return <ComplianceRisksPage />;
<BusinessDocsPage
title="مدیریت ریسک"
module="compliance"
docType="risk"
/>
);
} }

View File

@ -1,13 +1,5 @@
"use client"; import { InventoryValuationPage } from "@/components/accounting/DomainScreens";
import { BusinessDocsPage } from "@/components/accounting/BusinessDocsPage";
export default function Page() { export default function Page() {
return ( return <InventoryValuationPage />;
<BusinessDocsPage
title="ارزش‌گذاری موجودی"
module="inventory"
docType="valuation"
/>
);
} }

View File

@ -1,13 +1,5 @@
"use client"; import { MonitoringHealthPage } from "@/components/accounting/DomainScreens";
import { BusinessDocsPage } from "@/components/accounting/BusinessDocsPage";
export default function Page() { export default function Page() {
return ( return <MonitoringHealthPage />;
<BusinessDocsPage
title="وضعیت سرویس"
module="monitoring"
docType="health"
/>
);
} }

View File

@ -1,13 +1,5 @@
"use client"; import { PayrollEmployeesPage } from "@/components/accounting/DomainScreens";
import { BusinessDocsPage } from "@/components/accounting/BusinessDocsPage";
export default function Page() { export default function Page() {
return ( return <PayrollEmployeesPage />;
<BusinessDocsPage
title="کارمندان"
module="payroll"
docType="employee"
/>
);
} }

View File

@ -1,13 +1,5 @@
"use client"; import { PurchaseGoodsReceiptPage } from "@/components/accounting/DomainScreens";
import { BusinessDocsPage } from "@/components/accounting/BusinessDocsPage";
export default function Page() { export default function Page() {
return ( return <PurchaseGoodsReceiptPage />;
<BusinessDocsPage
title="رسید کالا"
module="purchase"
docType="goods_receipt"
/>
);
} }

View File

@ -1,13 +1,5 @@
"use client"; import { SettlementsPage } from "@/components/accounting/DomainScreens";
import { BusinessDocsPage } from "@/components/accounting/BusinessDocsPage";
export default function Page() { export default function Page() {
return ( return <SettlementsPage partyType="supplier" />;
<BusinessDocsPage
title="تسویه با تأمین‌کننده"
module="purchase"
docType="settlement"
/>
);
} }

View File

@ -1,13 +1,5 @@
"use client"; import { SalesInvoiceAccountingPage } from "@/components/accounting/DomainScreens";
import { BusinessDocsPage } from "@/components/accounting/BusinessDocsPage";
export default function Page() { export default function Page() {
return ( return <SalesInvoiceAccountingPage />;
<BusinessDocsPage
title="فاکتور فروش"
module="sales"
docType="invoice"
/>
);
} }

View File

@ -1,13 +1,5 @@
"use client"; import { SettlementsPage } from "@/components/accounting/DomainScreens";
import { BusinessDocsPage } from "@/components/accounting/BusinessDocsPage";
export default function Page() { export default function Page() {
return ( return <SettlementsPage partyType="customer" />;
<BusinessDocsPage
title="تسویه حساب مشتریان"
module="sales"
docType="settlement"
/>
);
} }

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { Suspense, useMemo } from "react"; import { Suspense, useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
@ -17,6 +17,7 @@ import {
ErrorState, ErrorState,
Badge, Badge,
JalaliDateText, JalaliDateText,
Pagination,
} from "@/components/ds"; } from "@/components/ds";
const STATUS_TITLES: Record<string, string> = { const STATUS_TITLES: Record<string, string> = {
@ -29,10 +30,12 @@ function VouchersList() {
const { tenantId } = useTenantId(); const { tenantId } = useTenantId();
const sp = useSearchParams(); const sp = useSearchParams();
const statusFilter = sp.get("status") || ""; const statusFilter = sp.get("status") || "";
const [page, setPage] = useState(1);
const pageSize = 50;
const listQ = useQuery({ const listQ = useQuery({
queryKey: ["accounting", tenantId, "vouchers"], queryKey: ["accounting", tenantId, "vouchers", page, pageSize],
queryFn: () => accountingApi.vouchers.list(tenantId!), queryFn: () => accountingApi.vouchers.list(tenantId!, page, pageSize),
enabled: !!tenantId, enabled: !!tenantId,
}); });
@ -102,6 +105,16 @@ function VouchersList() {
/> />
} }
/> />
<Pagination
page={page}
pageSize={pageSize}
total={
(listQ.data?.length ?? 0) === pageSize
? page * pageSize + 1
: (page - 1) * pageSize + Math.max(listQ.data?.length ?? 0, 1)
}
onPageChange={setPage}
/>
</div> </div>
); );
} }

View File

@ -6,7 +6,7 @@ import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { Plus } from "lucide-react"; import { Eye, Pencil, Plus } from "lucide-react";
import { accountingApi } from "@/lib/accounting-api"; import { accountingApi } from "@/lib/accounting-api";
import { useTenantId } from "@/hooks/useTenantId"; import { useTenantId } from "@/hooks/useTenantId";
import { formatMoney } from "@/lib/utils"; import { formatMoney } from "@/lib/utils";
@ -54,6 +54,8 @@ export function BusinessDocsPage({
const qc = useQueryClient(); const qc = useQueryClient();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [confirmId, setConfirmId] = useState<string | null>(null); const [confirmId, setConfirmId] = useState<string | null>(null);
const [detailRow, setDetailRow] = useState<Record<string, unknown> | null>(null);
const [editRow, setEditRow] = useState<Record<string, unknown> | null>(null);
const listQ = useQuery({ const listQ = useQuery({
queryKey: ["accounting", tenantId, "ops-docs", module, docType], queryKey: ["accounting", tenantId, "ops-docs", module, docType],
@ -71,6 +73,16 @@ export function BusinessDocsPage({
description: "", description: "",
}, },
}); });
const editForm = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: {
number: "",
doc_date: "",
party_name: "",
amount: "0",
description: "",
},
});
const createM = useMutation({ const createM = useMutation({
mutationFn: (d: FormValues) => mutationFn: (d: FormValues) =>
@ -109,6 +121,33 @@ export function BusinessDocsPage({
onError: (e: Error) => toast.error(e.message), onError: (e: Error) => toast.error(e.message),
}); });
const openEdit = (row: Record<string, unknown>) => {
setEditRow(row);
editForm.reset({
number: String(row.number ?? ""),
doc_date: String(row.doc_date ?? ""),
party_name: String(row.party_name ?? ""),
amount: String(row.amount ?? "0"),
description: String(row.description ?? ""),
});
};
const updateM = useMutation({
mutationFn: (d: FormValues) =>
accountingApi.ops.updateDocument(tenantId!, String(editRow!.id), {
doc_date: d.doc_date,
party_name: d.party_name || undefined,
amount: d.amount,
description: d.description || undefined,
}),
onSuccess: async () => {
toast.success("ویرایش ذخیره شد");
setEditRow(null);
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "ops-docs", module, docType] });
},
onError: (e: Error) => toast.error(e.message),
});
if (!tenantId || listQ.isLoading) return <LoadingState />; if (!tenantId || listQ.isLoading) return <LoadingState />;
if (listQ.error) { if (listQ.error) {
return <ErrorState message={listQ.error.message} onRetry={() => listQ.refetch()} />; return <ErrorState message={listQ.error.message} onRetry={() => listQ.refetch()} />;
@ -151,14 +190,25 @@ export function BusinessDocsPage({
{ {
key: "id", key: "id",
header: "عملیات", header: "عملیات",
render: (r) => render: (r) => (
r.status === "draft" ? ( <div className="flex flex-wrap gap-2">
<Button type="button" size="sm" variant="outline" onClick={() => setConfirmId(String(r.id))}> <Button type="button" size="sm" variant="ghost" onClick={() => setDetailRow(r)}>
تأیید <Eye className="h-4 w-4" />
جزئیات
</Button> </Button>
) : ( {r.status === "draft" ? (
"—" <>
), <Button type="button" size="sm" variant="outline" onClick={() => openEdit(r)}>
<Pencil className="h-4 w-4" />
ویرایش
</Button>
<Button type="button" size="sm" variant="outline" onClick={() => setConfirmId(String(r.id))}>
تأیید
</Button>
</>
) : null}
</div>
),
}, },
]} ]}
rows={(listQ.data ?? []) as unknown as Record<string, unknown>[]} rows={(listQ.data ?? []) as unknown as Record<string, unknown>[]}
@ -201,6 +251,51 @@ export function BusinessDocsPage({
</form> </form>
</Dialog> </Dialog>
<Dialog open={!!editRow} onClose={() => setEditRow(null)} title={`ویرایش ${title}`} size="lg">
<form className="grid gap-3 sm:grid-cols-2" onSubmit={editForm.handleSubmit((d) => updateM.mutate(d))}>
<FormField label="شماره">
<Input {...editForm.register("number")} readOnly />
</FormField>
<FormField label="تاریخ" error={editForm.formState.errors.doc_date?.message}>
<Controller
control={editForm.control}
name="doc_date"
render={({ field }) => (
<DatePicker value={field.value} onChange={field.onChange} onBlur={field.onBlur} name={field.name} />
)}
/>
</FormField>
<FormField label="طرف حساب">
<Input {...editForm.register("party_name")} />
</FormField>
<FormField label="مبلغ" error={editForm.formState.errors.amount?.message}>
<MoneyInput {...editForm.register("amount")} />
</FormField>
<div className="sm:col-span-2">
<FormField label="توضیح">
<Input {...editForm.register("description")} />
</FormField>
</div>
<div className="flex justify-end gap-2 sm:col-span-2">
<Button type="button" variant="outline" onClick={() => setEditRow(null)}>انصراف</Button>
<Button type="submit" disabled={updateM.isPending}>ذخیره تغییرات</Button>
</div>
</form>
</Dialog>
<Dialog open={!!detailRow} onClose={() => setDetailRow(null)} title={`جزئیات ${title}`}>
{detailRow ? (
<dl className="grid gap-4 text-sm sm:grid-cols-2">
<div><dt className="text-[var(--muted)]">شماره</dt><dd className="mt-1 text-secondary">{String(detailRow.number ?? "—")}</dd></div>
<div><dt className="text-[var(--muted)]">تاریخ</dt><dd className="mt-1 text-secondary"><JalaliDateText value={String(detailRow.doc_date ?? "")} /></dd></div>
<div><dt className="text-[var(--muted)]">طرف حساب</dt><dd className="mt-1 text-secondary">{String(detailRow.party_name ?? "—")}</dd></div>
<div><dt className="text-[var(--muted)]">مبلغ</dt><dd className="mt-1 text-secondary">{formatMoney(String(detailRow.amount ?? "0"))}</dd></div>
<div><dt className="text-[var(--muted)]">وضعیت</dt><dd className="mt-1"><Badge>{String(detailRow.status ?? "—")}</Badge></dd></div>
<div className="sm:col-span-2"><dt className="text-[var(--muted)]">توضیح</dt><dd className="mt-1 text-secondary">{String(detailRow.description ?? "—")}</dd></div>
</dl>
) : null}
</Dialog>
<ConfirmDialog <ConfirmDialog
open={!!confirmId} open={!!confirmId}
onClose={() => setConfirmId(null)} onClose={() => setConfirmId(null)}
@ -581,6 +676,8 @@ export function ReceiptsPaymentsPage() {
date: new Date().toISOString().slice(0, 10), date: new Date().toISOString().slice(0, 10),
amount: "0", amount: "0",
payment_method: "cash", payment_method: "cash",
cash_box_id: "",
bank_account_id: "",
description: "", description: "",
}, },
}); });
@ -595,6 +692,16 @@ export function ReceiptsPaymentsPage() {
queryFn: () => accountingApi.treasury.listPayments(tenantId!), queryFn: () => accountingApi.treasury.listPayments(tenantId!),
enabled: !!tenantId, enabled: !!tenantId,
}); });
const cashBoxesQ = useQuery({
queryKey: ["accounting", tenantId, "cash-boxes"],
queryFn: () => accountingApi.treasury.listCashBoxes(tenantId!),
enabled: !!tenantId,
});
const bankAccountsQ = useQuery({
queryKey: ["accounting", tenantId, "bank-accounts"],
queryFn: () => accountingApi.treasury.listBankAccounts(tenantId!),
enabled: !!tenantId,
});
const createM = useMutation({ const createM = useMutation({
mutationFn: async (d: { mutationFn: async (d: {
@ -602,6 +709,8 @@ export function ReceiptsPaymentsPage() {
date: string; date: string;
amount: string; amount: string;
payment_method: string; payment_method: string;
cash_box_id?: string;
bank_account_id?: string;
description?: string; description?: string;
}) => { }) => {
if (mode === "receipt") { if (mode === "receipt") {
@ -610,6 +719,8 @@ export function ReceiptsPaymentsPage() {
receipt_date: d.date, receipt_date: d.date,
amount: d.amount, amount: d.amount,
payment_method: d.payment_method, payment_method: d.payment_method,
cash_box_id: d.payment_method === "cash" ? d.cash_box_id || undefined : undefined,
bank_account_id: d.payment_method === "bank" ? d.bank_account_id || undefined : undefined,
description: d.description, description: d.description,
}); });
} }
@ -618,6 +729,8 @@ export function ReceiptsPaymentsPage() {
payment_date: d.date, payment_date: d.date,
amount: d.amount, amount: d.amount,
payment_method: d.payment_method, payment_method: d.payment_method,
cash_box_id: d.payment_method === "cash" ? d.cash_box_id || undefined : undefined,
bank_account_id: d.payment_method === "bank" ? d.bank_account_id || undefined : undefined,
description: d.description, description: d.description,
}); });
}, },
@ -631,11 +744,11 @@ export function ReceiptsPaymentsPage() {
onError: (e: Error) => toast.error(e.message), onError: (e: Error) => toast.error(e.message),
}); });
if (!tenantId || receiptsQ.isLoading || paymentsQ.isLoading) return <LoadingState />; if (!tenantId || receiptsQ.isLoading || paymentsQ.isLoading || cashBoxesQ.isLoading || bankAccountsQ.isLoading) return <LoadingState />;
if (receiptsQ.error || paymentsQ.error) { if (receiptsQ.error || paymentsQ.error || cashBoxesQ.error || bankAccountsQ.error) {
return ( return (
<ErrorState <ErrorState
message={(receiptsQ.error || paymentsQ.error)!.message} message={(receiptsQ.error || paymentsQ.error || cashBoxesQ.error || bankAccountsQ.error)!.message}
onRetry={() => { onRetry={() => {
receiptsQ.refetch(); receiptsQ.refetch();
paymentsQ.refetch(); paymentsQ.refetch();
@ -742,6 +855,22 @@ export function ReceiptsPaymentsPage() {
<option value="cheque">چک</option> <option value="cheque">چک</option>
</Select> </Select>
</FormField> </FormField>
<FormField label="صندوق">
<Select {...form.register("cash_box_id")} disabled={form.watch("payment_method") !== "cash"}>
<option value="">انتخاب صندوق</option>
{(cashBoxesQ.data ?? []).map((cashBox) => (
<option key={cashBox.id} value={cashBox.id}>{cashBox.name}</option>
))}
</Select>
</FormField>
<FormField label="حساب بانکی">
<Select {...form.register("bank_account_id")} disabled={form.watch("payment_method") !== "bank"}>
<option value="">انتخاب حساب</option>
{(bankAccountsQ.data ?? []).map((account) => (
<option key={account.id} value={account.id}>{account.account_number}</option>
))}
</Select>
</FormField>
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<FormField label="توضیح"> <FormField label="توضیح">
<Input {...form.register("description")} /> <Input {...form.register("description")} />

View File

@ -0,0 +1,560 @@
"use client";
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Plus } from "lucide-react";
import { toast } from "sonner";
import { z } from "zod";
import { accountingApi } from "@/lib/accounting-api";
import { useTenantId } from "@/hooks/useTenantId";
import { formatMoney } from "@/lib/utils";
import {
Badge,
Button,
Card,
CardContent,
CardHeader,
CardTitle,
DataTable,
DatePicker,
Dialog,
EmptyState,
ErrorState,
FormField,
Input,
JalaliDateText,
LoadingState,
MoneyInput,
PageHeader,
Select,
StatCard,
Textarea,
} from "@/components/ds";
const today = () => new Date().toISOString().slice(0, 10);
const requiredText = z.string().min(1, "این فیلد الزامی است");
function Actions({
onCancel,
pending,
submitLabel = "ذخیره",
}: {
onCancel: () => void;
pending: boolean;
submitLabel?: string;
}) {
return (
<div className="flex justify-end gap-2 sm:col-span-2">
<Button type="button" variant="outline" onClick={onCancel}>
انصراف
</Button>
<Button type="submit" disabled={pending}>
{submitLabel}
</Button>
</div>
);
}
const employeeSchema = z.object({
employee_code: requiredText,
first_name: requiredText,
last_name: requiredText,
base_salary: requiredText,
department_id: z.string(),
});
export function PayrollEmployeesPage() {
const { tenantId } = useTenantId();
const qc = useQueryClient();
const [open, setOpen] = useState(false);
const form = useForm<z.infer<typeof employeeSchema>>({
resolver: zodResolver(employeeSchema),
defaultValues: { employee_code: "", first_name: "", last_name: "", base_salary: "0", department_id: "" },
});
const employeesQ = useQuery({
queryKey: ["accounting", tenantId, "payroll-employees"],
queryFn: () => accountingApi.payroll.listEmployees(tenantId!),
enabled: !!tenantId,
});
const departmentsQ = useQuery({
queryKey: ["accounting", tenantId, "payroll-departments"],
queryFn: () => accountingApi.payroll.listDepartments(tenantId!),
enabled: !!tenantId,
});
const createM = useMutation({
mutationFn: (values: z.infer<typeof employeeSchema>) =>
accountingApi.payroll.createEmployee(tenantId!, {
...values,
department_id: values.department_id || undefined,
}),
onSuccess: async () => {
toast.success("کارمند ثبت شد");
setOpen(false);
form.reset();
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "payroll-employees"] });
},
onError: (error: Error) => toast.error(error.message),
});
if (!tenantId || employeesQ.isLoading || departmentsQ.isLoading) return <LoadingState />;
if (employeesQ.error || departmentsQ.error) {
const error = employeesQ.error || departmentsQ.error;
return <ErrorState message={error!.message} onRetry={() => { employeesQ.refetch(); departmentsQ.refetch(); }} />;
}
return (
<div>
<PageHeader
title="کارمندان"
description="تعریف کارکنان و اتصال آن‌ها به واحد سازمانی."
actions={<Button onClick={() => setOpen(true)}><Plus className="h-4 w-4" />کارمند جدید</Button>}
/>
<DataTable
columns={[{ key: "code", header: "کد پرسنلی" }, { key: "name", header: "نام و نام خانوادگی" }]}
rows={employeesQ.data as unknown as Record<string, unknown>[]}
empty={<EmptyState title="کارمندی ثبت نشده" />}
/>
<Dialog open={open} onClose={() => setOpen(false)} title="ثبت کارمند" size="lg">
<form className="grid gap-3 sm:grid-cols-2" onSubmit={form.handleSubmit((v) => createM.mutate(v))}>
<FormField label="کد پرسنلی" error={form.formState.errors.employee_code?.message}><Input {...form.register("employee_code")} /></FormField>
<FormField label="نام" error={form.formState.errors.first_name?.message}><Input {...form.register("first_name")} /></FormField>
<FormField label="نام خانوادگی" error={form.formState.errors.last_name?.message}><Input {...form.register("last_name")} /></FormField>
<FormField label="حقوق پایه" error={form.formState.errors.base_salary?.message}><MoneyInput {...form.register("base_salary")} /></FormField>
<FormField label="واحد سازمانی">
<Select {...form.register("department_id")}>
<option value="">بدون واحد</option>
{(departmentsQ.data ?? []).map((d) => <option key={d.id} value={d.id}>{d.name}</option>)}
</Select>
</FormField>
<Actions onCancel={() => setOpen(false)} pending={createM.isPending} />
</form>
</Dialog>
</div>
);
}
const policySchema = z.object({
code: requiredText,
name: requiredText,
policy_type: requiredText,
rules_config: requiredText.refine((value) => {
try { JSON.parse(value); return true; } catch { return false; }
}, "JSON معتبر وارد کنید"),
});
export function CompliancePoliciesPage() {
const { tenantId } = useTenantId();
const qc = useQueryClient();
const [open, setOpen] = useState(false);
const form = useForm<z.infer<typeof policySchema>>({
resolver: zodResolver(policySchema),
defaultValues: { code: "", name: "", policy_type: "", rules_config: "{}" },
});
const listQ = useQuery({
queryKey: ["accounting", tenantId, "compliance-policies"],
queryFn: () => accountingApi.compliance.listPolicies(tenantId!),
enabled: !!tenantId,
});
const createM = useMutation({
mutationFn: (values: z.infer<typeof policySchema>) => accountingApi.compliance.createPolicy(tenantId!, values),
onSuccess: async () => {
toast.success("سیاست ثبت شد");
setOpen(false);
form.reset();
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "compliance-policies"] });
},
onError: (error: Error) => toast.error(error.message),
});
if (!tenantId || listQ.isLoading) return <LoadingState />;
if (listQ.error) return <ErrorState message={listQ.error.message} onRetry={() => listQ.refetch()} />;
return (
<div>
<PageHeader title="سیاست‌ها و قوانین" description="تعریف سیاست‌های انطباق و پیکربندی قواعد." actions={<Button onClick={() => setOpen(true)}><Plus className="h-4 w-4" />سیاست جدید</Button>} />
<DataTable
columns={[
{ key: "code", header: "کد" }, { key: "name", header: "نام" }, { key: "policy_type", header: "نوع" },
{ key: "is_active", header: "وضعیت", render: (r) => <Badge tone={r.is_active ? "success" : "default"}>{r.is_active ? "فعال" : "غیرفعال"}</Badge> },
]}
rows={listQ.data as unknown as Record<string, unknown>[]}
empty={<EmptyState title="سیاستی ثبت نشده" />}
/>
<Dialog open={open} onClose={() => setOpen(false)} title="سیاست جدید" size="lg">
<form className="grid gap-3 sm:grid-cols-2" onSubmit={form.handleSubmit((v) => createM.mutate(v))}>
<FormField label="کد" error={form.formState.errors.code?.message}><Input {...form.register("code")} /></FormField>
<FormField label="نام" error={form.formState.errors.name?.message}><Input {...form.register("name")} /></FormField>
<FormField label="نوع سیاست" error={form.formState.errors.policy_type?.message}><Input {...form.register("policy_type")} placeholder="tax, audit, ..." /></FormField>
<div className="sm:col-span-2"><FormField label="پیکربندی قواعد (JSON)" error={form.formState.errors.rules_config?.message}><Textarea dir="ltr" {...form.register("rules_config")} /></FormField></div>
<Actions onCancel={() => setOpen(false)} pending={createM.isPending} />
</form>
</Dialog>
</div>
);
}
const riskSchema = z.object({
code: requiredText,
title: requiredText,
risk_category: requiredText,
risk_level: z.enum(["low", "medium", "high", "critical"]),
risk_score: z.number().int().min(0),
});
export function ComplianceRisksPage() {
const { tenantId } = useTenantId();
const qc = useQueryClient();
const [open, setOpen] = useState(false);
const form = useForm<z.infer<typeof riskSchema>>({
resolver: zodResolver(riskSchema),
defaultValues: { code: "", title: "", risk_category: "", risk_level: "low", risk_score: 0 },
});
const listQ = useQuery({ queryKey: ["accounting", tenantId, "compliance-risks"], queryFn: () => accountingApi.compliance.listRisks(tenantId!), enabled: !!tenantId });
const createM = useMutation({
mutationFn: (values: z.infer<typeof riskSchema>) => accountingApi.compliance.createRisk(tenantId!, values),
onSuccess: async () => {
toast.success("ریسک ثبت شد");
setOpen(false);
form.reset();
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "compliance-risks"] });
},
onError: (error: Error) => toast.error(error.message),
});
if (!tenantId || listQ.isLoading) return <LoadingState />;
if (listQ.error) return <ErrorState message={listQ.error.message} onRetry={() => listQ.refetch()} />;
return (
<div>
<PageHeader title="مدیریت ریسک" description="ثبت و پایش ریسک‌های مالی و عملیاتی." actions={<Button onClick={() => setOpen(true)}><Plus className="h-4 w-4" />ریسک جدید</Button>} />
<DataTable
columns={[
{ key: "code", header: "کد" }, { key: "title", header: "عنوان" }, { key: "risk_category", header: "دسته" },
{ key: "risk_level", header: "سطح", render: (r) => <Badge tone={r.risk_level === "critical" || r.risk_level === "high" ? "danger" : r.risk_level === "medium" ? "warning" : "default"}>{String(r.risk_level)}</Badge> },
{ key: "risk_score", header: "امتیاز" }, { key: "is_resolved", header: "وضعیت", render: (r) => r.is_resolved ? "رفع شده" : "باز" },
]}
rows={listQ.data as unknown as Record<string, unknown>[]}
empty={<EmptyState title="ریسکی ثبت نشده" />}
/>
<Dialog open={open} onClose={() => setOpen(false)} title="ثبت ریسک" size="lg">
<form className="grid gap-3 sm:grid-cols-2" onSubmit={form.handleSubmit((v) => createM.mutate(v))}>
<FormField label="کد" error={form.formState.errors.code?.message}><Input {...form.register("code")} /></FormField>
<FormField label="عنوان" error={form.formState.errors.title?.message}><Input {...form.register("title")} /></FormField>
<FormField label="دسته ریسک" error={form.formState.errors.risk_category?.message}><Input {...form.register("risk_category")} /></FormField>
<FormField label="سطح"><Select {...form.register("risk_level")}><option value="low">کم</option><option value="medium">متوسط</option><option value="high">زیاد</option><option value="critical">بحرانی</option></Select></FormField>
<FormField label="امتیاز" error={form.formState.errors.risk_score?.message}><Input type="number" min={0} {...form.register("risk_score", { valueAsNumber: true })} /></FormField>
<Actions onCancel={() => setOpen(false)} pending={createM.isPending} />
</form>
</Dialog>
</div>
);
}
const settlementSchema = z.object({
settlement_number: requiredText,
settlement_date: requiredText,
settlement_type: z.enum(["receipt", "payment"]),
party_id: requiredText,
total_amount: requiredText,
allocations: requiredText.refine((value) => {
try { return Array.isArray(JSON.parse(value)); } catch { return false; }
}, "آرایه JSON معتبر وارد کنید"),
});
export function SettlementsPage({ partyType }: { partyType: "customer" | "supplier" }) {
const { tenantId } = useTenantId();
const qc = useQueryClient();
const [open, setOpen] = useState(false);
const form = useForm<z.infer<typeof settlementSchema>>({
resolver: zodResolver(settlementSchema),
defaultValues: { settlement_number: "", settlement_date: today(), settlement_type: partyType === "customer" ? "receipt" : "payment", party_id: "", total_amount: "0", allocations: "[]" },
});
const listQ = useQuery({ queryKey: ["accounting", tenantId, "settlements", partyType], queryFn: () => accountingApi.settlements.list(tenantId!, partyType), enabled: !!tenantId });
const partiesQ = useQuery({
queryKey: ["accounting", tenantId, partyType === "customer" ? "customers" : "suppliers"],
queryFn: () => partyType === "customer" ? accountingApi.customers.list(tenantId!) : accountingApi.suppliers.list(tenantId!),
enabled: !!tenantId,
});
const createM = useMutation({
mutationFn: (values: z.infer<typeof settlementSchema>) => accountingApi.settlements.create(tenantId!, {
...values, party_type: partyType, allocations: JSON.parse(values.allocations) as { invoice_id?: string; amount: string }[],
}),
onSuccess: async () => {
toast.success("تسویه ثبت شد");
setOpen(false);
form.reset();
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "settlements", partyType] });
},
onError: (error: Error) => toast.error(error.message),
});
if (!tenantId || listQ.isLoading || partiesQ.isLoading) return <LoadingState />;
if (listQ.error || partiesQ.error) return <ErrorState message={(listQ.error || partiesQ.error)!.message} onRetry={() => { listQ.refetch(); partiesQ.refetch(); }} />;
const partyNames = new Map((partiesQ.data ?? []).map((p) => [p.id, p.name]));
return (
<div>
<PageHeader title={partyType === "customer" ? "تسویه حساب مشتریان" : "تسویه با تأمین‌کنندگان"} description="ثبت تسویه و تخصیص آن به اسناد طرف حساب." actions={<Button onClick={() => setOpen(true)}><Plus className="h-4 w-4" />تسویه جدید</Button>} />
<DataTable
columns={[
{ key: "settlement_number", header: "شماره" }, { key: "settlement_date", header: "تاریخ", render: (r) => <JalaliDateText value={String(r.settlement_date)} /> },
{ key: "party_id", header: "طرف حساب", render: (r) => partyNames.get(String(r.party_id)) || String(r.party_id) },
{ key: "settlement_type", header: "نوع" }, { key: "total_amount", header: "مبلغ", render: (r) => formatMoney(String(r.total_amount)) },
{ key: "status", header: "وضعیت", render: (r) => <Badge>{String(r.status)}</Badge> },
]}
rows={listQ.data as unknown as Record<string, unknown>[]}
empty={<EmptyState title="تسویه‌ای ثبت نشده" />}
/>
<Dialog open={open} onClose={() => setOpen(false)} title="ثبت تسویه" size="lg">
<form className="grid gap-3 sm:grid-cols-2" onSubmit={form.handleSubmit((v) => createM.mutate(v))}>
<FormField label="شماره" error={form.formState.errors.settlement_number?.message}><Input {...form.register("settlement_number")} /></FormField>
<FormField label="تاریخ" error={form.formState.errors.settlement_date?.message}><Controller control={form.control} name="settlement_date" render={({ field }) => <DatePicker {...field} />} /></FormField>
<FormField label="نوع"><Select {...form.register("settlement_type")}><option value="receipt">دریافت</option><option value="payment">پرداخت</option></Select></FormField>
<FormField label="طرف حساب" error={form.formState.errors.party_id?.message}><Select {...form.register("party_id")}><option value="">انتخاب</option>{(partiesQ.data ?? []).map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}</Select></FormField>
<FormField label="مبلغ کل" error={form.formState.errors.total_amount?.message}><MoneyInput {...form.register("total_amount")} /></FormField>
<div className="sm:col-span-2"><FormField label="تخصیص‌ها (JSON)" error={form.formState.errors.allocations?.message}><Textarea dir="ltr" placeholder='[{"invoice_id":"...","amount":"1000"}]' {...form.register("allocations")} /></FormField></div>
<Actions onCancel={() => setOpen(false)} pending={createM.isPending} />
</form>
</Dialog>
</div>
);
}
const documentSchema = z.object({ number: requiredText, doc_date: requiredText, party_name: z.string(), amount: requiredText, description: z.string() });
const salesPostSchema = z.object({ source_document_id: requiredText, total_amount: requiredText, discount_amount: requiredText, tax_amount: requiredText, profile_id: z.string() });
export function SalesInvoiceAccountingPage() {
const { tenantId } = useTenantId();
const qc = useQueryClient();
const [createOpen, setCreateOpen] = useState(false);
const [profileOpen, setProfileOpen] = useState(false);
const [postOpen, setPostOpen] = useState(false);
const [preview, setPreview] = useState<{ is_balanced: boolean; total_debit: string; total_credit: string } | null>(null);
const docForm = useForm<z.infer<typeof documentSchema>>({ resolver: zodResolver(documentSchema), defaultValues: { number: "", doc_date: today(), party_name: "", amount: "0", description: "" } });
const profileForm = useForm({ defaultValues: { name: "" } });
const postForm = useForm<z.infer<typeof salesPostSchema>>({ resolver: zodResolver(salesPostSchema), defaultValues: { source_document_id: "", total_amount: "0", discount_amount: "0", tax_amount: "0", profile_id: "" } });
const docsQ = useQuery({ queryKey: ["accounting", tenantId, "ops-docs", "sales", "invoice"], queryFn: () => accountingApi.ops.listDocuments(tenantId!, "sales", "invoice"), enabled: !!tenantId });
const profilesQ = useQuery({ queryKey: ["accounting", tenantId, "sales-profiles"], queryFn: () => accountingApi.salesAccounting.listProfiles(tenantId!), enabled: !!tenantId });
const createDocM = useMutation({
mutationFn: (v: z.infer<typeof documentSchema>) => accountingApi.ops.createDocument(tenantId!, { module: "sales", doc_type: "invoice", status: "draft", ...v }),
onSuccess: async () => { toast.success("فاکتور ثبت شد"); setCreateOpen(false); docForm.reset(); await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "ops-docs", "sales", "invoice"] }); },
onError: (e: Error) => toast.error(e.message),
});
const createProfileM = useMutation({
mutationFn: (v: { name: string }) => accountingApi.salesAccounting.createProfile(tenantId!, { name: v.name, document_type: "invoice" }),
onSuccess: async () => { toast.success("پروفایل ثبت شد"); setProfileOpen(false); profileForm.reset(); await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "sales-profiles"] }); },
onError: (e: Error) => toast.error(e.message),
});
const previewM = useMutation({
mutationFn: (v: z.infer<typeof salesPostSchema>) => accountingApi.salesAccounting.preview(tenantId!, { document_type: "invoice", total_amount: v.total_amount, discount_amount: v.discount_amount, tax_amount: v.tax_amount, profile_id: v.profile_id || undefined }),
onSuccess: (data) => { setPreview(data); toast.success("پیش‌نمایش آماده شد"); },
onError: (e: Error) => toast.error(e.message),
});
const postM = useMutation({
mutationFn: (v: z.infer<typeof salesPostSchema>) => accountingApi.salesAccounting.post(tenantId!, { document_type: "invoice", ...v, profile_id: v.profile_id || undefined }),
onSuccess: (data) => { toast.success(`سند ${data.voucher_number} ثبت شد`); setPostOpen(false); setPreview(null); },
onError: (e: Error) => toast.error(e.message),
});
if (!tenantId || docsQ.isLoading || profilesQ.isLoading) return <LoadingState />;
if (docsQ.error || profilesQ.error) return <ErrorState message={(docsQ.error || profilesQ.error)!.message} onRetry={() => { docsQ.refetch(); profilesQ.refetch(); }} />;
const openPosting = (row: Record<string, unknown>) => {
postForm.reset({ source_document_id: String(row.id), total_amount: String(row.amount || "0"), discount_amount: "0", tax_amount: "0", profile_id: "" });
setPreview(null);
setPostOpen(true);
};
return (
<div>
<PageHeader title="فاکتور فروش و ثبت حسابداری" description="ثبت فاکتور عملیاتی، پیش‌نمایش و ارسال به دفتر حسابداری." actions={<><Button variant="outline" onClick={() => setProfileOpen(true)}>پروفایل ثبت</Button><Button onClick={() => setCreateOpen(true)}><Plus className="h-4 w-4" />فاکتور جدید</Button></>} />
<DataTable
columns={[
{ key: "number", header: "شماره" }, { key: "doc_date", header: "تاریخ", render: (r) => <JalaliDateText value={String(r.doc_date)} /> },
{ key: "party_name", header: "مشتری" }, { key: "amount", header: "مبلغ", render: (r) => formatMoney(String(r.amount)) },
{ key: "status", header: "وضعیت", render: (r) => <Badge>{String(r.status)}</Badge> },
{ key: "actions", header: "عملیات", render: (r) => <Button size="sm" variant="outline" onClick={() => openPosting(r)}>ثبت حسابداری</Button> },
]}
rows={docsQ.data as unknown as Record<string, unknown>[]}
empty={<EmptyState title="فاکتور فروشی ثبت نشده" />}
/>
<DocumentDialog open={createOpen} onClose={() => setCreateOpen(false)} title="ثبت فاکتور فروش" form={docForm} mutation={createDocM} />
<Dialog open={profileOpen} onClose={() => setProfileOpen(false)} title="پروفایل ثبت فروش">
<form className="grid gap-3" onSubmit={profileForm.handleSubmit((v) => createProfileM.mutate(v))}><FormField label="نام پروفایل"><Input {...profileForm.register("name", { required: true })} /></FormField><Actions onCancel={() => setProfileOpen(false)} pending={createProfileM.isPending} /></form>
</Dialog>
<Dialog open={postOpen} onClose={() => setPostOpen(false)} title="ثبت حسابداری فاکتور" size="lg">
<form className="grid gap-3 sm:grid-cols-2" onSubmit={postForm.handleSubmit((v) => postM.mutate(v))}>
<FormField label="شناسه سند مبدأ" error={postForm.formState.errors.source_document_id?.message}><Input dir="ltr" {...postForm.register("source_document_id")} /></FormField>
<FormField label="پروفایل"><Select {...postForm.register("profile_id")}><option value="">پروفایل پیشفرض</option>{(profilesQ.data ?? []).map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}</Select></FormField>
<FormField label="مبلغ کل"><MoneyInput {...postForm.register("total_amount")} /></FormField>
<FormField label="تخفیف"><MoneyInput {...postForm.register("discount_amount")} /></FormField>
<FormField label="مالیات"><MoneyInput {...postForm.register("tax_amount")} /></FormField>
{preview ? <div className="sm:col-span-2 rounded-xl bg-[var(--surface-muted)] p-3 text-sm">تراز: {preview.is_balanced ? "بله" : "خیر"} بدهکار: {formatMoney(preview.total_debit)} بستانکار: {formatMoney(preview.total_credit)}</div> : null}
<div className="flex justify-end gap-2 sm:col-span-2"><Button type="button" variant="outline" disabled={previewM.isPending} onClick={postForm.handleSubmit((v) => previewM.mutate(v))}>پیشنمایش</Button><Button type="submit" disabled={postM.isPending}>ثبت نهایی</Button></div>
</form>
</Dialog>
</div>
);
}
type DocumentForm = ReturnType<typeof useForm<z.infer<typeof documentSchema>>>;
type DocumentMutation = ReturnType<typeof useMutation<unknown, Error, z.infer<typeof documentSchema>>>;
function DocumentDialog({ open, onClose, title, form, mutation }: { open: boolean; onClose: () => void; title: string; form: DocumentForm; mutation: DocumentMutation }) {
return (
<Dialog open={open} onClose={onClose} title={title} size="lg">
<form className="grid gap-3 sm:grid-cols-2" onSubmit={form.handleSubmit((v) => mutation.mutate(v))}>
<FormField label="شماره" error={form.formState.errors.number?.message}><Input {...form.register("number")} /></FormField>
<FormField label="تاریخ" error={form.formState.errors.doc_date?.message}><Controller control={form.control} name="doc_date" render={({ field }) => <DatePicker {...field} />} /></FormField>
<FormField label="طرف حساب"><Input {...form.register("party_name")} /></FormField>
<FormField label="مبلغ"><MoneyInput {...form.register("amount")} /></FormField>
<div className="sm:col-span-2"><FormField label="توضیح"><Input {...form.register("description")} /></FormField></div>
<Actions onCancel={onClose} pending={mutation.isPending} />
</form>
</Dialog>
);
}
const purchasePostSchema = z.object({ source_document_id: requiredText, amount: requiredText, profile_id: z.string() });
export function PurchaseGoodsReceiptPage() {
const { tenantId } = useTenantId();
const qc = useQueryClient();
const [createOpen, setCreateOpen] = useState(false);
const [postOpen, setPostOpen] = useState(false);
const [preview, setPreview] = useState<{ is_balanced: boolean; total_debit: string } | null>(null);
const docForm = useForm<z.infer<typeof documentSchema>>({ resolver: zodResolver(documentSchema), defaultValues: { number: "", doc_date: today(), party_name: "", amount: "0", description: "" } });
const postForm = useForm<z.infer<typeof purchasePostSchema>>({ resolver: zodResolver(purchasePostSchema), defaultValues: { source_document_id: "", amount: "0", profile_id: "" } });
const docsQ = useQuery({ queryKey: ["accounting", tenantId, "ops-docs", "purchase", "goods_receipt"], queryFn: () => accountingApi.ops.listDocuments(tenantId!, "purchase", "goods_receipt"), enabled: !!tenantId });
const profilesQ = useQuery({ queryKey: ["accounting", tenantId, "purchase-profiles"], queryFn: () => accountingApi.purchaseInventory.listProfiles(tenantId!), enabled: !!tenantId });
const createM = useMutation({
mutationFn: (v: z.infer<typeof documentSchema>) => accountingApi.ops.createDocument(tenantId!, { module: "purchase", doc_type: "goods_receipt", status: "draft", ...v }),
onSuccess: async () => { toast.success("رسید کالا ثبت شد"); setCreateOpen(false); docForm.reset(); await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "ops-docs", "purchase", "goods_receipt"] }); },
onError: (e: Error) => toast.error(e.message),
});
const previewM = useMutation({
mutationFn: (v: z.infer<typeof purchasePostSchema>) => accountingApi.purchaseInventory.previewGoodsReceipt(tenantId!, { ...v, profile_id: v.profile_id || undefined }),
onSuccess: (data) => { setPreview(data); toast.success("پیش‌نمایش آماده شد"); },
onError: (e: Error) => toast.error(e.message),
});
const postM = useMutation({
mutationFn: (v: z.infer<typeof purchasePostSchema>) => accountingApi.purchaseInventory.postGoodsReceipt(tenantId!, { ...v, profile_id: v.profile_id || undefined }),
onSuccess: () => { toast.success("رسید به حسابداری ارسال شد"); setPostOpen(false); setPreview(null); },
onError: (e: Error) => toast.error(e.message),
});
if (!tenantId || docsQ.isLoading || profilesQ.isLoading) return <LoadingState />;
if (docsQ.error || profilesQ.error) return <ErrorState message={(docsQ.error || profilesQ.error)!.message} onRetry={() => { docsQ.refetch(); profilesQ.refetch(); }} />;
const openPosting = (r: Record<string, unknown>) => { postForm.reset({ source_document_id: String(r.id), amount: String(r.amount || "0"), profile_id: "" }); setPreview(null); setPostOpen(true); };
return (
<div>
<PageHeader title="رسید کالا" description="ثبت رسید عملیاتی و ارسال آن به حسابداری خرید و انبار." actions={<Button onClick={() => setCreateOpen(true)}><Plus className="h-4 w-4" />رسید جدید</Button>} />
<DataTable
columns={[
{ key: "number", header: "شماره" }, { key: "doc_date", header: "تاریخ", render: (r) => <JalaliDateText value={String(r.doc_date)} /> },
{ key: "party_name", header: "تأمین‌کننده" }, { key: "amount", header: "مبلغ", render: (r) => formatMoney(String(r.amount)) },
{ key: "status", header: "وضعیت", render: (r) => <Badge>{String(r.status)}</Badge> },
{ key: "actions", header: "عملیات", render: (r) => <Button size="sm" variant="outline" onClick={() => openPosting(r)}>ثبت حسابداری</Button> },
]}
rows={docsQ.data as unknown as Record<string, unknown>[]}
empty={<EmptyState title="رسید کالایی ثبت نشده" />}
/>
<DocumentDialog open={createOpen} onClose={() => setCreateOpen(false)} title="ثبت رسید کالا" form={docForm} mutation={createM} />
<Dialog open={postOpen} onClose={() => setPostOpen(false)} title="ثبت حسابداری رسید کالا">
<form className="grid gap-3" onSubmit={postForm.handleSubmit((v) => postM.mutate(v))}>
<FormField label="شناسه سند مبدأ"><Input dir="ltr" {...postForm.register("source_document_id")} /></FormField>
<FormField label="مبلغ"><MoneyInput {...postForm.register("amount")} /></FormField>
<FormField label="پروفایل"><Select {...postForm.register("profile_id")}><option value="">پروفایل پیشفرض</option>{(profilesQ.data ?? []).map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}</Select></FormField>
{preview ? <div className="rounded-xl bg-[var(--surface-muted)] p-3 text-sm">تراز: {preview.is_balanced ? "بله" : "خیر"} بدهکار: {formatMoney(preview.total_debit)}</div> : null}
<div className="flex justify-end gap-2"><Button type="button" variant="outline" onClick={postForm.handleSubmit((v) => previewM.mutate(v))} disabled={previewM.isPending}>پیشنمایش</Button><Button type="submit" disabled={postM.isPending}>ثبت نهایی</Button></div>
</form>
</Dialog>
</div>
);
}
const valuationSchema = z.object({ item_id: requiredText, quantity: requiredText, unit_cost: requiredText, warehouse_id: z.string() });
export function InventoryValuationPage() {
const { tenantId } = useTenantId();
const [result, setResult] = useState<string | null>(null);
const form = useForm<z.infer<typeof valuationSchema>>({ resolver: zodResolver(valuationSchema), defaultValues: { item_id: "", quantity: "0", unit_cost: "0", warehouse_id: "" } });
const itemsQ = useQuery({ queryKey: ["accounting", tenantId, "ops-items"], queryFn: () => accountingApi.ops.listItems(tenantId!), enabled: !!tenantId });
const warehousesQ = useQuery({ queryKey: ["accounting", tenantId, "ops-warehouses"], queryFn: () => accountingApi.ops.listWarehouses(tenantId!), enabled: !!tenantId });
const valuationM = useMutation({
mutationFn: (v: z.infer<typeof valuationSchema>) => accountingApi.purchaseInventory.valuation(tenantId!, { ...v, warehouse_id: v.warehouse_id || undefined }),
onSuccess: (data) => { setResult(data.total_value); toast.success("ارزش‌گذاری ثبت شد"); },
onError: (e: Error) => toast.error(e.message),
});
if (!tenantId || itemsQ.isLoading || warehousesQ.isLoading) return <LoadingState />;
if (itemsQ.error || warehousesQ.error) return <ErrorState message={(itemsQ.error || warehousesQ.error)!.message} onRetry={() => { itemsQ.refetch(); warehousesQ.refetch(); }} />;
return (
<div>
<PageHeader title="ارزش‌گذاری موجودی" description="محاسبه و ثبت ارزش موجودی بر اساس قلم، تعداد و بهای واحد." />
<Card className="mb-6">
<CardHeader><CardTitle>ثبت ارزشگذاری</CardTitle></CardHeader>
<CardContent>
<form className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4" onSubmit={form.handleSubmit((v) => valuationM.mutate(v))}>
<FormField label="کالا"><Select {...form.register("item_id")}><option value="">انتخاب</option>{(itemsQ.data ?? []).map((i) => <option key={i.id} value={i.id}>{i.code} {i.name}</option>)}</Select></FormField>
<FormField label="تعداد"><Input {...form.register("quantity")} /></FormField>
<FormField label="بهای واحد"><MoneyInput {...form.register("unit_cost")} /></FormField>
<FormField label="انبار (اختیاری)"><Select {...form.register("warehouse_id")}><option value="">بدون انبار</option>{(warehousesQ.data ?? []).map((w) => <option key={w.id} value={w.id}>{w.name}</option>)}</Select></FormField>
<div className="flex items-end gap-3 lg:col-span-4"><Button type="submit" disabled={valuationM.isPending}>محاسبه و ثبت</Button>{result ? <span className="text-sm text-secondary">ارزش کل: {formatMoney(result)}</span> : null}</div>
</form>
</CardContent>
</Card>
<DataTable
columns={[{ key: "code", header: "کد" }, { key: "name", header: "نام" }, { key: "unit", header: "واحد" }, { key: "quantity_on_hand", header: "موجودی" }, { key: "unit_cost", header: "بهای واحد", render: (r) => formatMoney(String(r.unit_cost)) }]}
rows={itemsQ.data as unknown as Record<string, unknown>[]}
empty={<EmptyState title="کالایی تعریف نشده" />}
/>
</div>
);
}
export function MonitoringHealthPage() {
const { tenantId } = useTenantId();
const healthQ = useQuery({ queryKey: ["accounting", "health"], queryFn: () => accountingApi.health() });
const setupQ = useQuery({ queryKey: ["accounting", tenantId, "setup-status"], queryFn: () => accountingApi.setup.status(tenantId!), enabled: !!tenantId });
if (!tenantId || healthQ.isLoading || setupQ.isLoading) return <LoadingState />;
if (healthQ.error || setupQ.error) return <ErrorState message={(healthQ.error || setupQ.error)!.message} onRetry={() => { healthQ.refetch(); setupQ.refetch(); }} />;
return (
<div>
<PageHeader title="وضعیت سرویس حسابداری" description="سلامت API و میزان تکمیل پیکربندی مستأجر." />
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard label="وضعیت سرویس" value={healthQ.data!.status} hint={healthQ.data!.service} />
<StatCard label="نسخه" value={healthQ.data!.version} />
<StatCard label="پیشرفت راه‌اندازی" value={`${setupQ.data!.percent}%`} hint={`${setupQ.data!.completed_steps} از ${setupQ.data!.total_steps} مرحله`} />
<StatCard label="آمادگی پایه" value={setupQ.data!.has_chart && setupQ.data!.has_fiscal_period ? "آماده" : "نیازمند تکمیل"} />
</div>
</div>
);
}
export function AssetSchedulePage() {
const { tenantId } = useTenantId();
const [selectedName, setSelectedName] = useState("");
const [schedule, setSchedule] = useState<Record<string, unknown>[] | null>(null);
const assetsQ = useQuery({ queryKey: ["accounting", tenantId, "assets"], queryFn: () => accountingApi.assets.list(tenantId!), enabled: !!tenantId });
const scheduleM = useMutation({
mutationFn: (id: string) => accountingApi.assets.schedule(tenantId!, id),
onSuccess: (data) => setSchedule(data.schedule as Record<string, unknown>[]),
onError: (e: Error) => toast.error(e.message),
});
if (!tenantId || assetsQ.isLoading) return <LoadingState />;
if (assetsQ.error) return <ErrorState message={assetsQ.error.message} onRetry={() => assetsQ.refetch()} />;
return (
<div>
<PageHeader title="برنامه استهلاک دارایی‌ها" description="مشاهده دوره‌ها، مبلغ استهلاک و ارزش دفتری باقیمانده." />
<DataTable
columns={[
{ key: "code", header: "کد" }, { key: "name", header: "نام دارایی" }, { key: "status", header: "وضعیت", render: (r) => <Badge>{String(r.status)}</Badge> },
{ key: "actions", header: "عملیات", render: (r) => <Button size="sm" variant="outline" disabled={scheduleM.isPending} onClick={() => { setSelectedName(String(r.name)); scheduleM.mutate(String(r.id)); }}>مشاهده برنامه</Button> },
]}
rows={assetsQ.data as unknown as Record<string, unknown>[]}
empty={<EmptyState title="دارایی ثبت نشده" />}
/>
<Dialog open={schedule !== null} onClose={() => setSchedule(null)} title={`برنامه استهلاک ${selectedName}`} size="lg">
<DataTable
columns={[
{ key: "period", header: "دوره" },
{ key: "amount", header: "استهلاک", render: (r) => formatMoney(String(r.amount ?? "0")) },
{ key: "book_value_after", header: "ارزش دفتری پایان دوره", render: (r) => formatMoney(String(r.book_value_after ?? "0")) },
]}
rows={schedule ?? []}
empty={<EmptyState title="برنامه‌ای محاسبه نشده" />}
/>
</Dialog>
</div>
);
}

View File

@ -884,6 +884,23 @@ export const accountingApi = {
method: "POST", method: "POST",
body: JSON.stringify(body), body: JSON.stringify(body),
}), }),
updateDocument: (
tenantId: string,
id: string,
body: {
party_name?: string;
amount?: string;
status?: string;
description?: string;
doc_date?: string;
meta_json?: string;
}
) =>
request<{ id: string; status: string }>(`/api/v1/ops/documents/${id}`, {
tenantId,
method: "PATCH",
body: JSON.stringify(body),
}),
confirmDocument: (tenantId: string, id: string) => confirmDocument: (tenantId: string, id: string) =>
request<{ id: string; status: string }>(`/api/v1/ops/documents/${id}/confirm`, { request<{ id: string; status: string }>(`/api/v1/ops/documents/${id}/confirm`, {
tenantId, tenantId,
@ -1142,5 +1159,205 @@ export const accountingApi = {
method: "POST", method: "POST",
body: JSON.stringify(body), body: JSON.stringify(body),
}), }),
requestApproval: (
tenantId: string,
body: { workflow_id: string; resource_type: string; resource_id: string }
) =>
request<{ id: string; status: string }>("/api/v1/compliance/approvals", {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
approve: (tenantId: string, requestId: string) =>
request<{ id: string; status: string }>(`/api/v1/compliance/approvals/${requestId}/approve`, {
tenantId,
method: "POST",
body: JSON.stringify({}),
}),
reject: (tenantId: string, requestId: string, reason: string) =>
request<{ id: string; status: string }>(`/api/v1/compliance/approvals/${requestId}/reject`, {
tenantId,
method: "POST",
body: JSON.stringify({ reason }),
}),
listRisks: (tenantId: string) =>
request<
{
id: string;
code: string;
title: string;
risk_category: string;
risk_level: string;
risk_score: number;
is_resolved: boolean;
}[]
>("/api/v1/compliance/risks", { tenantId }),
createRisk: (
tenantId: string,
body: {
code: string;
title: string;
risk_category: string;
risk_level: string;
risk_score?: number;
description?: string;
}
) =>
request<{ id: string; risk_level: string }>("/api/v1/compliance/risks", {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
},
settlements: {
list: (tenantId: string, partyType?: string) => {
const q = partyType ? `?party_type=${encodeURIComponent(partyType)}` : "";
return request<
{
id: string;
settlement_number: string;
settlement_date: string;
settlement_type: string;
party_type: string;
party_id: string;
total_amount: string;
status: string;
}[]
>(`/api/v1/ar-ap/settlements${q}`, { tenantId });
},
create: (
tenantId: string,
body: {
settlement_number: string;
settlement_date: string;
settlement_type: string;
party_type: string;
party_id: string;
total_amount: string;
allocations: { invoice_id?: string; amount: string }[];
}
) =>
request<{ id: string; status: string }>("/api/v1/ar-ap/settlements", {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
},
salesAccounting: {
listProfiles: (tenantId: string) =>
request<{ id: string; name: string; document_type: string; is_default: boolean; is_active: boolean }[]>(
"/api/v1/sales-accounting/posting-profiles",
{ tenantId }
),
createProfile: (
tenantId: string,
body: {
name: string;
document_type: string;
revenue_account_id?: string;
receivable_account_id?: string;
cash_account_id?: string;
discount_account_id?: string;
tax_account_id?: string;
is_default?: boolean;
}
) =>
request<{ id: string }>("/api/v1/sales-accounting/posting-profiles", {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
preview: (
tenantId: string,
body: {
document_type?: string;
total_amount: string;
discount_amount?: string;
tax_amount?: string;
profile_id?: string;
}
) =>
request<{
is_balanced: boolean;
total_debit: string;
total_credit: string;
preview_data: unknown;
}>("/api/v1/sales-accounting/preview", {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
post: (
tenantId: string,
body: {
document_type?: string;
source_document_id: string;
total_amount: string;
discount_amount?: string;
tax_amount?: string;
profile_id?: string;
}
) =>
request<{ voucher_id: string; voucher_number: string; status: string }>(
"/api/v1/sales-accounting/post",
{ tenantId, method: "POST", body: JSON.stringify(body) }
),
},
purchaseInventory: {
listProfiles: (tenantId: string) =>
request<{ id: string; name: string; is_default: boolean; is_active: boolean }[]>(
"/api/v1/purchase-inventory/posting-profiles",
{ tenantId }
),
createProfile: (
tenantId: string,
body: {
name: string;
inventory_account_id?: string;
grni_account_id?: string;
liability_account_id?: string;
is_default?: boolean;
}
) =>
request<{ id: string; name: string }>("/api/v1/purchase-inventory/posting-profiles", {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
previewGoodsReceipt: (
tenantId: string,
body: { source_document_id: string; amount: string; profile_id?: string }
) =>
request<{ is_balanced: boolean; total_debit: string }>(
"/api/v1/purchase-inventory/preview/goods-receipt",
{ tenantId, method: "POST", body: JSON.stringify(body) }
),
postGoodsReceipt: (
tenantId: string,
body: { source_document_id: string; amount: string; profile_id?: string }
) =>
request<{ voucher_id: string; status: string }>("/api/v1/purchase-inventory/post/goods-receipt", {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
valuation: (
tenantId: string,
body: {
item_id: string;
quantity: string;
unit_cost: string;
method?: string;
warehouse_id?: string;
}
) =>
request<{ id: string; total_value: string }>("/api/v1/purchase-inventory/valuation", {
tenantId,
method: "POST",
body: JSON.stringify(body),
}),
}, },
}; };

View File

@ -13,28 +13,74 @@ export type ModuleScore = {
}; };
export const ACCOUNTING_MODULE_SCORES: ModuleScore[] = [ export const ACCOUNTING_MODULE_SCORES: ModuleScore[] = [
{ id: "dashboard", label: "داشبورد", href: "/accounting", percent: 90, state: "complete" }, { id: "dashboard", label: "داشبورد", href: "/accounting", percent: 95, state: "complete" },
{ id: "coa", label: "دفتر حساب‌ها", href: "/accounting/chart-of-accounts", percent: 98, state: "complete" }, { id: "coa", label: "دفتر حساب‌ها", href: "/accounting/chart-of-accounts", percent: 98, state: "complete" },
{ id: "fiscal", label: "سال و دوره مالی", href: "/accounting/fiscal", percent: 98, state: "complete" }, { id: "fiscal", label: "سال و دوره مالی", href: "/accounting/fiscal", percent: 98, state: "complete" },
{ id: "currencies", label: "ارزها", href: "/accounting/currencies", percent: 98, state: "complete" }, { id: "currencies", label: "ارزها", href: "/accounting/currencies", percent: 98, state: "complete" },
{ id: "cost-centers", label: "مراکز هزینه", href: "/accounting/cost-centers", percent: 95, state: "complete" }, { id: "cost-centers", label: "مراکز هزینه", href: "/accounting/cost-centers", percent: 95, state: "complete" },
{ id: "projects", label: "پروژه‌ها", href: "/accounting/projects", percent: 95, state: "complete" }, { id: "projects", label: "پروژه‌ها", href: "/accounting/projects", percent: 95, state: "complete" },
{ id: "vouchers", label: "اسناد", href: "/accounting/vouchers", percent: 95, state: "complete" }, { id: "vouchers", label: "اسناد", href: "/accounting/vouchers", percent: 96, state: "complete" },
{ id: "ledger", label: "دفتر کل", href: "/accounting/ledger", percent: 95, state: "complete" }, { id: "ledger", label: "دفتر کل", href: "/accounting/ledger", percent: 95, state: "complete" },
{ id: "treasury", label: "خزانه", href: "/accounting/treasury", percent: 90, state: "complete" }, {
id: "treasury",
label: "خزانه",
href: "/accounting/treasury",
percent: 88,
state: "partial",
note: "لیست/ثبت چک و انتقال؛ ضمانت/تسهیلات هنوز ops",
},
{ id: "customers", label: "مشتریان", href: "/accounting/customers", percent: 92, state: "complete" }, { id: "customers", label: "مشتریان", href: "/accounting/customers", percent: 92, state: "complete" },
{ id: "suppliers", label: "تأمین‌کنندگان", href: "/accounting/suppliers", percent: 85, state: "complete" }, {
{ id: "assets", label: "دارایی‌ها", href: "/accounting/assets", percent: 90, state: "complete" }, id: "suppliers",
{ id: "payroll", label: "حقوق", href: "/accounting/payroll", percent: 88, state: "complete" }, label: "تأمین‌کنندگان",
href: "/accounting/suppliers",
percent: 80,
state: "partial",
note: "لیست/ایجاد؛ PATCH بک‌اند ندارد",
},
{
id: "sales",
label: "فروش حسابداری",
href: "/accounting/sales/invoices",
percent: 85,
state: "partial",
note: "فاکتور + preview/post؛ فرصت/سفارش هنوز ops",
},
{
id: "purchase",
label: "خرید/انبار",
href: "/accounting/purchase/goods-receipts",
percent: 85,
state: "partial",
note: "رسید کالا + valuation متصل",
},
{
id: "assets",
label: "دارایی‌ها",
href: "/accounting/assets",
percent: 88,
state: "partial",
note: "ثبت/استهلاک/برنامه؛ انتقال/فروش ops",
},
{ id: "payroll", label: "حقوق", href: "/accounting/payroll", percent: 90, state: "complete" },
{ {
id: "reports", id: "reports",
label: "گزارش‌ها", label: "گزارش‌ها",
href: "/accounting/reports", href: "/accounting/reports",
percent: 92, percent: 90,
state: "complete", state: "partial",
note: "تراز/ترازنامه/سود و زیان زنده؛ سایر انواع ops",
}, },
{ id: "audit", label: "حسابرسی", href: "/accounting/audit", percent: 88, state: "complete" }, { id: "audit", label: "حسابرسی", href: "/accounting/audit", percent: 90, state: "complete" },
{ id: "settings", label: "تنظیمات", href: "/accounting/settings", percent: 85, state: "complete" }, {
id: "compliance",
label: "انطباق",
href: "/accounting/compliance/policies",
percent: 82,
state: "partial",
note: "سیاست/ریسک متصل؛ SoD هنوز ops",
},
{ id: "settings", label: "تنظیمات", href: "/accounting/settings", percent: 80, state: "partial" },
{ id: "setup", label: "راه‌اندازی", href: "/accounting/setup", percent: 95, state: "complete" }, { id: "setup", label: "راه‌اندازی", href: "/accounting/setup", percent: 95, state: "complete" },
{ {
id: "ai", id: "ai",