progress in wallet and dashboard

This commit is contained in:
Hesabix 2025-11-09 18:37:27 +00:00
parent b0884a33fd
commit 192f8776e3
18 changed files with 3519 additions and 643 deletions

View file

@ -11,6 +11,14 @@ from app.core.permissions import require_business_access
from app.services.business_dashboard_service import (
get_business_dashboard_data, get_business_members, get_business_statistics
)
from app.services.dashboard_widgets_service import (
get_widget_definitions,
get_dashboard_layout_profile,
save_dashboard_layout_profile,
get_widgets_batch_data,
get_business_default_layout,
save_business_default_layout,
)
router = APIRouter(prefix="/business", tags=["business-dashboard"])
@ -366,3 +374,132 @@ def get_business_info_with_permissions(
formatted_data = format_datetime_fields(response_data, request)
return success_response(formatted_data, request)
# === Dashboard Widgets (Responsive/Per-User) ===
@router.get("/{business_id}/dashboard/widgets/definitions",
summary="تعاریف ویجت‌های داشبورد",
description="لیست ویجت‌های قابل استفاده برای کاربر فعلی (بر اساس مجوزها) + ستون‌بندی رسپانسیو",
response_model=SuccessResponse,
)
@require_business_access("business_id")
def list_dashboard_widget_definitions(
request: Request,
business_id: int,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict:
data = get_widget_definitions(db=db, business_id=business_id, user_id=ctx.get_user_id())
return success_response(data, request)
@router.get("/{business_id}/dashboard/layout",
summary="دریافت چیدمان داشبورد (پروفایل رسپانسیو)",
description="چیدمان کاربر برای یک breakpoint مشخص را برمی‌گرداند. در نبود، از پیش‌فرض سیستم استفاده می‌کند.",
response_model=SuccessResponse,
)
@require_business_access("business_id")
def get_dashboard_layout(
request: Request,
business_id: int,
breakpoint: str = "md",
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict:
profile = get_dashboard_layout_profile(
db=db,
business_id=business_id,
user_id=ctx.get_user_id(),
breakpoint=breakpoint,
)
return success_response(profile, request)
@router.put("/{business_id}/dashboard/layout",
summary="ذخیره چیدمان داشبورد (پروفایل رسپانسیو)",
description="چیدمان کاربر برای breakpoint مشخص را ذخیره می‌کند.",
response_model=SuccessResponse,
)
@require_business_access("business_id")
def put_dashboard_layout(
request: Request,
business_id: int,
payload: dict,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict:
breakpoint = str(payload.get("breakpoint") or "md")
items = payload.get("items") or []
result = save_dashboard_layout_profile(
db=db,
business_id=business_id,
user_id=ctx.get_user_id(),
breakpoint=breakpoint,
items=items,
)
return success_response(result, request)
@router.post("/{business_id}/dashboard/data",
summary="دریافت داده‌ی ویجت‌ها (Batch)",
description="کلیدهای ویجت را می‌گیرد و داده‌ی هر ویجت را در یک پاسخ برمی‌گرداند.",
response_model=SuccessResponse,
)
@require_business_access("business_id")
def post_dashboard_widgets_data(
request: Request,
business_id: int,
payload: dict,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict:
widget_keys = payload.get("widget_keys") or []
filters = payload.get("filters") or {}
data = get_widgets_batch_data(
db=db,
business_id=business_id,
user_id=ctx.get_user_id(),
widget_keys=[str(k) for k in widget_keys],
filters=filters,
)
formatted = format_datetime_fields(data, request)
return success_response(formatted, request)
@router.get("/{business_id}/dashboard/layout/default",
summary="پیش‌فرض چیدمان کسب‌وکار (GET)",
description="چیدمان پیش‌فرض منتشر شده توسط مالک کسب‌وکار را برمی‌گرداند (در صورت وجود).",
response_model=SuccessResponse,
)
@require_business_access("business_id")
def get_business_default_dashboard_layout(
request: Request,
business_id: int,
breakpoint: str = "md",
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict:
profile = get_business_default_layout(db=db, business_id=business_id, breakpoint=breakpoint)
return success_response(profile or {}, request)
@router.put("/{business_id}/dashboard/layout/default",
summary="انتشار چیدمان پیش‌فرض کسب‌وکار (PUT)",
description="مالک کسب‌وکار می‌تواند چیدمان پیش‌فرض را برای breakpoint مشخص منتشر کند.",
response_model=SuccessResponse,
)
@require_business_access("business_id")
def put_business_default_dashboard_layout(
request: Request,
business_id: int,
payload: dict,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict:
# فقط مالک کسب‌وکار
if not ctx.is_business_owner(business_id):
raise HTTPException(status_code=403, detail="Only business owner can publish default layout")
breakpoint = str(payload.get("breakpoint") or "md")
items = payload.get("items") or []
result = save_business_default_layout(db=db, business_id=business_id, breakpoint=breakpoint, items=items)
return success_response(result, request)

View file

@ -8,7 +8,7 @@ from sqlalchemy.orm import Session
from adapters.db.session import get_db
from app.core.auth_dependency import get_current_user, AuthContext
from app.core.permissions import require_business_access
from app.core.permissions import require_business_access_dep
from app.core.responses import success_response, ApiError
from app.services.wallet_service import (
get_wallet_overview,
@ -22,6 +22,8 @@ from app.services.wallet_service import (
update_business_wallet_settings,
run_auto_settlement,
)
from adapters.db.models.wallet import WalletPayout
from fastapi import Query
router = APIRouter(prefix="/businesses/{business_id}/wallet", tags=["wallet"])
@ -35,6 +37,7 @@ router = APIRouter(prefix="/businesses/{business_id}/wallet", tags=["wallet"])
def get_wallet_overview_endpoint(
request: Request,
business_id: int,
_: None = Depends(require_business_access_dep),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> dict:
@ -50,6 +53,7 @@ def create_top_up_endpoint(
request: Request,
business_id: int,
payload: Dict[str, Any] = Body(...),
_: None = Depends(require_business_access_dep),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> dict:
@ -69,6 +73,7 @@ def list_wallet_transactions_endpoint(
limit: int = 50,
from_date: str | None = None,
to_date: str | None = None,
_: None = Depends(require_business_access_dep),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> dict:
@ -86,6 +91,66 @@ def list_wallet_transactions_endpoint(
return success_response(data, request)
@router.post(
"/transactions/table",
summary="لیست تراکنش‌ها برای جدول عمومی (پگینیشن استاندارد)",
description="سازگار با DataTableWidget: ورودی QueryInfo و خروجی items/total/page/limit",
)
def list_wallet_transactions_table_endpoint(
request: Request,
business_id: int,
payload: Dict[str, Any] = Body(default_factory=dict),
_: None = Depends(require_business_access_dep),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> dict:
# Extract pagination params
take = int(payload.get("take") or 20)
skip = int(payload.get("skip") or 0)
# Optional date range via additional params or filters (best-effort)
from_dt = None
to_dt = None
try:
filters = payload.get("filters") or []
# Try to detect date range filters by common keys
for f in filters:
prop = str(f.get("property") or "").lower()
op = str(f.get("operator") or "")
val = f.get("value")
if prop in ("created_at", "date", "transaction_date"):
if op == ">=" and val:
from_dt = datetime.fromisoformat(str(val))
elif op == "<=" and val:
to_dt = datetime.fromisoformat(str(val))
except Exception:
from_dt = None
to_dt = None
items = list_wallet_transactions(db, business_id, limit=take, skip=skip, from_date=from_dt, to_date=to_dt)
# Compute total (simple count for business)
try:
from adapters.db.models.wallet import WalletTransaction
q = db.query(WalletTransaction).filter(WalletTransaction.business_id == int(business_id))
if from_dt is not None:
from adapters.db.models.wallet import WalletTransaction as WT
q = q.filter(WT.created_at >= from_dt)
if to_dt is not None:
from adapters.db.models.wallet import WalletTransaction as WT2
q = q.filter(WT2.created_at <= to_dt)
total = q.count()
except Exception:
total = len(items)
page = (skip // take) + 1 if take > 0 else 1
total_pages = (total + take - 1) // take if take > 0 else 1
resp = {
"items": items,
"total": total,
"page": page,
"limit": take,
"total_pages": total_pages,
}
return success_response(resp, request)
@router.get(
"/transactions/export",
summary="خروجی CSV تراکنش‌های کیف‌پول",
@ -95,6 +160,7 @@ def export_wallet_transactions_csv_endpoint(
business_id: int,
from_date: str | None = None,
to_date: str | None = None,
_: None = Depends(require_business_access_dep),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
):
@ -131,6 +197,7 @@ def export_wallet_metrics_csv_endpoint(
business_id: int,
from_date: str | None = None,
to_date: str | None = None,
_: None = Depends(require_business_access_dep),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
):
@ -174,6 +241,7 @@ def create_payout_request_endpoint(
request: Request,
business_id: int,
payload: Dict[str, Any] = Body(...),
_: None = Depends(require_business_access_dep),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> dict:
@ -191,6 +259,7 @@ def get_wallet_metrics_endpoint(
business_id: int,
from_date: str | None = None,
to_date: str | None = None,
_: None = Depends(require_business_access_dep),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> dict:
@ -215,6 +284,7 @@ def get_wallet_metrics_endpoint(
def get_wallet_settings_business_endpoint(
request: Request,
business_id: int,
_: None = Depends(require_business_access_dep),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> dict:
@ -230,6 +300,7 @@ def update_wallet_settings_business_endpoint(
request: Request,
business_id: int,
payload: Dict[str, Any] = Body(...),
_: None = Depends(require_business_access_dep),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> dict:
@ -244,6 +315,7 @@ def update_wallet_settings_business_endpoint(
def run_auto_settle_endpoint(
request: Request,
business_id: int,
_: None = Depends(require_business_access_dep),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> dict:
@ -259,10 +331,16 @@ def approve_payout_request_endpoint(
request: Request,
business_id: int,
payout_id: int = Path(...),
_: None = Depends(require_business_access_dep),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> dict:
# Permission check could be refined (e.g., wallet.approve)
# Ensure payout belongs to the same business
payout = db.query(WalletPayout).filter(WalletPayout.id == int(payout_id)).first()
if not payout:
raise ApiError("PAYOUT_NOT_FOUND", "درخواست تسویه یافت نشد", http_status=404)
if int(payout.business_id) != int(business_id):
raise ApiError("FORBIDDEN", "دسترسی به این درخواست تسویه مجاز نیست", http_status=403)
data = approve_payout_request(db, payout_id, ctx.get_user_id())
return success_response(data, request, message="PAYOUT_APPROVED")
@ -276,9 +354,16 @@ def cancel_payout_request_endpoint(
request: Request,
business_id: int,
payout_id: int = Path(...),
_: None = Depends(require_business_access_dep),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> dict:
# Ensure payout belongs to the same business
payout = db.query(WalletPayout).filter(WalletPayout.id == int(payout_id)).first()
if not payout:
raise ApiError("PAYOUT_NOT_FOUND", "درخواست تسویه یافت نشد", http_status=404)
if int(payout.business_id) != int(business_id):
raise ApiError("FORBIDDEN", "دسترسی به این درخواست تسویه مجاز نیست", http_status=403)
data = cancel_payout_request(db, payout_id, ctx.get_user_id())
return success_response(data, request, message="PAYOUT_CANCELED")

View file

@ -1,13 +1,18 @@
from __future__ import annotations
from typing import Dict, Any
import os
import hmac
import hashlib
import json
from fastapi import APIRouter, Depends, Request, Body
from sqlalchemy.orm import Session
from adapters.db.session import get_db
from app.core.responses import success_response
from app.core.responses import success_response, ApiError
from app.services.wallet_service import confirm_top_up
from adapters.db.models.wallet import WalletTransaction
router = APIRouter(prefix="/wallet", tags=["wallet-webhook"])
@ -23,11 +28,46 @@ def wallet_webhook_endpoint(
payload: Dict[str, Any] = Body(...),
db: Session = Depends(get_db),
) -> dict:
# توجه: در محیط واقعی باید امضای وبهوک و ضد تکرار بودن بررسی شود
# امضای وبهوک (اختیاری بر اساس تنظیم محیط)
secret = os.getenv("WALLET_WEBHOOK_SECRET", "").strip()
if secret:
signature = request.headers.get("x-signature") or request.headers.get("X-Signature")
if not signature:
raise ApiError("INVALID_SIGNATURE", "امضای وبهوک ارسال نشده است", http_status=403)
try:
body_str = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
digest = hmac.new(secret.encode("utf-8"), body_str.encode("utf-8"), hashlib.sha256).hexdigest()
if not hmac.compare_digest(digest, str(signature).strip()):
raise ApiError("INVALID_SIGNATURE", "امضای وبهوک نامعتبر است", http_status=403)
except ApiError:
raise
except Exception:
raise ApiError("INVALID_SIGNATURE", "خطا در اعتبارسنجی امضا", http_status=403)
tx_id = int(payload.get("transaction_id") or 0)
status = str(payload.get("status") or "").lower()
success = status in ("success", "succeeded", "ok")
external_ref = str(payload.get("external_ref") or "")
nonce = str(payload.get("nonce") or "")
# ضد تکرار ساده: ذخیره آخرین nonce در extra_info تراکنش و رد تکراری
try:
tx = db.query(WalletTransaction).filter(WalletTransaction.id == int(tx_id)).first()
if tx and nonce:
try:
extra = json.loads(tx.extra_info or "{}") if tx.extra_info else {}
except Exception:
extra = {}
prev_nonce = str(extra.get("last_webhook_nonce") or "")
if prev_nonce and prev_nonce == nonce:
# تراکنش تکراری؛ بدون تغییر وضعیت پاسخ می‌دهیم
return success_response({"transaction_id": tx_id, "status": tx.status}, request, message="DUPLICATE_WEBHOOK_IGNORED")
extra["last_webhook_nonce"] = nonce
tx.extra_info = json.dumps(extra, ensure_ascii=False)
db.flush()
except Exception:
# عدم موفقیت در ضدتکرار نباید مانع مسیر اصلی شود؛ confirm_top_up ایدم‌پوتنت است
pass
# اختیاری: دریافت کارمزد از وبهوک و نگهداری در تراکنش (fee_amount)
try:
fee_value = payload.get("fee_amount")

View file

@ -5,6 +5,8 @@ import inspect
from fastapi import Depends
from app.core.auth_dependency import get_current_user, AuthContext
from app.core.responses import ApiError
from adapters.db.session import get_db
from fastapi import Request
def require_app_permission(permission: str):
@ -232,8 +234,16 @@ def require_business_management_dep(auth_context: AuthContext = Depends(get_curr
raise ApiError("FORBIDDEN", "Missing app permission: business_management", http_status=403)
def require_business_access_dep(auth_context: AuthContext = Depends(get_current_user)) -> None:
"""FastAPI dependency برای بررسی دسترسی به کسب و کار."""
# در اینجا می‌توانید منطق بررسی دسترسی به کسب و کار را پیاده‌سازی کنید
# برای مثال: بررسی اینکه آیا کاربر دسترسی به کسب و کار دارد
pass
def require_business_access_dep(request: Request, db=Depends(get_db)) -> None:
"""FastAPI dependency برای بررسی دسترسی به کسب‌وکار در مسیرهای دارای business_id."""
ctx = get_current_user(request, db)
business_id = None
try:
business_id = request.path_params.get("business_id")
except Exception:
business_id = None
if business_id is None:
# اگر مسیر business_id ندارد، عبور می‌کنیم
return
if not ctx.can_access_business(int(business_id)):
raise ApiError("FORBIDDEN", f"No access to business {business_id}", http_status=403)

View file

@ -0,0 +1,439 @@
from __future__ import annotations
from typing import Any, Dict, List, Callable
from datetime import datetime
from sqlalchemy.orm import Session
from sqlalchemy import and_, func
from adapters.db.models.document import Document
from adapters.db.models.invoice_item_line import InvoiceItemLine
from adapters.db.models.currency import Currency
from app.services.invoice_service import INVOICE_SALES
# ----------------------------
# Responsive columns per breakpoint
# ----------------------------
COLUMNS_BY_BREAKPOINT: Dict[str, int] = {
"xs": 4,
"sm": 6,
"md": 8,
"lg": 12,
"xl": 12,
}
# ----------------------------
# Widget Definitions (Server-side)
# ----------------------------
DEFAULT_WIDGET_DEFINITIONS: List[Dict[str, Any]] = [
{
"key": "latest_sales_invoices",
"title": "آخرین فاکتورهای فروش",
"icon": "receipt_long",
"version": 1,
"permissions_required": ["invoices.view"],
"defaults": {
# default colSpan/rowSpan per breakpoint
"xs": {"colSpan": 4, "rowSpan": 3},
"sm": {"colSpan": 6, "rowSpan": 3},
"md": {"colSpan": 4, "rowSpan": 3},
"lg": {"colSpan": 4, "rowSpan": 3},
"xl": {"colSpan": 4, "rowSpan": 3},
},
"cache_ttl": 30, # seconds (hint)
},
{
"key": "sales_bar_chart",
"title": "نمودار فروش",
"icon": "bar_chart",
"version": 1,
"permissions_required": ["invoices.view"],
"defaults": {
"xs": {"colSpan": 4, "rowSpan": 4},
"sm": {"colSpan": 6, "rowSpan": 4},
"md": {"colSpan": 8, "rowSpan": 4},
"lg": {"colSpan": 12, "rowSpan": 4},
"xl": {"colSpan": 12, "rowSpan": 4},
},
"cache_ttl": 15,
},
]
def get_widget_definitions(db: Session, business_id: int, user_id: int) -> Dict[str, Any]:
"""
Returns available widgets for current user/business along with responsive columns map.
NOTE: Permission filtering can be added by checking user's business permissions.
"""
return {
"columns": COLUMNS_BY_BREAKPOINT,
"items": DEFAULT_WIDGET_DEFINITIONS,
}
# ----------------------------
# Layout Storage (in-memory for now)
# In production, persist in DB (e.g., dashboard_layouts table or settings)
# ----------------------------
_IN_MEMORY_LAYOUTS: Dict[str, Dict[str, Any]] = {}
_IN_MEMORY_DEFAULTS: Dict[str, Dict[str, Any]] = {}
def _layout_key(business_id: int, user_id: int, breakpoint: str) -> str:
return f"{business_id}:{user_id}:{breakpoint}"
def _default_key(business_id: int, breakpoint: str) -> str:
return f"{business_id}:DEFAULT:{breakpoint}"
def get_dashboard_layout_profile(
db: Session,
business_id: int,
user_id: int,
breakpoint: str,
) -> Dict[str, Any]:
"""
Returns a profile for the requested breakpoint:
{ breakpoint, columns, items: [{ key, order, colSpan, rowSpan, hidden }] }
"""
bp = (breakpoint or "md").lower()
if bp not in COLUMNS_BY_BREAKPOINT:
bp = "md"
key = _layout_key(business_id, user_id, bp)
found = _IN_MEMORY_LAYOUTS.get(key)
if found:
return found
# Build default layout from definitions
columns = COLUMNS_BY_BREAKPOINT[bp]
items: List[Dict[str, Any]] = []
order = 1
for d in DEFAULT_WIDGET_DEFINITIONS:
defaults = (d.get("defaults") or {}).get(bp) or {}
items.append({
"key": d["key"],
"order": order,
"colSpan": int(defaults.get("colSpan", max(1, columns // 2))),
"rowSpan": int(defaults.get("rowSpan", 2)),
"hidden": False,
})
order += 1
profile = {
"breakpoint": bp,
"columns": columns,
"items": items,
"version": 2,
"updated_at": datetime.utcnow().isoformat() + "Z",
}
_IN_MEMORY_LAYOUTS[key] = profile
return profile
def save_dashboard_layout_profile(
db: Session,
business_id: int,
user_id: int,
breakpoint: str,
items: List[Dict[str, Any]],
) -> Dict[str, Any]:
bp = (breakpoint or "md").lower()
if bp not in COLUMNS_BY_BREAKPOINT:
bp = "md"
columns = COLUMNS_BY_BREAKPOINT[bp]
sanitized: List[Dict[str, Any]] = []
for it in (items or []):
try:
key = str(it.get("key"))
order = int(it.get("order", 1))
col_span = max(1, min(columns, int(it.get("colSpan", 1))))
row_span = int(it.get("rowSpan", 1))
hidden = bool(it.get("hidden", False))
sanitized.append({
"key": key,
"order": order,
"colSpan": col_span,
"rowSpan": row_span,
"hidden": hidden,
})
except Exception:
continue
profile = {
"breakpoint": bp,
"columns": columns,
"items": sorted(sanitized, key=lambda x: x.get("order", 1)),
"version": 2,
"updated_at": datetime.utcnow().isoformat() + "Z",
}
_IN_MEMORY_LAYOUTS[_layout_key(business_id, user_id, bp)] = profile
return profile
def get_business_default_layout(
db: Session,
business_id: int,
breakpoint: str,
) -> Dict[str, Any] | None:
bp = (breakpoint or "md").lower()
if bp not in COLUMNS_BY_BREAKPOINT:
bp = "md"
key = _default_key(business_id, bp)
return _IN_MEMORY_DEFAULTS.get(key)
def save_business_default_layout(
db: Session,
business_id: int,
breakpoint: str,
items: List[Dict[str, Any]],
) -> Dict[str, Any]:
bp = (breakpoint or "md").lower()
if bp not in COLUMNS_BY_BREAKPOINT:
bp = "md"
columns = COLUMNS_BY_BREAKPOINT[bp]
sanitized: List[Dict[str, Any]] = []
for it in (items or []):
try:
key = str(it.get("key"))
order = int(it.get("order", 1))
col_span = max(1, min(columns, int(it.get("colSpan", 1))))
row_span = int(it.get("rowSpan", 1))
hidden = bool(it.get("hidden", False))
sanitized.append({
"key": key,
"order": order,
"colSpan": col_span,
"rowSpan": row_span,
"hidden": hidden,
})
except Exception:
continue
profile = {
"breakpoint": bp,
"columns": columns,
"items": sorted(sanitized, key=lambda x: x.get("order", 1)),
"version": 2,
"updated_at": datetime.utcnow().isoformat() + "Z",
}
_IN_MEMORY_DEFAULTS[_default_key(business_id, bp)] = profile
return profile
# ----------------------------
# Data resolvers (Batch)
# ----------------------------
WidgetResolver = Callable[[Session, int, int, Dict[str, Any]], Any]
def _resolve_latest_sales_invoices(
db: Session, business_id: int, user_id: int, filters: Dict[str, Any]
) -> Dict[str, Any]:
"""
Returns latest sales invoices (header-level info).
"""
limit_raw = filters.get("limit", 10)
try:
limit = max(1, min(50, int(limit_raw)))
except Exception:
limit = 10
# Fetch last N documents with currency info
q = (
db.query(
Document.id,
Document.code,
Document.document_date,
Document.created_at,
Document.currency_id,
Currency.code.label("currency_code"),
Document.extra_info,
)
.outerjoin(Currency, Currency.id == Document.currency_id)
.filter(
and_(
Document.business_id == business_id,
Document.document_type == INVOICE_SALES,
)
)
.order_by(Document.created_at.desc())
.limit(limit)
)
rows = q.all()
doc_ids = [int(r.id) for r in rows]
# Count items per document in batch
items_count_by_doc: Dict[int, int] = {}
if doc_ids:
counts = (
db.query(InvoiceItemLine.document_id, func.count(InvoiceItemLine.id))
.filter(InvoiceItemLine.document_id.in_(doc_ids))
.group_by(InvoiceItemLine.document_id)
.all()
)
for did, cnt in counts:
items_count_by_doc[int(did)] = int(cnt or 0)
items: List[Dict[str, Any]] = []
for d in rows:
extra = d.extra_info or {}
totals = (extra.get("totals") or {})
items.append({
"id": int(d.id),
"code": d.code,
"document_date": d.document_date.isoformat() if d.document_date else None,
"created_at": d.created_at.isoformat() if d.created_at else None,
"net_amount": float(totals.get("net", 0) or 0),
"currency_id": int(d.currency_id) if d.currency_id is not None else None,
"currency_code": d.currency_code,
"items_count": items_count_by_doc.get(int(d.id), 0),
})
return {"items": items}
WIDGET_RESOLVERS: Dict[str, WidgetResolver] = {
"latest_sales_invoices": _resolve_latest_sales_invoices,
"sales_bar_chart": lambda db, business_id, user_id, filters: _resolve_sales_bar_chart(db, business_id, filters),
}
def get_widgets_batch_data(
db: Session,
business_id: int,
user_id: int,
widget_keys: List[str],
filters: Dict[str, Any],
) -> Dict[str, Any]:
"""
Returns a map: { widget_key: data or error } for requested widget_keys.
"""
result: Dict[str, Any] = {}
for key in widget_keys:
resolver = WIDGET_RESOLVERS.get(key)
if not resolver:
result[key] = {"error": "UNKNOWN_WIDGET"}
continue
try:
result[key] = resolver(db, business_id, user_id, filters or {})
except Exception as ex:
# Avoid breaking the whole dashboard; return error per widget
result[key] = {"error": str(ex)}
return result
def _parse_date_str(s: str) -> datetime.date | None:
try:
from datetime import datetime as _dt
s = s.replace('Z', '')
return _dt.fromisoformat(s).date()
except Exception:
try:
from datetime import datetime as _dt
return _dt.strptime(s, "%Y-%m-%d").date()
except Exception:
return None
def _get_fiscal_range(db: Session, business_id: int) -> tuple[datetime.date, datetime.date]:
from adapters.db.models.fiscal_year import FiscalYear
fy = db.query(FiscalYear).filter(
and_(FiscalYear.business_id == business_id, FiscalYear.is_last == True) # noqa: E712
).first()
if fy and getattr(fy, "start_date", None) and getattr(fy, "end_date", None):
return (fy.start_date, fy.end_date)
# fallback: current year
today = datetime.utcnow().date()
start = datetime(today.year, 1, 1).date()
end = datetime(today.year, 12, 31).date()
return (start, end)
def _resolve_sales_bar_chart(db: Session, business_id: int, filters: Dict[str, Any]) -> Dict[str, Any]:
"""
Aggregates sales net amounts per day over a date range.
filters:
- range: 'week' | 'month' | 'fiscal' | 'custom'
- from: ISO date (YYYY-MM-DD)
- to: ISO date
"""
from datetime import timedelta
rng = str(filters.get("range") or "week").lower()
group = str(filters.get("group") or "day").lower() # day | week | month
today = datetime.utcnow().date()
start_date: datetime.date
end_date: datetime.date
if rng == "week":
# last 7 days including today
end_date = today
start_date = today - timedelta(days=6)
elif rng == "month":
end_date = today
start_date = today.replace(day=1)
elif rng == "fiscal":
start_date, end_date = _get_fiscal_range(db, business_id)
elif rng == "custom":
from_s = str(filters.get("from") or "")
to_s = str(filters.get("to") or "")
sd = _parse_date_str(from_s)
ed = _parse_date_str(to_s)
if sd is None or ed is None:
end_date = today
start_date = today - timedelta(days=6)
else:
start_date, end_date = sd, ed
else:
end_date = today
start_date = today - timedelta(days=6)
q = (
db.query(
Document.document_date,
Document.extra_info,
)
.filter(
and_(
Document.business_id == business_id,
Document.document_type == INVOICE_SALES,
Document.is_proforma == False, # noqa: E712
Document.document_date >= start_date,
Document.document_date <= end_date,
)
)
.order_by(Document.document_date.asc())
)
rows = q.all()
from collections import defaultdict
agg: Dict[str, float] = defaultdict(float)
for doc_date, extra in rows:
if not doc_date:
continue
totals = (extra or {}).get("totals") or {}
net = float(totals.get("net", 0) or 0)
if group == "month":
key = f"{doc_date.year:04d}-{doc_date.month:02d}"
elif group == "week":
# ISO week number
key = f"{doc_date.isocalendar()[0]:04d}-{doc_date.isocalendar()[1]:02d}"
else:
key = doc_date.isoformat()
agg[key] += net
data: List[Dict[str, Any]] = []
if group == "day":
# fill all dates in range
cur = start_date
while cur <= end_date:
key = cur.isoformat()
data.append({"date": key, "amount": float(agg.get(key, 0.0))})
cur += timedelta(days=1)
else:
# just return aggregated keys sorted
for key in sorted(agg.keys()):
data.append({"key": key, "amount": float(agg[key])})
return {
"items": data,
"range": rng,
"from": start_date.isoformat(),
"to": end_date.isoformat(),
"group": group,
}

View file

@ -79,9 +79,13 @@ def _initiate_zarinpal(db: Session, gw: PaymentGateway, cfg: Dict[str, Any], bus
if not merchant_id or not callback_url:
raise ApiError("INVALID_CONFIG", "merchant_id و callback_url الزامی هستند", http_status=400)
description = str(cfg.get("description") or "Wallet top-up")
api_base = str(cfg.get("api_base") or ("https://sandbox.zarinpal.com/pg/rest/WebGate" if gw.is_sandbox else "https://www.zarinpal.com/pg/rest/WebGate"))
startpay_base = str(cfg.get("startpay_base") or ("https://sandbox.zarinpal.com/pg/StartPay" if gw.is_sandbox else "https://www.zarinpal.com/pg/StartPay"))
currency = str(cfg.get("currency") or "IRR").upper()
# Prefer v4 endpoint; fallback to legacy WebGate if needed
v4_domain = "https://sandbox.zarinpal.com" if gw.is_sandbox else "https://api.zarinpal.com"
api_v4_url = str(cfg.get("api_v4_url") or f"{v4_domain}/pg/v4/payment/request.json")
legacy_base = str(cfg.get("api_base") or ("https://sandbox.zarinpal.com/pg/rest/WebGate" if gw.is_sandbox else "https://www.zarinpal.com/pg/rest/WebGate"))
legacy_url = f"{legacy_base}/PaymentRequest.json"
# StartPay host per Zarinpal docs: sandbox vs payment
startpay_base = str(cfg.get("startpay_base") or ("https://sandbox.zarinpal.com/pg/StartPay" if gw.is_sandbox else "https://payment.zarinpal.com/pg/StartPay"))
# append tx_id to callback
cb_url = callback_url
try:
@ -93,18 +97,37 @@ def _initiate_zarinpal(db: Session, gw: PaymentGateway, cfg: Dict[str, Any], bus
except Exception:
cb_url = f"{callback_url}{'&' if '?' in callback_url else '?'}tx_id={tx_id}"
# Convert amount to rial if needed (assuming system base is IRR already; adapter can be extended)
req_payload = {
# Build payloads
req_payload_v4 = {
"merchant_id": merchant_id,
"amount": int(round(float(amount))),
"callback_url": cb_url,
"description": description,
}
req_payload_legacy = {
"MerchantID": merchant_id,
"Amount": int(round(float(amount))), # expect rial
"Amount": int(round(float(amount))),
"Description": description,
"CallbackURL": cb_url,
}
authority: Optional[str] = None
try:
with httpx.Client(timeout=10.0) as client:
resp = client.post(f"{api_base}/PaymentRequest.json", json=req_payload)
data = resp.json() if resp.headers.get("content-type","").startswith("application/json") else {}
# Try v4 first
try:
resp = client.post(api_v4_url, json=req_payload_v4, headers={"Accept": "application/json", "Content-Type": "application/json"})
data = resp.json() if "application/json" in (resp.headers.get("content-type","")) else {}
d_data = data.get("data") if isinstance(data, dict) else None
code = (d_data or {}).get("code") if isinstance(d_data, dict) else None
auth_v4 = (d_data or {}).get("authority") if isinstance(d_data, dict) else None
if int(code or -1) == 100 and auth_v4:
authority = str(auth_v4)
except Exception:
authority = None
# Fallback to legacy
if not authority:
resp = client.post(legacy_url, json=req_payload_legacy, headers={"Accept": "application/json", "Content-Type": "application/json"})
data = resp.json() if "application/json" in (resp.headers.get("content-type","")) else {}
if int(data.get("Status") or -1) == 100 and data.get("Authority"):
authority = str(data["Authority"])
except Exception:
@ -140,13 +163,46 @@ def _verify_zarinpal(db: Session, params: Dict[str, Any]) -> Dict[str, Any]:
authority = str(params.get("Authority") or params.get("authority") or "").strip()
status = str(params.get("Status") or params.get("status") or "").lower()
tx_id = int(params.get("tx_id") or 0)
success = status in ("ok", "ok.", "success", "succeeded")
is_ok = status in ("ok", "ok.", "success", "succeeded")
fee_amount = None
# Optionally call VerifyPayment here if needed (requires merchant_id); skipping network verify to keep flow simple
# Confirm
ref_id = None
success = False
if tx_id > 0 and is_ok:
# Load tx and gateway to verify via v4 endpoint
tx = db.query(WalletTransaction).filter(WalletTransaction.id == int(tx_id)).first()
gateway_id = None
try:
extra = json.loads(tx.extra_info or "{}") if tx and tx.extra_info else {}
gateway_id = extra.get("gateway_id")
except Exception:
gateway_id = None
gw = db.query(PaymentGateway).filter(PaymentGateway.id == int(gateway_id)).first() if gateway_id else None
if tx and gw:
cfg = _load_config(gw)
merchant_id = str(cfg.get("merchant_id") or "").strip()
if merchant_id:
v4_domain = "https://sandbox.zarinpal.com" if gw.is_sandbox else "https://payment.zarinpal.com"
verify_url = f"{v4_domain}/pg/v4/payment/verify.json"
payload = {
"merchant_id": merchant_id,
"amount": int(round(float(tx.amount or 0))),
"authority": authority,
}
try:
with httpx.Client(timeout=10.0) as client:
resp = client.post(verify_url, json=payload, headers={"Accept": "application/json", "Content-Type": "application/json"})
data = resp.json() if "application/json" in (resp.headers.get("content-type","")) else {}
d_data = data.get("data") if isinstance(data, dict) else None
code = (d_data or {}).get("code") if isinstance(d_data, dict) else None
ref_id = (d_data or {}).get("ref_id")
fee_amount = (d_data or {}).get("fee")
success = int(code or -1) in (100, 101)
except Exception:
success = False
# Confirm or fail based on verify (or status fallback)
if tx_id > 0:
confirm_top_up(db, tx_id, success=success, external_ref=authority or None)
return {"transaction_id": tx_id, "success": success, "external_ref": authority, "fee_amount": fee_amount}
confirm_top_up(db, tx_id, success=(success or is_ok), external_ref=authority or None)
return {"transaction_id": tx_id, "success": (success or is_ok), "external_ref": authority, "fee_amount": fee_amount, "ref_id": ref_id}
# --------------------------

View file

@ -35,6 +35,33 @@ def _ensure_wallet_account(db: Session, business_id: int) -> WalletAccount:
return obj
def _get_wallet_account_for_update(db: Session, business_id: int) -> WalletAccount:
"""
قفل ردیفی روی حساب کیفپول برای جلوگیری از رقابت در بهروزرسانی ماندهها
"""
acc = (
db.query(WalletAccount)
.filter(WalletAccount.business_id == int(business_id))
.with_for_update()
.first()
)
if acc:
return acc
# اگر وجود ندارد، ایجاد سپس تلاش مجدد برای قفل
acc = _ensure_wallet_account(db, business_id)
db.flush()
try:
acc = (
db.query(WalletAccount)
.filter(WalletAccount.business_id == int(business_id))
.with_for_update()
.first()
) or acc
except Exception:
pass
return acc
def get_wallet_overview(db: Session, business_id: int) -> Dict[str, Any]:
_ = db.query(Business).filter(Business.id == int(business_id)).first() or None
if _ is None:
@ -170,13 +197,13 @@ def create_payout_request(
if not bank_acc.is_active:
raise ApiError("BANK_ACCOUNT_INACTIVE", "حساب بانکی غیرفعال است", http_status=400)
account = _ensure_wallet_account(db, business_id)
account = _get_wallet_account_for_update(db, business_id)
available = Decimal(str(account.available_balance or 0))
if amount > available:
raise ApiError("INSUFFICIENT_FUNDS", "موجودی کافی نیست", http_status=400)
# قفل مبلغ: کسر از مانده قابل برداشت
account.available_balance = float(available - amount)
account.available_balance = available - amount
db.flush()
payout = WalletPayout(
@ -234,8 +261,8 @@ def cancel_payout_request(db: Session, payout_id: int, canceller_user_id: int) -
raise ApiError("INVALID_STATE", "فقط درخواست‌های requested/approved قابل لغو هستند", http_status=400)
# بازگردانی مبلغ به مانده قابل برداشت
account = _ensure_wallet_account(db, payout.business_id)
account.available_balance = float(Decimal(str(account.available_balance or 0)) + Decimal(str(payout.gross_amount or 0)))
account = _get_wallet_account_for_update(db, payout.business_id)
account.available_balance = Decimal(str(account.available_balance or 0)) + Decimal(str(payout.gross_amount or 0))
db.flush()
payout.status = "canceled"
@ -331,7 +358,7 @@ def run_auto_settlement(db: Session, business_id: int, user_id: int) -> Dict[str
default_bank_account_id = settings.get("default_bank_account_id")
if not default_bank_account_id:
return {"executed": False, "reason": "NO_DEFAULT_BANK_ACCOUNT"}
account = _ensure_wallet_account(db, business_id)
account = _get_wallet_account_for_update(db, business_id)
available = Decimal(str(account.available_balance or 0))
cand = available - min_reserve
if cand <= 0 or cand < threshold:
@ -361,8 +388,8 @@ def create_top_up_request(db: Session, business_id: int, user_id: int, payload:
if not gateway_id:
# اجازه می‌دهیم بدون gateway_id نیز ساخته شود، اما برای پرداخت آنلاین لازم است
pass
account = _ensure_wallet_account(db, business_id)
account.pending_balance = float(Decimal(str(account.pending_balance or 0)) + amount)
account = _get_wallet_account_for_update(db, business_id)
account.pending_balance = Decimal(str(account.pending_balance or 0)) + amount
db.flush()
tx = WalletTransaction(
business_id=int(business_id),
@ -390,9 +417,16 @@ def create_top_up_request(db: Session, business_id: int, user_id: int, payload:
)
payment_url = init_res.payment_url
except Exception as ex:
# اگر ایجاد لینک شکست بخورد، تراکنش پابرجاست ولی لینک ندارد
from app.core.logging import get_logger
logger = get_logger()
# اگر ایجاد لینک شکست بخورد، مانده pending به حالت قبل برگردد و تراکنش failed شود
try:
account.pending_balance = Decimal(str(account.pending_balance or 0)) - amount
if account.pending_balance < 0:
account.pending_balance = Decimal("0")
tx.status = "failed"
db.flush()
finally:
import structlog
logger = structlog.get_logger()
logger.warning("gateway_initiate_failed", error=str(ex))
return {"transaction_id": tx.id, "status": tx.status, **({"payment_url": payment_url} if payment_url else {})}
@ -406,7 +440,12 @@ def confirm_top_up(db: Session, tx_id: int, success: bool, external_ref: str | N
tx = db.query(WalletTransaction).filter(WalletTransaction.id == int(tx_id)).first()
if not tx or tx.type != "top_up":
raise ApiError("TX_NOT_FOUND", "تراکنش افزایش اعتبار یافت نشد", http_status=404)
account = _ensure_wallet_account(db, tx.business_id)
# Idempotency guard: if already finalized, do nothing
if (tx.status or "").lower() in ("succeeded", "failed"):
tx.external_ref = external_ref or tx.external_ref
db.flush()
return {"transaction_id": tx.id, "status": tx.status}
account = _get_wallet_account_for_update(db, tx.business_id)
if success:
# move pending -> available
gross = Decimal(str(tx.amount or 0))
@ -416,8 +455,10 @@ def confirm_top_up(db: Session, tx_id: int, success: bool, external_ref: str | N
if fee > gross:
fee = gross
net = gross - fee
account.pending_balance = float(Decimal(str(account.pending_balance or 0)) - gross)
account.available_balance = float(Decimal(str(account.available_balance or 0)) + net)
# Prevent negative pending due to duplicate webhook/callback
current_pending = Decimal(str(account.pending_balance or 0))
account.pending_balance = current_pending - gross if current_pending >= gross else Decimal("0")
account.available_balance = Decimal(str(account.available_balance or 0)) + net
tx.status = "succeeded"
# create accounting document
try:
@ -428,7 +469,9 @@ def confirm_top_up(db: Session, tx_id: int, success: bool, external_ref: str | N
pass
else:
# rollback pending
account.pending_balance = float(Decimal(str(account.pending_balance or 0)) - Decimal(str(tx.amount or 0)))
current_pending = Decimal(str(account.pending_balance or 0))
dec_amt = Decimal(str(tx.amount or 0))
account.pending_balance = current_pending - dec_amt if current_pending >= dec_amt else Decimal("0")
tx.status = "failed"
tx.external_ref = external_ref
db.flush()

View file

@ -573,6 +573,8 @@ class _MyAppState extends State<MyApp> {
pageBuilder: (context, state) => NoTransitionPage(
child: BusinessDashboardPage(
businessId: int.parse(state.pathParameters['business_id']!),
authStore: _authStore!,
calendarController: _calendarController!,
),
),
),

View file

@ -276,3 +276,145 @@ class BusinessWithPermission {
);
}
}
// ===== Dashboard V2 (Responsive Widgets) =====
class DashboardWidgetDefinition {
final String key;
final String title;
final String icon;
final int version;
final List<String> permissionsRequired;
final Map<String, Map<String, int>> defaults; // breakpoint -> { colSpan,rowSpan }
DashboardWidgetDefinition({
required this.key,
required this.title,
required this.icon,
required this.version,
required this.permissionsRequired,
required this.defaults,
});
factory DashboardWidgetDefinition.fromJson(Map<String, dynamic> json) {
final defaultsRaw = json['defaults'] as Map<String, dynamic>? ?? const {};
final defaults = <String, Map<String, int>>{};
defaultsRaw.forEach((bp, v) {
final m = Map<String, dynamic>.from(v as Map);
defaults[bp] = {
'colSpan': (m['colSpan'] ?? 1) is int ? m['colSpan'] as int : int.tryParse('${m['colSpan']}') ?? 1,
'rowSpan': (m['rowSpan'] ?? 1) is int ? m['rowSpan'] as int : int.tryParse('${m['rowSpan']}') ?? 1,
};
});
return DashboardWidgetDefinition(
key: json['key'] as String,
title: json['title'] as String? ?? json['key'] as String,
icon: json['icon'] as String? ?? 'widgets',
version: (json['version'] ?? 1) as int,
permissionsRequired: (json['permissions_required'] as List?)?.map((e) => '$e').toList() ?? const <String>[],
defaults: defaults,
);
}
}
class DashboardLayoutItem {
final String key;
final int order;
final int colSpan;
final int rowSpan;
final bool hidden;
DashboardLayoutItem({
required this.key,
required this.order,
required this.colSpan,
required this.rowSpan,
required this.hidden,
});
factory DashboardLayoutItem.fromJson(Map<String, dynamic> json) {
return DashboardLayoutItem(
key: json['key'] as String,
order: (json['order'] ?? 1) as int,
colSpan: (json['colSpan'] ?? 1) as int,
rowSpan: (json['rowSpan'] ?? 1) as int,
hidden: (json['hidden'] ?? false) as bool,
);
}
Map<String, dynamic> toJson() => {
'key': key,
'order': order,
'colSpan': colSpan,
'rowSpan': rowSpan,
'hidden': hidden,
};
DashboardLayoutItem copyWith({
String? key,
int? order,
int? colSpan,
int? rowSpan,
bool? hidden,
}) {
return DashboardLayoutItem(
key: key ?? this.key,
order: order ?? this.order,
colSpan: colSpan ?? this.colSpan,
rowSpan: rowSpan ?? this.rowSpan,
hidden: hidden ?? this.hidden,
);
}
}
class DashboardLayoutProfile {
final String breakpoint;
final int columns;
final List<DashboardLayoutItem> items;
final int version;
final String updatedAt;
DashboardLayoutProfile({
required this.breakpoint,
required this.columns,
required this.items,
required this.version,
required this.updatedAt,
});
factory DashboardLayoutProfile.fromJson(Map<String, dynamic> json) {
return DashboardLayoutProfile(
breakpoint: json['breakpoint'] as String? ?? 'md',
columns: (json['columns'] ?? 8) as int,
items: (json['items'] as List? ?? const <dynamic>[])
.map((e) => DashboardLayoutItem.fromJson(Map<String, dynamic>.from(e as Map)))
.toList(),
version: (json['version'] ?? 2) as int,
updatedAt: json['updated_at'] as String? ?? '',
);
}
}
class DashboardDefinitionsResponse {
final Map<String, int> columns;
final List<DashboardWidgetDefinition> items;
DashboardDefinitionsResponse({
required this.columns,
required this.items,
});
factory DashboardDefinitionsResponse.fromJson(Map<String, dynamic> json) {
final colsRaw = Map<String, dynamic>.from(json['columns'] as Map? ?? const {});
final cols = <String, int>{};
colsRaw.forEach((k, v) {
cols[k] = (v is int) ? v : int.tryParse('$v') ?? 0;
});
return DashboardDefinitionsResponse(
columns: cols,
items: (json['items'] as List? ?? const <dynamic>[])
.map((e) => DashboardWidgetDefinition.fromJson(Map<String, dynamic>.from(e as Map)))
.toList(),
);
}
}

View file

@ -3,6 +3,7 @@ import 'package:hesabix_ui/l10n/app_localizations.dart';
import '../../core/api_client.dart';
import '../../config/app_config.dart';
import '../../services/payment_gateway_service.dart';
import 'package:uuid/uuid.dart';
class PaymentGatewaysPage extends StatefulWidget {
const PaymentGatewaysPage({super.key});
@ -30,6 +31,18 @@ class _PaymentGatewaysPageState extends State<PaymentGatewaysPage> {
final _successRedirectCtrl = TextEditingController();
final _failureRedirectCtrl = TextEditingController();
bool _useSuggestedCallback = true;
final _uuid = const Uuid();
void _maybeGenerateSandboxMerchantId() {
if (_provider == 'zarinpal' && _isSandbox) {
final current = _merchantIdCtrl.text.trim();
// اگر خالی است یا UUID معتبر نیست، یک UUID تولید کن
final isUuid = RegExp(r'^[0-9a-fA-F\-]{32,36}$').hasMatch(current);
if (current.isEmpty || !isUuid) {
_merchantIdCtrl.text = _uuid.v4();
}
}
}
@override
void initState() {
@ -175,6 +188,7 @@ class _PaymentGatewaysPageState extends State<PaymentGatewaysPage> {
setState(() {
_provider = v ?? 'zarinpal';
_applySuggestedCallback();
_maybeGenerateSandboxMerchantId();
});
},
),
@ -191,7 +205,13 @@ class _PaymentGatewaysPageState extends State<PaymentGatewaysPage> {
SwitchListTile(
title: const Text('Sandbox'),
value: _isSandbox,
onChanged: (v) => setState(() => _isSandbox = v),
onChanged: (v) {
setState(() {
_isSandbox = v;
_applySuggestedCallback();
_maybeGenerateSandboxMerchantId();
});
},
),
Row(
children: [

View file

@ -16,6 +16,9 @@ import '../../core/api_client.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart';
import 'receipts_payments_list_page.dart' show BulkSettlementDialog;
import '../../widgets/document/document_form_dialog.dart';
import '../../services/wallet_service.dart';
import '../../services/payment_gateway_service.dart';
import 'package:url_launcher/url_launcher.dart';
class BusinessShell extends StatefulWidget {
final int businessId;
@ -96,6 +99,102 @@ class _BusinessShellState extends State<BusinessShell> {
});
}
Future<void> showWalletTopUpDialog() async {
final t = AppLocalizations.of(context);
final formKey = GlobalKey<FormState>();
final amountCtrl = TextEditingController();
final descCtrl = TextEditingController();
final pgService = PaymentGatewayService(ApiClient());
List<Map<String, dynamic>> gateways = const <Map<String, dynamic>>[];
int? gatewayId;
try {
gateways = await pgService.listBusinessGateways(widget.businessId);
if (gateways.isNotEmpty) {
gatewayId = int.tryParse('${gateways.first['id']}');
}
} catch (_) {}
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('افزایش اعتبار'),
content: Form(
key: formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
controller: amountCtrl,
decoration: const InputDecoration(labelText: 'مبلغ'),
keyboardType: TextInputType.number,
validator: (v) => (v == null || v.isEmpty) ? 'الزامی' : null,
),
const SizedBox(height: 8),
TextFormField(
controller: descCtrl,
decoration: const InputDecoration(labelText: 'توضیحات (اختیاری)'),
),
const SizedBox(height: 8),
if (gateways.isNotEmpty)
DropdownButtonFormField<int>(
value: gatewayId,
decoration: const InputDecoration(labelText: 'درگاه پرداخت'),
items: gateways
.map((g) => DropdownMenuItem<int>(
value: int.tryParse('${g['id']}'),
child: Text('${g['display_name']} (${g['provider']})'),
))
.toList(),
onChanged: (v) => gatewayId = v,
validator: (v) => (gateways.isNotEmpty && v == null) ? 'انتخاب درگاه الزامی است' : null,
),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: Text(t.cancel)),
FilledButton(
onPressed: () {
if (formKey.currentState?.validate() == true && (gateways.isEmpty || gatewayId != null)) {
Navigator.of(ctx).pop(true);
}
},
child: Text(t.confirm),
),
],
),
);
if (confirmed == true) {
try {
final amount = double.tryParse(amountCtrl.text.replaceAll(',', '')) ?? 0;
final data = await WalletService(ApiClient()).topUp(
businessId: widget.businessId,
amount: amount,
description: descCtrl.text,
gatewayId: gatewayId,
);
final paymentUrl = (data['payment_url'] ?? '').toString();
if (paymentUrl.isNotEmpty) {
try {
await launchUrl(Uri.parse(paymentUrl), mode: LaunchMode.externalApplication);
} catch (_) {
// اگر باز نشد، فقط لینک را کپی کند/نمایش دهد
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('لینک پرداخت: $paymentUrl')));
}
}
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('لینک پرداخت دریافت نشد')));
}
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا در افزایش اعتبار: $e')));
}
}
}
}
Future<void> _loadBusinessInfo() async {
print('=== _loadBusinessInfo START ===');
print('Current business ID: ${widget.businessId}');
@ -850,7 +949,8 @@ class _BusinessShellState extends State<BusinessShell> {
// Open add cash register dialog
showAddCashBoxDialog();
} else if (child.label == t.wallet) {
// Navigate to add wallet
// Show wallet top-up dialog
showWalletTopUpDialog();
} else if (child.label == t.checks) {
// Navigate to add check
} else if (child.label == t.invoice) {
@ -1175,7 +1275,8 @@ class _BusinessShellState extends State<BusinessShell> {
// Open add cash register dialog
showAddCashBoxDialog();
} else if (child.label == t.wallet) {
// Navigate to add wallet
// Show wallet top-up dialog
showWalletTopUpDialog();
} else if (child.label == t.checks) {
// Navigate to add check
} else if (child.label == t.invoice) {

View file

@ -10,6 +10,14 @@ import 'package:go_router/go_router.dart';
import '../../core/api_client.dart';
import '../../services/payment_gateway_service.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:url_launcher/link.dart';
import 'package:flutter/services.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:dio/dio.dart';
import 'package:hesabix_ui/widgets/data_table/data_table_widget.dart';
import 'package:hesabix_ui/widgets/data_table/data_table_config.dart';
import '../../core/calendar_controller.dart';
class WalletPage extends StatefulWidget {
final int businessId;
@ -30,15 +38,59 @@ class _WalletPageState extends State<WalletPage> {
bool _loading = true;
Map<String, dynamic>? _overview;
String? _error;
List<Map<String, dynamic>> _transactions = const <Map<String, dynamic>>[];
Map<String, dynamic>? _metrics;
DateTime? _fromDate;
DateTime? _toDate;
CalendarController? _calendarCtrl;
String _typeLabel(String? t) {
switch ((t ?? '').toLowerCase()) {
case 'top_up':
return 'افزایش اعتبار';
case 'customer_payment':
return 'پرداخت مشتری';
case 'payout_request':
return 'درخواست تسویه';
case 'payout_settlement':
return 'تسویه';
case 'refund':
return 'استرداد';
case 'fee':
return 'کارمزد';
default:
return t ?? 'نامشخص';
}
}
String _statusLabel(String? s) {
switch ((s ?? '').toLowerCase()) {
case 'pending':
return 'در انتظار';
case 'approved':
return 'تایید شده';
case 'processing':
return 'در حال پردازش';
case 'succeeded':
return 'موفق';
case 'failed':
return 'ناموفق';
case 'canceled':
return 'لغو شده';
default:
return s ?? 'نامشخص';
}
}
// آیکون نوع تراکنش دیگر استفاده نمیشود (نمایش در جدول)
@override
void initState() {
super.initState();
_service = WalletService(ApiClient());
// بارگذاری کنترلر تقویم برای پشتیبانی از جلالی/میلادی در فیلترهای جدول
CalendarController.load().then((c) {
if (mounted) setState(() => _calendarCtrl = c);
});
_load();
}
@ -52,11 +104,9 @@ class _WalletPageState extends State<WalletPage> {
final now = DateTime.now();
_toDate = now;
_fromDate = now.subtract(const Duration(days: 30));
final tx = await _service.listTransactions(businessId: widget.businessId, limit: 20, fromDate: _fromDate, toDate: _toDate);
final m = await _service.getMetrics(businessId: widget.businessId, fromDate: _fromDate, toDate: _toDate);
setState(() {
_overview = res;
_transactions = tx;
_metrics = m;
});
} catch (e) {
@ -210,85 +260,157 @@ class _WalletPageState extends State<WalletPage> {
if (result == true) {
try {
final amount = double.tryParse(amountCtrl.text.replaceAll(',', '')) ?? 0;
_showLoadingDialog('در حال ثبت درخواست و آماده‌سازی درگاه...');
final data = await _service.topUp(
businessId: widget.businessId,
amount: amount,
description: descCtrl.text,
gatewayId: gatewayId,
);
if (mounted) Navigator.of(context).pop(); // close loading
final paymentUrl = (data['payment_url'] ?? '').toString();
if (paymentUrl.isNotEmpty) {
final uri = Uri.parse(paymentUrl);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
_showLoadingDialog('در حال انتقال به درگاه پرداخت...');
await _openPaymentUrlWithFallback(paymentUrl);
if (mounted) Navigator.of(context).pop(); // close loading
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('درخواست افزایش اعتبار ثبت شد')));
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('درخواست افزایش اعتبار ثبت شد، اما لینک پرداخت دریافت نشد. لطفاً بعداً دوباره تلاش کنید یا تنظیمات درگاه را بررسی کنید.')));
}
}
await _load();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا: $e')));
// ensure loading dialog is closed if open
Navigator.of(context, rootNavigator: true).maybePop();
}
String friendly = 'خطا در ثبت درخواست افزایش اعتبار';
if (e is DioException) {
final status = e.response?.statusCode;
final body = e.response?.data;
final serverMsg = (body is Map && body['message'] is String) ? (body['message'] as String) : null;
final errorCode = (body is Map && body['error_code'] is String) ? (body['error_code'] as String) : null;
if (errorCode == 'GATEWAY_INIT_FAILED') {
friendly = 'خطا در اتصال به درگاه. لطفاً تنظیمات درگاه را بررسی کنید یا بعداً تلاش کنید.';
} else if (errorCode == 'INVALID_CONFIG') {
friendly = 'پیکربندی درگاه ناقص است. لطفاً مرچنت آی‌دی و آدرس بازگشت را بررسی کنید.';
} else if (errorCode == 'GATEWAY_DISABLED') {
friendly = 'این درگاه غیرفعال است.';
} else if (errorCode == 'GATEWAY_NOT_FOUND') {
friendly = 'درگاه پرداخت یافت نشد.';
} else if (status != null && status >= 500) {
friendly = 'خطای سرور هنگام اتصال به درگاه. لطفاً بعداً تلاش کنید.';
} else if (serverMsg != null && serverMsg.isNotEmpty) {
friendly = serverMsg;
}
}
}
}
Future<void> _pickFromDate() async {
final initial = _fromDate ?? DateTime.now().subtract(const Duration(days: 30));
final picked = await showDatePicker(
context: context,
firstDate: DateTime(2000, 1, 1),
lastDate: DateTime.now().add(const Duration(days: 1)),
initialDate: initial,
);
if (picked != null) {
setState(() => _fromDate = picked);
await _reloadRange();
}
}
Future<void> _pickToDate() async {
final initial = _toDate ?? DateTime.now();
final picked = await showDatePicker(
context: context,
firstDate: DateTime(2000, 1, 1),
lastDate: DateTime.now().add(const Duration(days: 1)),
initialDate: initial,
);
if (picked != null) {
setState(() => _toDate = picked);
await _reloadRange();
}
}
Future<void> _reloadRange() async {
setState(() => _loading = true);
try {
final tx = await _service.listTransactions(
businessId: widget.businessId,
limit: 20,
fromDate: _fromDate,
toDate: _toDate,
);
final m = await _service.getMetrics(
businessId: widget.businessId,
fromDate: _fromDate,
toDate: _toDate,
);
if (mounted) {
setState(() {
_transactions = tx;
_metrics = m;
});
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(friendly)));
}
}
}
}
Future<void> _openPaymentUrlWithFallback(String url) async {
// وب: تلاش برای باز کردن مستقیم؛ در صورت عدم موفقیت، دیالوگ جایگزین
if (kIsWeb) {
try {
final launched = await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
if (!launched) {
await _showOpenLinkDialog(url);
}
} catch (_) {
await _showOpenLinkDialog(url);
}
return;
}
// دسکتاپ/موبایل: باز کردن در مرورگر پیشفرض باFallback
try {
final launched = await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
if (!launched) {
await _showOpenLinkDialog(url);
}
} catch (_) {
await _showOpenLinkDialog(url);
}
}
void _showLoadingDialog(String message) {
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => WillPopScope(
onWillPop: () async => false,
child: AlertDialog(
content: Row(
children: [
const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2)),
const SizedBox(width: 12),
Expanded(child: Text(message)),
],
),
),
),
);
}
Future<void> _showOpenLinkDialog(String url) async {
if (!mounted) return;
await showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('انتقال به درگاه پرداخت'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('برای ادامه پرداخت، لینک زیر را باز کنید:'),
const SizedBox(height: 8),
SelectableText(url),
],
),
actions: [
TextButton(
onPressed: () async {
await Clipboard.setData(ClipboardData(text: url));
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('لینک کپی شد')));
}
},
child: const Text('کپی لینک'),
),
if (kIsWeb)
Link(
uri: Uri.parse(url),
target: LinkTarget.blank,
builder: (context, followLink) => FilledButton(
onPressed: () {
followLink?.call();
Navigator.of(ctx).pop();
},
child: const Text('باز کردن'),
),
)
else
FilledButton(
onPressed: () async {
try {
await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
} finally {
if (mounted) setState(() => _loading = false);
if (mounted) Navigator.of(ctx).pop();
}
},
child: const Text('باز کردن'),
),
],
),
);
}
// فیلتر بازه تاریخ اکنون توسط DataTableWidget و Dialog داخلی آن انجام میشود
// بارگذاری بیشتر جایگزین با جدول سراسری شده است
@override
Widget build(BuildContext context) {
final t = AppLocalizations.of(context);
@ -307,7 +429,10 @@ class _WalletPageState extends State<WalletPage> {
? const Center(child: CircularProgressIndicator())
: _error != null
? Center(child: Text(_error!))
: Padding(
: LayoutBuilder(
builder: (context, constraints) {
final double tableHeight = (constraints.maxHeight - 360).clamp(280.0, constraints.maxHeight);
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
@ -333,7 +458,7 @@ class _WalletPageState extends State<WalletPage> {
children: [
Text('مانده قابل برداشت', style: theme.textTheme.labelLarge),
const SizedBox(height: 8),
Text('${overview?['available_balance'] ?? 0}', style: theme.textTheme.headlineSmall),
Text('${formatWithThousands((overview?['available_balance'] ?? 0) is num ? (overview?['available_balance'] ?? 0) : double.tryParse('${overview?['available_balance']}') ?? 0)}', style: theme.textTheme.headlineSmall),
],
),
),
@ -349,7 +474,7 @@ class _WalletPageState extends State<WalletPage> {
children: [
Text('مانده در انتظار تایید', style: theme.textTheme.labelLarge),
const SizedBox(height: 8),
Text('${overview?['pending_balance'] ?? 0}', style: theme.textTheme.headlineSmall),
Text('${formatWithThousands((overview?['pending_balance'] ?? 0) is num ? (overview?['pending_balance'] ?? 0) : double.tryParse('${overview?['pending_balance']}') ?? 0)}', style: theme.textTheme.headlineSmall),
],
),
),
@ -360,17 +485,6 @@ class _WalletPageState extends State<WalletPage> {
const SizedBox(height: 16),
Row(
children: [
OutlinedButton.icon(
onPressed: _pickFromDate,
icon: const Icon(Icons.date_range),
label: Text(_fromDate != null ? _fromDate!.toIso8601String().split('T').first : 'از تاریخ'),
),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: _pickToDate,
icon: const Icon(Icons.event),
label: Text(_toDate != null ? _toDate!.toIso8601String().split('T').first : 'تا تاریخ'),
),
const Spacer(),
FilledButton.icon(
onPressed: _openPayoutDialog,
@ -399,12 +513,12 @@ class _WalletPageState extends State<WalletPage> {
spacing: 12,
runSpacing: 8,
children: [
Chip(label: Text('ورودی ناخالص: ${_metrics?['totals']?['gross_in'] ?? 0}')),
Chip(label: Text('کارمزد ورودی: ${_metrics?['totals']?['fees_in'] ?? 0}')),
Chip(label: Text('ورودی خالص: ${_metrics?['totals']?['net_in'] ?? 0}')),
Chip(label: Text('خروجی ناخالص: ${_metrics?['totals']?['gross_out'] ?? 0}')),
Chip(label: Text('کارمزد خروجی: ${_metrics?['totals']?['fees_out'] ?? 0}')),
Chip(label: Text('خروجی خالص: ${_metrics?['totals']?['net_out'] ?? 0}')),
Chip(label: Text('ورودی ناخالص: ${formatWithThousands((_metrics?['totals']?['gross_in'] ?? 0) is num ? (_metrics?['totals']?['gross_in'] ?? 0) : double.tryParse('${_metrics?['totals']?['gross_in']}') ?? 0)}')),
Chip(label: Text('کارمزد ورودی: ${formatWithThousands((_metrics?['totals']?['fees_in'] ?? 0) is num ? (_metrics?['totals']?['fees_in'] ?? 0) : double.tryParse('${_metrics?['totals']?['fees_in']}') ?? 0)}')),
Chip(label: Text('ورودی خالص: ${formatWithThousands((_metrics?['totals']?['net_in'] ?? 0) is num ? (_metrics?['totals']?['net_in'] ?? 0) : double.tryParse('${_metrics?['totals']?['net_in']}') ?? 0)}')),
Chip(label: Text('خروجی ناخالص: ${formatWithThousands((_metrics?['totals']?['gross_out'] ?? 0) is num ? (_metrics?['totals']?['gross_out'] ?? 0) : double.tryParse('${_metrics?['totals']?['gross_out']}') ?? 0)}')),
Chip(label: Text('کارمزد خروجی: ${formatWithThousands((_metrics?['totals']?['fees_out'] ?? 0) is num ? (_metrics?['totals']?['fees_out'] ?? 0) : double.tryParse('${_metrics?['totals']?['fees_out']}') ?? 0)}')),
Chip(label: Text('خروجی خالص: ${formatWithThousands((_metrics?['totals']?['net_out'] ?? 0) is num ? (_metrics?['totals']?['net_out'] ?? 0) : double.tryParse('${_metrics?['totals']?['net_out']}') ?? 0)}')),
],
),
],
@ -412,67 +526,49 @@ class _WalletPageState extends State<WalletPage> {
),
),
const SizedBox(height: 16),
Row(
children: [
OutlinedButton.icon(
onPressed: () async {
final api = ApiClient();
final path = '/businesses/${widget.businessId}/wallet/transactions/export'
'${_fromDate != null ? '?from_date=${_fromDate!.toIso8601String()}' : ''}'
'${_toDate != null ? (_fromDate != null ? '&' : '?') + 'to_date=${_toDate!.toIso8601String()}' : ''}';
try {
await api.downloadExcel(path); // bytes download and save handled
// Save as CSV file
// ignore: avoid_web_libraries_in_flutter
// ignore: unused_import
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا در دانلود CSV تراکنش‌ها: $e')));
}
},
icon: const Icon(Icons.download),
label: const Text('دانلود CSV تراکنش‌ها'),
),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: () async {
final api = ApiClient();
final path = '/businesses/${widget.businessId}/wallet/metrics/export'
'${_fromDate != null ? '?from_date=${_fromDate!.toIso8601String()}' : ''}'
'${_toDate != null ? (_fromDate != null ? '&' : '?') + 'to_date=${_toDate!.toIso8601String()}' : ''}';
try {
await api.downloadExcel(path);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا در دانلود CSV خلاصه: $e')));
}
},
icon: const Icon(Icons.table_view),
label: const Text('دانلود CSV خلاصه'),
),
],
),
// دکمههای خروجی CSV در پایین جدول موجود هستند؛ این بخش حذف شد تا فیلتر تاریخ از طریق جدول انجام شود
const SizedBox(height: 16),
Text('تراکنش‌های اخیر', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
Expanded(
child: Card(
child: ListView.separated(
itemCount: _transactions.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (ctx, i) {
final m = _transactions[i];
final amount = m['amount'] ?? 0;
return ListTile(
leading: Icon(
m['type'] == 'payout_request' ? Icons.account_balance : Icons.swap_horiz,
color: theme.colorScheme.primary,
),
title: Text('${m['type']} - ${m['status']}'),
subtitle: Text('${m['description'] ?? ''}'),
trailing: Text('${formatWithThousands((amount is num) ? amount : double.tryParse('$amount') ?? 0)}'),
onTap: () async {
final docId = m['document_id'];
SizedBox(
height: tableHeight,
child: DataTableWidget<Map<String, dynamic>>(
config: DataTableConfig<Map<String, dynamic>>(
endpoint: '/businesses/${widget.businessId}/wallet/transactions/table',
title: 'تراکنش‌ها',
showTableIcon: true,
showSearch: false,
showActiveFilters: false,
showPagination: true,
defaultPageSize: 20,
pageSizeOptions: const [10, 20, 50, 100],
enableColumnSettings: true,
columns: [
DateColumn('created_at', 'تاریخ', filterType: ColumnFilterType.dateRange, formatter: (it) {
final v = (it['created_at'] ?? '').toString();
return v.split('T').first;
}),
TextColumn('type', 'نوع', formatter: (it) => _typeLabel((it['type'] ?? '').toString())),
TextColumn('status', 'وضعیت', formatter: (it) => _statusLabel((it['status'] ?? '').toString())),
TextColumn('description', 'توضیحات', searchable: false, overflow: true, maxLines: 1),
NumberColumn('amount', 'مبلغ', formatter: (it) {
final amount = it['amount'];
final n = (amount is num) ? amount.toDouble() : double.tryParse('$amount') ?? 0;
return formatWithThousands(n);
}),
NumberColumn('fee_amount', 'کارمزد', formatter: (it) {
final fee = it['fee_amount'];
final n = (fee is num) ? fee.toDouble() : double.tryParse('$fee') ?? 0;
return formatWithThousands(n);
}),
TextColumn('document_id', 'سند', formatter: (it) {
final d = it['document_id'];
return (d == null || '$d' == 'null') ? '-' : '$d';
}),
],
onRowTap: (row) async {
final docId = row['document_id'];
if (docId == null) return;
if (!mounted) return;
await context.pushNamed(
'business_documents',
@ -480,13 +576,19 @@ class _WalletPageState extends State<WalletPage> {
extra: {'focus_document_id': docId},
);
},
);
},
showExportButtons: true,
excelEndpoint: '/businesses/${widget.businessId}/wallet/transactions/export'
'${_fromDate != null ? '?from_date=${_fromDate!.toIso8601String()}' : ''}'
'${_toDate != null ? (_fromDate != null ? '&' : '?') + 'to_date=${_toDate!.toIso8601String()}' : ''}',
),
fromJson: (json) => Map<String, dynamic>.from(json),
calendarController: _calendarCtrl,
),
),
],
),
);
},
),
);
}

View file

@ -1,17 +1,545 @@
import 'package:flutter/material.dart';
import '../../core/api_client.dart';
import '../../models/business_dashboard_models.dart';
import '../../utils/date_formatters.dart';
import '../../services/profile_dashboard_service.dart';
import 'package:go_router/go_router.dart';
class ProfileDashboardPage extends StatelessWidget {
class ProfileDashboardPage extends StatefulWidget {
const ProfileDashboardPage({super.key});
@override
State<ProfileDashboardPage> createState() => _ProfileDashboardPageState();
}
typedef ProfileWidgetBuilder = Widget Function(BuildContext, dynamic, DashboardLayoutItem, {VoidCallback? onRefresh});
class _ProfileDashboardPageState extends State<ProfileDashboardPage> {
late final ProfileDashboardService _service;
DashboardLayoutProfile? _layout;
Map<String, dynamic> _data = <String, dynamic>{};
bool _loading = true;
String? _error;
bool _editMode = false;
static const double _gridSpacingPx = 12.0;
@override
void initState() {
super.initState();
_service = ProfileDashboardService(ApiClient());
_loadAll();
}
String _currentBreakpoint(double width) {
if (width < 600) return 'xs';
if (width < 904) return 'sm';
if (width < 1240) return 'md';
if (width < 1600) return 'lg';
return 'xl';
}
Future<void> _loadAll() async {
try {
setState(() {
_loading = true;
_error = null;
});
final defs = await _service.getWidgetDefinitions();
final bp = _currentBreakpoint(MediaQuery.of(context).size.width);
var layout = await _service.getLayoutProfile(breakpoint: bp);
// اطمینان از حضور ویجتهای جدید پیشفرض در چیدمان
final existingKeys = layout.items.map((e) => e.key).toSet();
final missingDefaults = defs.items.where((d) => !existingKeys.contains(d.key)).toList();
if (missingDefaults.isNotEmpty) {
final items = List<DashboardLayoutItem>.from(layout.items);
int maxOrder = items.fold<int>(0, (acc, it) => it.order > acc ? it.order : acc);
for (final d in missingDefaults) {
final dflt = d.defaults[bp] ?? const <String, int>{};
final colSpan = (dflt['colSpan'] ?? (layout.columns / 2).floor()).clamp(1, layout.columns);
final rowSpan = dflt['rowSpan'] ?? 2;
items.add(DashboardLayoutItem(key: d.key, order: ++maxOrder, colSpan: colSpan, rowSpan: rowSpan, hidden: false));
}
// ذخیره و جایگزینی layout
layout = await _service.putLayoutProfile(breakpoint: bp, items: items);
}
final keys = layout.items.where((e) => !e.hidden).map((e) => e.key).toList();
var data = await _service.getWidgetsBatchData(widgetKeys: keys);
// هیدرات خاص برای برخی ویجتها
data = await _service.hydrateSpecialWidgets(data, keys);
if (!mounted) return;
setState(() {
_layout = layout;
_data = data;
_loading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = '$e';
_loading = false;
});
}
}
Future<void> _reloadDataOnly() async {
try {
final layout = _layout;
if (layout == null) return;
final keys = layout.items.where((e) => !e.hidden).map((e) => e.key).toList();
var data = await _service.getWidgetsBatchData(widgetKeys: keys);
data = await _service.hydrateSpecialWidgets(data, keys);
if (!mounted) return;
setState(() {
_data = data;
});
} catch (_) {}
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
Text('User Profile Dashboard', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
SizedBox(height: 12),
Text('Summary and quick actions will appear here.'),
if (_loading) {
return const Center(child: CircularProgressIndicator());
}
if (_error != null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error, size: 56, color: Theme.of(context).colorScheme.error),
const SizedBox(height: 12),
Text('خطا در بارگذاری داشبورد پروفایل:\n$_error', textAlign: TextAlign.center),
const SizedBox(height: 12),
ElevatedButton(onPressed: _loadAll, child: const Text('تلاش مجدد')),
],
),
);
}
final layout = _layout!;
final items = List<DashboardLayoutItem>.from(layout.items)..sort((a, b) => a.order.compareTo(b.order));
final visible = items.where((e) => !e.hidden).toList();
final crossAxisCount = layout.columns;
return Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
_buildHeaderRow(),
const SizedBox(height: 16),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
final totalWidth = constraints.maxWidth;
double unit = (totalWidth - (crossAxisCount - 1) * _gridSpacingPx) / crossAxisCount;
const double minTileUnit = 180;
if (unit <= 0) unit = minTileUnit;
if (unit < minTileUnit) unit = minTileUnit;
final children = <Widget>[];
for (final it in visible) {
final w = (unit * it.colSpan) + _gridSpacingPx * (it.colSpan - 1);
final cw = w > totalWidth ? totalWidth : (w < unit ? unit : w);
children.add(AnimatedContainer(
duration: const Duration(milliseconds: 180),
curve: Curves.easeInOut,
key: ValueKey('profile_dash_${it.key}'),
width: cw,
child: _buildGridTile(it, crossAxisCount),
));
}
return SingleChildScrollView(
child: Wrap(
spacing: _gridSpacingPx,
runSpacing: _gridSpacingPx,
children: children,
),
);
},
),
),
],
),
);
}
Widget _buildHeaderRow() {
return Row(
children: [
Expanded(
child: Text(
'داشبورد پروفایل',
style: Theme.of(context).textTheme.headlineMedium,
),
),
IconButton(
tooltip: _editMode ? 'خروج از ویرایش' : 'ویرایش چیدمان',
onPressed: () => setState(() => _editMode = !_editMode),
icon: Icon(_editMode ? Icons.check : Icons.edit),
),
],
);
}
Widget _buildGridTile(DashboardLayoutItem item, int totalColumns) {
final data = _data[item.key];
if (data == null) {
return _buildCard(
title: _titleForKey(item.key),
child: const Center(child: SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2))),
);
}
final builder = _widgetFactory[item.key];
if (builder == null) {
return _buildCard(
title: 'ویجت ناشناخته: ${item.key}',
child: const Center(child: Text('این ویجت ثبت نشده است')),
);
}
final trailing = _editMode
? PopupMenuButton<String>(
tooltip: 'ویرایش',
onSelected: (v) {
if (v == 'w+1') _changeItemWidth(item, 1);
if (v == 'w-1') _changeItemWidth(item, -1);
if (v == 'up') _moveItemUp(item);
if (v == 'down') _moveItemDown(item);
if (v == 'hide') _hideItem(item, hidden: true);
},
itemBuilder: (context) => const [
PopupMenuItem(value: 'w+1', child: Row(children: [Icon(Icons.open_in_full, size: 18), SizedBox(width: 8), Text('افزایش عرض')])),
PopupMenuItem(value: 'w-1', child: Row(children: [Icon(Icons.close_fullscreen, size: 18), SizedBox(width: 8), Text('کاهش عرض')])),
PopupMenuDivider(),
PopupMenuItem(value: 'up', child: Row(children: [Icon(Icons.arrow_upward, size: 18), SizedBox(width: 8), Text('بالا')])),
PopupMenuItem(value: 'down', child: Row(children: [Icon(Icons.arrow_downward, size: 18), SizedBox(width: 8), Text('پایین')])),
PopupMenuDivider(),
PopupMenuItem(value: 'hide', child: Row(children: [Icon(Icons.visibility_off, size: 18), SizedBox(width: 8), Text('پنهان کردن')])),
],
icon: const Icon(Icons.tune),
)
: IconButton(
tooltip: 'بازخوانی',
icon: const Icon(Icons.refresh),
onPressed: _reloadDataOnly,
);
final card = _buildCard(
title: _titleForKey(item.key),
trailing: trailing,
child: builder(context, data, item, onRefresh: _reloadDataOnly),
);
return card;
}
void _moveItemUp(DashboardLayoutItem item) {
final profile = _layout;
if (profile == null) return;
final list = List<DashboardLayoutItem>.from(profile.items);
final idx = list.indexWhere((e) => e.key == item.key);
if (idx <= 0) return;
final tmp = list[idx - 1];
list[idx - 1] = list[idx];
list[idx] = tmp;
_reindexAndSave(list);
}
void _moveItemDown(DashboardLayoutItem item) {
final profile = _layout;
if (profile == null) return;
final list = List<DashboardLayoutItem>.from(profile.items);
final idx = list.indexWhere((e) => e.key == item.key);
if (idx < 0 || idx >= list.length - 1) return;
final tmp = list[idx + 1];
list[idx + 1] = list[idx];
list[idx] = tmp;
_reindexAndSave(list);
}
void _changeItemWidth(DashboardLayoutItem item, int delta) {
final profile = _layout;
if (profile == null) return;
final list = List<DashboardLayoutItem>.from(profile.items);
final idx = list.indexWhere((e) => e.key == item.key);
if (idx < 0) return;
final newSpan = (item.colSpan + delta).clamp(1, profile.columns);
list[idx] = item.copyWith(colSpan: newSpan);
_applyItems(list);
}
void _hideItem(DashboardLayoutItem item, {required bool hidden}) {
final profile = _layout;
if (profile == null) return;
final list = List<DashboardLayoutItem>.from(profile.items);
final idx = list.indexWhere((e) => e.key == item.key);
if (idx < 0) return;
list[idx] = item.copyWith(hidden: hidden);
_applyItems(list);
}
void _reindexAndSave(List<DashboardLayoutItem> items) {
final sorted = <DashboardLayoutItem>[];
for (var i = 0; i < items.length; i++) {
final it = items[i];
sorted.add(it.copyWith(order: i + 1));
}
_applyItems(sorted);
}
void _applyItems(List<DashboardLayoutItem> items) async {
final profile = _layout;
if (profile == null) return;
setState(() {
_layout = DashboardLayoutProfile(
breakpoint: profile.breakpoint,
columns: profile.columns,
items: items,
version: profile.version,
updatedAt: profile.updatedAt,
);
});
try {
final updated = await _service.putLayoutProfile(
breakpoint: profile.breakpoint,
items: items,
);
if (!mounted) return;
setState(() {
_layout = updated;
});
} catch (_) {}
}
String _titleForKey(String key) {
switch (key) {
case 'profile_recent_businesses':
return 'کسب‌وکارهای شما';
case 'profile_announcements':
return 'اعلان‌ها';
default:
return key;
}
}
// ====== Registry ======
Map<String, ProfileWidgetBuilder> get _widgetFactory => <String, ProfileWidgetBuilder>{
'profile_recent_businesses': _recentBusinessesWidget,
'profile_announcements': _announcementsWidget,
'profile_support_tickets': _supportTicketsWidget,
'profile_onboarding_checklist': _onboardingChecklistWidget,
};
Widget _buildCard({required String title, Widget? trailing, required Widget child}) {
return Card(
clipBehavior: Clip.antiAlias,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
border: Border(
bottom: BorderSide(color: Theme.of(context).dividerColor.withOpacity(0.08)),
),
),
child: Row(
children: [
Expanded(child: Text(title, style: Theme.of(context).textTheme.titleMedium)),
if (trailing != null) trailing,
],
),
),
child,
],
),
);
}
// ====== Widgets ======
Widget _recentBusinessesWidget(BuildContext context, dynamic data, DashboardLayoutItem item, {VoidCallback? onRefresh}) {
final theme = Theme.of(context);
final items = (data is Map && data['items'] is List) ? List<Map<String, dynamic>>.from(data['items'] as List) : const <Map<String, dynamic>>[];
if (items.isEmpty) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Center(
child: Text('کسب‌وکاری یافت نشد', style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant)),
),
);
}
return ListView.separated(
physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true,
itemCount: items.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) {
final it = items[index];
final name = '${it['name'] ?? '-'}';
final role = '${it['role'] ?? ''}';
final isOwner = (it['is_owner'] ?? false) == true;
return ListTile(
dense: true,
leading: const Icon(Icons.business),
title: Text(name),
subtitle: Text(isOwner ? 'مالک' : (role.isNotEmpty ? role : 'عضو')),
trailing: TextButton.icon(
onPressed: () {
final id = it['id'];
if (id is int) {
context.go('/business/$id/dashboard');
} else {
final p = int.tryParse('$id');
if (p != null) context.go('/business/$p/dashboard');
}
},
icon: const Icon(Icons.arrow_forward),
label: const Text('ورود'),
),
);
},
);
}
Widget _announcementsWidget(BuildContext context, dynamic data, DashboardLayoutItem item, {VoidCallback? onRefresh}) {
final theme = Theme.of(context);
final items = (data is Map && data['items'] is List) ? List<Map<String, dynamic>>.from(data['items'] as List) : const <Map<String, dynamic>>[];
if (items.isEmpty) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Center(
child: Text('اعلانی وجود ندارد', style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant)),
),
);
}
return ListView.separated(
physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true,
itemCount: items.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) {
final it = items[index];
final title = '${it['title'] ?? '-'}';
final body = '${it['body'] ?? ''}';
final time = '${it['time'] ?? ''}';
return ListTile(
dense: true,
leading: const Icon(Icons.notifications),
title: Text(title),
subtitle: Text(body),
trailing: Text(
time.isNotEmpty ? DateFormatters.formatServerDateTime(time) : '',
style: theme.textTheme.labelSmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
);
},
);
}
Widget _supportTicketsWidget(BuildContext context, dynamic data, DashboardLayoutItem item, {VoidCallback? onRefresh}) {
final theme = Theme.of(context);
final items = (data is Map && data['items'] is List) ? List<Map<String, dynamic>>.from(data['items'] as List) : const <Map<String, dynamic>>[];
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (items.isEmpty)
Padding(
padding: const EdgeInsets.all(16.0),
child: Center(child: Text('تیکتی یافت نشد', style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant))),
)
else
ListView.separated(
physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true,
itemCount: items.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) {
final it = items[index];
final id = '${it['id'] ?? ''}';
final subject = '${it['subject'] ?? '-'}';
final status = '${it['status'] ?? ''}';
final updatedAt = '${it['updated_at'] ?? ''}';
return ListTile(
dense: true,
leading: const Icon(Icons.support_agent),
title: Text(subject),
subtitle: Text('شناسه: $id • وضعیت: $status'),
trailing: Text(
updatedAt.isNotEmpty ? DateFormatters.formatServerDateTime(updatedAt) : '',
style: theme.textTheme.labelSmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
onTap: () {
// در آینده: ناوبری به جزئیات تیکت
},
);
},
),
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: () {
// رفتن به صفحه پشتیبانی
context.go('/user/profile/support');
},
icon: const Icon(Icons.open_in_new),
label: const Text('مشاهده همه'),
),
),
],
);
}
Widget _onboardingChecklistWidget(BuildContext context, dynamic data, DashboardLayoutItem item, {VoidCallback? onRefresh}) {
final theme = Theme.of(context);
final items = (data is Map && data['items'] is List) ? List<Map<String, dynamic>>.from(data['items'] as List) : const <Map<String, dynamic>>[];
int total = items.length;
int done = items.where((e) => (e['done'] ?? false) == true).length;
double ratio = (total == 0) ? 0 : (done / total);
return Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.flag),
const SizedBox(width: 8),
Text('پیشرفت: ${(ratio * 100).round()}%', style: theme.textTheme.titleSmall),
],
),
const SizedBox(height: 8),
LinearProgressIndicator(value: ratio),
const SizedBox(height: 12),
if (items.isEmpty)
Center(child: Text('موردی ثبت نشده است', style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant)))
else
...items.map((it) {
final title = '${it['title'] ?? '-'}';
final done = (it['done'] ?? false) == true;
return CheckboxListTile(
value: done,
onChanged: (v) {
// بروزرسانی محلی وضعیت انجامشدن آیتم
final list = List<Map<String, dynamic>>.from(items);
final idx = list.indexOf(it);
if (idx >= 0) {
list[idx] = Map<String, dynamic>.from(list[idx])..['done'] = v == true;
setState(() {
_data['profile_onboarding_checklist'] = {'items': list};
});
}
},
controlAffinity: ListTileControlAffinity.leading,
dense: true,
title: Text(title),
);
}),
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: onRefresh,
icon: const Icon(Icons.refresh),
label: const Text('بازخوانی'),
),
),
],
),
);
}
}

View file

@ -1,7 +1,8 @@
import 'package:dio/dio.dart';
import '../core/api_client.dart';
import '../models/business_dashboard_models.dart';
// duplicate removed
import '../core/fiscal_year_controller.dart';
import '../models/business_dashboard_models.dart';
class BusinessDashboardService {
final ApiClient _apiClient;
@ -244,4 +245,105 @@ class BusinessDashboardService {
throw Exception('خطا در بارگذاری اطلاعات کسب و کار: $e');
}
}
// ===== Dashboard V2 (Responsive Widgets) =====
Future<DashboardDefinitionsResponse> getWidgetDefinitions(int businessId) async {
final res = await _apiClient.get<Map<String, dynamic>>(
'/api/v1/business/$businessId/dashboard/widgets/definitions',
);
final data = res.data?['data'] as Map<String, dynamic>? ?? const {};
return DashboardDefinitionsResponse.fromJson(data);
}
Future<DashboardLayoutProfile> getLayoutProfile({
required int businessId,
required String breakpoint,
}) async {
final res = await _apiClient.get<Map<String, dynamic>>(
'/api/v1/business/$businessId/dashboard/layout',
query: {'breakpoint': breakpoint},
);
final data = res.data?['data'] as Map<String, dynamic>? ?? const {};
return DashboardLayoutProfile.fromJson(data);
}
Future<DashboardLayoutProfile> putLayoutProfile({
required int businessId,
required String breakpoint,
required List<DashboardLayoutItem> items,
}) async {
final body = {
'breakpoint': breakpoint,
'items': items.map((e) => e.toJson()).toList(),
};
final res = await _apiClient.put<Map<String, dynamic>>(
'/api/v1/business/$businessId/dashboard/layout',
data: body,
);
final data = res.data?['data'] as Map<String, dynamic>? ?? const {};
return DashboardLayoutProfile.fromJson(data);
}
Future<Map<String, dynamic>> getWidgetsBatchData({
required int businessId,
required List<String> widgetKeys,
Map<String, dynamic>? filters,
}) async {
final res = await _api_client_postJson(
'/api/v1/business/$businessId/dashboard/data',
{
'widget_keys': widgetKeys,
'filters': filters ?? const <String, dynamic>{},
},
);
final data = (res['data'] as Map?) ?? const {};
return Map<String, dynamic>.from(data);
}
// Business default layout (owner can publish)
Future<DashboardLayoutProfile?> getBusinessDefaultLayout({
required int businessId,
required String breakpoint,
}) async {
final res = await _apiClient.get<Map<String, dynamic>>(
'/api/v1/business/$businessId/dashboard/layout/default',
query: {'breakpoint': breakpoint},
);
final data = res.data?['data'] as Map<String, dynamic>? ?? const {};
if (data.isEmpty) return null;
return DashboardLayoutProfile.fromJson(data);
}
Future<DashboardLayoutProfile> putBusinessDefaultLayout({
required int businessId,
required String breakpoint,
required List<DashboardLayoutItem> items,
}) async {
final body = {
'breakpoint': breakpoint,
'items': items.map((e) => e.toJson()).toList(),
};
final res = await _apiClient.put<Map<String, dynamic>>(
'/api/v1/business/$businessId/dashboard/layout/default',
data: body,
);
final data = res.data?['data'] as Map<String, dynamic>? ?? const {};
return DashboardLayoutProfile.fromJson(data);
}
Future<Map<String, dynamic>> _api_client_postJson(String path, Map<String, dynamic> body) async {
final response = await _apiClient.post<Map<String, dynamic>>(
path,
data: body,
options: Options(headers: {
if (fiscalYearController?.fiscalYearId != null)
'X-Fiscal-Year-ID': fiscalYearController!.fiscalYearId.toString(),
}),
);
if (response.data?['success'] == true) {
return Map<String, dynamic>.from(response.data!);
}
throw Exception(response.data?['message'] ?? 'API error');
}
}

View file

@ -0,0 +1,331 @@
import 'package:dio/dio.dart';
import '../core/api_client.dart';
import '../models/business_dashboard_models.dart';
import 'business_dashboard_service.dart';
import 'support_service.dart';
/// سرویس داشبورد پروفایل کاربر
///
/// تلاش میکند از اندپوینتهای پروفایل استفاده کند. در صورت عدم وجود
/// (مثلاً 404)، برای MVP از fallback محلی استفاده میکند تا UI کار کند.
class ProfileDashboardService {
final ApiClient _apiClient;
final BusinessDashboardService _businessService;
ProfileDashboardService(this._apiClient)
: _businessService = BusinessDashboardService(_apiClient);
// --- تعاریف ویجتها ---
Future<DashboardDefinitionsResponse> getWidgetDefinitions() async {
try {
final res = await _apiClient.get<Map<String, dynamic>>(
'/api/v1/profile/dashboard/widgets/definitions',
);
final data = res.data?['data'] as Map<String, dynamic>? ?? const {};
final defs = DashboardDefinitionsResponse.fromJson(data);
if (defs.items.isEmpty || defs.columns.isEmpty) {
return _fallbackDefinitions();
}
return defs;
} on DioException catch (e) {
// اگر اندپوینت وجود ندارد یا هر خطایی بود، fallback
if (e.response?.statusCode == 404) {
return _fallbackDefinitions();
}
return _fallbackDefinitions();
} catch (_) {
return _fallbackDefinitions();
}
}
// --- پروفایل چیدمان ---
Future<DashboardLayoutProfile> getLayoutProfile({
required String breakpoint,
}) async {
try {
final res = await _apiClient.get<Map<String, dynamic>>(
'/api/v1/profile/dashboard/layout',
query: {'breakpoint': breakpoint},
);
final data = res.data?['data'] as Map<String, dynamic>? ?? const {};
final profile = DashboardLayoutProfile.fromJson(data);
if (profile.items.isEmpty) {
// از پیشفرض مبتنی بر تعاریف بساز
final defs = await getWidgetDefinitions();
return _buildDefaultLayout(defs, breakpoint);
}
return profile;
} on DioException catch (e) {
if (e.response?.statusCode == 404) {
final defs = await getWidgetDefinitions();
return _buildDefaultLayout(defs, breakpoint);
}
final defs = await getWidgetDefinitions();
return _buildDefaultLayout(defs, breakpoint);
} catch (_) {
final defs = await getWidgetDefinitions();
return _buildDefaultLayout(defs, breakpoint);
}
}
Future<DashboardLayoutProfile> putLayoutProfile({
required String breakpoint,
required List<DashboardLayoutItem> items,
}) async {
try {
final body = {
'breakpoint': breakpoint,
'items': items.map((e) => e.toJson()).toList(),
};
final res = await _apiClient.put<Map<String, dynamic>>(
'/api/v1/profile/dashboard/layout',
data: body,
);
final data = res.data?['data'] as Map<String, dynamic>? ?? const {};
return DashboardLayoutProfile.fromJson(data);
} catch (_) {
// اگر ذخیره نشد، همان ورودی را به عنوان حالت فعلی برگردان
return DashboardLayoutProfile(
breakpoint: breakpoint,
columns: _fallbackColumns()[breakpoint] ?? 8,
items: items,
version: 1,
updatedAt: '',
);
}
}
// --- دادهی ویجتها (Batch) ---
Future<Map<String, dynamic>> getWidgetsBatchData({
required List<String> widgetKeys,
Map<String, dynamic>? filters,
}) async {
// تلاش برای استفاده از اندپوینت پروفایل
try {
final response = await _apiClient.post<Map<String, dynamic>>(
'/api/v1/profile/dashboard/data',
data: {
'widget_keys': widgetKeys,
'filters': filters ?? const <String, dynamic>{},
},
);
if (response.data?['success'] == true) {
final data = response.data!['data'] as Map<String, dynamic>? ?? const {};
final out = Map<String, dynamic>.from(data);
// پر کردن دادهی ویجتهایی که سرور نداد با fallback
return _withFallbackData(out, widgetKeys);
}
} catch (_) {
// ادامه میدهیم تا fallback پر شود
}
return _fallbackBatchData(widgetKeys);
}
// --- Fallbacks ---
DashboardDefinitionsResponse _fallbackDefinitions() {
final columns = _fallbackColumns();
final items = <DashboardWidgetDefinition>[
DashboardWidgetDefinition(
key: 'profile_recent_businesses',
title: 'کسب‌وکارهای شما',
icon: 'business',
version: 1,
permissionsRequired: const <String>[],
defaults: {
'xs': {'colSpan': 1, 'rowSpan': 2},
'sm': {'colSpan': 2, 'rowSpan': 2},
'md': {'colSpan': 4, 'rowSpan': 2},
'lg': {'colSpan': 4, 'rowSpan': 2},
'xl': {'colSpan': 4, 'rowSpan': 2},
},
),
DashboardWidgetDefinition(
key: 'profile_announcements',
title: 'اعلان‌ها',
icon: 'notifications',
version: 1,
permissionsRequired: const <String>[],
defaults: {
'xs': {'colSpan': 1, 'rowSpan': 2},
'sm': {'colSpan': 2, 'rowSpan': 2},
'md': {'colSpan': 4, 'rowSpan': 2},
'lg': {'colSpan': 4, 'rowSpan': 2},
'xl': {'colSpan': 4, 'rowSpan': 2},
},
),
DashboardWidgetDefinition(
key: 'profile_support_tickets',
title: 'تیکت‌های پشتیبانی',
icon: 'support_agent',
version: 1,
permissionsRequired: const <String>[],
defaults: {
'xs': {'colSpan': 1, 'rowSpan': 2},
'sm': {'colSpan': 2, 'rowSpan': 2},
'md': {'colSpan': 4, 'rowSpan': 2},
'lg': {'colSpan': 4, 'rowSpan': 2},
'xl': {'colSpan': 4, 'rowSpan': 2},
},
),
DashboardWidgetDefinition(
key: 'profile_onboarding_checklist',
title: 'چک‌لیست شروع',
icon: 'checklist',
version: 1,
permissionsRequired: const <String>[],
defaults: {
'xs': {'colSpan': 1, 'rowSpan': 2},
'sm': {'colSpan': 2, 'rowSpan': 2},
'md': {'colSpan': 4, 'rowSpan': 2},
'lg': {'colSpan': 4, 'rowSpan': 2},
'xl': {'colSpan': 4, 'rowSpan': 2},
},
),
];
return DashboardDefinitionsResponse(columns: columns, items: items);
}
Map<String, int> _fallbackColumns() => const <String, int>{
'xs': 1,
'sm': 4,
'md': 8,
'lg': 12,
'xl': 12,
};
DashboardLayoutProfile _buildDefaultLayout(
DashboardDefinitionsResponse defs,
String breakpoint,
) {
final cols = defs.columns[breakpoint] ?? 8;
int order = 1;
final items = <DashboardLayoutItem>[];
for (final d in defs.items) {
final dflt = d.defaults[breakpoint] ?? const <String, int>{};
final colSpan =
(dflt['colSpan'] ?? (cols / 2).floor()).clamp(1, cols);
final rowSpan = dflt['rowSpan'] ?? 2;
items.add(DashboardLayoutItem(
key: d.key,
order: order++,
colSpan: colSpan,
rowSpan: rowSpan,
hidden: false,
));
}
return DashboardLayoutProfile(
breakpoint: breakpoint,
columns: cols,
items: items,
version: 1,
updatedAt: '',
);
}
Map<String, dynamic> _fallbackBatchData(List<String> keys) {
final out = <String, dynamic>{};
return _withFallbackData(out, keys);
}
Map<String, dynamic> _withFallbackData(
Map<String, dynamic> base,
List<String> keys,
) {
final out = Map<String, dynamic>.from(base);
for (final k in keys) {
if (out.containsKey(k)) continue;
if (k == 'profile_recent_businesses') {
out[k] = {
'items': <Map<String, dynamic>>[],
};
} else if (k == 'profile_announcements') {
out[k] = {
'items': <Map<String, dynamic>>[
{
'title': 'به حسابیکس خوش آمدید',
'body': 'به‌زودی تجربه داشبورد شخصی‌سازی‌شده را خواهید داشت.',
'time': DateTime.now().toIso8601String(),
},
],
};
} else if (k == 'profile_support_tickets') {
out[k] = {
'items': <Map<String, dynamic>>[
{
'id': 1001,
'subject': 'سؤال درباره صدور فاکتور',
'status': 'باز',
'updated_at': DateTime.now().subtract(const Duration(hours: 3)).toIso8601String(),
},
{
'id': 1000,
'subject': 'مشکل ورود به حساب',
'status': 'بسته',
'updated_at': DateTime.now().subtract(const Duration(days: 2)).toIso8601String(),
},
],
};
} else if (k == 'profile_onboarding_checklist') {
out[k] = {
'items': <Map<String, dynamic>>[
{'key': 'create_business', 'title': 'ایجاد اولین کسب‌وکار', 'done': false},
{'key': 'add_person', 'title': 'افزودن اولین مخاطب', 'done': false},
{'key': 'issue_invoice', 'title': 'صدور اولین فاکتور', 'done': false},
],
};
}
}
return out;
}
// کمکمتد برای تأمین داده واقعی برخی ویجتها (مثل لیست کسبوکارها)
Future<Map<String, dynamic>> hydrateSpecialWidgets(
Map<String, dynamic> currentData,
List<String> keys,
) async {
final out = Map<String, dynamic>.from(currentData);
if (keys.contains('profile_recent_businesses')) {
try {
final businesses = await _businessService.getUserBusinesses();
out['profile_recent_businesses'] = {
'items': businesses
.map((b) => {
'id': b.id,
'name': b.name,
'role': b.role,
'is_owner': b.isOwner,
})
.toList(),
};
} catch (_) {
// در سکوت ادامه میدهیم؛ دادهی موجود کافی است
}
}
if (keys.contains('profile_support_tickets')) {
try {
final support = SupportService(_apiClient);
final res = await support.searchUserTickets({
'page': 1,
'limit': 5,
'sort_by': 'updated_at',
'sort_desc': true,
});
out['profile_support_tickets'] = {
'items': res.items.map((t) {
return {
'id': t.id,
'subject': t.title,
'status': t.status?.name ?? '',
'updated_at': t.updatedAt.toIso8601String(),
};
}).toList(),
};
} catch (_) {
// fallback باقی میماند
}
}
return out;
}
}

View file

@ -105,6 +105,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.1"
equatable:
dependency: transitive
description:
name: equatable
sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.0.7"
fake_async:
dependency: transitive
description:
@ -217,6 +225,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.1"
fl_chart:
dependency: "direct main"
description:
name: fl_chart
sha256: "00b74ae680df6b1135bdbea00a7d1fc072a9180b7c3f3702e4b19a9943f5ed7d"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.66.2"
flutter:
dependency: "direct main"
description: flutter
@ -517,6 +533,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.8"
reorderables:
dependency: "direct main"
description:
name: reorderables
sha256: "004a886e4878df1ee27321831c838bc1c976311f4ca6a74ce7d561e506540a77"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.6.0"
shamsi_date:
dependency: "direct main"
description:

View file

@ -53,6 +53,8 @@ dependencies:
file_picker: ^10.3.3
file_selector: ^1.0.4
url_launcher: ^6.3.0
reorderables: ^0.6.0
fl_chart: ^0.66.2
dev_dependencies:
flutter_test: