progress in wallet and dashboard
This commit is contained in:
parent
b0884a33fd
commit
192f8776e3
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
439
hesabixAPI/app/services/dashboard_widgets_service.py
Normal file
439
hesabixAPI/app/services/dashboard_widgets_service.py
Normal 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,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -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,20 +97,39 @@ 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 {}
|
||||
if int(data.get("Status") or -1) == 100 and data.get("Authority"):
|
||||
authority = str(data["Authority"])
|
||||
# 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:
|
||||
# Fallback: in dev, generate a pseudo authority to continue flow
|
||||
authority = authority or f"TEST-AUTH-{tx_id}"
|
||||
|
|
@ -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}
|
||||
|
||||
|
||||
# --------------------------
|
||||
|
|
|
|||
|
|
@ -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,10 +417,17 @@ 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()
|
||||
logger.warning("gateway_initiate_failed", error=str(ex))
|
||||
# اگر ایجاد لینک شکست بخورد، مانده 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()
|
||||
|
|
|
|||
|
|
@ -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!,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(friendly)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
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);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
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) Navigator.of(ctx).pop();
|
||||
}
|
||||
},
|
||||
child: const Text('باز کردن'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// فیلتر بازه تاریخ اکنون توسط DataTableWidget و Dialog داخلی آن انجام میشود
|
||||
|
||||
// بارگذاری بیشتر جایگزین با جدول سراسری شده است
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = AppLocalizations.of(context);
|
||||
|
|
@ -307,172 +429,146 @@ class _WalletPageState extends State<WalletPage> {
|
|||
? const Center(child: CircularProgressIndicator())
|
||||
: _error != null
|
||||
? Center(child: Text(_error!))
|
||||
: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
: 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,
|
||||
children: [
|
||||
Icon(Icons.wallet, size: 32, color: theme.colorScheme.primary),
|
||||
const SizedBox(width: 12),
|
||||
Text('کیفپول کسبوکار', style: theme.textTheme.titleLarge),
|
||||
const Spacer(),
|
||||
Chip(label: Text(currency)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Card(
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.wallet, size: 32, color: theme.colorScheme.primary),
|
||||
const SizedBox(width: 12),
|
||||
Text('کیفپول کسبوکار', style: theme.textTheme.titleLarge),
|
||||
const Spacer(),
|
||||
Chip(label: Text(currency)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('مانده قابل برداشت', style: theme.textTheme.labelLarge),
|
||||
const SizedBox(height: 8),
|
||||
Text('${formatWithThousands((overview?['available_balance'] ?? 0) is num ? (overview?['available_balance'] ?? 0) : double.tryParse('${overview?['available_balance']}') ?? 0)}', style: theme.textTheme.headlineSmall),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('مانده در انتظار تایید', style: theme.textTheme.labelLarge),
|
||||
const SizedBox(height: 8),
|
||||
Text('${formatWithThousands((overview?['pending_balance'] ?? 0) is num ? (overview?['pending_balance'] ?? 0) : double.tryParse('${overview?['pending_balance']}') ?? 0)}', style: theme.textTheme.headlineSmall),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
const Spacer(),
|
||||
FilledButton.icon(
|
||||
onPressed: _openPayoutDialog,
|
||||
icon: const Icon(Icons.account_balance),
|
||||
label: const Text('درخواست تسویه'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _openTopUpDialog,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('افزایش اعتبار'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (_metrics != null)
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('مانده قابل برداشت', style: theme.textTheme.labelLarge),
|
||||
Text('گزارش ۳۰ روز اخیر', style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
Text('${overview?['available_balance'] ?? 0}', style: theme.textTheme.headlineSmall),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
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)}')),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('مانده در انتظار تایید', style: theme.textTheme.labelLarge),
|
||||
const SizedBox(height: 8),
|
||||
Text('${overview?['pending_balance'] ?? 0}', style: theme.textTheme.headlineSmall),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
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,
|
||||
icon: const Icon(Icons.account_balance),
|
||||
label: const Text('درخواست تسویه'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _openTopUpDialog,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('افزایش اعتبار'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (_metrics != null)
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('گزارش ۳۰ روز اخیر', style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
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}')),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
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 خلاصه'),
|
||||
),
|
||||
],
|
||||
),
|
||||
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'];
|
||||
const SizedBox(height: 16),
|
||||
// دکمههای خروجی CSV در پایین جدول موجود هستند؛ این بخش حذف شد تا فیلتر تاریخ از طریق جدول انجام شود
|
||||
const SizedBox(height: 16),
|
||||
Text('تراکنشهای اخیر', style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,547 @@
|
|||
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('بازخوانی'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
331
hesabixUI/hesabix_ui/lib/services/profile_dashboard_service.dart
Normal file
331
hesabixUI/hesabix_ui/lib/services/profile_dashboard_service.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in a new issue