From 192f8776e32855a4cf94edea81d47317a078b09f Mon Sep 17 00:00:00 2001 From: Babak Alizadeh Date: Sun, 9 Nov 2025 18:37:27 +0000 Subject: [PATCH] progress in wallet and dashboard --- .../adapters/api/v1/business_dashboard.py | 137 ++ hesabixAPI/adapters/api/v1/wallet.py | 89 +- hesabixAPI/adapters/api/v1/wallet_webhook.py | 44 +- hesabixAPI/app/core/permissions.py | 20 +- .../app/services/dashboard_widgets_service.py | 439 +++++ hesabixAPI/app/services/payment_service.py | 86 +- hesabixAPI/app/services/wallet_service.py | 73 +- hesabixUI/hesabix_ui/lib/main.dart | 2 + .../lib/models/business_dashboard_models.dart | 142 ++ .../pages/admin/payment_gateways_page.dart | 22 +- .../lib/pages/business/business_shell.dart | 105 +- .../dashboard/business_dashboard_page.dart | 1462 ++++++++++++----- .../lib/pages/business/wallet_page.dart | 538 +++--- .../pages/profile/profile_dashboard_page.dart | 542 +++++- .../services/business_dashboard_service.dart | 104 +- .../services/profile_dashboard_service.dart | 331 ++++ hesabixUI/hesabix_ui/pubspec.lock | 24 + hesabixUI/hesabix_ui/pubspec.yaml | 2 + 18 files changed, 3519 insertions(+), 643 deletions(-) create mode 100644 hesabixAPI/app/services/dashboard_widgets_service.py create mode 100644 hesabixUI/hesabix_ui/lib/services/profile_dashboard_service.dart diff --git a/hesabixAPI/adapters/api/v1/business_dashboard.py b/hesabixAPI/adapters/api/v1/business_dashboard.py index 35b7c68..a62116c 100644 --- a/hesabixAPI/adapters/api/v1/business_dashboard.py +++ b/hesabixAPI/adapters/api/v1/business_dashboard.py @@ -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) diff --git a/hesabixAPI/adapters/api/v1/wallet.py b/hesabixAPI/adapters/api/v1/wallet.py index 66633d4..74cdf3d 100644 --- a/hesabixAPI/adapters/api/v1/wallet.py +++ b/hesabixAPI/adapters/api/v1/wallet.py @@ -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") diff --git a/hesabixAPI/adapters/api/v1/wallet_webhook.py b/hesabixAPI/adapters/api/v1/wallet_webhook.py index af95760..da1184d 100644 --- a/hesabixAPI/adapters/api/v1/wallet_webhook.py +++ b/hesabixAPI/adapters/api/v1/wallet_webhook.py @@ -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") diff --git a/hesabixAPI/app/core/permissions.py b/hesabixAPI/app/core/permissions.py index e9abc28..7af2b24 100644 --- a/hesabixAPI/app/core/permissions.py +++ b/hesabixAPI/app/core/permissions.py @@ -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) diff --git a/hesabixAPI/app/services/dashboard_widgets_service.py b/hesabixAPI/app/services/dashboard_widgets_service.py new file mode 100644 index 0000000..72d91c7 --- /dev/null +++ b/hesabixAPI/app/services/dashboard_widgets_service.py @@ -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, + } + + diff --git a/hesabixAPI/app/services/payment_service.py b/hesabixAPI/app/services/payment_service.py index 7e284d8..00ee84c 100644 --- a/hesabixAPI/app/services/payment_service.py +++ b/hesabixAPI/app/services/payment_service.py @@ -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} # -------------------------- diff --git a/hesabixAPI/app/services/wallet_service.py b/hesabixAPI/app/services/wallet_service.py index 5503a91..8721907 100644 --- a/hesabixAPI/app/services/wallet_service.py +++ b/hesabixAPI/app/services/wallet_service.py @@ -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() diff --git a/hesabixUI/hesabix_ui/lib/main.dart b/hesabixUI/hesabix_ui/lib/main.dart index 0c727f8..a5e2b91 100644 --- a/hesabixUI/hesabix_ui/lib/main.dart +++ b/hesabixUI/hesabix_ui/lib/main.dart @@ -573,6 +573,8 @@ class _MyAppState extends State { pageBuilder: (context, state) => NoTransitionPage( child: BusinessDashboardPage( businessId: int.parse(state.pathParameters['business_id']!), + authStore: _authStore!, + calendarController: _calendarController!, ), ), ), diff --git a/hesabixUI/hesabix_ui/lib/models/business_dashboard_models.dart b/hesabixUI/hesabix_ui/lib/models/business_dashboard_models.dart index 4c30916..17adc09 100644 --- a/hesabixUI/hesabix_ui/lib/models/business_dashboard_models.dart +++ b/hesabixUI/hesabix_ui/lib/models/business_dashboard_models.dart @@ -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 permissionsRequired; + final Map> 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 json) { + final defaultsRaw = json['defaults'] as Map? ?? const {}; + final defaults = >{}; + defaultsRaw.forEach((bp, v) { + final m = Map.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 [], + 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 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 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 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 json) { + return DashboardLayoutProfile( + breakpoint: json['breakpoint'] as String? ?? 'md', + columns: (json['columns'] ?? 8) as int, + items: (json['items'] as List? ?? const []) + .map((e) => DashboardLayoutItem.fromJson(Map.from(e as Map))) + .toList(), + version: (json['version'] ?? 2) as int, + updatedAt: json['updated_at'] as String? ?? '', + ); + } +} + +class DashboardDefinitionsResponse { + final Map columns; + final List items; + + DashboardDefinitionsResponse({ + required this.columns, + required this.items, + }); + + factory DashboardDefinitionsResponse.fromJson(Map json) { + final colsRaw = Map.from(json['columns'] as Map? ?? const {}); + final cols = {}; + colsRaw.forEach((k, v) { + cols[k] = (v is int) ? v : int.tryParse('$v') ?? 0; + }); + return DashboardDefinitionsResponse( + columns: cols, + items: (json['items'] as List? ?? const []) + .map((e) => DashboardWidgetDefinition.fromJson(Map.from(e as Map))) + .toList(), + ); + } +} diff --git a/hesabixUI/hesabix_ui/lib/pages/admin/payment_gateways_page.dart b/hesabixUI/hesabix_ui/lib/pages/admin/payment_gateways_page.dart index 44d2734..3fd3bd1 100644 --- a/hesabixUI/hesabix_ui/lib/pages/admin/payment_gateways_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/admin/payment_gateways_page.dart @@ -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 { 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 { setState(() { _provider = v ?? 'zarinpal'; _applySuggestedCallback(); + _maybeGenerateSandboxMerchantId(); }); }, ), @@ -191,7 +205,13 @@ class _PaymentGatewaysPageState extends State { SwitchListTile( title: const Text('Sandbox'), value: _isSandbox, - onChanged: (v) => setState(() => _isSandbox = v), + onChanged: (v) { + setState(() { + _isSandbox = v; + _applySuggestedCallback(); + _maybeGenerateSandboxMerchantId(); + }); + }, ), Row( children: [ diff --git a/hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart b/hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart index bd4ac3e..67d3240 100644 --- a/hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart @@ -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 { }); } + Future showWalletTopUpDialog() async { + final t = AppLocalizations.of(context); + final formKey = GlobalKey(); + final amountCtrl = TextEditingController(); + final descCtrl = TextEditingController(); + final pgService = PaymentGatewayService(ApiClient()); + List> gateways = const >[]; + int? gatewayId; + try { + gateways = await pgService.listBusinessGateways(widget.businessId); + if (gateways.isNotEmpty) { + gatewayId = int.tryParse('${gateways.first['id']}'); + } + } catch (_) {} + final confirmed = await showDialog( + 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( + value: gatewayId, + decoration: const InputDecoration(labelText: 'درگاه پرداخت'), + items: gateways + .map((g) => DropdownMenuItem( + 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 _loadBusinessInfo() async { print('=== _loadBusinessInfo START ==='); print('Current business ID: ${widget.businessId}'); @@ -850,7 +949,8 @@ class _BusinessShellState extends State { // 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 { // 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) { diff --git a/hesabixUI/hesabix_ui/lib/pages/business/dashboard/business_dashboard_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/dashboard/business_dashboard_page.dart index e35f40b..ce6baa7 100644 --- a/hesabixUI/hesabix_ui/lib/pages/business/dashboard/business_dashboard_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/dashboard/business_dashboard_page.dart @@ -1,15 +1,28 @@ import 'package:flutter/material.dart'; +import 'package:reorderables/reorderables.dart'; +import 'dart:async'; import 'package:hesabix_ui/l10n/app_localizations.dart'; import '../../../services/business_dashboard_service.dart'; import '../../../core/api_client.dart'; import '../../../models/business_dashboard_models.dart'; import '../../../core/fiscal_year_controller.dart'; +import '../../../core/calendar_controller.dart'; import '../../../widgets/fiscal_year_switcher.dart'; +import '../../../core/auth_store.dart'; +import '../../../utils/date_formatters.dart'; +import '../../../utils/number_formatters.dart'; +import 'package:fl_chart/fl_chart.dart'; +import 'package:shamsi_date/shamsi_date.dart'; +import 'package:hesabix_ui/widgets/jalali_date_picker.dart'; + +typedef DashboardWidgetBuilder = Widget Function(BuildContext, dynamic, DashboardLayoutItem, {VoidCallback? onRefresh}); class BusinessDashboardPage extends StatefulWidget { final int businessId; + final AuthStore? authStore; + final CalendarController? calendarController; - const BusinessDashboardPage({super.key, required this.businessId}); + const BusinessDashboardPage({super.key, required this.businessId, this.authStore, this.calendarController}); @override State createState() => _BusinessDashboardPageState(); @@ -18,9 +31,18 @@ class BusinessDashboardPage extends StatefulWidget { class _BusinessDashboardPageState extends State { late final FiscalYearController _fiscalController; late final BusinessDashboardService _service; - BusinessDashboardResponse? _dashboardData; + + DashboardDefinitionsResponse? _definitions; + DashboardLayoutProfile? _layout; + Map _data = {}; bool _loading = true; String? _error; + bool _editMode = false; + Timer? _saveDebounce; + double _columnUnitPx = 0; + static const double _gridSpacingPx = 12.0; + String _salesChartType = 'bar'; // bar | line + String _salesChartGroup = 'day'; // day | week | month @override void initState() { @@ -33,39 +55,174 @@ class _BusinessDashboardPageState extends State { _service = BusinessDashboardService(ApiClient(), fiscalYearController: _fiscalController); ApiClient.bindFiscalYear(ValueNotifier(_fiscalController.fiscalYearId)); _fiscalController.addListener(() { - // به‌روزرسانی هدر سراسری ApiClient.bindFiscalYear(ValueNotifier(_fiscalController.fiscalYearId)); - // رفرش داشبورد - _loadDashboard(); + _reloadDataOnly(); }); - await _loadDashboard(); + await _loadAll(); } - Future _loadDashboard() async { + 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 _loadAll() async { try { setState(() { _loading = true; _error = null; }); - - final data = await _service.getDashboard(widget.businessId); - - if (mounted) { - setState(() { - _dashboardData = data; - _loading = false; - }); + final defs = await _definitionsOrLoad(); + final bp = _currentBreakpoint(MediaQuery.of(context).size.width); + var layout = await _service.getLayoutProfile(businessId: widget.businessId, 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.from(layout.items); + int maxOrder = items.fold(0, (acc, it) => it.order > acc ? it.order : acc); + for (final d in missingDefaults) { + final dflt = d.defaults[bp] ?? const {}; + 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(businessId: widget.businessId, breakpoint: bp, items: items); } + final keys = layout.items.where((e) => !e.hidden).map((e) => e.key).toList(); + final data = await _service.getWidgetsBatchData( + businessId: widget.businessId, + widgetKeys: keys, + filters: {'group': _salesChartGroup}, + ); + if (!mounted) return; + setState(() { + _definitions = defs; + _layout = layout; + _data = data; + _loading = false; + }); } catch (e) { - if (mounted) { - setState(() { - _error = e.toString(); - _loading = false; - }); - } + if (!mounted) return; + setState(() { + _error = '$e'; + _loading = false; + }); } } + Future _reloadDataOnly() async { + try { + final layout = _layout; + if (layout == null) return; + final keys = layout.items.where((e) => !e.hidden).map((e) => e.key).toList(); + final data = await _service.getWidgetsBatchData(businessId: widget.businessId, widgetKeys: keys); + if (!mounted) return; + setState(() { + _data = data; + }); + } catch (_) {} + } + + Future _definitionsOrLoad() async { + if (_definitions != null) return _definitions!; + return await _service.getWidgetDefinitions(widget.businessId); + } + + void _scheduleSaveLayout() { + _saveDebounce?.cancel(); + _saveDebounce = Timer(const Duration(milliseconds: 600), () async { + final profile = _layout; + if (profile == null) return; + try { + final updated = await _service.putLayoutProfile( + businessId: widget.businessId, + breakpoint: profile.breakpoint, + items: profile.items, + ); + if (!mounted) return; + setState(() { + _layout = updated; + }); + } catch (_) { + // ignore save errors silently for now + } + }); + } + + void _applyItems(List items) { + final profile = _layout; + if (profile == null) return; + setState(() { + _layout = DashboardLayoutProfile( + breakpoint: profile.breakpoint, + columns: profile.columns, + items: items, + version: profile.version, + updatedAt: profile.updatedAt, + ); + }); + _scheduleSaveLayout(); + } + + void _reindexAndSave(List items) { + final sorted = []; + for (var i = 0; i < items.length; i++) { + final it = items[i]; + sorted.add(it.copyWith(order: i + 1)); + } + _applyItems(sorted); + } + + void _moveItemUp(DashboardLayoutItem item) { + final profile = _layout; + if (profile == null) return; + final list = List.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.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.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.from(profile.items); + final idx = list.indexWhere((e) => e.key == item.key); + if (idx < 0) return; + list[idx] = item.copyWith(hidden: hidden); + _applyItems(list); + } + @override Widget build(BuildContext context) { final t = AppLocalizations.of(context); @@ -73,418 +230,973 @@ class _BusinessDashboardPageState extends State { if (_loading) { return const Center(child: CircularProgressIndicator()); } - if (_error != null) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.error, size: 64, color: Colors.red), - const SizedBox(height: 16), - Text( - _error!, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyLarge, - ), - const SizedBox(height: 16), - ElevatedButton( - onPressed: _loadDashboard, - child: Text(t.retry), - ), + 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: Text(t.retry)), ], ), ); } + final layout = _layout!; + final items = List.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( - crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - children: [ - Expanded( - child: Text( - t.businessDashboard, - style: Theme.of(context).textTheme.headlineMedium, - ), - ), - FutureBuilder>>( - future: _service.listFiscalYears(widget.businessId), - builder: (context, snapshot) { - final items = snapshot.data ?? const >[]; - if (snapshot.connectionState == ConnectionState.waiting) { - return const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2)); + _buildHeaderRow(t), + const SizedBox(height: 16), + if (!_editMode) + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + final totalWidth = constraints.maxWidth; + double unit = (totalWidth - (crossAxisCount - 1) * _gridSpacingPx) / crossAxisCount; + const double minTileUnit = 180; // حداقل عرض یک ستون برای جلوگیری از صفر/منفی + if (unit <= 0) { + // ignore: avoid_print + print('[DASH][WARN] unit<=0 totalWidth=$totalWidth columns=$crossAxisCount spacing=$_gridSpacingPx'); + unit = minTileUnit; + } else if (unit < minTileUnit) { + // ignore: avoid_print + print('[DASH][INFO] unit too small -> clamped to $minTileUnit (was $unit)'); + unit = minTileUnit; } - if (items.isEmpty) { - return const SizedBox.shrink(); + if (unit > 0 && _columnUnitPx != unit) { + _columnUnitPx = unit; } - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), + final children = []; + for (final it in visible) { + final w = (unit * it.colSpan) + _gridSpacingPx * (it.colSpan - 1); + final cw = w > totalWidth ? totalWidth : (w < unit ? unit : w); + // ignore: avoid_print + print('[DASH] view child key=${it.key} colSpan=${it.colSpan} -> width=$w clamped=$cw unit=$unit totalWidth=$totalWidth'); + children.add(AnimatedContainer( + duration: const Duration(milliseconds: 180), + curve: Curves.easeInOut, + key: ValueKey('dash_item_view_${it.key}'), + width: cw, + child: _buildGridTile(it, crossAxisCount), + )); + } + return SingleChildScrollView( + child: Wrap( + spacing: _gridSpacingPx, + runSpacing: _gridSpacingPx, + children: children, ), - child: Row( + ); + }, + ), + ) + else + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + final totalWidth = constraints.maxWidth; + double unit = (totalWidth - (crossAxisCount - 1) * _gridSpacingPx) / crossAxisCount; + const double minTileUnit = 180; + if (unit <= 0) { + // ignore: avoid_print + print('[DASH][WARN] unit<=0 totalWidth=$totalWidth columns=$crossAxisCount spacing=$_gridSpacingPx'); + unit = minTileUnit; + } else if (unit < minTileUnit) { + // ignore: avoid_print + print('[DASH][INFO] unit too small -> clamped to $minTileUnit (was $unit)'); + unit = minTileUnit; + } + // ذخیره آخرین اندازه واحد ستون برای رزایز اسنپی + if (unit > 0 && _columnUnitPx != unit) { + _columnUnitPx = unit; + } + + final children = []; + for (final it in visible) { + final w = (unit * it.colSpan) + _gridSpacingPx * (it.colSpan - 1); + final cw = w > totalWidth ? totalWidth : (w < unit ? unit : w); + // ignore: avoid_print + print('[DASH] edit child key=${it.key} colSpan=${it.colSpan} -> width=$w clamped=$cw unit=$unit totalWidth=$totalWidth'); + children.add(AnimatedContainer( + duration: const Duration(milliseconds: 180), + curve: Curves.easeInOut, + key: ValueKey('dash_item_${it.key}'), + width: cw, + child: _buildGridTile(it, crossAxisCount), + )); + } + + return SingleChildScrollView( + child: Stack( children: [ - const Icon(Icons.timeline, size: 16), - const SizedBox(width: 6), - FiscalYearSwitcher( - controller: _fiscalController, - fiscalYears: items, - onChanged: () => _loadDashboard(), + // خطوط راهنمای ستون‌ها در حالت ویرایش + if (_editMode) + SizedBox( + width: totalWidth, + child: CustomPaint( + painter: _GridGuidesPainter( + columns: crossAxisCount, + unitWidth: unit, + spacing: _gridSpacingPx, + color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.06), + ), + child: SizedBox(height: children.isEmpty ? 0 : 1), // ارتفاع حداقلی برای render + ), + ), + ReorderableWrap( + spacing: _gridSpacingPx, + runSpacing: _gridSpacingPx, + needsLongPressDraggable: true, + onReorder: (oldIndex, newIndex) { + final list = List.from(visible); + final moved = list.removeAt(oldIndex); + list.insert(newIndex, moved); + final profile = _layout!; + final newItems = []; + final visibleKeys = list.map((e) => e.key).toSet(); + newItems.addAll(list); + for (final it in profile.items) { + if (!visibleKeys.contains(it.key) && it.hidden == false) continue; + if (it.hidden) newItems.add(it); + } + _reindexAndSave(newItems); + }, + children: children, ), ], ), ); }, ), - ], - ), - const SizedBox(height: 16), - if (_dashboardData != null) ...[ - _buildBusinessInfo(_dashboardData!.businessInfo), - const SizedBox(height: 24), - _buildStatistics(_dashboardData!.statistics), - const SizedBox(height: 24), - _buildRecentActivities(_dashboardData!.recentActivities), + ), + if (_editMode) ...[ + const SizedBox(height: 12), + _buildHiddenSection(), ], ], ), ); } - Widget _buildBusinessInfo(BusinessInfo info) { - return Card( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(Icons.business, size: 32, color: Theme.of(context).colorScheme.primary), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - info.name, - style: Theme.of(context).textTheme.headlineSmall, - ), - const SizedBox(height: 4), - Text( - '${info.businessType} - ${info.businessField}', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ], - ), + Widget _buildGridTile(DashboardLayoutItem item, int totalColumns) { + final data = _data[item.key]; + if (data == null) { + return _buildCard( + title: _titleForKey(item.key), + trailing: _editMode + ? const Icon(Icons.tune) + : IconButton( + tooltip: 'بازخوانی', + icon: const Icon(Icons.refresh), + onPressed: _reloadDataOnly, + ), + 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: Center(child: Text('این ویجت ثبت نشده است')), + ); + } + final trailing = _editMode + ? PopupMenuButton( + 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) => [ + PopupMenuItem(value: 'w+1', child: Row(children: const [Icon(Icons.open_in_full, size: 18), SizedBox(width: 8), Text('افزایش عرض')],)), + PopupMenuItem(value: 'w-1', child: Row(children: const [Icon(Icons.close_fullscreen, size: 18), SizedBox(width: 8), Text('کاهش عرض')],)), + const PopupMenuDivider(), + PopupMenuItem(value: 'up', child: Row(children: const [Icon(Icons.arrow_upward, size: 18), SizedBox(width: 8), Text('بالا')],)), + PopupMenuItem(value: 'down', child: Row(children: const [Icon(Icons.arrow_downward, size: 18), SizedBox(width: 8), Text('پایین')],)), + const PopupMenuDivider(), + PopupMenuItem(value: 'hide', child: Row(children: const [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), + ); + if (!_editMode) return card; + // دستگیره رزایز افقی در حالت ویرایش (لبه راست کارت) + return Stack( + children: [ + // سایه ملایم برای کارت در حالت ویرایش + AnimatedContainer( + duration: const Duration(milliseconds: 150), + decoration: BoxDecoration( + boxShadow: [ + BoxShadow( + color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.08), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: card, + ), + // دستگیره رزایز + Positioned.fill( + child: Align( + alignment: Alignment.centerRight, + child: MouseRegion( + cursor: SystemMouseCursors.resizeLeftRight, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onHorizontalDragUpdate: (details) => _resizeItemByDx(item, details.delta.dx), + child: Container( + width: 10, + height: double.infinity, + color: Colors.transparent, + child: const SizedBox.shrink(), ), - ], + ), ), - const SizedBox(height: 16), - Row( - children: [ - _buildInfoChip( - Icons.people, - '${info.memberCount} عضو', - Theme.of(context).colorScheme.primary, - ), - const SizedBox(width: 8), - _buildInfoChip( - Icons.calendar_today, - 'تأسیس: ${_formatDate(info.createdAt)}', - Theme.of(context).colorScheme.secondary, - ), - ], + ), + ), + ], + ); + } + + Widget _buildHeaderRow(AppLocalizations t) { + return Row( + children: [ + Expanded( + child: Text( + t.businessDashboard, + style: Theme.of(context).textTheme.headlineMedium, + ), + ), + if (_editMode) ...[ + IconButton( + tooltip: 'افزودن ویجت', + onPressed: _showAddWidgetDialog, + icon: const Icon(Icons.add_box_outlined), + ), + IconButton( + tooltip: 'بازنشانی چیدمان', + onPressed: _resetLayoutToDefaults, + icon: const Icon(Icons.restore), + ), + if ((widget.authStore?.currentBusiness?.isOwner ?? false)) + IconButton( + tooltip: 'انتشار چیدمان پیش‌فرض کسب‌وکار', + onPressed: _publishBusinessDefaultLayout, + icon: const Icon(Icons.publish), ), - if (info.address != null) ...[ - const SizedBox(height: 12), - Row( + ], + FutureBuilder>>( + future: _service.listFiscalYears(widget.businessId), + builder: (context, snapshot) { + final items = snapshot.data ?? const >[]; + if (snapshot.connectionState == ConnectionState.waiting) { + return const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2)); + } + if (items.isEmpty) { + return const SizedBox.shrink(); + } + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Row( children: [ - Icon(Icons.location_on, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), - const SizedBox(width: 4), - Expanded( - child: Text( - info.address!, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), + const Icon(Icons.timeline, size: 16), + const SizedBox(width: 6), + FiscalYearSwitcher( + controller: _fiscalController, + fiscalYears: items, + onChanged: _reloadDataOnly, ), ], ), - ], - if (info.phone != null || info.mobile != null) ...[ - const SizedBox(height: 8), - Row( - children: [ - Icon(Icons.phone, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), - const SizedBox(width: 4), - Text( - info.phone ?? info.mobile!, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ], + ); + }, + ), + const SizedBox(width: 8), + IconButton( + tooltip: _editMode ? 'خروج از ویرایش' : 'ویرایش چیدمان', + onPressed: () => setState(() => _editMode = !_editMode), + icon: Icon(_editMode ? Icons.check : Icons.edit), + ), + ], + ); + } + + // ====== Widget Registry ====== + Map get _widgetFactory => { + 'latest_sales_invoices': _latestSalesInvoicesWidget, + 'sales_bar_chart': _salesBarChartWidget, + }; + + Widget _latestSalesInvoicesWidget(BuildContext context, dynamic data, DashboardLayoutItem item, {VoidCallback? onRefresh}) { + final theme = Theme.of(context); + final items = (data is Map && data['items'] is List) ? List>.from(data['items'] as List) : const >[]; + return items.isEmpty + ? Padding( + padding: const EdgeInsets.all(16.0), + child: Center( + child: Text('داده‌ای یافت نشد', style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant)), ), - ], - ], - ), - ), - ); + ) + : ListView.separated( + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + itemCount: items.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, index) { + final it = items[index]; + final code = '${it['code'] ?? '-'}'; + final date = DateFormatters.formatServerDateOnly(it['document_date']); + final net = formatWithThousands(it['net_amount']); + final currency = (it['currency_code'] ?? '').toString(); + final itemsCount = (it['items_count'] ?? 0) as int; + final subtitle = StringBuffer() + ..write(date) + ..write(' • ') + ..write(currency.isNotEmpty ? currency : '—') + ..write(' • ') + ..write('اقلام: $itemsCount'); + return ListTile( + dense: true, + leading: const Icon(Icons.receipt_long), + title: Text(code), + subtitle: Text(subtitle.toString()), + trailing: Text( + currency.isNotEmpty ? '$net $currency' : net, + style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w700), + ), + onTap: () { + // TODO: ناوبری به صفحه فاکتور در صورت نیاز + }, + ); + }, + ); } - Widget _buildInfoChip(IconData icon, String text, Color color) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(16), - border: Border.all(color: color.withValues(alpha: 0.3)), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 16, color: color), - const SizedBox(width: 4), - Text( - text, - style: TextStyle( - color: color, - fontSize: 12, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ); - } + Widget _salesBarChartWidget(BuildContext context, dynamic data, DashboardLayoutItem item, {VoidCallback? onRefresh}) { + final theme = Theme.of(context); + final Map payload = (data is Map) ? data : const {}; + final List> items = (payload['items'] is List) ? List>.from(payload['items']) : const >[]; + String currentRange = (payload['range'] ?? 'week').toString(); + String currentGroup = (payload['group'] ?? _salesChartGroup).toString(); + _salesChartGroup = currentGroup; // sync with server if needed - Widget _buildStatistics(BusinessStatistics stats) { - return Card( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - AppLocalizations.of(context).businessStatistics, - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: _buildStatCard( - 'فروش کل', - _formatCurrency(stats.totalSales), - Icons.trending_up, - Colors.green, - ), - ), - const SizedBox(width: 16), - Expanded( - child: _buildStatCard( - 'خرید کل', - _formatCurrency(stats.totalPurchases), - Icons.trending_down, - Colors.orange, - ), - ), - ], - ), - const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: _buildStatCard( - 'اعضای فعال', - stats.activeMembers.toString(), - Icons.people, - Colors.blue, - ), - ), - const SizedBox(width: 16), - Expanded( - child: _buildStatCard( - 'تراکنش‌های اخیر', - stats.recentTransactions.toString(), - Icons.receipt, - Colors.purple, - ), - ), - ], - ), - ], - ), - ), - ); - } - - Widget _buildStatCard(String title, String value, IconData icon, Color color) { - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: color.withValues(alpha: 0.2)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(icon, color: color, size: 20), - const SizedBox(width: 8), - Expanded( - child: Text( - title, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - ], - ), - const SizedBox(height: 8), - Text( - value, - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - color: color, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ); - } - - Widget _buildRecentActivities(List activities) { - return Card( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - AppLocalizations.of(context).recentActivities, - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 16), - if (activities.isEmpty) - Center( - child: Column( - children: [ - Icon( - Icons.inbox, - size: 48, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - const SizedBox(height: 8), - Text( - 'هیچ فعالیتی یافت نشد', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ) - else - ...activities.map((activity) => _buildActivityItem(activity)), - ], - ), - ), - ); - } - - Widget _buildActivityItem(Activity activity) { - IconData activityIcon; - Color iconColor; - - switch (activity.icon) { - case 'sell': - activityIcon = Icons.sell; - iconColor = Colors.green; - break; - case 'person_add': - activityIcon = Icons.person_add; - iconColor = Colors.blue; - break; - case 'assessment': - activityIcon = Icons.assessment; - iconColor = Colors.orange; - break; - default: - activityIcon = Icons.info; - iconColor = Colors.grey; + Future _reloadWith(Map filters) async { + try { + final d = await _service.getWidgetsBatchData( + businessId: widget.businessId, + widgetKeys: const ['sales_bar_chart'], + filters: { + ...filters, + 'group': _salesChartGroup, + }, + ); + if (!mounted) return; + setState(() { + _data['sales_bar_chart'] = d['sales_bar_chart']; + }); + } catch (_) {} } - return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Row( + Widget _filters() { + return Wrap( + spacing: 8, + runSpacing: 8, children: [ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: iconColor.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), - ), - child: Icon(activityIcon, color: iconColor, size: 20), + ChoiceChip( + label: const Text('این هفته'), + selected: currentRange == 'week', + onSelected: (_) => _reloadWith({'range': 'week'}), + ), + ChoiceChip( + label: const Text('این ماه'), + selected: currentRange == 'month', + onSelected: (_) => _reloadWith({'range': 'month'}), + ), + ChoiceChip( + label: const Text('سال مالی'), + selected: currentRange == 'fiscal', + onSelected: (_) => _reloadWith({'range': 'fiscal'}), + ), + ActionChip( + label: const Text('بازه سفارشی'), + onPressed: () async { + final picked = await _pickCustomRange(context); + if (picked != null) { + _reloadWith({'range': 'custom', 'from': picked.$1, 'to': picked.$2}); + } + }, ), const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + // Chart type + ChoiceChip( + label: const Text('میله‌ای'), + selected: _salesChartType == 'bar', + onSelected: (_) => setState(() => _salesChartType = 'bar'), + ), + ChoiceChip( + label: const Text('خطی'), + selected: _salesChartType == 'line', + onSelected: (_) => setState(() => _salesChartType = 'line'), + ), + const SizedBox(width: 12), + ChoiceChip( + label: const Text('روزانه'), + selected: _salesChartGroup == 'day', + onSelected: (_) async { + setState(() => _salesChartGroup = 'day'); + await _reloadWith({'range': currentRange}); + }, + ), + ChoiceChip( + label: const Text('هفتگی'), + selected: _salesChartGroup == 'week', + onSelected: (_) async { + setState(() => _salesChartGroup = 'week'); + await _reloadWith({'range': currentRange}); + }, + ), + ChoiceChip( + label: const Text('ماهانه'), + selected: _salesChartGroup == 'month', + onSelected: (_) async { + setState(() => _salesChartGroup = 'month'); + await _reloadWith({'range': currentRange}); + }, + ), + ], + ); + } + + final List> grouped = items; // already grouped by server (or daily for day) + final bars = []; + final points = []; + double maxY = 0; + for (int i = 0; i < grouped.length; i++) { + final it = grouped[i]; + final amount = (it['amount'] as num?)?.toDouble() ?? 0; + if (amount > maxY) maxY = amount; + bars.add( + BarChartGroupData( + x: i, + barRods: [ + BarChartRodData( + toY: amount, + width: 12, + color: theme.colorScheme.primary, + borderRadius: BorderRadius.circular(4), + ), + ], + ), + ); + points.add(FlSpot(i.toDouble(), amount)); + } + if (maxY <= 0) maxY = 1; + + String _labelForIndex(int i) { + if (i < 0 || i >= grouped.length) return ''; + final item = grouped[i]; + final key = (item['key'] ?? item['date'] ?? '').toString(); + // key may be iso day, week key (yyyy-ww), or month key (yyyy-mm) + try { + if (_salesChartGroup == 'month') { + final parts = key.split('-'); + final year = int.parse(parts[0]); + final month = int.parse(parts[1]); + if (widget.calendarController?.isJalali == true) { + // convert first day of that month to jalali month label + final d = DateTime(year, month, 1); + final j = Jalali.fromDateTime(d); + return _jalaliMonthName(j.month); + } + return _gregorianMonthName(month); + } else if (_salesChartGroup == 'week') { + // key format: yyyy-ww; show 'Wxx' + final ww = key.split('-').last; + return 'هفته $ww'; + } else { + // day: key is iso date + final d = DateTime.parse(key); + if (widget.calendarController?.isJalali == true) { + final j = Jalali.fromDateTime(d); + return '${j.day}'; + } + return d.day.toString(); + } + } catch (_) { + return key; + } + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 4), + child: _filters(), + ), + SizedBox( + height: 240, + child: Padding( + padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), + child: grouped.isEmpty + ? Center(child: Text('داده‌ای برای نمایش نیست', style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant))) + : (_salesChartType == 'bar' + ? BarChart( + BarChartData( + gridData: FlGridData(show: true, horizontalInterval: maxY / 4), + titlesData: FlTitlesData( + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 40, + interval: maxY / 4, + getTitlesWidget: (value, meta) => Text(formatWithThousands(value, decimalPlaces: 0), style: theme.textTheme.labelSmall), + ), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + getTitlesWidget: (value, meta) => Padding( + padding: const EdgeInsets.only(top: 4), + child: Text(_labelForIndex(value.toInt()), style: theme.textTheme.labelSmall), + ), + ), + ), + rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + ), + borderData: FlBorderData(show: false), + barGroups: bars, + alignment: BarChartAlignment.spaceBetween, + maxY: maxY * 1.2, + ), + ) + : LineChart( + LineChartData( + gridData: FlGridData(show: true, horizontalInterval: maxY / 4), + titlesData: FlTitlesData( + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 40, + interval: maxY / 4, + getTitlesWidget: (value, meta) => Text(formatWithThousands(value, decimalPlaces: 0), style: theme.textTheme.labelSmall), + ), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + getTitlesWidget: (value, meta) => Padding( + padding: const EdgeInsets.only(top: 4), + child: Text(_labelForIndex(value.toInt()), style: theme.textTheme.labelSmall), + ), + ), + ), + rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + ), + borderData: FlBorderData(show: false), + lineBarsData: [ + LineChartBarData( + isCurved: true, + color: theme.colorScheme.primary, + barWidth: 3, + dotData: FlDotData(show: false), + spots: points, + ), + ], + minY: 0, + maxY: maxY * 1.2, + ), + )), + ), + ), + ], + ); + } + + // Pick custom date range; returns iso from/to + Future<(String, String)?> _pickCustomRange(BuildContext context) async { + final isJalali = widget.calendarController?.isJalali == true; + if (isJalali) { + try { + final now = DateTime.now(); + final from = await showJalaliDatePicker( + context: context, + initialDate: now, + firstDate: DateTime(now.year - 10, 1, 1), + lastDate: DateTime(now.year + 10, 12, 31), + helpText: 'انتخاب تاریخ شروع', + ); + if (from == null) return null; + final to = await showJalaliDatePicker( + context: context, + initialDate: from, + firstDate: from, + lastDate: DateTime(now.year + 10, 12, 31), + helpText: 'انتخاب تاریخ پایان', + ); + if (to == null) return null; + final a = from.isBefore(to) ? from : to; + final b = from.isBefore(to) ? to : from; + return (_isoDate(a), _isoDate(b)); + } catch (_) {/* fallback below */} + } + // Gregorian fallback + DateTime? from = await showDatePicker( + context: context, + initialDate: DateTime.now(), + firstDate: DateTime(2000), + lastDate: DateTime(2100), + ); + if (from == null) return null; + DateTime? to = await showDatePicker( + context: context, + initialDate: from, + firstDate: from, + lastDate: DateTime(2100), + ); + if (to == null) return null; + final a = from.isBefore(to) ? from : to; + final b = from.isBefore(to) ? to : from; + return (_isoDate(a), _isoDate(b)); + } + + 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: [ - Text( - activity.title, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 2), - Text( - activity.description, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), + Expanded(child: Text(title, style: Theme.of(context).textTheme.titleMedium)), + if (trailing != null) trailing, ], ), ), - Text( - activity.timeAgo, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), + // در محیط Wrap/Flow نباید از Expanded استفاده کنیم + child, ], ), ); } - String _formatCurrency(double amount) { - if (amount >= 1000000) { - return '${(amount / 1000000).toStringAsFixed(1)}M تومان'; - } else if (amount >= 1000) { - return '${(amount / 1000).toStringAsFixed(1)}K تومان'; - } else { - return '${amount.toStringAsFixed(0)} تومان'; + Widget _buildHiddenSection() { + final profile = _layout; + if (profile == null) return const SizedBox.shrink(); + final hidden = profile.items.where((e) => e.hidden).toList(); + if (hidden.isEmpty) return const SizedBox.shrink(); + return Card( + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('ویجت‌های پنهان', style: Theme.of(context).textTheme.titleSmall), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: hidden.map((it) { + return InputChip( + label: Text(_titleForKey(it.key)), + avatar: const Icon(Icons.widgets, size: 18), + onPressed: () => _hideItem(it, hidden: false), + ); + }).toList(), + ), + ], + ), + ), + ); + } + + Future _showAddWidgetDialog() async { + if (_definitions == null || _layout == null) return; + final defs = _definitions!; + final profile = _layout!; + final rows = List.from(defs.items); + String query = ''; + await showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('افزودن ویجت'), + content: SizedBox( + width: 420, + child: StatefulBuilder( + builder: (context, setSt) { + final filtered = rows.where((d) { + if (query.trim().isEmpty) return true; + final q = query.toLowerCase(); + return d.title.toLowerCase().contains(q) || d.key.toLowerCase().contains(q); + }).toList(); + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + decoration: const InputDecoration(prefixIcon: Icon(Icons.search), hintText: 'جست‌وجوی ویجت...'), + onChanged: (v) => setSt(() => query = v), + ), + const SizedBox(height: 8), + if (filtered.isEmpty) + const Expanded(child: Center(child: Text('ویجت جدیدی برای افزودن موجود نیست.'))) + else + Expanded( + child: ListView.builder( + shrinkWrap: true, + itemCount: filtered.length, + itemBuilder: (context, index) { + final d = filtered[index]; + final existing = profile.items.where((it) => it.key == d.key).toList(); + DashboardLayoutItem? existingItem = existing.isNotEmpty ? existing.first : null; + final isVisible = existingItem != null && !existingItem.hidden; + final isHidden = existingItem != null && existingItem.hidden; + final status = isVisible ? 'در حال نمایش' : (isHidden ? 'پنهان' : 'افزوده نشده'); + return ListTile( + leading: const Icon(Icons.widgets_outlined), + title: Text(d.title), + subtitle: Text('${d.key} • $status'), + trailing: isVisible + ? const SizedBox.shrink() + : ElevatedButton( + onPressed: () async { + if (isHidden && existingItem != null) { + _hideItem(existingItem, hidden: false); + } else { + _addWidgets([d.key]); + } + if (context.mounted) Navigator.pop(context); + }, + child: Text(isHidden ? 'نمایش' : 'افزودن'), + ), + ); + }, + ), + ), + ], + ); + }, + ), + ), + actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('بستن'))], + ); + }, + ); + if (!mounted) return; + } + + void _addWidgets(List keys) async { + if (keys.isEmpty || _definitions == null || _layout == null) return; + final defs = _definitions!; + final profile = _layout!; + final bp = profile.breakpoint; + final list = List.from(profile.items); + var maxOrder = 0; + for (final it in list) { + if (it.order > maxOrder) maxOrder = it.order; + } + for (final k in keys) { + final def = defs.items.firstWhere((d) => d.key == k, orElse: () => DashboardWidgetDefinition( + key: k, title: k, icon: 'widgets', version: 1, permissionsRequired: const [], defaults: const {}, + )); + final dflt = def.defaults[bp] ?? const {}; + final colSpan = (dflt['colSpan'] ?? (profile.columns / 2).floor()).clamp(1, profile.columns); + final rowSpan = dflt['rowSpan'] ?? 2; + // اگر موجود است ولی hidden بود → آشکار کن + final idxExisting = list.indexWhere((e) => e.key == k); + if (idxExisting >= 0) { + list[idxExisting] = list[idxExisting].copyWith(hidden: false, colSpan: colSpan, rowSpan: rowSpan); + } else { + maxOrder += 1; + list.add(DashboardLayoutItem(key: k, order: maxOrder, colSpan: colSpan, rowSpan: rowSpan, hidden: false)); + } + } + _applyItems(list); + // داده‌ی ویجت‌های جدید + try { + final newData = await _service.getWidgetsBatchData(businessId: widget.businessId, widgetKeys: keys); + if (!mounted) return; + setState(() { + _data.addAll(newData); + }); + } catch (_) {} + } + + Future _resetLayoutToDefaults() async { + if (_definitions == null || _layout == null) return; + final defs = _definitions!; + final profile = _layout!; + final bp = profile.breakpoint; + final columns = profile.columns; + // تلاش برای دریافت پیش‌فرض کسب‌وکار + try { + final businessDefault = await _service.getBusinessDefaultLayout(businessId: widget.businessId, breakpoint: bp); + if (businessDefault != null && businessDefault.items.isNotEmpty) { + setState(() { + _layout = businessDefault; + }); + final keys = businessDefault.items.where((e) => !e.hidden).map((e) => e.key).toList(); + final data = await _service.getWidgetsBatchData(businessId: widget.businessId, widgetKeys: keys); + if (!mounted) return; + setState(() => _data = data); + _scheduleSaveLayout(); + return; + } + } catch (_) { + // fallback به پیش‌فرض سیستم + } + int order = 1; + final items = []; + for (final d in defs.items) { + final dflt = d.defaults[bp] ?? const {}; + final colSpan = (dflt['colSpan'] ?? (columns / 2).floor()).clamp(1, columns); + final rowSpan = dflt['rowSpan'] ?? 2; + items.add(DashboardLayoutItem(key: d.key, order: order++, colSpan: colSpan, rowSpan: rowSpan, hidden: false)); + } + _applyItems(items); + // بازخوانی داده‌ها + final keys = items.where((e) => !e.hidden).map((e) => e.key).toList(); + try { + final data = await _service.getWidgetsBatchData(businessId: widget.businessId, widgetKeys: keys); + if (!mounted) return; + setState(() => _data = data); + } catch (_) {} + } + + Future _publishBusinessDefaultLayout() async { + final profile = _layout; + if (profile == null) return; + try { + await _service.putBusinessDefaultLayout( + businessId: widget.businessId, + breakpoint: profile.breakpoint, + items: profile.items, + ); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('چیدمان پیش‌فرض کسب‌وکار منتشر شد'))); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا در انتشار: $e'))); } } - String _formatDate(String dateString) { - try { - final date = DateTime.parse(dateString); - return '${date.year}/${date.month.toString().padLeft(2, '0')}/${date.day.toString().padLeft(2, '0')}'; - } catch (e) { - return dateString; + String _titleForKey(String key) { + switch (key) { + case 'latest_sales_invoices': + return 'آخرین فاکتورهای فروش'; + case 'sales_bar_chart': + return 'نمودار فروش'; + default: + return key; } } + + // old formatters removed; using DateFormatters and number_formatters + + // --- Resize helpers --- + void _resizeItemByDx(DashboardLayoutItem item, double dx) { + final profile = _layout; + if (profile == null || _columnUnitPx <= 0) return; + // هر ستون موثر: unit + spacing به جز آخرین ستون + final colPixel = _columnUnitPx + _gridSpacingPx; + // برآورد تعداد ستون‌های جدید بر اساس جابجایی + final deltaCols = (dx / colPixel).round(); + if (deltaCols == 0) return; + _changeItemWidth(item, deltaCols); + } +} + +String _jalaliMonthName(int m) { + const months = [ + '', + 'فروردین','اردیبهشت','خرداد','تیر','مرداد','شهریور', + 'مهر','آبان','آذر','دی','بهمن','اسفند' + ]; + if (m >= 1 && m <= 12) return months[m]; + return '$m'; +} + +String _gregorianMonthName(int m) { + const months = [ + '', + 'ژانویه','فوریه','مارس','آوریل','مه','ژوئن', + 'ژوئیه','اوت','سپتامبر','اکتبر','نوامبر','دسامبر' + ]; + if (m >= 1 && m <= 12) return months[m]; + return '$m'; +} + +String _isoDate(DateTime d) => '${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}'; + +class _GridGuidesPainter extends CustomPainter { + final int columns; + final double unitWidth; + final double spacing; + final Color color; + + _GridGuidesPainter({ + required this.columns, + required this.unitWidth, + required this.spacing, + required this.color, + }); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color + ..style = PaintingStyle.fill; + double x = 0; + for (int i = 0; i < columns; i++) { + // رسم نوار خیلی کم‌رنگ برای هر ستون + final rect = Rect.fromLTWH(x, 0, unitWidth, size.height <= 0 ? 2000 : size.height); + canvas.drawRect(rect, paint); + x += unitWidth + spacing; + } + } + + @override + bool shouldRepaint(covariant _GridGuidesPainter oldDelegate) { + return oldDelegate.columns != columns || + oldDelegate.unitWidth != unitWidth || + oldDelegate.spacing != spacing || + oldDelegate.color != color; + } } diff --git a/hesabixUI/hesabix_ui/lib/pages/business/wallet_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/wallet_page.dart index 7664a32..9bbccd2 100644 --- a/hesabixUI/hesabix_ui/lib/pages/business/wallet_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/wallet_page.dart @@ -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 { bool _loading = true; Map? _overview; String? _error; - List> _transactions = const >[]; Map? _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 { 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 { 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 _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 _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 _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 _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( + 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 _showOpenLinkDialog(String url) async { + if (!mounted) return; + await showDialog( + 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 { ? 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>( + config: DataTableConfig>( + 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 { 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.from(json), + calendarController: _calendarCtrl, + ), ), - ), + ], ), - ], - ), + ); + }, ), ); } diff --git a/hesabixUI/hesabix_ui/lib/pages/profile/profile_dashboard_page.dart b/hesabixUI/hesabix_ui/lib/pages/profile/profile_dashboard_page.dart index c335f04..370904e 100644 --- a/hesabixUI/hesabix_ui/lib/pages/profile/profile_dashboard_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/profile/profile_dashboard_page.dart @@ -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 createState() => _ProfileDashboardPageState(); +} + +typedef ProfileWidgetBuilder = Widget Function(BuildContext, dynamic, DashboardLayoutItem, {VoidCallback? onRefresh}); + +class _ProfileDashboardPageState extends State { + late final ProfileDashboardService _service; + DashboardLayoutProfile? _layout; + Map _data = {}; + 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 _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.from(layout.items); + int maxOrder = items.fold(0, (acc, it) => it.order > acc ? it.order : acc); + for (final d in missingDefaults) { + final dflt = d.defaults[bp] ?? const {}; + 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 _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.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 = []; + 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( + 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.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.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.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.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 items) { + final sorted = []; + for (var i = 0; i < items.length; i++) { + final it = items[i]; + sorted.add(it.copyWith(order: i + 1)); + } + _applyItems(sorted); + } + + void _applyItems(List 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 get _widgetFactory => { + '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>.from(data['items'] as List) : const >[]; + 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>.from(data['items'] as List) : const >[]; + 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>.from(data['items'] as List) : const >[]; + 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>.from(data['items'] as List) : const >[]; + 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>.from(items); + final idx = list.indexOf(it); + if (idx >= 0) { + list[idx] = Map.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('بازخوانی'), + ), + ), + ], + ), + ); + } } diff --git a/hesabixUI/hesabix_ui/lib/services/business_dashboard_service.dart b/hesabixUI/hesabix_ui/lib/services/business_dashboard_service.dart index f12c693..9ce5272 100644 --- a/hesabixUI/hesabix_ui/lib/services/business_dashboard_service.dart +++ b/hesabixUI/hesabix_ui/lib/services/business_dashboard_service.dart @@ -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 getWidgetDefinitions(int businessId) async { + final res = await _apiClient.get>( + '/api/v1/business/$businessId/dashboard/widgets/definitions', + ); + final data = res.data?['data'] as Map? ?? const {}; + return DashboardDefinitionsResponse.fromJson(data); + } + + Future getLayoutProfile({ + required int businessId, + required String breakpoint, + }) async { + final res = await _apiClient.get>( + '/api/v1/business/$businessId/dashboard/layout', + query: {'breakpoint': breakpoint}, + ); + final data = res.data?['data'] as Map? ?? const {}; + return DashboardLayoutProfile.fromJson(data); + } + + Future putLayoutProfile({ + required int businessId, + required String breakpoint, + required List items, + }) async { + final body = { + 'breakpoint': breakpoint, + 'items': items.map((e) => e.toJson()).toList(), + }; + final res = await _apiClient.put>( + '/api/v1/business/$businessId/dashboard/layout', + data: body, + ); + final data = res.data?['data'] as Map? ?? const {}; + return DashboardLayoutProfile.fromJson(data); + } + + Future> getWidgetsBatchData({ + required int businessId, + required List widgetKeys, + Map? filters, + }) async { + final res = await _api_client_postJson( + '/api/v1/business/$businessId/dashboard/data', + { + 'widget_keys': widgetKeys, + 'filters': filters ?? const {}, + }, + ); + final data = (res['data'] as Map?) ?? const {}; + return Map.from(data); + } + + // Business default layout (owner can publish) + Future getBusinessDefaultLayout({ + required int businessId, + required String breakpoint, + }) async { + final res = await _apiClient.get>( + '/api/v1/business/$businessId/dashboard/layout/default', + query: {'breakpoint': breakpoint}, + ); + final data = res.data?['data'] as Map? ?? const {}; + if (data.isEmpty) return null; + return DashboardLayoutProfile.fromJson(data); + } + + Future putBusinessDefaultLayout({ + required int businessId, + required String breakpoint, + required List items, + }) async { + final body = { + 'breakpoint': breakpoint, + 'items': items.map((e) => e.toJson()).toList(), + }; + final res = await _apiClient.put>( + '/api/v1/business/$businessId/dashboard/layout/default', + data: body, + ); + final data = res.data?['data'] as Map? ?? const {}; + return DashboardLayoutProfile.fromJson(data); + } + + Future> _api_client_postJson(String path, Map body) async { + final response = await _apiClient.post>( + path, + data: body, + options: Options(headers: { + if (fiscalYearController?.fiscalYearId != null) + 'X-Fiscal-Year-ID': fiscalYearController!.fiscalYearId.toString(), + }), + ); + if (response.data?['success'] == true) { + return Map.from(response.data!); + } + throw Exception(response.data?['message'] ?? 'API error'); + } } diff --git a/hesabixUI/hesabix_ui/lib/services/profile_dashboard_service.dart b/hesabixUI/hesabix_ui/lib/services/profile_dashboard_service.dart new file mode 100644 index 0000000..3634cf3 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/services/profile_dashboard_service.dart @@ -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 getWidgetDefinitions() async { + try { + final res = await _apiClient.get>( + '/api/v1/profile/dashboard/widgets/definitions', + ); + final data = res.data?['data'] as Map? ?? 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 getLayoutProfile({ + required String breakpoint, + }) async { + try { + final res = await _apiClient.get>( + '/api/v1/profile/dashboard/layout', + query: {'breakpoint': breakpoint}, + ); + final data = res.data?['data'] as Map? ?? 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 putLayoutProfile({ + required String breakpoint, + required List items, + }) async { + try { + final body = { + 'breakpoint': breakpoint, + 'items': items.map((e) => e.toJson()).toList(), + }; + final res = await _apiClient.put>( + '/api/v1/profile/dashboard/layout', + data: body, + ); + final data = res.data?['data'] as Map? ?? const {}; + return DashboardLayoutProfile.fromJson(data); + } catch (_) { + // اگر ذخیره نشد، همان ورودی را به عنوان حالت فعلی برگردان + return DashboardLayoutProfile( + breakpoint: breakpoint, + columns: _fallbackColumns()[breakpoint] ?? 8, + items: items, + version: 1, + updatedAt: '', + ); + } + } + + // --- داده‌ی ویجت‌ها (Batch) --- + Future> getWidgetsBatchData({ + required List widgetKeys, + Map? filters, + }) async { + // تلاش برای استفاده از اندپوینت پروفایل + try { + final response = await _apiClient.post>( + '/api/v1/profile/dashboard/data', + data: { + 'widget_keys': widgetKeys, + 'filters': filters ?? const {}, + }, + ); + if (response.data?['success'] == true) { + final data = response.data!['data'] as Map? ?? const {}; + final out = Map.from(data); + // پر کردن داده‌ی ویجت‌هایی که سرور نداد با fallback + return _withFallbackData(out, widgetKeys); + } + } catch (_) { + // ادامه می‌دهیم تا fallback پر شود + } + return _fallbackBatchData(widgetKeys); + } + + // --- Fallbacks --- + DashboardDefinitionsResponse _fallbackDefinitions() { + final columns = _fallbackColumns(); + final items = [ + DashboardWidgetDefinition( + key: 'profile_recent_businesses', + title: 'کسب‌وکارهای شما', + icon: 'business', + version: 1, + permissionsRequired: const [], + 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 [], + 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 [], + 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 [], + 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 _fallbackColumns() => const { + '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 = []; + for (final d in defs.items) { + final dflt = d.defaults[breakpoint] ?? const {}; + 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 _fallbackBatchData(List keys) { + final out = {}; + return _withFallbackData(out, keys); + } + + Map _withFallbackData( + Map base, + List keys, + ) { + final out = Map.from(base); + for (final k in keys) { + if (out.containsKey(k)) continue; + if (k == 'profile_recent_businesses') { + out[k] = { + 'items': >[], + }; + } else if (k == 'profile_announcements') { + out[k] = { + 'items': >[ + { + 'title': 'به حسابیکس خوش آمدید', + 'body': 'به‌زودی تجربه داشبورد شخصی‌سازی‌شده را خواهید داشت.', + 'time': DateTime.now().toIso8601String(), + }, + ], + }; + } else if (k == 'profile_support_tickets') { + out[k] = { + 'items': >[ + { + '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': >[ + {'key': 'create_business', 'title': 'ایجاد اولین کسب‌وکار', 'done': false}, + {'key': 'add_person', 'title': 'افزودن اولین مخاطب', 'done': false}, + {'key': 'issue_invoice', 'title': 'صدور اولین فاکتور', 'done': false}, + ], + }; + } + } + return out; + } + + // کمک‌متد برای تأمین داده واقعی برخی ویجت‌ها (مثل لیست کسب‌وکارها) + Future> hydrateSpecialWidgets( + Map currentData, + List keys, + ) async { + final out = Map.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; + } +} + + diff --git a/hesabixUI/hesabix_ui/pubspec.lock b/hesabixUI/hesabix_ui/pubspec.lock index a43cecf..12c4029 100644 --- a/hesabixUI/hesabix_ui/pubspec.lock +++ b/hesabixUI/hesabix_ui/pubspec.lock @@ -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: diff --git a/hesabixUI/hesabix_ui/pubspec.yaml b/hesabixUI/hesabix_ui/pubspec.yaml index 3cb3449..8e304bc 100644 --- a/hesabixUI/hesabix_ui/pubspec.yaml +++ b/hesabixUI/hesabix_ui/pubspec.yaml @@ -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: