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:
parent
735f343e0d
commit
067b499193
@ -184,6 +184,36 @@ async def reject_request(
|
||||
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)
|
||||
async def create_risk(
|
||||
body: RiskCreate,
|
||||
|
||||
@ -59,6 +59,16 @@ async def create_purchase_profile(
|
||||
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")
|
||||
async def preview_goods_receipt(
|
||||
body: GoodsReceiptRequest,
|
||||
|
||||
@ -216,6 +216,36 @@ async def create_receivable_invoice(
|
||||
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")
|
||||
async def create_settlement(
|
||||
body: SettlementCreate,
|
||||
|
||||
@ -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
|
||||
116
docs/accounting-frontend-integration-report.md
Normal file
116
docs/accounting-frontend-integration-report.md
Normal file
@ -0,0 +1,116 @@
|
||||
# Accounting Frontend Integration Report
|
||||
|
||||
> Phase: Enterprise Accounting Frontend Integration (connect FE ↔ existing BE 5.1–5.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`.
|
||||
@ -3,30 +3,31 @@
|
||||
> Source of truth for enterprise completion. Updated after every module gate.
|
||||
> States: **Complete** | **Partial** | **Missing** | **Blocked**
|
||||
|
||||
Last updated: 2026-07-24
|
||||
Last updated: 2026-07-25 (Frontend Integration)
|
||||
|
||||
## Overall
|
||||
|
||||
| Module | % | Gate |
|
||||
|--------|---|------|
|
||||
| Dashboard | 90% | Complete |
|
||||
| Dashboard | 95% | Complete |
|
||||
| Chart of Accounts | 98% | Complete |
|
||||
| Fiscal | 98% | Complete |
|
||||
| Currencies | 98% | Complete |
|
||||
| Cost Centers | 95% | Complete |
|
||||
| Projects | 95% | Complete |
|
||||
| Vouchers | 95% | Complete |
|
||||
| Vouchers | 96% | Complete |
|
||||
| Ledger | 95% | Complete |
|
||||
| Treasury | 90% | Complete |
|
||||
| Treasury | 88% | Partial |
|
||||
| Customers / AR | 92% | Complete |
|
||||
| Suppliers / AP | 85% | Complete |
|
||||
| Assets | 90% | Complete |
|
||||
| Payroll | 88% | Complete |
|
||||
| Reports | 92% | Complete |
|
||||
| Compliance / Audit | 88% | Complete |
|
||||
| Settings | 85% | Complete (IAM deep-links) |
|
||||
| Suppliers / AP | 80% | Partial |
|
||||
| Sales accounting | 85% | Partial |
|
||||
| Purchase / Inventory | 85% | Partial |
|
||||
| Assets | 88% | Partial |
|
||||
| Payroll | 90% | Complete |
|
||||
| Reports | 90% | Partial |
|
||||
| Compliance / Audit | 82–90% | Partial / Complete |
|
||||
| Settings | 80% | Partial |
|
||||
| Onboarding / Setup | 95% | Complete |
|
||||
| Mobile / Nav polish | 90% | Complete |
|
||||
| AI | 0% | Blocked (provider Planned) |
|
||||
|
||||
## Category legend
|
||||
@ -36,15 +37,20 @@ API · CRUD · Routing · Nav · Dashboard · Forms · Dialogs · Drawers · Tab
|
||||
## Module notes
|
||||
|
||||
- **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/*`.
|
||||
- **Treasury**: cash boxes, banks, bank accounts, cash receipts.
|
||||
- **Customers**: edit drawer, aging, receivable invoices; suppliers list/create.
|
||||
- **Assets**: categories, activate, depreciate, schedule.
|
||||
- **Payroll**: departments, periods, calculate, post.
|
||||
- **Reports**: trial balance / BS / IS from reporting API; structured display of `data`.
|
||||
- **Setup**: `/api/v1/setup/status`, skippable tour, COA template import.
|
||||
- **Treasury**: cash boxes, banks, receipts/payments (cash/bank binding), transfers, recon, cheques; guarantees/facilities still ops.
|
||||
- **Customers**: edit drawer, aging, receivable invoices; settlements via `/ar-ap/settlements`.
|
||||
- **Sales**: invoice docs + `/sales-accounting` preview/post; other sales nav still ops.
|
||||
- **Purchase/Inventory**: goods receipt preview/post + valuation; item/warehouse masters via `/ops`.
|
||||
- **Assets**: categories, activate, depreciate, schedule page; transfer/dispose ops.
|
||||
- **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.
|
||||
- **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).
|
||||
|
||||
136
docs/progress.md
136
docs/progress.md
@ -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.1–5.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 ✅
|
||||
|
||||
- [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.0–8.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.0–9.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
|
||||
|
||||
- [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.2](crm-phase-6-2.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)
|
||||
|
||||
@ -1,13 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { BusinessDocsPage } from "@/components/accounting/BusinessDocsPage";
|
||||
import { AssetSchedulePage } from "@/components/accounting/DomainScreens";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<BusinessDocsPage
|
||||
title="برنامه استهلاک"
|
||||
module="assets"
|
||||
docType="schedule"
|
||||
/>
|
||||
);
|
||||
return <AssetSchedulePage />;
|
||||
}
|
||||
|
||||
@ -1,13 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { BusinessDocsPage } from "@/components/accounting/BusinessDocsPage";
|
||||
import { CompliancePoliciesPage } from "@/components/accounting/DomainScreens";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<BusinessDocsPage
|
||||
title="سیاستها و قوانین"
|
||||
module="compliance"
|
||||
docType="policy"
|
||||
/>
|
||||
);
|
||||
return <CompliancePoliciesPage />;
|
||||
}
|
||||
|
||||
@ -1,13 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { BusinessDocsPage } from "@/components/accounting/BusinessDocsPage";
|
||||
import { ComplianceRisksPage } from "@/components/accounting/DomainScreens";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<BusinessDocsPage
|
||||
title="مدیریت ریسک"
|
||||
module="compliance"
|
||||
docType="risk"
|
||||
/>
|
||||
);
|
||||
return <ComplianceRisksPage />;
|
||||
}
|
||||
|
||||
@ -1,13 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { BusinessDocsPage } from "@/components/accounting/BusinessDocsPage";
|
||||
import { InventoryValuationPage } from "@/components/accounting/DomainScreens";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<BusinessDocsPage
|
||||
title="ارزشگذاری موجودی"
|
||||
module="inventory"
|
||||
docType="valuation"
|
||||
/>
|
||||
);
|
||||
return <InventoryValuationPage />;
|
||||
}
|
||||
|
||||
@ -1,13 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { BusinessDocsPage } from "@/components/accounting/BusinessDocsPage";
|
||||
import { MonitoringHealthPage } from "@/components/accounting/DomainScreens";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<BusinessDocsPage
|
||||
title="وضعیت سرویس"
|
||||
module="monitoring"
|
||||
docType="health"
|
||||
/>
|
||||
);
|
||||
return <MonitoringHealthPage />;
|
||||
}
|
||||
|
||||
@ -1,13 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { BusinessDocsPage } from "@/components/accounting/BusinessDocsPage";
|
||||
import { PayrollEmployeesPage } from "@/components/accounting/DomainScreens";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<BusinessDocsPage
|
||||
title="کارمندان"
|
||||
module="payroll"
|
||||
docType="employee"
|
||||
/>
|
||||
);
|
||||
return <PayrollEmployeesPage />;
|
||||
}
|
||||
|
||||
@ -1,13 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { BusinessDocsPage } from "@/components/accounting/BusinessDocsPage";
|
||||
import { PurchaseGoodsReceiptPage } from "@/components/accounting/DomainScreens";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<BusinessDocsPage
|
||||
title="رسید کالا"
|
||||
module="purchase"
|
||||
docType="goods_receipt"
|
||||
/>
|
||||
);
|
||||
return <PurchaseGoodsReceiptPage />;
|
||||
}
|
||||
|
||||
@ -1,13 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { BusinessDocsPage } from "@/components/accounting/BusinessDocsPage";
|
||||
import { SettlementsPage } from "@/components/accounting/DomainScreens";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<BusinessDocsPage
|
||||
title="تسویه با تأمینکننده"
|
||||
module="purchase"
|
||||
docType="settlement"
|
||||
/>
|
||||
);
|
||||
return <SettlementsPage partyType="supplier" />;
|
||||
}
|
||||
|
||||
@ -1,13 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { BusinessDocsPage } from "@/components/accounting/BusinessDocsPage";
|
||||
import { SalesInvoiceAccountingPage } from "@/components/accounting/DomainScreens";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<BusinessDocsPage
|
||||
title="فاکتور فروش"
|
||||
module="sales"
|
||||
docType="invoice"
|
||||
/>
|
||||
);
|
||||
return <SalesInvoiceAccountingPage />;
|
||||
}
|
||||
|
||||
@ -1,13 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { BusinessDocsPage } from "@/components/accounting/BusinessDocsPage";
|
||||
import { SettlementsPage } from "@/components/accounting/DomainScreens";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<BusinessDocsPage
|
||||
title="تسویه حساب مشتریان"
|
||||
module="sales"
|
||||
docType="settlement"
|
||||
/>
|
||||
);
|
||||
return <SettlementsPage partyType="customer" />;
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useMemo } from "react";
|
||||
import { Suspense, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
@ -17,6 +17,7 @@ import {
|
||||
ErrorState,
|
||||
Badge,
|
||||
JalaliDateText,
|
||||
Pagination,
|
||||
} from "@/components/ds";
|
||||
|
||||
const STATUS_TITLES: Record<string, string> = {
|
||||
@ -29,10 +30,12 @@ function VouchersList() {
|
||||
const { tenantId } = useTenantId();
|
||||
const sp = useSearchParams();
|
||||
const statusFilter = sp.get("status") || "";
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 50;
|
||||
|
||||
const listQ = useQuery({
|
||||
queryKey: ["accounting", tenantId, "vouchers"],
|
||||
queryFn: () => accountingApi.vouchers.list(tenantId!),
|
||||
queryKey: ["accounting", tenantId, "vouchers", page, pageSize],
|
||||
queryFn: () => accountingApi.vouchers.list(tenantId!, page, pageSize),
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -6,7 +6,7 @@ import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Eye, Pencil, Plus } from "lucide-react";
|
||||
import { accountingApi } from "@/lib/accounting-api";
|
||||
import { useTenantId } from "@/hooks/useTenantId";
|
||||
import { formatMoney } from "@/lib/utils";
|
||||
@ -54,6 +54,8 @@ export function BusinessDocsPage({
|
||||
const qc = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
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({
|
||||
queryKey: ["accounting", tenantId, "ops-docs", module, docType],
|
||||
@ -71,6 +73,16 @@ export function BusinessDocsPage({
|
||||
description: "",
|
||||
},
|
||||
});
|
||||
const editForm = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
number: "",
|
||||
doc_date: "",
|
||||
party_name: "",
|
||||
amount: "0",
|
||||
description: "",
|
||||
},
|
||||
});
|
||||
|
||||
const createM = useMutation({
|
||||
mutationFn: (d: FormValues) =>
|
||||
@ -109,6 +121,33 @@ export function BusinessDocsPage({
|
||||
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 (listQ.error) {
|
||||
return <ErrorState message={listQ.error.message} onRetry={() => listQ.refetch()} />;
|
||||
@ -151,13 +190,24 @@ export function BusinessDocsPage({
|
||||
{
|
||||
key: "id",
|
||||
header: "عملیات",
|
||||
render: (r) =>
|
||||
r.status === "draft" ? (
|
||||
render: (r) => (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" size="sm" variant="ghost" onClick={() => setDetailRow(r)}>
|
||||
<Eye className="h-4 w-4" />
|
||||
جزئیات
|
||||
</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>
|
||||
),
|
||||
},
|
||||
]}
|
||||
@ -201,6 +251,51 @@ export function BusinessDocsPage({
|
||||
</form>
|
||||
</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
|
||||
open={!!confirmId}
|
||||
onClose={() => setConfirmId(null)}
|
||||
@ -581,6 +676,8 @@ export function ReceiptsPaymentsPage() {
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
amount: "0",
|
||||
payment_method: "cash",
|
||||
cash_box_id: "",
|
||||
bank_account_id: "",
|
||||
description: "",
|
||||
},
|
||||
});
|
||||
@ -595,6 +692,16 @@ export function ReceiptsPaymentsPage() {
|
||||
queryFn: () => accountingApi.treasury.listPayments(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({
|
||||
mutationFn: async (d: {
|
||||
@ -602,6 +709,8 @@ export function ReceiptsPaymentsPage() {
|
||||
date: string;
|
||||
amount: string;
|
||||
payment_method: string;
|
||||
cash_box_id?: string;
|
||||
bank_account_id?: string;
|
||||
description?: string;
|
||||
}) => {
|
||||
if (mode === "receipt") {
|
||||
@ -610,6 +719,8 @@ export function ReceiptsPaymentsPage() {
|
||||
receipt_date: d.date,
|
||||
amount: d.amount,
|
||||
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,
|
||||
});
|
||||
}
|
||||
@ -618,6 +729,8 @@ export function ReceiptsPaymentsPage() {
|
||||
payment_date: d.date,
|
||||
amount: d.amount,
|
||||
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,
|
||||
});
|
||||
},
|
||||
@ -631,11 +744,11 @@ export function ReceiptsPaymentsPage() {
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
if (!tenantId || receiptsQ.isLoading || paymentsQ.isLoading) return <LoadingState />;
|
||||
if (receiptsQ.error || paymentsQ.error) {
|
||||
if (!tenantId || receiptsQ.isLoading || paymentsQ.isLoading || cashBoxesQ.isLoading || bankAccountsQ.isLoading) return <LoadingState />;
|
||||
if (receiptsQ.error || paymentsQ.error || cashBoxesQ.error || bankAccountsQ.error) {
|
||||
return (
|
||||
<ErrorState
|
||||
message={(receiptsQ.error || paymentsQ.error)!.message}
|
||||
message={(receiptsQ.error || paymentsQ.error || cashBoxesQ.error || bankAccountsQ.error)!.message}
|
||||
onRetry={() => {
|
||||
receiptsQ.refetch();
|
||||
paymentsQ.refetch();
|
||||
@ -742,6 +855,22 @@ export function ReceiptsPaymentsPage() {
|
||||
<option value="cheque">چک</option>
|
||||
</Select>
|
||||
</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">
|
||||
<FormField label="توضیح">
|
||||
<Input {...form.register("description")} />
|
||||
|
||||
560
frontend/components/accounting/DomainScreens.tsx
Normal file
560
frontend/components/accounting/DomainScreens.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -884,6 +884,23 @@ export const accountingApi = {
|
||||
method: "POST",
|
||||
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) =>
|
||||
request<{ id: string; status: string }>(`/api/v1/ops/documents/${id}/confirm`, {
|
||||
tenantId,
|
||||
@ -1142,5 +1159,205 @@ export const accountingApi = {
|
||||
method: "POST",
|
||||
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),
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
@ -13,28 +13,74 @@ export type 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: "fiscal", label: "سال و دوره مالی", href: "/accounting/fiscal", 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: "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: "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: "suppliers", label: "تأمینکنندگان", href: "/accounting/suppliers", percent: 85, state: "complete" },
|
||||
{ id: "assets", label: "داراییها", href: "/accounting/assets", percent: 90, state: "complete" },
|
||||
{ id: "payroll", label: "حقوق", href: "/accounting/payroll", percent: 88, state: "complete" },
|
||||
{
|
||||
id: "suppliers",
|
||||
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",
|
||||
label: "گزارشها",
|
||||
href: "/accounting/reports",
|
||||
percent: 92,
|
||||
state: "complete",
|
||||
percent: 90,
|
||||
state: "partial",
|
||||
note: "تراز/ترازنامه/سود و زیان زنده؛ سایر انواع ops",
|
||||
},
|
||||
{ id: "audit", label: "حسابرسی", href: "/accounting/audit", percent: 88, state: "complete" },
|
||||
{ id: "settings", label: "تنظیمات", href: "/accounting/settings", percent: 85, state: "complete" },
|
||||
{ id: "audit", label: "حسابرسی", href: "/accounting/audit", percent: 90, 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: "ai",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user