From f1a5bb4c411ca7ee5b538fb31bc3845892bb5f03 Mon Sep 17 00:00:00 2001 From: Babak Alizadeh Date: Fri, 19 Sep 2025 04:35:13 +0330 Subject: [PATCH] more progress in base system and table design --- .../AUTH_FOOTER_INTEGRATION.md | 0 .../CALENDAR_FEATURE_README.md | 0 .../CALENDAR_SWITCHER_ICON_ONLY.md | 0 .../CALENDAR_SWITCHER_MULTILINGUAL.md | 0 .../CALENDAR_SWITCHER_REDESIGN.md | 0 .../CALENDAR_SWITCHER_UPDATE.md | 0 .../FLUTTER_MIRROR_SETUP.md | 0 .../FLUTTER_WEB_TROUBLESHOOTING.md | 0 .../JALALI_CALENDAR_FINAL.md | 0 .../JALALI_CALENDAR_IMPLEMENTATION.md | 0 .../LOGIN_PAGE_LAYOUT_FIX.md | 0 .../MARKETING_CALENDAR_INTEGRATION.md | 0 hesabixAPI/=3.1.0 | 9 + hesabixAPI/adapters/api/v1/auth.py | 312 +++- hesabixAPI/adapters/api/v1/schemas.py | 17 + hesabixAPI/adapters/api/v1/users.py | 144 ++ .../adapters/db/repositories/base_repo.py | 55 + .../adapters/db/repositories/user_repo.py | 21 +- hesabixAPI/app/core/auth_dependency.py | 155 +- hesabixAPI/app/core/calendar.py | 2 +- hesabixAPI/app/core/i18n.py | 5 + hesabixAPI/app/core/responses.py | 6 +- hesabixAPI/app/main.py | 2 + hesabixAPI/app/services/pdf/README.md | 148 ++ hesabixAPI/app/services/pdf/__init__.py | 6 + .../app/services/pdf/base_pdf_service.py | 135 ++ .../app/services/pdf/modules/__init__.py | 3 + .../pdf/modules/marketing/__init__.py | 6 + .../pdf/modules/marketing/marketing_module.py | 441 +++++ .../templates/marketing_referrals.html | 331 ++++ hesabixAPI/app/services/query_service.py | 162 ++ hesabixAPI/hesabix_api.egg-info/PKG-INFO | 2 + hesabixAPI/hesabix_api.egg-info/SOURCES.txt | 5 + hesabixAPI/hesabix_api.egg-info/requires.txt | 2 + hesabixAPI/locales/en/LC_MESSAGES/messages.mo | Bin 1599 -> 3214 bytes hesabixAPI/locales/en/LC_MESSAGES/messages.po | 120 ++ hesabixAPI/locales/fa/LC_MESSAGES/messages.mo | Bin 2066 -> 4023 bytes hesabixAPI/locales/fa/LC_MESSAGES/messages.po | 120 ++ hesabixAPI/pyproject.toml | 5 +- hesabixAPI/templates/README.md | 72 + .../templates/pdf/marketing_referrals.html | 331 ++++ hesabixUI/hesabix_ui/lib/core/api_client.dart | 16 +- .../hesabix_ui/lib/core/referral_store.dart | 4 +- hesabixUI/hesabix_ui/lib/l10n/app_en.arb | 79 +- hesabixUI/hesabix_ui/lib/l10n/app_fa.arb | 79 +- .../lib/l10n/app_localizations.dart | 412 ++++- .../lib/l10n/app_localizations_en.dart | 215 ++- .../lib/l10n/app_localizations_fa.dart | 213 ++- hesabixUI/hesabix_ui/lib/main.dart | 168 +- .../hesabix_ui/lib/pages/login_page.dart | 10 +- .../lib/pages/profile/marketing_page.dart | 583 +++--- .../widgets/data_table/BUGFIXES_SUMMARY.md | 91 + .../widgets/data_table/ISOLATION_ANALYSIS.md | 132 ++ .../lib/widgets/data_table/README.md | 333 ++++ .../data_table/README_COLUMN_SETTINGS.md | 149 ++ .../data_table/column_settings_dialog.dart | 318 ++++ .../lib/widgets/data_table/data_table.dart | 5 + .../widgets/data_table/data_table_config.dart | 424 +++++ .../data_table/data_table_search_dialog.dart | 352 ++++ .../widgets/data_table/data_table_widget.dart | 1588 +++++++++++++++++ .../lib/widgets/data_table/example_usage.dart | 261 +++ .../helpers/column_settings_service.dart | 148 ++ .../data_table/helpers/data_table_utils.dart | 258 +++ .../lib/widgets/date_input_field.dart | 6 +- .../lib/widgets/jalali_date_picker.dart | 191 +- hesabixUI/hesabix_ui/pubspec.lock | 10 +- hesabixUI/hesabix_ui/pubspec.yaml | 2 + .../test/column_settings_isolation_test.dart | 132 ++ .../hesabix_ui/test/column_settings_test.dart | 88 + .../test/column_settings_validation_test.dart | 86 + 70 files changed, 8645 insertions(+), 325 deletions(-) rename AUTH_FOOTER_INTEGRATION.md => docs/AUTH_FOOTER_INTEGRATION.md (100%) rename CALENDAR_FEATURE_README.md => docs/CALENDAR_FEATURE_README.md (100%) rename CALENDAR_SWITCHER_ICON_ONLY.md => docs/CALENDAR_SWITCHER_ICON_ONLY.md (100%) rename CALENDAR_SWITCHER_MULTILINGUAL.md => docs/CALENDAR_SWITCHER_MULTILINGUAL.md (100%) rename CALENDAR_SWITCHER_REDESIGN.md => docs/CALENDAR_SWITCHER_REDESIGN.md (100%) rename CALENDAR_SWITCHER_UPDATE.md => docs/CALENDAR_SWITCHER_UPDATE.md (100%) rename FLUTTER_MIRROR_SETUP.md => docs/FLUTTER_MIRROR_SETUP.md (100%) rename FLUTTER_WEB_TROUBLESHOOTING.md => docs/FLUTTER_WEB_TROUBLESHOOTING.md (100%) rename JALALI_CALENDAR_FINAL.md => docs/JALALI_CALENDAR_FINAL.md (100%) rename JALALI_CALENDAR_IMPLEMENTATION.md => docs/JALALI_CALENDAR_IMPLEMENTATION.md (100%) rename LOGIN_PAGE_LAYOUT_FIX.md => docs/LOGIN_PAGE_LAYOUT_FIX.md (100%) rename MARKETING_CALENDAR_INTEGRATION.md => docs/MARKETING_CALENDAR_INTEGRATION.md (100%) create mode 100644 hesabixAPI/=3.1.0 create mode 100644 hesabixAPI/adapters/api/v1/users.py create mode 100644 hesabixAPI/adapters/db/repositories/base_repo.py create mode 100644 hesabixAPI/app/services/pdf/README.md create mode 100644 hesabixAPI/app/services/pdf/__init__.py create mode 100644 hesabixAPI/app/services/pdf/base_pdf_service.py create mode 100644 hesabixAPI/app/services/pdf/modules/__init__.py create mode 100644 hesabixAPI/app/services/pdf/modules/marketing/__init__.py create mode 100644 hesabixAPI/app/services/pdf/modules/marketing/marketing_module.py create mode 100644 hesabixAPI/app/services/pdf/modules/marketing/templates/marketing_referrals.html create mode 100644 hesabixAPI/app/services/query_service.py create mode 100644 hesabixAPI/templates/README.md create mode 100644 hesabixAPI/templates/pdf/marketing_referrals.html create mode 100644 hesabixUI/hesabix_ui/lib/widgets/data_table/BUGFIXES_SUMMARY.md create mode 100644 hesabixUI/hesabix_ui/lib/widgets/data_table/ISOLATION_ANALYSIS.md create mode 100644 hesabixUI/hesabix_ui/lib/widgets/data_table/README.md create mode 100644 hesabixUI/hesabix_ui/lib/widgets/data_table/README_COLUMN_SETTINGS.md create mode 100644 hesabixUI/hesabix_ui/lib/widgets/data_table/column_settings_dialog.dart create mode 100644 hesabixUI/hesabix_ui/lib/widgets/data_table/data_table.dart create mode 100644 hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_config.dart create mode 100644 hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_search_dialog.dart create mode 100644 hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_widget.dart create mode 100644 hesabixUI/hesabix_ui/lib/widgets/data_table/example_usage.dart create mode 100644 hesabixUI/hesabix_ui/lib/widgets/data_table/helpers/column_settings_service.dart create mode 100644 hesabixUI/hesabix_ui/lib/widgets/data_table/helpers/data_table_utils.dart create mode 100644 hesabixUI/hesabix_ui/test/column_settings_isolation_test.dart create mode 100644 hesabixUI/hesabix_ui/test/column_settings_test.dart create mode 100644 hesabixUI/hesabix_ui/test/column_settings_validation_test.dart diff --git a/AUTH_FOOTER_INTEGRATION.md b/docs/AUTH_FOOTER_INTEGRATION.md similarity index 100% rename from AUTH_FOOTER_INTEGRATION.md rename to docs/AUTH_FOOTER_INTEGRATION.md diff --git a/CALENDAR_FEATURE_README.md b/docs/CALENDAR_FEATURE_README.md similarity index 100% rename from CALENDAR_FEATURE_README.md rename to docs/CALENDAR_FEATURE_README.md diff --git a/CALENDAR_SWITCHER_ICON_ONLY.md b/docs/CALENDAR_SWITCHER_ICON_ONLY.md similarity index 100% rename from CALENDAR_SWITCHER_ICON_ONLY.md rename to docs/CALENDAR_SWITCHER_ICON_ONLY.md diff --git a/CALENDAR_SWITCHER_MULTILINGUAL.md b/docs/CALENDAR_SWITCHER_MULTILINGUAL.md similarity index 100% rename from CALENDAR_SWITCHER_MULTILINGUAL.md rename to docs/CALENDAR_SWITCHER_MULTILINGUAL.md diff --git a/CALENDAR_SWITCHER_REDESIGN.md b/docs/CALENDAR_SWITCHER_REDESIGN.md similarity index 100% rename from CALENDAR_SWITCHER_REDESIGN.md rename to docs/CALENDAR_SWITCHER_REDESIGN.md diff --git a/CALENDAR_SWITCHER_UPDATE.md b/docs/CALENDAR_SWITCHER_UPDATE.md similarity index 100% rename from CALENDAR_SWITCHER_UPDATE.md rename to docs/CALENDAR_SWITCHER_UPDATE.md diff --git a/FLUTTER_MIRROR_SETUP.md b/docs/FLUTTER_MIRROR_SETUP.md similarity index 100% rename from FLUTTER_MIRROR_SETUP.md rename to docs/FLUTTER_MIRROR_SETUP.md diff --git a/FLUTTER_WEB_TROUBLESHOOTING.md b/docs/FLUTTER_WEB_TROUBLESHOOTING.md similarity index 100% rename from FLUTTER_WEB_TROUBLESHOOTING.md rename to docs/FLUTTER_WEB_TROUBLESHOOTING.md diff --git a/JALALI_CALENDAR_FINAL.md b/docs/JALALI_CALENDAR_FINAL.md similarity index 100% rename from JALALI_CALENDAR_FINAL.md rename to docs/JALALI_CALENDAR_FINAL.md diff --git a/JALALI_CALENDAR_IMPLEMENTATION.md b/docs/JALALI_CALENDAR_IMPLEMENTATION.md similarity index 100% rename from JALALI_CALENDAR_IMPLEMENTATION.md rename to docs/JALALI_CALENDAR_IMPLEMENTATION.md diff --git a/LOGIN_PAGE_LAYOUT_FIX.md b/docs/LOGIN_PAGE_LAYOUT_FIX.md similarity index 100% rename from LOGIN_PAGE_LAYOUT_FIX.md rename to docs/LOGIN_PAGE_LAYOUT_FIX.md diff --git a/MARKETING_CALENDAR_INTEGRATION.md b/docs/MARKETING_CALENDAR_INTEGRATION.md similarity index 100% rename from MARKETING_CALENDAR_INTEGRATION.md rename to docs/MARKETING_CALENDAR_INTEGRATION.md diff --git a/hesabixAPI/=3.1.0 b/hesabixAPI/=3.1.0 new file mode 100644 index 0000000..1b5b713 --- /dev/null +++ b/hesabixAPI/=3.1.0 @@ -0,0 +1,9 @@ +Collecting openpyxl + Downloading openpyxl-3.1.5-py2.py3-none-any.whl.metadata (2.5 kB) +Collecting et-xmlfile (from openpyxl) + Downloading et_xmlfile-2.0.0-py3-none-any.whl.metadata (2.7 kB) +Downloading openpyxl-3.1.5-py2.py3-none-any.whl (250 kB) +Downloading et_xmlfile-2.0.0-py3-none-any.whl (18 kB) +Installing collected packages: et-xmlfile, openpyxl + +Successfully installed et-xmlfile-2.0.0 openpyxl-3.1.5 diff --git a/hesabixAPI/adapters/api/v1/auth.py b/hesabixAPI/adapters/api/v1/auth.py index 1fc5be0..a2c48a6 100644 --- a/hesabixAPI/adapters/api/v1/auth.py +++ b/hesabixAPI/adapters/api/v1/auth.py @@ -1,13 +1,16 @@ from __future__ import annotations +import datetime from fastapi import APIRouter, Depends, Request +from fastapi.responses import Response from sqlalchemy.orm import Session from adapters.db.session import get_db from app.core.responses import success_response, format_datetime_fields from app.services.captcha_service import create_captcha -from app.services.auth_service import register_user, login_user, create_password_reset, reset_password, change_password, referral_stats, referral_list -from .schemas import RegisterRequest, LoginRequest, ForgotPasswordRequest, ResetPasswordRequest, ChangePasswordRequest, CreateApiKeyRequest +from app.services.auth_service import register_user, login_user, create_password_reset, reset_password, change_password, referral_stats +from app.services.pdf import PDFService +from .schemas import RegisterRequest, LoginRequest, ForgotPasswordRequest, ResetPasswordRequest, ChangePasswordRequest, CreateApiKeyRequest, QueryInfo, FilterItem from app.core.auth_dependency import get_current_user, AuthContext from app.services.api_key_service import list_personal_keys, create_personal_key, revoke_key @@ -96,13 +99,13 @@ def reset_password_endpoint(payload: ResetPasswordRequest, db: Session = Depends @router.get("/api-keys", summary="List personal API keys") -def list_keys(ctx: AuthContext = Depends(get_current_user), db: Session = Depends(get_db)) -> dict: +def list_keys(request: Request, ctx: AuthContext = Depends(get_current_user), db: Session = Depends(get_db)) -> dict: items = list_personal_keys(db, ctx.user.id) return success_response(items) @router.post("/api-keys", summary="Create personal API key") -def create_key(payload: CreateApiKeyRequest, ctx: AuthContext = Depends(get_current_user), db: Session = Depends(get_db)) -> dict: +def create_key(request: Request, payload: CreateApiKeyRequest, ctx: AuthContext = Depends(get_current_user), db: Session = Depends(get_db)) -> dict: id_, api_key = create_personal_key(db, ctx.user.id, payload.name, payload.scopes, None) return success_response({"id": id_, "api_key": api_key}) @@ -124,13 +127,13 @@ def change_password_endpoint(request: Request, payload: ChangePasswordRequest, c @router.delete("/api-keys/{key_id}", summary="Revoke API key") -def delete_key(key_id: int, ctx: AuthContext = Depends(get_current_user), db: Session = Depends(get_db)) -> dict: +def delete_key(request: Request, key_id: int, ctx: AuthContext = Depends(get_current_user), db: Session = Depends(get_db)) -> dict: revoke_key(db, ctx.user.id, key_id) return success_response({"ok": True}) @router.get("/referrals/stats", summary="Referral stats for current user") -def get_referral_stats(ctx: AuthContext = Depends(get_current_user), db: Session = Depends(get_db), start: str | None = None, end: str | None = None) -> dict: +def get_referral_stats(request: Request, ctx: AuthContext = Depends(get_current_user), db: Session = Depends(get_db), start: str | None = None, end: str | None = None) -> dict: from datetime import datetime start_dt = datetime.fromisoformat(start) if start else None end_dt = datetime.fromisoformat(end) if end else None @@ -138,19 +141,292 @@ def get_referral_stats(ctx: AuthContext = Depends(get_current_user), db: Session return success_response(stats) -@router.get("/referrals/list", summary="Referral list for current user") -def get_referral_list( +@router.post("/referrals/list", summary="Referral list with advanced filtering") +def get_referral_list_advanced( + request: Request, + query_info: QueryInfo, ctx: AuthContext = Depends(get_current_user), - db: Session = Depends(get_db), - start: str | None = None, - end: str | None = None, - search: str | None = None, - page: int = 1, - limit: int = 20, + db: Session = Depends(get_db) ) -> dict: + """ + دریافت لیست معرفی‌ها با قابلیت فیلتر پیشرفته + + پارامترهای QueryInfo: + - sort_by: فیلد مرتب‌سازی (مثال: created_at, first_name, last_name, email) + - sort_desc: ترتیب نزولی (true/false) + - take: تعداد رکورد در هر صفحه (پیش‌فرض: 10) + - skip: تعداد رکورد صرف‌نظر شده (پیش‌فرض: 0) + - search: عبارت جستجو + - search_fields: فیلدهای جستجو (مثال: ["first_name", "last_name", "email"]) + - filters: آرایه فیلترها با ساختار: + [ + { + "property": "created_at", + "operator": ">=", + "value": "2024-01-01T00:00:00" + }, + { + "property": "first_name", + "operator": "*", + "value": "احمد" + } + ] + """ + from adapters.db.repositories.user_repo import UserRepository + from adapters.db.models.user import User from datetime import datetime - start_dt = datetime.fromisoformat(start) if start else None - end_dt = datetime.fromisoformat(end) if end else None - resp = referral_list(db=db, user_id=ctx.user.id, start=start_dt, end=end_dt, search=search, page=page, limit=limit) - return success_response(resp) + + # Create a custom query for referrals + repo = UserRepository(db) + + # Add filter for referrals only (users with referred_by_user_id = current user) + referral_filter = FilterItem( + property="referred_by_user_id", + operator="=", + value=ctx.user.id + ) + + # Add referral filter to existing filters + if query_info.filters is None: + query_info.filters = [referral_filter] + else: + query_info.filters.append(referral_filter) + + # Set default search fields for referrals + if query_info.search_fields is None: + query_info.search_fields = ["first_name", "last_name", "email"] + + # Execute query with filters + referrals, total = repo.query_with_filters(query_info) + + # Convert to dictionary format + referral_dicts = [repo.to_dict(referral) for referral in referrals] + + # Format datetime fields + formatted_referrals = format_datetime_fields(referral_dicts, request) + + # Calculate pagination info + page = (query_info.skip // query_info.take) + 1 + total_pages = (total + query_info.take - 1) // query_info.take + + return success_response({ + "items": formatted_referrals, + "total": total, + "page": page, + "limit": query_info.take, + "total_pages": total_pages, + "has_next": page < total_pages, + "has_prev": page > 1 + }, request) + + +@router.post("/referrals/export/pdf", summary="Export referrals to PDF") +def export_referrals_pdf( + request: Request, + query_info: QueryInfo, + selected_only: bool = False, + selected_indices: str | None = None, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db) +) -> Response: + """ + خروجی PDF لیست معرفی‌ها + + پارامترها: + - selected_only: آیا فقط سطرهای انتخاب شده export شوند + - selected_indices: لیست ایندکس‌های انتخاب شده (JSON string) + - سایر پارامترهای QueryInfo برای فیلتر + """ + from app.services.pdf import PDFService + from app.services.auth_service import referral_stats + import json + + # Parse selected indices if provided + indices = None + if selected_only and selected_indices: + try: + indices = json.loads(selected_indices) + except (json.JSONDecodeError, TypeError): + indices = None + + # Get stats for the report + stats = None + try: + # Extract date range from filters if available + start_date = None + end_date = None + if query_info.filters: + for filter_item in query_info.filters: + if filter_item.property == 'created_at': + if filter_item.operator == '>=': + start_date = filter_item.value + elif filter_item.operator == '<': + end_date = filter_item.value + + stats = referral_stats( + db=db, + user_id=ctx.user.id, + start=start_date, + end=end_date + ) + except Exception: + pass # Continue without stats + + # Get calendar type from request headers + calendar_header = request.headers.get("X-Calendar-Type", "jalali") + calendar_type = "jalali" if calendar_header.lower() in ["jalali", "persian", "shamsi"] else "gregorian" + + # Generate PDF using new modular service + pdf_service = PDFService() + + # Get locale from request headers + locale_header = request.headers.get("Accept-Language", "fa") + locale = "fa" if locale_header.startswith("fa") else "en" + + pdf_bytes = pdf_service.generate_pdf( + module_name='marketing', + data={}, # Empty data - module will fetch its own data + calendar_type=calendar_type, + locale=locale, + db=db, + user_id=ctx.user.id, + query_info=query_info, + selected_indices=indices, + stats=stats + ) + + # Return PDF response + from fastapi.responses import Response + import datetime + + filename = f"referrals_export_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf" + + return Response( + content=pdf_bytes, + media_type="application/pdf", + headers={ + "Content-Disposition": f"attachment; filename={filename}", + "Content-Length": str(len(pdf_bytes)) + } + ) + + +@router.post("/referrals/export/excel", summary="Export referrals to Excel") +def export_referrals_excel( + request: Request, + query_info: QueryInfo, + selected_only: bool = False, + selected_indices: str | None = None, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db) +) -> Response: + """ + خروجی Excel لیست معرفی‌ها (فایل Excel واقعی برای دانلود) + + پارامترها: + - selected_only: آیا فقط سطرهای انتخاب شده export شوند + - selected_indices: لیست ایندکس‌های انتخاب شده (JSON string) + - سایر پارامترهای QueryInfo برای فیلتر + """ + from app.services.pdf import PDFService + import json + import io + from openpyxl import Workbook + from openpyxl.styles import Font, Alignment, PatternFill, Border, Side + + # Parse selected indices if provided + indices = None + if selected_only and selected_indices: + try: + indices = json.loads(selected_indices) + except (json.JSONDecodeError, TypeError): + indices = None + + # Get calendar type from request headers + calendar_header = request.headers.get("X-Calendar-Type", "jalali") + calendar_type = "jalali" if calendar_header.lower() in ["jalali", "persian", "shamsi"] else "gregorian" + + # Generate Excel data using new modular service + pdf_service = PDFService() + + # Get locale from request headers + locale_header = request.headers.get("Accept-Language", "fa") + locale = "fa" if locale_header.startswith("fa") else "en" + + excel_data = pdf_service.generate_excel_data( + module_name='marketing', + data={}, # Empty data - module will fetch its own data + calendar_type=calendar_type, + locale=locale, + db=db, + user_id=ctx.user.id, + query_info=query_info, + selected_indices=indices + ) + + # Create Excel workbook + wb = Workbook() + ws = wb.active + ws.title = "Referrals" + + # Define styles + header_font = Font(bold=True, color="FFFFFF") + header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid") + header_alignment = Alignment(horizontal="center", vertical="center") + border = Border( + left=Side(style='thin'), + right=Side(style='thin'), + top=Side(style='thin'), + bottom=Side(style='thin') + ) + + # Add headers + if excel_data: + headers = list(excel_data[0].keys()) + for col, header in enumerate(headers, 1): + cell = ws.cell(row=1, column=col, value=header) + cell.font = header_font + cell.fill = header_fill + cell.alignment = header_alignment + cell.border = border + + # Add data rows + for row, data in enumerate(excel_data, 2): + for col, header in enumerate(headers, 1): + cell = ws.cell(row=row, column=col, value=data.get(header, "")) + cell.border = border + # Center align for numbers and dates + if header in ["ردیف", "Row", "تاریخ ثبت", "Registration Date"]: + cell.alignment = Alignment(horizontal="center") + + # Auto-adjust column widths + for column in ws.columns: + max_length = 0 + column_letter = column[0].column_letter + for cell in column: + try: + if len(str(cell.value)) > max_length: + max_length = len(str(cell.value)) + except: + pass + adjusted_width = min(max_length + 2, 50) + ws.column_dimensions[column_letter].width = adjusted_width + + # Save to BytesIO + excel_buffer = io.BytesIO() + wb.save(excel_buffer) + excel_buffer.seek(0) + + # Generate filename + filename = f"referrals_export_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" + + # Return Excel file as response + return Response( + content=excel_buffer.getvalue(), + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={ + "Content-Disposition": f"attachment; filename={filename}", + "Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + } + ) diff --git a/hesabixAPI/adapters/api/v1/schemas.py b/hesabixAPI/adapters/api/v1/schemas.py index b0e9afa..07f806b 100644 --- a/hesabixAPI/adapters/api/v1/schemas.py +++ b/hesabixAPI/adapters/api/v1/schemas.py @@ -1,8 +1,25 @@ from __future__ import annotations +from typing import Any from pydantic import BaseModel, EmailStr, Field +class FilterItem(BaseModel): + property: str = Field(..., description="نام فیلد مورد نظر برای اعمال فیلتر") + operator: str = Field(..., description="نوع عملگر: =, >, >=, <, <=, !=, *, ?*, *?, in") + value: Any = Field(..., description="مقدار مورد نظر") + + +class QueryInfo(BaseModel): + sort_by: str | None = Field(default=None, description="نام فیلد مورد نظر برای مرتب سازی") + sort_desc: bool = Field(default=False, description="false = مرتب سازی صعودی، true = مرتب سازی نزولی") + take: int = Field(default=10, ge=1, le=1000, description="حداکثر تعداد رکورد بازگشتی") + skip: int = Field(default=0, ge=0, description="تعداد رکوردی که از ابتدای لیست صرف نظر می شود") + search: str | None = Field(default=None, description="عبارت جستجو") + search_fields: list[str] | None = Field(default=None, description="آرایه ای از فیلدهایی که جستجو در آن انجام می گیرد") + filters: list[FilterItem] | None = Field(default=None, description="آرایه ای از اشیا برای اعمال فیلتر بر روی لیست") + + class CaptchaSolve(BaseModel): captcha_id: str = Field(..., min_length=8) captcha_code: str = Field(..., min_length=3, max_length=8) diff --git a/hesabixAPI/adapters/api/v1/users.py b/hesabixAPI/adapters/api/v1/users.py new file mode 100644 index 0000000..3e837a8 --- /dev/null +++ b/hesabixAPI/adapters/api/v1/users.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, Request, Query +from sqlalchemy.orm import Session + +from adapters.db.session import get_db +from adapters.db.repositories.user_repo import UserRepository +from adapters.api.v1.schemas import QueryInfo +from app.core.responses import success_response, format_datetime_fields +from app.core.auth_dependency import get_current_user, AuthContext + + +router = APIRouter(prefix="/users", tags=["users"]) + + +@router.get("", summary="لیست کاربران با فیلتر پیشرفته") +def list_users( + request: Request, + query_info: QueryInfo = Depends(), + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db) +) -> dict: + """ + دریافت لیست کاربران با قابلیت فیلتر، جستجو، مرتب‌سازی و صفحه‌بندی + + پارامترهای QueryInfo: + - sort_by: فیلد مرتب‌سازی (مثال: created_at, first_name) + - sort_desc: ترتیب نزولی (true/false) + - take: تعداد رکورد در هر صفحه (پیش‌فرض: 10) + - skip: تعداد رکورد صرف‌نظر شده (پیش‌فرض: 0) + - search: عبارت جستجو + - search_fields: فیلدهای جستجو (مثال: ["first_name", "last_name", "email"]) + - filters: آرایه فیلترها با ساختار: + [ + { + "property": "is_active", + "operator": "=", + "value": true + }, + { + "property": "first_name", + "operator": "*", + "value": "احمد" + } + ] + + عملگرهای پشتیبانی شده: + - = : برابر + - > : بزرگتر از + - >= : بزرگتر یا مساوی + - < : کوچکتر از + - <= : کوچکتر یا مساوی + - != : نامساوی + - * : شامل (contains) + - ?* : خاتمه یابد (ends with) + - *? : شروع شود (starts with) + - in : در بین مقادیر آرایه + """ + repo = UserRepository(db) + users, total = repo.query_with_filters(query_info) + + # تبدیل User objects به dictionary + user_dicts = [repo.to_dict(user) for user in users] + + # فرمت کردن تاریخ‌ها + formatted_users = [format_datetime_fields(user_dict, request) for user_dict in user_dicts] + + # محاسبه اطلاعات صفحه‌بندی + page = (query_info.skip // query_info.take) + 1 + total_pages = (total + query_info.take - 1) // query_info.take + + response_data = { + "items": formatted_users, + "pagination": { + "total": total, + "page": page, + "per_page": query_info.take, + "total_pages": total_pages, + "has_next": page < total_pages, + "has_prev": page > 1 + }, + "query_info": { + "sort_by": query_info.sort_by, + "sort_desc": query_info.sort_desc, + "search": query_info.search, + "search_fields": query_info.search_fields, + "filters": [{"property": f.property, "operator": f.operator, "value": f.value} for f in (query_info.filters or [])] + } + } + + return success_response(response_data, request) + + +@router.get("/{user_id}", summary="دریافت اطلاعات یک کاربر") +def get_user( + user_id: int, + request: Request, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db) +) -> dict: + """دریافت اطلاعات یک کاربر بر اساس ID""" + repo = UserRepository(db) + user = repo.get_by_id(user_id) + + if not user: + from fastapi import HTTPException + raise HTTPException(status_code=404, detail="کاربر یافت نشد") + + user_dict = repo.to_dict(user) + formatted_user = format_datetime_fields(user_dict, request) + + return success_response(formatted_user, request) + + +@router.get("/stats/summary", summary="آمار کلی کاربران") +def get_users_summary( + request: Request, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db) +) -> dict: + """دریافت آمار کلی کاربران""" + repo = UserRepository(db) + + # تعداد کل کاربران + total_users = repo.count_all() + + # تعداد کاربران فعال + active_users = repo.query_with_filters(QueryInfo( + filters=[{"property": "is_active", "operator": "=", "value": True}] + ))[1] + + # تعداد کاربران غیرفعال + inactive_users = total_users - active_users + + response_data = { + "total_users": total_users, + "active_users": active_users, + "inactive_users": inactive_users, + "active_percentage": round((active_users / total_users * 100), 2) if total_users > 0 else 0 + } + + return success_response(response_data, request) + + diff --git a/hesabixAPI/adapters/db/repositories/base_repo.py b/hesabixAPI/adapters/db/repositories/base_repo.py new file mode 100644 index 0000000..2b6109f --- /dev/null +++ b/hesabixAPI/adapters/db/repositories/base_repo.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from typing import Type, TypeVar, Generic, Any +from sqlalchemy.orm import Session +from sqlalchemy import select, func + +from app.services.query_service import QueryService +from adapters.api.v1.schemas import QueryInfo + +T = TypeVar('T') + + +class BaseRepository(Generic[T]): + """کلاس پایه برای Repository ها با قابلیت فیلتر پیشرفته""" + + def __init__(self, db: Session, model_class: Type[T]) -> None: + self.db = db + self.model_class = model_class + + def query_with_filters(self, query_info: QueryInfo) -> tuple[list[T], int]: + """ + اجرای کوئری با فیلتر و بازگرداندن نتایج و تعداد کل + + Args: + query_info: اطلاعات کوئری شامل فیلترها، مرتب‌سازی و صفحه‌بندی + + Returns: + tuple: (لیست نتایج, تعداد کل رکوردها) + """ + return QueryService.query_with_filters(self.model_class, self.db, query_info) + + def get_by_id(self, id: int) -> T | None: + """دریافت رکورد بر اساس ID""" + stmt = select(self.model_class).where(self.model_class.id == id) + return self.db.execute(stmt).scalars().first() + + def get_all(self, limit: int = 100, offset: int = 0) -> list[T]: + """دریافت تمام رکوردها با محدودیت""" + stmt = select(self.model_class).offset(offset).limit(limit) + return list(self.db.execute(stmt).scalars().all()) + + def count_all(self) -> int: + """شمارش تمام رکوردها""" + stmt = select(func.count()).select_from(self.model_class) + return int(self.db.execute(stmt).scalar() or 0) + + def exists(self, **filters) -> bool: + """بررسی وجود رکورد بر اساس فیلترهای مشخص شده""" + stmt = select(self.model_class) + for field, value in filters.items(): + if hasattr(self.model_class, field): + column = getattr(self.model_class, field) + stmt = stmt.where(column == value) + + return self.db.execute(stmt).scalars().first() is not None diff --git a/hesabixAPI/adapters/db/repositories/user_repo.py b/hesabixAPI/adapters/db/repositories/user_repo.py index 28cb64b..f75faa5 100644 --- a/hesabixAPI/adapters/db/repositories/user_repo.py +++ b/hesabixAPI/adapters/db/repositories/user_repo.py @@ -6,11 +6,13 @@ from sqlalchemy import select, func, and_, or_ from sqlalchemy.orm import Session from adapters.db.models.user import User +from adapters.db.repositories.base_repo import BaseRepository +from adapters.api.v1.schemas import QueryInfo -class UserRepository: +class UserRepository(BaseRepository[User]): def __init__(self, db: Session) -> None: - self.db = db + super().__init__(db, User) def get_by_email(self, email: str) -> Optional[User]: stmt = select(User).where(User.email == email) @@ -71,5 +73,20 @@ class UserRepository: stmt = stmt.where(or_(User.first_name.ilike(like), User.last_name.ilike(like), User.email.ilike(like))) stmt = stmt.order_by(User.created_at.desc()).offset(offset).limit(limit) return self.db.execute(stmt).scalars().all() + + def to_dict(self, user: User) -> dict: + """تبدیل User object به dictionary برای API response""" + return { + "id": user.id, + "email": user.email, + "mobile": user.mobile, + "first_name": user.first_name, + "last_name": user.last_name, + "is_active": user.is_active, + "referral_code": user.referral_code, + "referred_by_user_id": user.referred_by_user_id, + "created_at": user.created_at, + "updated_at": user.updated_at, + } diff --git a/hesabixAPI/app/core/auth_dependency.py b/hesabixAPI/app/core/auth_dependency.py index da7b1a9..7ee65ab 100644 --- a/hesabixAPI/app/core/auth_dependency.py +++ b/hesabixAPI/app/core/auth_dependency.py @@ -1,8 +1,7 @@ from __future__ import annotations from typing import Optional - -from fastapi import Depends, Header +from fastapi import Depends, Header, Request from sqlalchemy.orm import Session from adapters.db.session import get_db @@ -10,15 +9,99 @@ from adapters.db.repositories.api_key_repo import ApiKeyRepository from adapters.db.models.user import User from app.core.security import hash_api_key from app.core.responses import ApiError +from app.core.i18n import negotiate_locale, Translator +from app.core.calendar import get_calendar_type_from_header, CalendarType class AuthContext: - def __init__(self, user: User, api_key_id: int) -> None: + """کلاس مرکزی برای نگهداری اطلاعات کاربر کنونی و تنظیمات""" + + def __init__( + self, + user: User, + api_key_id: int, + language: str = "fa", + calendar_type: CalendarType = "jalali", + timezone: Optional[str] = None, + business_id: Optional[int] = None, + fiscal_year_id: Optional[int] = None + ) -> None: self.user = user self.api_key_id = api_key_id + self.language = language + self.calendar_type = calendar_type + self.timezone = timezone + self.business_id = business_id + self.fiscal_year_id = fiscal_year_id + + # ایجاد translator برای زبان تشخیص داده شده + self._translator = Translator(language) + + def get_translator(self) -> Translator: + """دریافت translator برای ترجمه""" + return self._translator + + def get_calendar_type(self) -> CalendarType: + """دریافت نوع تقویم""" + return self.calendar_type + + def get_user_id(self) -> int: + """دریافت ID کاربر""" + return self.user.id + + def get_user_email(self) -> Optional[str]: + """دریافت ایمیل کاربر""" + return self.user.email + + def get_user_mobile(self) -> Optional[str]: + """دریافت شماره موبایل کاربر""" + return self.user.mobile + + def get_user_name(self) -> str: + """دریافت نام کامل کاربر""" + first_name = self.user.first_name or "" + last_name = self.user.last_name or "" + return f"{first_name} {last_name}".strip() + + def get_referral_code(self) -> Optional[str]: + """دریافت کد معرف کاربر""" + return getattr(self.user, "referral_code", None) + + def is_user_active(self) -> bool: + """بررسی فعال بودن کاربر""" + return self.user.is_active + + def to_dict(self) -> dict: + """تبدیل به dictionary برای استفاده در API""" + return { + "user": { + "id": self.user.id, + "first_name": self.user.first_name, + "last_name": self.user.last_name, + "email": self.user.email, + "mobile": self.user.mobile, + "referral_code": getattr(self.user, "referral_code", None), + "is_active": self.user.is_active, + "created_at": self.user.created_at.isoformat() if self.user.created_at else None, + "updated_at": self.user.updated_at.isoformat() if self.user.updated_at else None, + }, + "api_key_id": self.api_key_id, + "settings": { + "language": self.language, + "calendar_type": self.calendar_type, + "timezone": self.timezone, + "business_id": self.business_id, + "fiscal_year_id": self.fiscal_year_id, + } + } -def get_current_user(authorization: Optional[str] = Header(default=None), db: Session = Depends(get_db)) -> AuthContext: +def get_current_user( + request: Request, + authorization: Optional[str] = Header(default=None), + db: Session = Depends(get_db) +) -> AuthContext: + """دریافت اطلاعات کامل کاربر کنونی و تنظیمات از درخواست""" if not authorization or not authorization.startswith("ApiKey "): raise ApiError("UNAUTHORIZED", "Missing or invalid API key", http_status=401) @@ -34,6 +117,68 @@ def get_current_user(authorization: Optional[str] = Header(default=None), db: Se if not user or not user.is_active: raise ApiError("UNAUTHORIZED", "Invalid API key", http_status=401) - return AuthContext(user=user, api_key_id=obj.id) + # تشخیص زبان از هدر Accept-Language + language = _detect_language(request) + + # تشخیص نوع تقویم از هدر X-Calendar-Type + calendar_type = _detect_calendar_type(request) + + # تشخیص منطقه زمانی از هدر X-Timezone (اختیاری) + timezone = _detect_timezone(request) + + # تشخیص کسب و کار از هدر X-Business-ID (آینده) + business_id = _detect_business_id(request) + + # تشخیص سال مالی از هدر X-Fiscal-Year-ID (آینده) + fiscal_year_id = _detect_fiscal_year_id(request) + + return AuthContext( + user=user, + api_key_id=obj.id, + language=language, + calendar_type=calendar_type, + timezone=timezone, + business_id=business_id, + fiscal_year_id=fiscal_year_id + ) + + +def _detect_language(request: Request) -> str: + """تشخیص زبان از هدر Accept-Language""" + accept_language = request.headers.get("Accept-Language") + return negotiate_locale(accept_language) + + +def _detect_calendar_type(request: Request) -> CalendarType: + """تشخیص نوع تقویم از هدر X-Calendar-Type""" + calendar_header = request.headers.get("X-Calendar-Type") + return get_calendar_type_from_header(calendar_header) + + +def _detect_timezone(request: Request) -> Optional[str]: + """تشخیص منطقه زمانی از هدر X-Timezone""" + return request.headers.get("X-Timezone") + + +def _detect_business_id(request: Request) -> Optional[int]: + """تشخیص ID کسب و کار از هدر X-Business-ID (آینده)""" + business_id_str = request.headers.get("X-Business-ID") + if business_id_str: + try: + return int(business_id_str) + except ValueError: + pass + return None + + +def _detect_fiscal_year_id(request: Request) -> Optional[int]: + """تشخیص ID سال مالی از هدر X-Fiscal-Year-ID (آینده)""" + fiscal_year_id_str = request.headers.get("X-Fiscal-Year-ID") + if fiscal_year_id_str: + try: + return int(fiscal_year_id_str) + except ValueError: + pass + return None diff --git a/hesabixAPI/app/core/calendar.py b/hesabixAPI/app/core/calendar.py index a48de03..27c10f1 100644 --- a/hesabixAPI/app/core/calendar.py +++ b/hesabixAPI/app/core/calendar.py @@ -41,7 +41,7 @@ class CalendarConverter: "date_only": jalali.strftime("%Y/%m/%d"), "time_only": jalali.strftime("%H:%M:%S"), "is_leap_year": jalali.isleap(), - "month_days": jalali.days_in_month, + "month_days": jdatetime.j_days_in_month[jalali.month - 1], } @staticmethod diff --git a/hesabixAPI/app/core/i18n.py b/hesabixAPI/app/core/i18n.py index b38f25d..54d59ce 100644 --- a/hesabixAPI/app/core/i18n.py +++ b/hesabixAPI/app/core/i18n.py @@ -46,3 +46,8 @@ async def locale_dependency(request: Request) -> Translator: return Translator(lang) +def get_translator(locale: str = "fa") -> Translator: + """Get translator for the given locale""" + return Translator(locale) + + diff --git a/hesabixAPI/app/core/responses.py b/hesabixAPI/app/core/responses.py index 1ad10c1..c8752fc 100644 --- a/hesabixAPI/app/core/responses.py +++ b/hesabixAPI/app/core/responses.py @@ -29,7 +29,11 @@ def format_datetime_fields(data: Any, request: Request) -> Any: for key, value in data.items(): if isinstance(value, datetime): formatted_data[key] = CalendarConverter.format_datetime(value, calendar_type) - formatted_data[f"{key}_raw"] = value.isoformat() # Keep original for reference + # Convert raw date to the same calendar type as the formatted date + if calendar_type == "jalali": + formatted_data[f"{key}_raw"] = CalendarConverter.to_jalali(value)["formatted"] + else: + formatted_data[f"{key}_raw"] = value.isoformat() elif isinstance(value, (dict, list)): formatted_data[key] = format_datetime_fields(value, request) else: diff --git a/hesabixAPI/app/main.py b/hesabixAPI/app/main.py index 0dabbfd..a96093c 100644 --- a/hesabixAPI/app/main.py +++ b/hesabixAPI/app/main.py @@ -5,6 +5,7 @@ from app.core.settings import get_settings from app.core.logging import configure_logging from adapters.api.v1.health import router as health_router from adapters.api.v1.auth import router as auth_router +from adapters.api.v1.users import router as users_router from app.core.i18n import negotiate_locale, Translator from app.core.error_handlers import register_error_handlers from app.core.smart_normalizer import smart_normalize_json, SmartNormalizerConfig @@ -60,6 +61,7 @@ def create_app() -> FastAPI: application.include_router(health_router, prefix=settings.api_v1_prefix) application.include_router(auth_router, prefix=settings.api_v1_prefix) + application.include_router(users_router, prefix=settings.api_v1_prefix) register_error_handlers(application) diff --git a/hesabixAPI/app/services/pdf/README.md b/hesabixAPI/app/services/pdf/README.md new file mode 100644 index 0000000..52962c0 --- /dev/null +++ b/hesabixAPI/app/services/pdf/README.md @@ -0,0 +1,148 @@ +# PDF Service - Modular Architecture + +## Overview +This is a modular PDF generation service designed for scalability and AI integration. Each module handles specific business domains and can be easily extended. + +## Structure +``` +app/services/pdf/ +├── __init__.py +├── base_pdf_service.py # Base classes and main service +├── modules/ # Business domain modules +│ ├── __init__.py +│ └── marketing/ # Marketing & referrals module +│ ├── __init__.py +│ ├── marketing_module.py # Module implementation +│ └── templates/ # HTML templates +│ └── marketing_referrals.html +└── README.md +``` + +## Adding New Modules + +### 1. Create Module Directory +```bash +mkdir -p app/services/pdf/modules/your_module/templates +``` + +### 2. Implement Module Class +```python +# app/services/pdf/modules/your_module/your_module.py +from ..base_pdf_service import BasePDFModule +from app.core.calendar import CalendarType + +class YourModulePDFModule(BasePDFModule): + def __init__(self): + super().__init__("your_module") + + def generate_pdf(self, data, calendar_type="jalali", locale="fa"): + # Your PDF generation logic + pass + + def generate_excel_data(self, data, calendar_type="jalali", locale="fa"): + # Your Excel data generation logic + pass +``` + +### 3. Register Module +Add to `base_pdf_service.py`: +```python +def _register_modules(self): + from .modules.marketing.marketing_module import MarketingPDFModule + from .modules.your_module.your_module import YourModulePDFModule + self.modules['marketing'] = MarketingPDFModule() + self.modules['your_module'] = YourModulePDFModule() +``` + +## Usage + +### Generate PDF +```python +from app.services.pdf import PDFService + +pdf_service = PDFService() +pdf_bytes = pdf_service.generate_pdf( + module_name='marketing', + data=your_data, + calendar_type='jalali', + locale='fa' +) +``` + +### Generate Excel Data +```python +excel_data = pdf_service.generate_excel_data( + module_name='marketing', + data=your_data, + calendar_type='jalali', + locale='fa' +) +``` + +## Features + +### 1. Calendar Support +- Automatic Jalali/Gregorian conversion +- Configurable via `calendar_type` parameter + +### 2. Internationalization +- Built-in translation support +- Template-level translation integration +- Configurable via `locale` parameter + +### 3. Modular Design +- Easy to add new business domains +- Clean separation of concerns +- Reusable base classes + +### 4. AI Integration Ready +- Function calling compatible +- Clear module boundaries +- Extensible architecture + +## Template Development + +### Using Translations +```html + +

{{ t('yourTranslationKey') }}

+
{{ t('anotherKey') }}
+``` + +### Calendar Formatting +```python +# In your module +formatted_date = self.format_datetime(datetime_obj, calendar_type) +``` + +### Data Formatting +```python +# In your module +formatted_data = self._format_data_for_template(data, calendar_type, translator) +``` + +## Future Extensions + +### AI Integration +Each module can be exposed as a function for AI systems: +```python +# Example AI function definition +{ + "name": "generate_marketing_pdf", + "description": "Generate marketing referrals PDF report", + "parameters": { + "type": "object", + "properties": { + "user_id": {"type": "integer"}, + "filters": {"type": "object"}, + "calendar_type": {"type": "string", "enum": ["jalali", "gregorian"]} + } + } +} +``` + +### Additional Modules +- `invoices/` - Invoice generation +- `reports/` - General reports +- `analytics/` - Analytics dashboards +- `notifications/` - Notification templates diff --git a/hesabixAPI/app/services/pdf/__init__.py b/hesabixAPI/app/services/pdf/__init__.py new file mode 100644 index 0000000..23f103e --- /dev/null +++ b/hesabixAPI/app/services/pdf/__init__.py @@ -0,0 +1,6 @@ +""" +PDF Service Package +""" +from .base_pdf_service import PDFService + +__all__ = ['PDFService'] diff --git a/hesabixAPI/app/services/pdf/base_pdf_service.py b/hesabixAPI/app/services/pdf/base_pdf_service.py new file mode 100644 index 0000000..d732522 --- /dev/null +++ b/hesabixAPI/app/services/pdf/base_pdf_service.py @@ -0,0 +1,135 @@ +""" +Base PDF Service for modular PDF generation +""" +import os +from abc import ABC, abstractmethod +from typing import Dict, Any, Optional, List +from datetime import datetime +from pathlib import Path + +from weasyprint import HTML, CSS +from weasyprint.text.fonts import FontConfiguration +from jinja2 import Environment, FileSystemLoader, select_autoescape + +from app.core.calendar import CalendarConverter, CalendarType +from app.core.i18n import get_translator +from adapters.api.v1.schemas import QueryInfo + + +class BasePDFModule(ABC): + """Base class for PDF modules""" + + def __init__(self, module_name: str): + self.module_name = module_name + self.template_dir = Path(__file__).parent / "modules" / module_name / "templates" + self.jinja_env = Environment( + loader=FileSystemLoader(str(self.template_dir)), + autoescape=select_autoescape(['html', 'xml']) + ) + self.font_config = FontConfiguration() + + @abstractmethod + def generate_pdf( + self, + data: Dict[str, Any], + calendar_type: CalendarType = "jalali", + locale: str = "fa" + ) -> bytes: + """Generate PDF for this module""" + pass + + @abstractmethod + def generate_excel_data( + self, + data: Dict[str, Any], + calendar_type: CalendarType = "jalali", + locale: str = "fa" + ) -> list: + """Generate Excel data for this module""" + pass + + def format_datetime(self, dt: datetime, calendar_type: CalendarType) -> str: + """Format datetime based on calendar type""" + if dt is None: + return "" + + formatted_date = CalendarConverter.format_datetime(dt, calendar_type) + return formatted_date['formatted'] + + def get_translator(self, locale: str = "fa"): + """Get translator for the given locale""" + return get_translator(locale) + + def render_template(self, template_name: str, context: Dict[str, Any]) -> str: + """Render template with context""" + template = self.jinja_env.get_template(template_name) + return template.render(**context) + + +class PDFService: + """Main PDF Service that manages modules""" + + def __init__(self): + self.modules: Dict[str, BasePDFModule] = {} + self._register_modules() + + def _register_modules(self): + """Register all available modules""" + from .modules.marketing.marketing_module import MarketingPDFModule + self.modules['marketing'] = MarketingPDFModule() + + def generate_pdf( + self, + module_name: str, + data: Dict[str, Any], + calendar_type: CalendarType = "jalali", + locale: str = "fa", + db=None, + user_id: Optional[int] = None, + query_info: Optional[QueryInfo] = None, + selected_indices: Optional[List[int]] = None, + stats: Optional[Dict[str, Any]] = None + ) -> bytes: + """Generate PDF using specified module""" + if module_name not in self.modules: + raise ValueError(f"Module '{module_name}' not found") + + return self.modules[module_name].generate_pdf_content( + db=db, + user_id=user_id, + query_info=query_info, + selected_indices=selected_indices, + stats=stats, + calendar_type=calendar_type, + locale=locale, + common_data=data + ) + + def generate_excel_data( + self, + module_name: str, + data: Dict[str, Any], + calendar_type: CalendarType = "jalali", + locale: str = "fa", + db=None, + user_id: Optional[int] = None, + query_info: Optional[QueryInfo] = None, + selected_indices: Optional[List[int]] = None + ) -> list: + """Generate Excel data using specified module""" + if module_name not in self.modules: + raise ValueError(f"Module '{module_name}' not found") + + return self.modules[module_name].generate_excel_content( + db=db, + user_id=user_id, + query_info=query_info, + selected_indices=selected_indices, + calendar_type=calendar_type, + locale=locale, + common_data=data + ) + + def list_modules(self) -> list: + """List all available modules""" + return list(self.modules.keys()) diff --git a/hesabixAPI/app/services/pdf/modules/__init__.py b/hesabixAPI/app/services/pdf/modules/__init__.py new file mode 100644 index 0000000..7d39827 --- /dev/null +++ b/hesabixAPI/app/services/pdf/modules/__init__.py @@ -0,0 +1,3 @@ +""" +PDF Modules Package +""" diff --git a/hesabixAPI/app/services/pdf/modules/marketing/__init__.py b/hesabixAPI/app/services/pdf/modules/marketing/__init__.py new file mode 100644 index 0000000..9088041 --- /dev/null +++ b/hesabixAPI/app/services/pdf/modules/marketing/__init__.py @@ -0,0 +1,6 @@ +""" +Marketing PDF Module +""" +from .marketing_module import MarketingPDFModule + +__all__ = ['MarketingPDFModule'] diff --git a/hesabixAPI/app/services/pdf/modules/marketing/marketing_module.py b/hesabixAPI/app/services/pdf/modules/marketing/marketing_module.py new file mode 100644 index 0000000..e55a5b4 --- /dev/null +++ b/hesabixAPI/app/services/pdf/modules/marketing/marketing_module.py @@ -0,0 +1,441 @@ +""" +Marketing PDF Module for referrals and marketing reports +""" +from typing import Dict, Any, List +from datetime import datetime + +from ...base_pdf_service import BasePDFModule +from app.core.calendar import CalendarType + + +class MarketingPDFModule(BasePDFModule): + """PDF Module for marketing and referrals""" + + def __init__(self): + super().__init__("marketing") + self.template_name = 'marketing_referrals.html' + + def generate_pdf( + self, + data: Dict[str, Any], + calendar_type: CalendarType = "jalali", + locale: str = "fa" + ) -> bytes: + """Generate marketing referrals PDF""" + # Get translator + t = self.get_translator(locale) + + # Format data with translations and calendar + formatted_data = self._format_data_for_template(data, calendar_type, t) + + # Render template + html_content = self.render_template('marketing_referrals.html', formatted_data) + + # Generate PDF + html_doc = HTML(string=html_content) + pdf_bytes = html_doc.write_pdf(font_config=self.font_config) + + return pdf_bytes + + def generate_excel_data( + self, + data: Dict[str, Any], + calendar_type: CalendarType = "jalali", + locale: str = "fa" + ) -> list: + """Generate marketing referrals Excel data""" + # Get translator + t = self.get_translator(locale) + + # Format data + items = data.get('items', []) + excel_data = [] + + for i, item in enumerate(items, 1): + # Format created_at based on calendar type + created_at = item.get('created_at', '') + if created_at and isinstance(created_at, datetime): + created_at = self.format_datetime(created_at, calendar_type) + elif created_at and isinstance(created_at, str): + try: + dt = datetime.fromisoformat(created_at.replace('Z', '+00:00')) + created_at = self.format_datetime(dt, calendar_type) + except: + pass + + excel_data.append({ + t('row_number'): i, + t('first_name'): item.get('first_name', ''), + t('last_name'): item.get('last_name', ''), + t('email'): item.get('email', ''), + t('registration_date'): created_at, + t('referral_code'): item.get('referral_code', ''), + t('status'): t('active') if item.get('is_active', False) else t('inactive') + }) + + return excel_data + + def _format_data_for_template( + self, + data: Dict[str, Any], + calendar_type: CalendarType, + translator + ) -> Dict[str, Any]: + """Format data for template rendering""" + # Format items + items = data.get('items', []) + formatted_items = [] + + for item in items: + formatted_item = item.copy() + if item.get('created_at'): + if isinstance(item['created_at'], datetime): + formatted_item['created_at'] = self.format_datetime(item['created_at'], calendar_type) + elif isinstance(item['created_at'], str): + try: + dt = datetime.fromisoformat(item['created_at'].replace('Z', '+00:00')) + formatted_item['created_at'] = self.format_datetime(dt, calendar_type) + except: + pass + formatted_items.append(formatted_item) + + # Format current date + now = datetime.now() + formatted_now = self.format_datetime(now, calendar_type) + + # Prepare template data with translations + template_data = { + 'items': formatted_items, + 'total_count': data.get('total_count', 0), + 'report_date': formatted_now.split(' ')[0] if ' ' in formatted_now else formatted_now, + 'report_time': formatted_now.split(' ')[1] if ' ' in formatted_now else '', + 'selected_only': data.get('selected_only', False), + 'stats': data.get('stats', {}), + 'filters': self._format_filters(data.get('filters', []), translator), + 'calendar_type': calendar_type, + 'locale': translator.locale, + 't': translator, # Pass translator to template + } + + return template_data + + def _format_filters(self, query_info, locale: str, calendar_type: CalendarType = "jalali") -> List[str]: + """Format query filters for display in PDF""" + formatted_filters = [] + translator = self.get_translator(locale) + + # Add search filter + if query_info.search and query_info.search.strip(): + search_fields = ', '.join(query_info.search_fields) if query_info.search_fields else translator.t('allFields') + formatted_filters.append(f"{translator.t('search')}: '{query_info.search}' {translator.t('in')} {search_fields}") + + # Add column filters + if query_info.filters: + for filter_item in query_info.filters: + if filter_item.property == "referred_by_user_id": + continue # Skip internal filter + + # Get translated column name + column_name = self._get_column_translation(filter_item.property, translator) + operator_text = self._get_operator_translation(filter_item.operator, translator) + + # Format value based on column type and calendar + formatted_value = self._format_filter_value(filter_item.property, filter_item.value, calendar_type, translator) + + formatted_filters.append(f"{column_name} {operator_text} '{formatted_value}'") + + return formatted_filters + + def _get_operator_translation(self, op: str, translator) -> str: + """Convert operator to translated text""" + operator_map = { + '=': translator.t('equals'), + '>': translator.t('greater_than'), + '>=': translator.t('greater_equal'), + '<': translator.t('less_than'), + '<=': translator.t('less_equal'), + '!=': translator.t('not_equals'), + '*': translator.t('contains'), + '*?': translator.t('starts_with'), + '?*': translator.t('ends_with'), + 'in': translator.t('in_list') + } + + operator_text = operator_map.get(op, op) + return operator_text + + def _get_column_translation(self, property_name: str, translator) -> str: + """Get translated column name""" + column_map = { + 'first_name': translator.t('firstName'), + 'last_name': translator.t('lastName'), + 'email': translator.t('email'), + 'created_at': translator.t('registrationDate'), + 'referral_code': translator.t('referralCode'), + 'is_active': translator.t('status'), + } + return column_map.get(property_name, property_name) + + def _format_filter_value(self, property_name: str, value: Any, calendar_type: CalendarType, translator) -> str: + """Format filter value based on column type and calendar""" + # Handle date fields + if property_name == 'created_at': + try: + if isinstance(value, str): + # Try to parse ISO format + dt = datetime.fromisoformat(value.replace('Z', '+00:00')) + elif isinstance(value, datetime): + dt = value + else: + return str(value) + + # Format based on calendar type - only date, no time + from app.core.calendar import CalendarConverter + formatted_date = CalendarConverter.format_datetime(dt, calendar_type) + return formatted_date['date_only'] # Only show date, not time + except: + return str(value) + + # Handle boolean fields + elif property_name == 'is_active': + if isinstance(value, bool): + return translator.t('active') if value else translator.t('inactive') + elif str(value).lower() in ['true', '1', 'yes']: + return translator.t('active') + elif str(value).lower() in ['false', '0', 'no']: + return translator.t('inactive') + else: + return str(value) + + # Default: return as string + return str(value) + + def _get_referral_data(self, db, user_id: int, query_info, selected_indices: List[int] | None = None) -> tuple[List[Dict[str, Any]], int]: + """Get referral data from database""" + from adapters.db.repositories.user_repo import UserRepository + from adapters.api.v1.schemas import FilterItem + from sqlalchemy.orm import Session + from adapters.db.models.user import User + + repo = UserRepository(db) + + # Add filter for referrals only (users with referred_by_user_id = current user) + referral_filter = FilterItem( + property="referred_by_user_id", + operator="=", + value=user_id + ) + + # Create a mutable copy of query_info.filters + current_filters = list(query_info.filters) if query_info.filters else [] + current_filters.append(referral_filter) + + # For export, we need to get all data without take limit + # Use the repository's direct query method + try: + # Get all referrals for the user without pagination + query = db.query(User).filter(User.referred_by_user_id == user_id) + + # Apply search if provided + if query_info.search and query_info.search.strip(): + search_term = f"%{query_info.search}%" + if query_info.search_fields: + search_conditions = [] + for field in query_info.search_fields: + if hasattr(User, field): + search_conditions.append(getattr(User, field).ilike(search_term)) + if search_conditions: + from sqlalchemy import or_ + query = query.filter(or_(*search_conditions)) + else: + # Search in common fields + query = query.filter( + (User.first_name.ilike(search_term)) | + (User.last_name.ilike(search_term)) | + (User.email.ilike(search_term)) + ) + + # Apply additional filters + for filter_item in current_filters: + if filter_item.property == "referred_by_user_id": + continue # Already applied + + if hasattr(User, filter_item.property): + field = getattr(User, filter_item.property) + if filter_item.operator == "=": + query = query.filter(field == filter_item.value) + elif filter_item.operator == "!=": + query = query.filter(field != filter_item.value) + elif filter_item.operator == ">": + query = query.filter(field > filter_item.value) + elif filter_item.operator == ">=": + query = query.filter(field >= filter_item.value) + elif filter_item.operator == "<": + query = query.filter(field < filter_item.value) + elif filter_item.operator == "<=": + query = query.filter(field <= filter_item.value) + elif filter_item.operator == "*": # contains + query = query.filter(field.ilike(f"%{filter_item.value}%")) + elif filter_item.operator == "*?": # starts with + query = query.filter(field.ilike(f"{filter_item.value}%")) + elif filter_item.operator == "?*": # ends with + query = query.filter(field.ilike(f"%{filter_item.value}")) + elif filter_item.operator == "in": + query = query.filter(field.in_(filter_item.value)) + + # Apply sorting + if query_info.sort_by and hasattr(User, query_info.sort_by): + sort_field = getattr(User, query_info.sort_by) + if query_info.sort_desc: + query = query.order_by(sort_field.desc()) + else: + query = query.order_by(sort_field.asc()) + else: + # Default sort by created_at desc + query = query.order_by(User.created_at.desc()) + + # Execute query + referrals = query.all() + total = len(referrals) + referral_dicts = [repo.to_dict(referral) for referral in referrals] + + # Apply selected indices filter if provided + if selected_indices is not None: + filtered_referrals = [referral_dicts[i] for i in selected_indices if i < len(referral_dicts)] + return filtered_referrals, len(filtered_referrals) + + return referral_dicts, total + + except Exception as e: + print(f"Error in _get_referral_data: {e}") + # Fallback to repository method with max take + data_query_info = query_info.__class__( + sort_by=query_info.sort_by, + sort_desc=query_info.sort_desc, + search=query_info.search, + search_fields=query_info.search_fields, + filters=current_filters, + take=1000, + skip=0, + ) + + referrals, total = repo.query_with_filters(data_query_info) + referral_dicts = [repo.to_dict(referral) for referral in referrals] + + if selected_indices is not None: + filtered_referrals = [referral_dicts[i] for i in selected_indices if i < len(referral_dicts)] + return filtered_referrals, len(filtered_referrals) + + return referral_dicts, total + + def generate_pdf_content( + self, + db, + user_id: int, + query_info, + selected_indices: List[int] | None = None, + stats: Dict[str, Any] | None = None, + calendar_type: CalendarType = "jalali", + locale: str = "fa", + common_data: Dict[str, Any] = None + ) -> bytes: + """Generate PDF content using the new signature""" + # Get referral data + referrals_data, total_count = self._get_referral_data(db, user_id, query_info, selected_indices) + + # Format datetime fields for display in PDF + for item in referrals_data: + if 'created_at' in item and item['created_at']: + if isinstance(item['created_at'], datetime): + from app.core.calendar import CalendarConverter + formatted_date = CalendarConverter.format_datetime(item['created_at'], calendar_type) + item['formatted_created_at'] = formatted_date['formatted'] + else: + try: + dt = datetime.fromisoformat(item['created_at'].replace('Z', '+00:00')) + from app.core.calendar import CalendarConverter + formatted_date = CalendarConverter.format_datetime(dt, calendar_type) + item['formatted_created_at'] = formatted_date['formatted'] + except: + item['formatted_created_at'] = str(item['created_at']) + else: + item['formatted_created_at'] = '-' + + # Prepare context for template + from app.core.calendar import CalendarConverter + current_time = datetime.now() + formatted_current_time = CalendarConverter.format_datetime(current_time, calendar_type) + + context = { + 'items': referrals_data, + 'total_count': total_count, + 'stats': stats, + 'filters': self._format_filters(query_info, locale, calendar_type), + 'report_date': formatted_current_time['date_only'], + 'report_time': formatted_current_time['time_only'], + 'locale': locale, + 'selected_only': selected_indices is not None and len(selected_indices) > 0, + } + + # Include common data if provided + if common_data: + context.update(common_data) + + # Get translator + t = self.get_translator(locale) + context['t'] = t.t # Pass the t method instead of the object + + # Render template + html_content = self.render_template(self.template_name, context) + + # Generate PDF from HTML + from weasyprint import HTML + from pathlib import Path + pdf_file = HTML(string=html_content, base_url=str(Path(__file__).parent / "templates")).write_pdf(font_config=self.font_config) + return pdf_file + + def generate_excel_content( + self, + db, + user_id: int, + query_info, + selected_indices: List[int] | None = None, + calendar_type: CalendarType = "jalali", + locale: str = "fa", + common_data: Dict[str, Any] = None + ) -> List[Dict[str, Any]]: + """Generate Excel content using the new signature""" + # Get referral data + referrals_data, total_count = self._get_referral_data(db, user_id, query_info, selected_indices) + + # Format data for Excel with calendar support + excel_data = [] + t = self.get_translator(locale) + + for i, item in enumerate(referrals_data, 1): + # Format created_at based on calendar type + created_at = item.get('created_at', '') + if created_at and isinstance(created_at, datetime): + from app.core.calendar import CalendarConverter + formatted_date = CalendarConverter.format_datetime(created_at, calendar_type) + created_at = formatted_date['formatted'] + elif created_at and isinstance(created_at, str): + try: + dt = datetime.fromisoformat(created_at.replace('Z', '+00:00')) + from app.core.calendar import CalendarConverter + formatted_date = CalendarConverter.format_datetime(dt, calendar_type) + created_at = formatted_date['formatted'] + except: + pass + + excel_data.append({ + t.t('rowNumber'): i, + t.t('firstName'): item.get('first_name', ''), + t.t('lastName'): item.get('last_name', ''), + t.t('email'): item.get('email', ''), + t.t('registrationDate'): created_at, + t.t('referralCode'): item.get('referral_code', ''), + t.t('status'): t.t('active') if item.get('is_active', False) else t.t('inactive') + }) + + return excel_data diff --git a/hesabixAPI/app/services/pdf/modules/marketing/templates/marketing_referrals.html b/hesabixAPI/app/services/pdf/modules/marketing/templates/marketing_referrals.html new file mode 100644 index 0000000..31717ba --- /dev/null +++ b/hesabixAPI/app/services/pdf/modules/marketing/templates/marketing_referrals.html @@ -0,0 +1,331 @@ + + + + + + {{ t('marketingReport') }} - {{ t('referralList') }} + + + +
+

{{ t('marketingReport') }}

+
{{ t('referralList') }}
+
+ + {% if stats %} +
+
+
{{ stats.today or 0 }}
+
{{ t('today') }}
+
+
+
{{ stats.this_month or 0 }}
+
{{ t('thisMonth') }}
+
+
+
{{ stats.total or 0 }}
+
{{ t('total') }}
+
+ {% if stats.range %} +
+
{{ stats.range }}
+
{{ t('selectedRange') }}
+
+ {% endif %} +
+ {% endif %} + +
+
+
{{ t('reportDate') }}
+
{{ report_date }}
+
+
+
{{ t('totalRecords') }}
+
{{ total_count }}
+
+
+
{{ t('displayedRecords') }}
+
{{ items|length }}
+
+ {% if selected_only %} +
+
{{ t('outputType') }}
+
{{ t('selectedOnly') }}
+
+ {% endif %} +
+ + {% if filters %} +
+

{{ t('activeFilters') }}:

+ {% for filter in filters %} + {{ filter }} + {% endfor %} +
+ {% endif %} + +
+ {% if items %} + + + + + + + + + + + + {% for item in items %} + + + + + + + + {% endfor %} + +
{{ t('rowNumber') }}{{ t('firstName') }}{{ t('lastName') }}{{ t('email') }}{{ t('registrationDate') }}
{{ loop.index }}{{ item.first_name or '-' }}{{ item.last_name or '-' }}{{ item.formatted_created_at }}
+ {% else %} +
+
📊
+
{{ t('noDataFound') }}
+
+ {% endif %} +
+ + + + diff --git a/hesabixAPI/app/services/query_service.py b/hesabixAPI/app/services/query_service.py new file mode 100644 index 0000000..59c4bb2 --- /dev/null +++ b/hesabixAPI/app/services/query_service.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +from typing import Any, Type, TypeVar +from sqlalchemy import select, func, or_, and_ +from sqlalchemy.orm import Session +from sqlalchemy.sql import Select + +from adapters.api.v1.schemas import QueryInfo, FilterItem + +T = TypeVar('T') + + +class QueryBuilder: + """سرویس برای ساخت کوئری‌های دینامیک بر اساس QueryInfo""" + + def __init__(self, model_class: Type[T], db_session: Session) -> None: + self.model_class = model_class + self.db = db_session + self.stmt: Select = select(model_class) + + def apply_filters(self, filters: list[FilterItem] | None) -> 'QueryBuilder': + """اعمال فیلترها بر روی کوئری""" + if not filters: + return self + + conditions = [] + for filter_item in filters: + try: + column = getattr(self.model_class, filter_item.property) + condition = self._build_condition(column, filter_item.operator, filter_item.value) + conditions.append(condition) + except AttributeError: + # اگر فیلد وجود نداشته باشد، آن را نادیده بگیر + continue + + if conditions: + self.stmt = self.stmt.where(and_(*conditions)) + + return self + + def apply_search(self, search: str | None, search_fields: list[str] | None) -> 'QueryBuilder': + """اعمال جستجو بر روی فیلدهای مشخص شده""" + if not search or not search_fields: + return self + + conditions = [] + for field in search_fields: + try: + column = getattr(self.model_class, field) + conditions.append(column.ilike(f"%{search}%")) + except AttributeError: + # اگر فیلد وجود نداشته باشد، آن را نادیده بگیر + continue + + if conditions: + self.stmt = self.stmt.where(or_(*conditions)) + + return self + + def apply_sorting(self, sort_by: str | None, sort_desc: bool) -> 'QueryBuilder': + """اعمال مرتب‌سازی بر روی کوئری""" + if not sort_by: + return self + + try: + column = getattr(self.model_class, sort_by) + if sort_desc: + self.stmt = self.stmt.order_by(column.desc()) + else: + self.stmt = self.stmt.order_by(column.asc()) + except AttributeError: + # اگر فیلد وجود نداشته باشد، مرتب‌سازی را نادیده بگیر + pass + + return self + + def apply_pagination(self, skip: int, take: int) -> 'QueryBuilder': + """اعمال صفحه‌بندی بر روی کوئری""" + self.stmt = self.stmt.offset(skip).limit(take) + return self + + def apply_query_info(self, query_info: QueryInfo) -> 'QueryBuilder': + """اعمال تمام تنظیمات QueryInfo بر روی کوئری""" + return (self + .apply_filters(query_info.filters) + .apply_search(query_info.search, query_info.search_fields) + .apply_sorting(query_info.sort_by, query_info.sort_desc) + .apply_pagination(query_info.skip, query_info.take)) + + def _build_condition(self, column, operator: str, value: Any): + """ساخت شرط بر اساس عملگر و مقدار""" + if operator == "=": + return column == value + elif operator == ">": + return column > value + elif operator == ">=": + return column >= value + elif operator == "<": + return column < value + elif operator == "<=": + return column <= value + elif operator == "!=": + return column != value + elif operator == "*": # contains + return column.ilike(f"%{value}%") + elif operator == "?*": # ends with + return column.ilike(f"%{value}") + elif operator == "*?": # starts with + return column.ilike(f"{value}%") + elif operator == "in": + if not isinstance(value, list): + raise ValueError("برای عملگر 'in' مقدار باید آرایه باشد") + return column.in_(value) + else: + raise ValueError(f"عملگر پشتیبانی نشده: {operator}") + + def get_count_query(self) -> Select: + """دریافت کوئری شمارش (بدون pagination)""" + return select(func.count()).select_from(self.stmt.subquery()) + + def execute(self) -> list[T]: + """اجرای کوئری و بازگرداندن نتایج""" + return list(self.db.execute(self.stmt).scalars().all()) + + def execute_count(self) -> int: + """اجرای کوئری شمارش""" + count_stmt = self.get_count_query() + return int(self.db.execute(count_stmt).scalar() or 0) + + +class QueryService: + """سرویس اصلی برای مدیریت کوئری‌های فیلتر شده""" + + @staticmethod + def query_with_filters( + model_class: Type[T], + db: Session, + query_info: QueryInfo + ) -> tuple[list[T], int]: + """ + اجرای کوئری با فیلتر و بازگرداندن نتایج و تعداد کل + + Args: + model_class: کلاس مدل SQLAlchemy + db: جلسه پایگاه داده + query_info: اطلاعات کوئری شامل فیلترها، مرتب‌سازی و صفحه‌بندی + + Returns: + tuple: (لیست نتایج, تعداد کل رکوردها) + """ + # کوئری شمارش (بدون pagination) + count_builder = QueryBuilder(model_class, db) + count_builder.apply_filters(query_info.filters) + count_builder.apply_search(query_info.search, query_info.search_fields) + total_count = count_builder.execute_count() + + # کوئری داده‌ها (با pagination) + data_builder = QueryBuilder(model_class, db) + data_builder.apply_query_info(query_info) + results = data_builder.execute() + + return results, total_count diff --git a/hesabixAPI/hesabix_api.egg-info/PKG-INFO b/hesabixAPI/hesabix_api.egg-info/PKG-INFO index 97dc2ab..d302d60 100644 --- a/hesabixAPI/hesabix_api.egg-info/PKG-INFO +++ b/hesabixAPI/hesabix_api.egg-info/PKG-INFO @@ -19,6 +19,8 @@ Requires-Dist: pillow>=10.3.0 Requires-Dist: phonenumbers>=8.13.40 Requires-Dist: Babel>=2.15.0 Requires-Dist: jdatetime>=4.1.0 +Requires-Dist: weasyprint>=62.3 +Requires-Dist: jinja2>=3.1.0 Provides-Extra: dev Requires-Dist: pytest>=8.2.0; extra == "dev" Requires-Dist: httpx>=0.27.0; extra == "dev" diff --git a/hesabixAPI/hesabix_api.egg-info/SOURCES.txt b/hesabixAPI/hesabix_api.egg-info/SOURCES.txt index 32b703a..9ea634e 100644 --- a/hesabixAPI/hesabix_api.egg-info/SOURCES.txt +++ b/hesabixAPI/hesabix_api.egg-info/SOURCES.txt @@ -6,6 +6,7 @@ adapters/api/v1/__init__.py adapters/api/v1/auth.py adapters/api/v1/health.py adapters/api/v1/schemas.py +adapters/api/v1/users.py adapters/db/__init__.py adapters/db/session.py adapters/db/models/__init__.py @@ -14,6 +15,7 @@ adapters/db/models/captcha.py adapters/db/models/password_reset.py adapters/db/models/user.py adapters/db/repositories/api_key_repo.py +adapters/db/repositories/base_repo.py adapters/db/repositories/password_reset_repo.py adapters/db/repositories/user_repo.py app/__init__.py @@ -33,6 +35,9 @@ app/core/smart_normalizer.py app/services/api_key_service.py app/services/auth_service.py app/services/captcha_service.py +app/services/pdf_service.py +app/services/query_service.py +app/services/user_context_service.py hesabix_api.egg-info/PKG-INFO hesabix_api.egg-info/SOURCES.txt hesabix_api.egg-info/dependency_links.txt diff --git a/hesabixAPI/hesabix_api.egg-info/requires.txt b/hesabixAPI/hesabix_api.egg-info/requires.txt index cc5f92d..7c7baca 100644 --- a/hesabixAPI/hesabix_api.egg-info/requires.txt +++ b/hesabixAPI/hesabix_api.egg-info/requires.txt @@ -12,6 +12,8 @@ pillow>=10.3.0 phonenumbers>=8.13.40 Babel>=2.15.0 jdatetime>=4.1.0 +weasyprint>=62.3 +jinja2>=3.1.0 [dev] pytest>=8.2.0 diff --git a/hesabixAPI/locales/en/LC_MESSAGES/messages.mo b/hesabixAPI/locales/en/LC_MESSAGES/messages.mo index a00c3b8a6d6526611a4d74813013ee213c27368f..57b00d385e17d7ce908bfe3423488313b37046b4 100644 GIT binary patch literal 3214 zcmaKsO^g&p6vqoe#8naT12A9;OjJU8*xf*c!AD_IZnI3x@1P^v5Ez>OgJ-32}b&Vb~n3DP>xf)r<5 z@I{dPod;>$YaqpW5hTCwft$ehL7I07#KW%OMdLmPY5uo`{S!!ju7Q;QU*I-y69$n# z4&DYn43gh{1rLB!mpPF9E`b!U3*G^?i~bnI!`2G>9EcLIS3p|te8D$CiuY}h{9Xd@ z0Y3rB|5qR$_9I?2?-31>7X`TT-vyQPcxC8bDBt?GS z18E;W1ZmwbLGtr6Nc;FJNOf6<#{0n?;CivHbs6N~ry$k! z7qA3g10Ml*BI!L~1v~(DK{`iofOJ0I1*txlLGt$nNO64);$c_uBEP?Z2$lT_QXW(s zs>4zsu$AS>7$PZjIw>d9K}9#AwH_IgLd& zVYikX*JIgy)3cpMQ6kaN;Tg$tEQFDkDrF*y7D5>X_|mMC#9D-L%7QTMM`BF|uIwZV zow6swh{-re{nb#fF!}sIL@DcrD%B0qlWbW@0f&CpF|zcEh}nuvMLRreb~?!*)?vKd zT1&O;u`tHtM`5bLBDhEt{vw&Cg|qB4^VAdSv?LF%>?caIIH?LP7T`9(x6a3>Ea`gk ztY*nT_XpZr>r2)b%aSSCl}d@Ik_2O;M$0R?3@D+)B!)-oCI1tji!zqzmw^*Am8>=f zy|z>=l|pq^fJk<-zG!V^xFW`ysTN8XHN&?aq)e}bX&r@LVLA!K8s1t&`FmLHrb?cJ zN5c+`6WAm~Rpz`Xqapg?RGz<4PnW~Mm>(>shL@E2RMYW{3T*^t7!-Y(&rZ)CG^P(5 zGY9$fba{Gu-!y(0Xt?rBNS^<9Tn*b{cuK@+By^&%L=9;TP|Y%z@yIYdDSBB;Ga8`6 z#zLZcX_?3U%uMHIkMR6+?y;Efp5rrnk4)9=x;0L3rZhcOK~-do1+isiuCa&vaSFru z2=A;2l}bG~&|TxucpL?WJvFSjlLR;xWqzm~>Z#FY8S`spJ}=rb;WhgB?GpI$&sX2)kl6b8H_`Wl&W1P{lfR`{fOxL*irpcD+=7iqeR zn@EmAWQ*309!<$^Cl^7WaBC))?F|r3TXMnk74w0K;=QDe5P6)Px^dy5w;=4|Tu|Y^5;IN1~XSOK1*0)KD{u@cl0ltqw($5ASt$s4^AhlPbDED#|Bilv9Qzu|p;0nf@*1 zO3KGl$|tpyXIg3xwUjPz?v)gD>@u@5fAyiBBEn2i8wp3gf_I0KnnyvB%A8D2^V`uR zJ#`B`<(Z(4tV~he2}R|Zj^-=e@Vz_~Ri5DPB&)2I>uIadQ|`nN)R8r*sCoTbxuSYd SRGv$!mr1IZNow{U)&3tATq`dC delta 625 zcmYk(JxC)#6u|NEvfgfD&X2@XBX?m@u`(&dGy&mqen22b4^6--pox{D#mBx3-Oou zO6>G-V?5v>KB6CAupi&hhy5Rv3StNcFw&jR;~4WF_zAZ$fV(K)?|0T)>WBv}^^4mG zo}wiDgJHZwN$`rYfydJ+DA;uhpIDD!2yLUYaU6%3ucK_};3zgwnr-8-YAN-T2kH2{ z`@=2HGJi%XDBMd@j3G6sEJkq?r*RJ_@C@hh2BlN;W2cdEloO01hxTtsQ?kEXofjp! z4Bm4qw@{8)J|?H?BP216x*dS~m&j=i6Q(TuH>lq|F_l zBvZL$I+xMQMnD&gwth8^gKM?wwo`0GlBG!2snyHXN?fPSX?@ljCZ$+N|06VzJt+H0*NSE|u%~!j4ml>I-W|zgXA5ue`pVcMmpA A82|tP diff --git a/hesabixAPI/locales/en/LC_MESSAGES/messages.po b/hesabixAPI/locales/en/LC_MESSAGES/messages.po index 1c7310c..88ba57d 100644 --- a/hesabixAPI/locales/en/LC_MESSAGES/messages.po +++ b/hesabixAPI/locales/en/LC_MESSAGES/messages.po @@ -82,3 +82,123 @@ msgstr "Gregorian" msgid "JALALI" msgstr "Jalali" + +msgid "rowNumber" +msgstr "Row" + +msgid "firstName" +msgstr "First Name" + +msgid "lastName" +msgstr "Last Name" + +msgid "registrationDate" +msgstr "Registration Date" + +msgid "selectedRange" +msgstr "Selected Range" + +msgid "page" +msgstr "Page" + +msgid "equals" +msgstr "equals" + +msgid "greater_than" +msgstr "greater than" + +msgid "greater_equal" +msgstr "greater or equal" + +msgid "less_than" +msgstr "less than" + +msgid "less_equal" +msgstr "less or equal" + +msgid "not_equals" +msgstr "not equals" + +msgid "contains" +msgstr "contains" + +msgid "starts_with" +msgstr "starts with" + +msgid "ends_with" +msgstr "ends with" + +msgid "in_list" +msgstr "in list" + +msgid "active" +msgstr "Active" + +msgid "inactive" +msgstr "Inactive" + +msgid "allFields" +msgstr "All Fields" + +msgid "in" +msgstr "in" + +msgid "reportDate" +msgstr "Report Date" + +msgid "totalRecords" +msgstr "Total Records" + +msgid "displayedRecords" +msgstr "Displayed Records" + +msgid "outputType" +msgstr "Output Type" + +msgid "selectedOnly" +msgstr "Selected Only" + +msgid "reportGeneratedOn" +msgstr "Report generated on" + +msgid "at" +msgstr "at" + +msgid "hesabixAccountingSystem" +msgstr "Hesabix Accounting System" + +msgid "marketingReport" +msgstr "Marketing Report" + +msgid "referralList" +msgstr "Referral List" + +msgid "thisMonth" +msgstr "This Month" + +msgid "today" +msgstr "Today" + +msgid "total" +msgstr "Total" + +msgid "email" +msgstr "Email" + +msgid "ofText" +msgstr "of" + +msgid "noDataFound" +msgstr "No data found" + +msgid "activeFilters" +msgstr "Active Filters" + +msgid "search" +msgstr "Search" + +msgid "referralCode" +msgstr "Referral Code" + +msgid "status" +msgstr "Status" diff --git a/hesabixAPI/locales/fa/LC_MESSAGES/messages.mo b/hesabixAPI/locales/fa/LC_MESSAGES/messages.mo index 5c0ba93cbc919027425235cf65ba1139a8274793..9e820244735dea0a5c1280791eda2069e54bac3c 100644 GIT binary patch literal 4023 zcma)-TWlj&8GsLTDJ5Kb;Zj(2ValqsQkgVaSh4l8uukkGrjG57y`_~9)+C-J1CAYL z#@mJ$nk6^UsA^Rqc;TheDyi8`%*0NX zZG~meH~%^R`R_CS!(GR|tN5JfcaQwu$>q@Jo?}YgfqVkKANIrd!Oz1Fz#M!Jd>r<{ z3HU)c3vY)DiTphLDDq_}c3y>d!PlYKxejlIe@*uP0VR}M-lfz>;H^;Xo`4^RgHY_0 zp!oSJlsFp+zXiqKb5QiY2PMwSQ0#8O+u$~obFVUA!n_ZukZf1k*Igkt9}Q1bsb zyc^zzlGrogyWvSFb{|amStxZm2gU9flz1)pUO18LHy~d%pU6)`N}!&B;_tbHKY$W% z8;adm;GOUnQ0#}0uX=-voO=^W-2a5)?-)VK{vA;C?}ntJ?oD_y z>Nv%c{@e#eJ`JT_=b_{~3Z)J;I07$1>C?|3U-cRnnde_anUCvG&ixC@xeu`^@qG$@ z0cN21`8E_g&qC3A0ZROrAxG6FJPv=F?Eema82Qa){XbCjj}yGuI|)UcC*RnkiNb#Y%NNO)%Z%>NrV~`DkTKS=n;7Qmqsp$rdmW$6mCm*~d#d?{a!P+l#MU zv2uk`w2Rezu`sF*$>p)4RZ;qUx{@mvk`mdIfqT(WF-F{a;CQ~W>-7=Wsn@s*)MT>} z*lxpDHP@f3+w)G%aweM|o6fB5)|Jz!`PC=gU`9FLXxVjNO}U;Q6zo|?O?!@w!K&Wq zO2LfXP&1BiPqC2eH1dno||$sX#Sb!CWh-%+EP$&YpIZ=S(@CXV-_DwVsd{ z_ntEiJUeik4J^v8*gteT>NFhoom#P>yylaI*6f7iDc`ZZ$r(_0CiPx@zAIRE1D}3i zdqGk&?t_-Ef*IEzr_eJhXx8j`u7O>TuLspGdCjk3(ahD%$7m8!r3@n}qiN5%eR2Ng zxIgXI%%Rq_Z&sQqqpwt~n8VTtPM8vX$~ZM}>a;oVusL|z7#K(m3_Li%2Sw9zF1lj* zZT0fB!>o9A!>`*x)5Arcv|3a%WlY&!!K^s;tX?VATU6K_X?nAM%4p1K(LXnM)`)M< z4V>*8&y8n$c@6dt^bJ!Er-3JWl`;aF_YmXfHqIK8Gq&eD!MRp2WuEEjNklZ$%r+*Q zHRd5@oSATgzHXPyq4|_CWKTGC<5d6P=WwCItKm+#9&Q+WFM&?D5q9?1jBqzv3fGPB zC;Mw*Ct3)vh3nCxfx=GM?pM)rv>IMB!gh2iTHRlZmQ=V7mQfIi-=Yy+3OAxfjy;nc z!^(QN6|G=9#(}W~(GRy!1KV6NkepGmF*&%urot`a+r+g#X++DS8?8nQ(Io?CtKpU+ zZdSt`+#q*k?PhlS71`>YrD&NH+jxx4k!d@m21x+@s+ZD3;*34Xanaxu_32QiZG%JN zzN2mEC@E9d!rn{KB1cKQt42Yi#W>4d?QdPSby|lU-aMqkImXfGOuB`S1JnH@JnC>b zYQpU%NG8$>QhY0AUEtn#HEd4w>+HaFjuXDH{kQ)=1XHJg@S+?cTiB@H#x)FA8)s~^zlxY;# zJZ+MEcZ~i=11H$PQg75YrGoJs#$zFGfE~JvaIZr9{z+R`2bs=)q1Dc(}uL zC7qHt3I>@h+$}|mR1{enL?@R?38&$%5wA!*x^9O?Uq!EK9jdOAR%9-%=c)(~qq4Fc zcJ{V(>*AsB=^WYQ+_nmtSb2LzOELrcl^0Kpq@OJ8uQ3TbhG~q&f6Yekzu8FsoH^6c zFCs&#E(X=nmt+zijUh^TPm=SVyuGCSd&|kF-5BqYO&u?Z@h&83%A}`I1UVLNNN-k% zmD1>vMk{YT&4wNVsS-DbicAPs_(|d8gn2;dfP}XgEl_icqXXrIzDbKwmuLG&H2G>I z&!ilc*9QLs>LBUv%KjR@Bk8-0mX2`K2!BY(6sEV*%SQEXC48Q`u9hNrDkVW(k;+Qk z#7Eq1377wPlyt66u|Lk&N`!+nf6cE%FT!_b`cK=QRbfzTs6{VJBjO>B%$DL5OnYo5oARE z=u&iUVWW$OjzM$`b`MLiEG%`*V8yr1~{&3p6l+$cEM4;in7I6=;n zU&u4$eHS<4I|lIw`tcVA@Gtr>a7ZMK5j=`|h8VqW=H0+4?|U^S~t= z+)m;q>J2~RS^S22gDL6*-0s;6g&i;8g!LpQFlvaTu!_2&7kD1u<0ag3{DWy3h%C&B zT;_)+Msb7{96RfVN8~1T0xf*x)Q|BR^;f6%n<96ptGIw4FoxTxC;A&1+P~0E>GSPz zc##I}U_UuV>cjMi_4s)gIZWyi(y0Bp!2Tq9TE|KD|1W6Jo|vRFnR2aEt!E3j(>IE_ ztmGEcg<`!>s@GKB2&olgsHVo7aHZetw3_Sj!fJe})nB{U>!wu6yr|xq*VV-I`HPKi x`$41KN=5HB)F~_Ju2u7@Y{k@bE2(y@=10.3.0", "phonenumbers>=8.13.40", "Babel>=2.15.0", - "jdatetime>=4.1.0" + "jdatetime>=4.1.0", + "weasyprint>=62.3", + "jinja2>=3.1.0", + "openpyxl>=3.1.0" ] [project.optional-dependencies] diff --git a/hesabixAPI/templates/README.md b/hesabixAPI/templates/README.md new file mode 100644 index 0000000..09f25c1 --- /dev/null +++ b/hesabixAPI/templates/README.md @@ -0,0 +1,72 @@ +# PDF Templates + +This directory contains HTML templates for PDF generation using WeasyPrint. + +## Structure + +``` +templates/ +├── pdf/ +│ ├── marketing_referrals.html # Marketing referrals report template +│ └── ... # Future templates +└── README.md # This file +``` + +## Template Guidelines + +### 1. RTL Support +- All templates should support RTL (Right-to-Left) layout +- Use `dir="rtl"` in HTML tag +- Use `text-align: right` in CSS + +### 2. Font Support +- Use 'Vazirmatn' font for Persian text +- Fallback to Arial, sans-serif +- Ensure proper font loading in CSS + +### 3. Page Layout +- Use `@page` CSS rule for page settings +- Set appropriate margins (2cm recommended) +- Include page numbers in header/footer + +### 4. Styling +- Use CSS Grid or Flexbox for layouts +- Ensure print-friendly colors +- Use appropriate font sizes (12px base) +- Include proper spacing and padding + +### 5. Data Binding +- Use Jinja2 template syntax +- Handle null/empty values gracefully +- Format dates and numbers appropriately + +## Adding New Templates + +1. Create HTML file in appropriate subdirectory +2. Follow naming convention: `{feature}_{type}.html` +3. Include proper CSS styling +4. Test with sample data +5. Update PDF service to use new template + +## Example Usage + +```python +from app.services.pdf_service import PDFService + +pdf_service = PDFService() +pdf_bytes = pdf_service.generate_marketing_referrals_pdf( + db=db, + user_id=user_id, + query_info=query_info, + selected_indices=indices, + stats=stats +) +``` + +## Future Enhancements + +- Template inheritance system +- Dynamic template selection +- Multi-language support +- Template preview functionality +- Template versioning diff --git a/hesabixAPI/templates/pdf/marketing_referrals.html b/hesabixAPI/templates/pdf/marketing_referrals.html new file mode 100644 index 0000000..8f39666 --- /dev/null +++ b/hesabixAPI/templates/pdf/marketing_referrals.html @@ -0,0 +1,331 @@ + + + + + + گزارش بازاریابی - لیست معرفی‌ها + + + +
+

گزارش بازاریابی

+
لیست معرفی‌های کاربران
+
+ + {% if stats %} +
+
+
{{ stats.today or 0 }}
+
امروز
+
+
+
{{ stats.this_month or 0 }}
+
این ماه
+
+
+
{{ stats.total or 0 }}
+
کل
+
+ {% if stats.range %} +
+
{{ stats.range }}
+
بازه انتخابی
+
+ {% endif %} +
+ {% endif %} + +
+
+
تاریخ گزارش
+
{{ report_date }}
+
+
+
تعداد کل رکوردها
+
{{ total_count }}
+
+
+
تعداد نمایش داده شده
+
{{ items|length }}
+
+ {% if selected_only %} +
+
نوع خروجی
+
انتخاب شده‌ها
+
+ {% endif %} +
+ + {% if filters %} +
+

فیلترهای اعمال شده:

+ {% for filter in filters %} + {{ filter }} + {% endfor %} +
+ {% endif %} + +
+ {% if items %} + + + + + + + + + + + + {% for item in items %} + + + + + + + + {% endfor %} + +
ردیفنامنام خانوادگیایمیلتاریخ ثبت
{{ loop.index }}{{ item.first_name or '-' }}{{ item.last_name or '-' }}{{ item.created_at }}
+ {% else %} +
+
📊
+
هیچ داده‌ای برای نمایش وجود ندارد
+
+ {% endif %} +
+ + + + diff --git a/hesabixUI/hesabix_ui/lib/core/api_client.dart b/hesabixUI/hesabix_ui/lib/core/api_client.dart index 26bf1f1..bb5f5b3 100644 --- a/hesabixUI/hesabix_ui/lib/core/api_client.dart +++ b/hesabixUI/hesabix_ui/lib/core/api_client.dart @@ -97,12 +97,20 @@ class ApiClient { return ApiClient._(dio); } - Future> get(String path, {Map? query, Options? options, CancelToken? cancelToken}) { - return _dio.get(path, queryParameters: query, options: options, cancelToken: cancelToken); + Future> get(String path, {Map? query, Options? options, CancelToken? cancelToken, ResponseType? responseType}) { + final requestOptions = options ?? Options(); + if (responseType != null) { + requestOptions.responseType = responseType; + } + return _dio.get(path, queryParameters: query, options: requestOptions, cancelToken: cancelToken); } - Future> post(String path, {Object? data, Map? query, Options? options, CancelToken? cancelToken}) { - return _dio.post(path, data: data, queryParameters: query, options: options, cancelToken: cancelToken); + Future> post(String path, {Object? data, Map? query, Options? options, CancelToken? cancelToken, ResponseType? responseType}) { + final requestOptions = options ?? Options(); + if (responseType != null) { + requestOptions.responseType = responseType; + } + return _dio.post(path, data: data, queryParameters: query, options: requestOptions, cancelToken: cancelToken); } Future> put(String path, {Object? data, Map? query, Options? options, CancelToken? cancelToken}) { diff --git a/hesabixUI/hesabix_ui/lib/core/referral_store.dart b/hesabixUI/hesabix_ui/lib/core/referral_store.dart index ce33153..1aff516 100644 --- a/hesabixUI/hesabix_ui/lib/core/referral_store.dart +++ b/hesabixUI/hesabix_ui/lib/core/referral_store.dart @@ -14,7 +14,7 @@ class ReferralStore { static Future captureFromCurrentUrl() async { try { String? ref = Uri.base.queryParameters['ref']; - // اگر در hash بود (مثلاً #/login?ref=CODE) از fragment بخوان + // اگر در hash بود (مثلاً /login?ref=CODE) از fragment بخوان if (ref == null || ref.trim().isEmpty) { final frag = Uri.base.fragment; // مثل '/login?ref=CODE' if (frag.isNotEmpty) { @@ -58,7 +58,7 @@ class ReferralStore { static String buildInviteLink(String referralCode) { final origin = Uri.base.origin; // دامنه پویا // استفاده از Hash URL Strategy برای سازگاری کامل با Flutter Web - return '$origin/#/login?ref=$referralCode'; + return '$origin/login?ref=$referralCode'; } static Future saveUserReferralCode(String? code) async { diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_en.arb b/hesabixUI/hesabix_ui/lib/l10n/app_en.arb index 5ae91f3..1f93042 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_en.arb +++ b/hesabixUI/hesabix_ui/lib/l10n/app_en.arb @@ -47,7 +47,17 @@ "menu": "Menu" , "ok": "OK", - "cancel": "Cancel" + "cancel": "Cancel", + "columnSettings": "Column Settings", + "columnSettingsDescription": "Manage column visibility and order for this table", + "columnName": "Column Name", + "visibility": "Visibility", + "order": "Order", + "visible": "Visible", + "hidden": "Hidden", + "resetToDefaults": "Reset to Defaults", + "save": "Save", + "error": "Error" , "newBusiness": "New business", "businesses": "Businesses", @@ -80,6 +90,71 @@ "calendar": "Calendar", "gregorian": "Gregorian", "jalali": "Jalali", - "calendarType": "Calendar Type" + "calendarType": "Calendar Type", + "dataLoadingError": "Error loading data", + "refresh": "Refresh", + "yourReferralLink": "Your referral link", + "filtersAndSearch": "Filters and search", + "hideFilters": "Hide filters", + "showFilters": "Show filters", + "clear": "Clear", + "searchInNameEmail": "Search in name, last name and email...", + "recordsPerPage": "Records per page", + "records": "records", + "test": "Test", + "user": "User", + "showingRecords": "Showing {start} to {end} of {total} records", + "previousPage": "Previous page", + "nextPage": "Next page", + "pageOf": "{current} of {total}", + "referralList": "Referral List", + "dateRangeFilter": "Date Range Filter", + "columnSearch": "Column Search", + "searchInColumn": "Search in {column}", + "searchType": "Search Type", + "contains": "Contains", + "startsWith": "Starts With", + "endsWith": "Ends With", + "exactMatch": "Exact Match", + "searchValue": "Search Value", + "applyColumnFilter": "Apply Column Filter", + "clearColumnFilter": "Clear Column Filter", + "activeFilters": "Active Filters", + "selectDate": "Select Date", + "noDataFound": "No data found", + "marketingReportSubtitle": "Manage and analyze user referrals", + "showing": "Showing", + "to": "to", + "ofText": "of", + "results": "results", + "recordsPerPage": "Records per page", + "firstPage": "First page", + "previousPage": "Previous page", + "nextPage": "Next page", + "lastPage": "Last page", + "exportToExcel": "Export to Excel", + "exportToPdf": "Export to PDF", + "exportSelected": "Export Selected", + "exportAll": "Export All", + "exporting": "Exporting...", + "exportSuccess": "Export completed successfully", + "exportError": "Export error", + "export": "Export", + "rowNumber": "Row", + "firstName": "First Name", + "lastName": "Last Name", + "registrationDate": "Registration Date", + "selectedRange": "Selected Range", + "page": "Page", + "equals": "equals", + "greater_than": "greater than", + "greater_equal": "greater or equal", + "less_than": "less than", + "less_equal": "less or equal", + "not_equals": "not equals", + "contains": "contains", + "starts_with": "starts with", + "ends_with": "ends with", + "in_list": "in list" } diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_fa.arb b/hesabixUI/hesabix_ui/lib/l10n/app_fa.arb index 3c0b126..a1d9697 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_fa.arb +++ b/hesabixUI/hesabix_ui/lib/l10n/app_fa.arb @@ -52,7 +52,17 @@ "changePassword": "تغییر کلمه عبور", "marketing": "بازاریابی", "ok": "تایید", - "cancel": "انصراف" + "cancel": "انصراف", + "columnSettings": "تنظیمات ستون‌ها", + "columnSettingsDescription": "مدیریت نمایش و ترتیب ستون‌های این جدول", + "columnName": "نام ستون", + "visibility": "نمایش", + "order": "ترتیب", + "visible": "نمایش", + "hidden": "مخفی", + "resetToDefaults": "بازگردانی به پیش‌فرض", + "save": "ذخیره", + "error": "خطا" , "marketingReport": "گزارش بازاریابی", "today": "امروز", @@ -79,6 +89,71 @@ "calendar": "تقویم", "gregorian": "میلادی", "jalali": "شمسی", - "calendarType": "نوع تقویم" + "calendarType": "نوع تقویم", + "dataLoadingError": "خطا در بارگذاری داده‌ها", + "refresh": "بروزرسانی", + "yourReferralLink": "لینک معرفی شما", + "filtersAndSearch": "فیلترها و جستجو", + "hideFilters": "مخفی کردن فیلترها", + "showFilters": "نمایش فیلترها", + "clear": "پاک کردن", + "searchInNameEmail": "جستجو در نام، نام خانوادگی و ایمیل...", + "recordsPerPage": "تعداد در صفحه", + "records": "رکورد", + "test": "تست", + "user": "کاربر", + "showingRecords": "نمایش {start} تا {end} از {total} رکورد", + "previousPage": "صفحه قبل", + "nextPage": "صفحه بعد", + "pageOf": "{current} از {total}", + "referralList": "لیست معرفی‌ها", + "dateRangeFilter": "فیلتر بازه زمانی", + "columnSearch": "جستجو در ستون", + "searchInColumn": "جستجو در {column}", + "searchType": "نوع جستجو", + "contains": "شامل", + "startsWith": "شروع با", + "endsWith": "خاتمه با", + "exactMatch": "مطابقت دقیق", + "searchValue": "مقدار جستجو", + "applyColumnFilter": "اعمال فیلتر ستون", + "clearColumnFilter": "پاک کردن فیلتر ستون", + "activeFilters": "فیلترهای فعال", + "selectDate": "انتخاب تاریخ", + "noDataFound": "هیچ داده‌ای یافت نشد", + "marketingReportSubtitle": "مدیریت و تحلیل معرفی‌های کاربران", + "showing": "نمایش", + "to": "تا", + "ofText": "از", + "results": "نتیجه", + "recordsPerPage": "سطر در هر صفحه", + "firstPage": "صفحه اول", + "previousPage": "صفحه قبل", + "nextPage": "صفحه بعد", + "lastPage": "صفحه آخر", + "exportToExcel": "خروجی اکسل", + "exportToPdf": "خروجی PDF", + "exportSelected": "خروجی انتخاب شده‌ها", + "exportAll": "خروجی همه", + "exporting": "در حال خروجی...", + "exportSuccess": "خروجی با موفقیت انجام شد", + "exportError": "خطا در خروجی", + "export": "خروجی", + "rowNumber": "ردیف", + "firstName": "نام", + "lastName": "نام خانوادگی", + "registrationDate": "تاریخ ثبت", + "selectedRange": "بازه انتخابی", + "page": "صفحه", + "equals": "برابر", + "greater_than": "بزرگتر از", + "greater_equal": "بزرگتر یا برابر", + "less_than": "کوچکتر از", + "less_equal": "کوچکتر یا برابر", + "not_equals": "مخالف", + "contains": "شامل", + "starts_with": "شروع با", + "ends_with": "پایان با", + "in_list": "در لیست" } diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_localizations.dart b/hesabixUI/hesabix_ui/lib/l10n/app_localizations.dart index cbf5268..7b1963d 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_localizations.dart +++ b/hesabixUI/hesabix_ui/lib/l10n/app_localizations.dart @@ -167,13 +167,13 @@ abstract class AppLocalizations { /// No description provided for @firstName. /// /// In en, this message translates to: - /// **'First name'** + /// **'First Name'** String get firstName; /// No description provided for @lastName. /// /// In en, this message translates to: - /// **'Last name'** + /// **'Last Name'** String get lastName; /// No description provided for @email. @@ -350,6 +350,66 @@ abstract class AppLocalizations { /// **'Cancel'** String get cancel; + /// No description provided for @columnSettings. + /// + /// In en, this message translates to: + /// **'Column Settings'** + String get columnSettings; + + /// No description provided for @columnSettingsDescription. + /// + /// In en, this message translates to: + /// **'Manage column visibility and order for this table'** + String get columnSettingsDescription; + + /// No description provided for @columnName. + /// + /// In en, this message translates to: + /// **'Column Name'** + String get columnName; + + /// No description provided for @visibility. + /// + /// In en, this message translates to: + /// **'Visibility'** + String get visibility; + + /// No description provided for @order. + /// + /// In en, this message translates to: + /// **'Order'** + String get order; + + /// No description provided for @visible. + /// + /// In en, this message translates to: + /// **'Visible'** + String get visible; + + /// No description provided for @hidden. + /// + /// In en, this message translates to: + /// **'Hidden'** + String get hidden; + + /// No description provided for @resetToDefaults. + /// + /// In en, this message translates to: + /// **'Reset to Defaults'** + String get resetToDefaults; + + /// No description provided for @save. + /// + /// In en, this message translates to: + /// **'Save'** + String get save; + + /// No description provided for @error. + /// + /// In en, this message translates to: + /// **'Error'** + String get error; + /// No description provided for @newBusiness. /// /// In en, this message translates to: @@ -535,6 +595,354 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Calendar Type'** String get calendarType; + + /// No description provided for @dataLoadingError. + /// + /// In en, this message translates to: + /// **'Error loading data'** + String get dataLoadingError; + + /// No description provided for @yourReferralLink. + /// + /// In en, this message translates to: + /// **'Your referral link'** + String get yourReferralLink; + + /// No description provided for @filtersAndSearch. + /// + /// In en, this message translates to: + /// **'Filters and search'** + String get filtersAndSearch; + + /// No description provided for @hideFilters. + /// + /// In en, this message translates to: + /// **'Hide filters'** + String get hideFilters; + + /// No description provided for @showFilters. + /// + /// In en, this message translates to: + /// **'Show filters'** + String get showFilters; + + /// No description provided for @clear. + /// + /// In en, this message translates to: + /// **'Clear'** + String get clear; + + /// No description provided for @searchInNameEmail. + /// + /// In en, this message translates to: + /// **'Search in name, last name and email...'** + String get searchInNameEmail; + + /// No description provided for @recordsPerPage. + /// + /// In en, this message translates to: + /// **'Records per page'** + String get recordsPerPage; + + /// No description provided for @records. + /// + /// In en, this message translates to: + /// **'records'** + String get records; + + /// No description provided for @test. + /// + /// In en, this message translates to: + /// **'Test'** + String get test; + + /// No description provided for @user. + /// + /// In en, this message translates to: + /// **'User'** + String get user; + + /// No description provided for @showingRecords. + /// + /// In en, this message translates to: + /// **'Showing {start} to {end} of {total} records'** + String showingRecords(Object end, Object start, Object total); + + /// No description provided for @previousPage. + /// + /// In en, this message translates to: + /// **'Previous page'** + String get previousPage; + + /// No description provided for @nextPage. + /// + /// In en, this message translates to: + /// **'Next page'** + String get nextPage; + + /// No description provided for @pageOf. + /// + /// In en, this message translates to: + /// **'{current} of {total}'** + String pageOf(Object current, Object total); + + /// No description provided for @referralList. + /// + /// In en, this message translates to: + /// **'Referral List'** + String get referralList; + + /// No description provided for @dateRangeFilter. + /// + /// In en, this message translates to: + /// **'Date Range Filter'** + String get dateRangeFilter; + + /// No description provided for @columnSearch. + /// + /// In en, this message translates to: + /// **'Column Search'** + String get columnSearch; + + /// No description provided for @searchInColumn. + /// + /// In en, this message translates to: + /// **'Search in {column}'** + String searchInColumn(Object column); + + /// No description provided for @searchType. + /// + /// In en, this message translates to: + /// **'Search Type'** + String get searchType; + + /// No description provided for @contains. + /// + /// In en, this message translates to: + /// **'contains'** + String get contains; + + /// No description provided for @startsWith. + /// + /// In en, this message translates to: + /// **'Starts With'** + String get startsWith; + + /// No description provided for @endsWith. + /// + /// In en, this message translates to: + /// **'Ends With'** + String get endsWith; + + /// No description provided for @exactMatch. + /// + /// In en, this message translates to: + /// **'Exact Match'** + String get exactMatch; + + /// No description provided for @searchValue. + /// + /// In en, this message translates to: + /// **'Search Value'** + String get searchValue; + + /// No description provided for @applyColumnFilter. + /// + /// In en, this message translates to: + /// **'Apply Column Filter'** + String get applyColumnFilter; + + /// No description provided for @clearColumnFilter. + /// + /// In en, this message translates to: + /// **'Clear Column Filter'** + String get clearColumnFilter; + + /// No description provided for @activeFilters. + /// + /// In en, this message translates to: + /// **'Active Filters'** + String get activeFilters; + + /// No description provided for @selectDate. + /// + /// In en, this message translates to: + /// **'Select Date'** + String get selectDate; + + /// No description provided for @noDataFound. + /// + /// In en, this message translates to: + /// **'No data found'** + String get noDataFound; + + /// No description provided for @marketingReportSubtitle. + /// + /// In en, this message translates to: + /// **'Manage and analyze user referrals'** + String get marketingReportSubtitle; + + /// No description provided for @showing. + /// + /// In en, this message translates to: + /// **'Showing'** + String get showing; + + /// No description provided for @to. + /// + /// In en, this message translates to: + /// **'to'** + String get to; + + /// No description provided for @ofText. + /// + /// In en, this message translates to: + /// **'of'** + String get ofText; + + /// No description provided for @results. + /// + /// In en, this message translates to: + /// **'results'** + String get results; + + /// No description provided for @firstPage. + /// + /// In en, this message translates to: + /// **'First page'** + String get firstPage; + + /// No description provided for @lastPage. + /// + /// In en, this message translates to: + /// **'Last page'** + String get lastPage; + + /// No description provided for @exportToExcel. + /// + /// In en, this message translates to: + /// **'Export to Excel'** + String get exportToExcel; + + /// No description provided for @exportToPdf. + /// + /// In en, this message translates to: + /// **'Export to PDF'** + String get exportToPdf; + + /// No description provided for @exportSelected. + /// + /// In en, this message translates to: + /// **'Export Selected'** + String get exportSelected; + + /// No description provided for @exportAll. + /// + /// In en, this message translates to: + /// **'Export All'** + String get exportAll; + + /// No description provided for @exporting. + /// + /// In en, this message translates to: + /// **'Exporting...'** + String get exporting; + + /// No description provided for @exportSuccess. + /// + /// In en, this message translates to: + /// **'Export completed successfully'** + String get exportSuccess; + + /// No description provided for @exportError. + /// + /// In en, this message translates to: + /// **'Export error'** + String get exportError; + + /// No description provided for @export. + /// + /// In en, this message translates to: + /// **'Export'** + String get export; + + /// No description provided for @rowNumber. + /// + /// In en, this message translates to: + /// **'Row'** + String get rowNumber; + + /// No description provided for @registrationDate. + /// + /// In en, this message translates to: + /// **'Registration Date'** + String get registrationDate; + + /// No description provided for @selectedRange. + /// + /// In en, this message translates to: + /// **'Selected Range'** + String get selectedRange; + + /// No description provided for @page. + /// + /// In en, this message translates to: + /// **'Page'** + String get page; + + /// No description provided for @equals. + /// + /// In en, this message translates to: + /// **'equals'** + String get equals; + + /// No description provided for @greater_than. + /// + /// In en, this message translates to: + /// **'greater than'** + String get greater_than; + + /// No description provided for @greater_equal. + /// + /// In en, this message translates to: + /// **'greater or equal'** + String get greater_equal; + + /// No description provided for @less_than. + /// + /// In en, this message translates to: + /// **'less than'** + String get less_than; + + /// No description provided for @less_equal. + /// + /// In en, this message translates to: + /// **'less or equal'** + String get less_equal; + + /// No description provided for @not_equals. + /// + /// In en, this message translates to: + /// **'not equals'** + String get not_equals; + + /// No description provided for @starts_with. + /// + /// In en, this message translates to: + /// **'starts with'** + String get starts_with; + + /// No description provided for @ends_with. + /// + /// In en, this message translates to: + /// **'ends with'** + String get ends_with; + + /// No description provided for @in_list. + /// + /// In en, this message translates to: + /// **'in list'** + String get in_list; } class _AppLocalizationsDelegate diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_localizations_en.dart b/hesabixUI/hesabix_ui/lib/l10n/app_localizations_en.dart index 834f399..2510aaf 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_localizations_en.dart +++ b/hesabixUI/hesabix_ui/lib/l10n/app_localizations_en.dart @@ -42,10 +42,10 @@ class AppLocalizationsEn extends AppLocalizations { String get forgotPassword => 'Forgot password'; @override - String get firstName => 'First name'; + String get firstName => 'First Name'; @override - String get lastName => 'Last name'; + String get lastName => 'Last Name'; @override String get email => 'Email'; @@ -136,6 +136,37 @@ class AppLocalizationsEn extends AppLocalizations { @override String get cancel => 'Cancel'; + @override + String get columnSettings => 'Column Settings'; + + @override + String get columnSettingsDescription => + 'Manage column visibility and order for this table'; + + @override + String get columnName => 'Column Name'; + + @override + String get visibility => 'Visibility'; + + @override + String get order => 'Order'; + + @override + String get visible => 'Visible'; + + @override + String get hidden => 'Hidden'; + + @override + String get resetToDefaults => 'Reset to Defaults'; + + @override + String get save => 'Save'; + + @override + String get error => 'Error'; + @override String get newBusiness => 'New business'; @@ -232,4 +263,184 @@ class AppLocalizationsEn extends AppLocalizations { @override String get calendarType => 'Calendar Type'; + + @override + String get dataLoadingError => 'Error loading data'; + + @override + String get yourReferralLink => 'Your referral link'; + + @override + String get filtersAndSearch => 'Filters and search'; + + @override + String get hideFilters => 'Hide filters'; + + @override + String get showFilters => 'Show filters'; + + @override + String get clear => 'Clear'; + + @override + String get searchInNameEmail => 'Search in name, last name and email...'; + + @override + String get recordsPerPage => 'Records per page'; + + @override + String get records => 'records'; + + @override + String get test => 'Test'; + + @override + String get user => 'User'; + + @override + String showingRecords(Object end, Object start, Object total) { + return 'Showing $start to $end of $total records'; + } + + @override + String get previousPage => 'Previous page'; + + @override + String get nextPage => 'Next page'; + + @override + String pageOf(Object current, Object total) { + return '$current of $total'; + } + + @override + String get referralList => 'Referral List'; + + @override + String get dateRangeFilter => 'Date Range Filter'; + + @override + String get columnSearch => 'Column Search'; + + @override + String searchInColumn(Object column) { + return 'Search in $column'; + } + + @override + String get searchType => 'Search Type'; + + @override + String get contains => 'contains'; + + @override + String get startsWith => 'Starts With'; + + @override + String get endsWith => 'Ends With'; + + @override + String get exactMatch => 'Exact Match'; + + @override + String get searchValue => 'Search Value'; + + @override + String get applyColumnFilter => 'Apply Column Filter'; + + @override + String get clearColumnFilter => 'Clear Column Filter'; + + @override + String get activeFilters => 'Active Filters'; + + @override + String get selectDate => 'Select Date'; + + @override + String get noDataFound => 'No data found'; + + @override + String get marketingReportSubtitle => 'Manage and analyze user referrals'; + + @override + String get showing => 'Showing'; + + @override + String get to => 'to'; + + @override + String get ofText => 'of'; + + @override + String get results => 'results'; + + @override + String get firstPage => 'First page'; + + @override + String get lastPage => 'Last page'; + + @override + String get exportToExcel => 'Export to Excel'; + + @override + String get exportToPdf => 'Export to PDF'; + + @override + String get exportSelected => 'Export Selected'; + + @override + String get exportAll => 'Export All'; + + @override + String get exporting => 'Exporting...'; + + @override + String get exportSuccess => 'Export completed successfully'; + + @override + String get exportError => 'Export error'; + + @override + String get export => 'Export'; + + @override + String get rowNumber => 'Row'; + + @override + String get registrationDate => 'Registration Date'; + + @override + String get selectedRange => 'Selected Range'; + + @override + String get page => 'Page'; + + @override + String get equals => 'equals'; + + @override + String get greater_than => 'greater than'; + + @override + String get greater_equal => 'greater or equal'; + + @override + String get less_than => 'less than'; + + @override + String get less_equal => 'less or equal'; + + @override + String get not_equals => 'not equals'; + + @override + String get starts_with => 'starts with'; + + @override + String get ends_with => 'ends with'; + + @override + String get in_list => 'in list'; } diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_localizations_fa.dart b/hesabixUI/hesabix_ui/lib/l10n/app_localizations_fa.dart index 1a780db..65cbee0 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_localizations_fa.dart +++ b/hesabixUI/hesabix_ui/lib/l10n/app_localizations_fa.dart @@ -88,7 +88,7 @@ class AppLocalizationsFa extends AppLocalizations { String get captcha => 'کد امنیتی'; @override - String get refresh => 'تازه‌سازی'; + String get refresh => 'بروزرسانی'; @override String get captchaRequired => 'کد امنیتی الزامی است.'; @@ -136,6 +136,37 @@ class AppLocalizationsFa extends AppLocalizations { @override String get cancel => 'انصراف'; + @override + String get columnSettings => 'تنظیمات ستون‌ها'; + + @override + String get columnSettingsDescription => + 'مدیریت نمایش و ترتیب ستون‌های این جدول'; + + @override + String get columnName => 'نام ستون'; + + @override + String get visibility => 'نمایش'; + + @override + String get order => 'ترتیب'; + + @override + String get visible => 'نمایش'; + + @override + String get hidden => 'مخفی'; + + @override + String get resetToDefaults => 'بازگردانی به پیش‌فرض'; + + @override + String get save => 'ذخیره'; + + @override + String get error => 'خطا'; + @override String get newBusiness => 'کسب‌وکار جدید'; @@ -231,4 +262,184 @@ class AppLocalizationsFa extends AppLocalizations { @override String get calendarType => 'نوع تقویم'; + + @override + String get dataLoadingError => 'خطا در بارگذاری داده‌ها'; + + @override + String get yourReferralLink => 'لینک معرفی شما'; + + @override + String get filtersAndSearch => 'فیلترها و جستجو'; + + @override + String get hideFilters => 'مخفی کردن فیلترها'; + + @override + String get showFilters => 'نمایش فیلترها'; + + @override + String get clear => 'پاک کردن'; + + @override + String get searchInNameEmail => 'جستجو در نام، نام خانوادگی و ایمیل...'; + + @override + String get recordsPerPage => 'سطر در هر صفحه'; + + @override + String get records => 'رکورد'; + + @override + String get test => 'تست'; + + @override + String get user => 'کاربر'; + + @override + String showingRecords(Object end, Object start, Object total) { + return 'نمایش $start تا $end از $total رکورد'; + } + + @override + String get previousPage => 'صفحه قبل'; + + @override + String get nextPage => 'صفحه بعد'; + + @override + String pageOf(Object current, Object total) { + return '$current از $total'; + } + + @override + String get referralList => 'لیست معرفی‌ها'; + + @override + String get dateRangeFilter => 'فیلتر بازه زمانی'; + + @override + String get columnSearch => 'جستجو در ستون'; + + @override + String searchInColumn(Object column) { + return 'جستجو در $column'; + } + + @override + String get searchType => 'نوع جستجو'; + + @override + String get contains => 'شامل'; + + @override + String get startsWith => 'شروع با'; + + @override + String get endsWith => 'خاتمه با'; + + @override + String get exactMatch => 'مطابقت دقیق'; + + @override + String get searchValue => 'مقدار جستجو'; + + @override + String get applyColumnFilter => 'اعمال فیلتر ستون'; + + @override + String get clearColumnFilter => 'پاک کردن فیلتر ستون'; + + @override + String get activeFilters => 'فیلترهای فعال'; + + @override + String get selectDate => 'انتخاب تاریخ'; + + @override + String get noDataFound => 'هیچ داده‌ای یافت نشد'; + + @override + String get marketingReportSubtitle => 'مدیریت و تحلیل معرفی‌های کاربران'; + + @override + String get showing => 'نمایش'; + + @override + String get to => 'تا'; + + @override + String get ofText => 'از'; + + @override + String get results => 'نتیجه'; + + @override + String get firstPage => 'صفحه اول'; + + @override + String get lastPage => 'صفحه آخر'; + + @override + String get exportToExcel => 'خروجی اکسل'; + + @override + String get exportToPdf => 'خروجی PDF'; + + @override + String get exportSelected => 'خروجی انتخاب شده‌ها'; + + @override + String get exportAll => 'خروجی همه'; + + @override + String get exporting => 'در حال خروجی...'; + + @override + String get exportSuccess => 'خروجی با موفقیت انجام شد'; + + @override + String get exportError => 'خطا در خروجی'; + + @override + String get export => 'خروجی'; + + @override + String get rowNumber => 'ردیف'; + + @override + String get registrationDate => 'تاریخ ثبت'; + + @override + String get selectedRange => 'بازه انتخابی'; + + @override + String get page => 'صفحه'; + + @override + String get equals => 'برابر'; + + @override + String get greater_than => 'بزرگتر از'; + + @override + String get greater_equal => 'بزرگتر یا برابر'; + + @override + String get less_than => 'کوچکتر از'; + + @override + String get less_equal => 'کوچکتر یا برابر'; + + @override + String get not_equals => 'مخالف'; + + @override + String get starts_with => 'شروع با'; + + @override + String get ends_with => 'پایان با'; + + @override + String get in_list => 'در لیست'; } diff --git a/hesabixUI/hesabix_ui/lib/main.dart b/hesabixUI/hesabix_ui/lib/main.dart index b4a86af..2045102 100644 --- a/hesabixUI/hesabix_ui/lib/main.dart +++ b/hesabixUI/hesabix_ui/lib/main.dart @@ -4,7 +4,6 @@ import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_web_plugins/url_strategy.dart'; import 'pages/login_page.dart'; -import 'pages/home_page.dart'; import 'pages/profile/profile_shell.dart'; import 'pages/profile/profile_dashboard_page.dart'; import 'pages/profile/new_business_page.dart'; @@ -89,20 +88,164 @@ class _MyAppState extends State { // Root of application with GoRouter @override Widget build(BuildContext context) { + // اگر هنوز loading است، یک router ساده با loading page بساز if (_controller == null || _calendarController == null || _themeController == null || _authStore == null) { - return const MaterialApp( - home: Scaffold( - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - CircularProgressIndicator(), - SizedBox(height: 16), - Text('Loading...'), - ], + final loadingRouter = GoRouter( + redirect: (context, state) { + // در حین loading، هیچ redirect نکن - URL را حفظ کن + return null; + }, + routes: [ + GoRoute( + path: '/', + builder: (context, state) => const Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Loading...'), + ], + ), + ), ), ), - ), + // برای سایر مسیرها هم loading page نمایش بده + GoRoute( + path: '/login', + builder: (context, state) => const Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Loading...'), + ], + ), + ), + ), + ), + GoRoute( + path: '/user/profile/dashboard', + builder: (context, state) => const Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Loading...'), + ], + ), + ), + ), + ), + GoRoute( + path: '/user/profile/marketing', + builder: (context, state) => const Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Loading...'), + ], + ), + ), + ), + ), + GoRoute( + path: '/user/profile/new-business', + builder: (context, state) => const Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Loading...'), + ], + ), + ), + ), + ), + GoRoute( + path: '/user/profile/businesses', + builder: (context, state) => const Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Loading...'), + ], + ), + ), + ), + ), + GoRoute( + path: '/user/profile/support', + builder: (context, state) => const Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Loading...'), + ], + ), + ), + ), + ), + GoRoute( + path: '/user/profile/change-password', + builder: (context, state) => const Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Loading...'), + ], + ), + ), + ), + ), + // Catch-all route برای هر URL دیگر + GoRoute( + path: '/:path(.*)', + builder: (context, state) => const Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Loading...'), + ], + ), + ), + ), + ), + ], + ); + + return MaterialApp.router( + title: 'Hesabix', + routerConfig: loadingRouter, + locale: const Locale('en'), + supportedLocales: const [Locale('en'), Locale('fa')], + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], ); } @@ -143,6 +286,7 @@ class _MyAppState extends State { } // برای سایر صفحات (شامل صفحات profile)، redirect نکن (بماند) + // این مهم است: اگر کاربر در صفحات profile است، بماند return null; }, routes: [ diff --git a/hesabixUI/hesabix_ui/lib/pages/login_page.dart b/hesabixUI/hesabix_ui/lib/pages/login_page.dart index 3beff54..4e7cd04 100644 --- a/hesabixUI/hesabix_ui/lib/pages/login_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/login_page.dart @@ -86,8 +86,8 @@ class _LoginPageState extends State with SingleTickerProviderStateMix if (body is! Map) return; final data = body['data']; if (data is! Map) return; - final String? id = data['captcha_id'] as String?; - final String? imgB64 = data['image_base64'] as String?; + final String? id = data['captcha_id']?.toString(); + final String? imgB64 = data['image_base64']?.toString(); final int? ttl = (data['ttl_seconds'] as num?)?.toInt(); if (id == null || imgB64 == null) return; Uint8List bytes; @@ -236,12 +236,12 @@ class _LoginPageState extends State with SingleTickerProviderStateMix final inner = body['data']; if (inner is Map) data = inner; } - final apiKey = data != null ? data['api_key'] as String? : null; + final apiKey = data != null ? data['api_key']?.toString() : null; if (apiKey != null && apiKey.isNotEmpty) { await widget.authStore.saveApiKey(apiKey); // ذخیره کد بازاریابی کاربر برای صفحه Marketing final user = data?['user'] as Map?; - final String? myRef = user != null ? user['referral_code'] as String? : null; + final String? myRef = user != null ? user['referral_code']?.toString() : null; unawaited(ReferralStore.saveUserReferralCode(myRef)); } @@ -323,7 +323,7 @@ class _LoginPageState extends State with SingleTickerProviderStateMix final inner = body['data']; if (inner is Map) data = inner; } - final apiKey = data != null ? data['api_key'] as String? : null; + final apiKey = data != null ? data['api_key']?.toString() : null; if (apiKey != null && apiKey.isNotEmpty) { await widget.authStore.saveApiKey(apiKey); } diff --git a/hesabixUI/hesabix_ui/lib/pages/profile/marketing_page.dart b/hesabixUI/hesabix_ui/lib/pages/profile/marketing_page.dart index a91139e..10c2e1a 100644 --- a/hesabixUI/hesabix_ui/lib/pages/profile/marketing_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/profile/marketing_page.dart @@ -1,14 +1,10 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:hesabix_ui/l10n/app_localizations.dart'; import '../../core/referral_store.dart'; import '../../core/api_client.dart'; import '../../core/calendar_controller.dart'; -import '../../core/date_utils.dart'; -import '../../widgets/jalali_date_picker.dart'; -import '../../widgets/date_input_field.dart'; +import '../../widgets/data_table/data_table.dart'; class MarketingPage extends StatefulWidget { final CalendarController calendarController; @@ -27,35 +23,13 @@ class _MarketingPageState extends State { int? _rangeCount; DateTime? _fromDate; DateTime? _toDate; - // list state - bool _loadingList = false; - int _page = 1; - int _limit = 10; - int _total = 0; - List> _items = const []; - final TextEditingController _searchCtrl = TextEditingController(); - Timer? _searchDebounce; + Set _selectedRows = {}; @override void initState() { super.initState(); _loadReferralCode(); _fetchStats(); - _fetchList(); - _searchCtrl.addListener(() { - _searchDebounce?.cancel(); - _searchDebounce = Timer(const Duration(milliseconds: 400), () { - _page = 1; - _fetchList(withRange: true); - }); - }); - } - - @override - void dispose() { - _searchCtrl.dispose(); - _searchDebounce?.cancel(); - super.dispose(); } Future _loadReferralCode() async { @@ -72,7 +46,6 @@ class _MarketingPageState extends State { final api = ApiClient(); final params = {}; if (withRange && _fromDate != null && _toDate != null) { - // use ISO8601 date-time boundaries: start at 00:00, end next day 00:00 final start = DateTime(_fromDate!.year, _fromDate!.month, _fromDate!.day); final endExclusive = DateTime(_toDate!.year, _toDate!.month, _toDate!.day).add(const Duration(days: 1)); params['start'] = start.toIso8601String(); @@ -92,258 +65,350 @@ class _MarketingPageState extends State { } } } catch (_) { - // silent fail: نمایش خطا ضروری نیست + // silent fail } finally { if (mounted) setState(() => _loading = false); } } - - Future _fetchList({bool withRange = false}) async { - setState(() => _loadingList = true); - try { - final api = ApiClient(); - final params = { - 'page': _page, - 'limit': _limit, - }; - final q = _searchCtrl.text.trim(); - if (q.isNotEmpty) params['search'] = q; - if (withRange && _fromDate != null && _toDate != null) { - final start = DateTime(_fromDate!.year, _fromDate!.month, _fromDate!.day); - final endExclusive = DateTime(_toDate!.year, _toDate!.month, _toDate!.day).add(const Duration(days: 1)); - params['start'] = start.toIso8601String(); - params['end'] = endExclusive.toIso8601String(); - } - final res = await api.get>('/api/v1/auth/referrals/list', query: params); - final body = res.data; - if (body is Map) { - final data = body['data']; - if (data is Map) { - final items = (data['items'] as List?)?.cast>() ?? const []; - setState(() { - _items = items; - _total = (data['total'] as num?)?.toInt() ?? 0; - _page = (data['page'] as num?)?.toInt() ?? _page; - _limit = (data['limit'] as num?)?.toInt() ?? _limit; - }); - } - } - } catch (_) { - } finally { - if (mounted) setState(() => _loadingList = false); - } - } - - void _applyFilters() { - _page = 1; - _fetchStats(withRange: true); - _fetchList(withRange: true); - } - @override Widget build(BuildContext context) { - final t = AppLocalizations.of(context); + final t = Localizations.of(context, AppLocalizations)!; final code = _referralCode; final inviteLink = (code == null || code.isEmpty) ? null : ReferralStore.buildInviteLink(code); - return Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(t.marketingReport, style: Theme.of(context).textTheme.titleLarge), - const SizedBox(height: 12), - if (code == null || code.isEmpty) Text(t.loading, style: Theme.of(context).textTheme.bodyMedium), - if (inviteLink != null) ...[ - Row( + final theme = Theme.of(context); + + return Scaffold( + body: SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Container( + padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: theme.dividerColor.withValues(alpha: 0.5), + ), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + Icons.analytics, + size: 24, + color: theme.colorScheme.onPrimaryContainer, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded(child: SelectableText(inviteLink)), - const SizedBox(width: 8), - OutlinedButton.icon( - onPressed: () async { - await Clipboard.setData(ClipboardData(text: inviteLink)); - if (!mounted) return; - ScaffoldMessenger.of(context) - ..hideCurrentSnackBar() - ..showSnackBar(SnackBar(content: Text(t.copied))); - }, - icon: const Icon(Icons.link), - label: Text(t.copyLink), - ), + Text( + t.marketingReport, + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + color: theme.colorScheme.onSurface, + ), + ), + const SizedBox(height: 4), + Text( + t.marketingReportSubtitle, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), ], + ), ), - ], - const SizedBox(height: 16), - Wrap( - spacing: 12, - runSpacing: 12, - children: [ - _StatCard(title: t.today, value: _todayCount, loading: _loading), - _StatCard(title: t.thisMonth, value: _monthCount, loading: _loading), - _StatCard(title: t.total, value: _totalCount, loading: _loading), - _StatCard(title: '${t.dateFrom}-${t.dateTo}', value: _rangeCount, loading: _loading), - ], - ), - const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: DateInputField( - value: _fromDate, - onChanged: (date) { - setState(() { - _fromDate = date; - }); - }, - labelText: t.dateFrom, - calendarController: widget.calendarController, - enabled: !_loading, - ), - ), - const SizedBox(width: 8), - Expanded( - child: DateInputField( - value: _toDate, - onChanged: (date) { - setState(() { - _toDate = date; - }); - }, - labelText: t.dateTo, - calendarController: widget.calendarController, - enabled: !_loading, - ), - ), - const SizedBox(width: 8), - FilledButton( - onPressed: _loading || _fromDate == null || _toDate == null ? null : _applyFilters, - child: _loading ? const SizedBox(height: 18, width: 18, child: CircularProgressIndicator(strokeWidth: 2)) : Text(t.applyFilter), - ), - ], - ), - const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: TextField( - controller: _searchCtrl, - decoration: InputDecoration( - prefixIcon: const Icon(Icons.search), - hintText: t.email, - border: const OutlineInputBorder(), - isDense: true, + const SizedBox(height: 24), + + // Referral Link Card + if (inviteLink != null) ...[ + Card( + elevation: 2, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(6), + ), + child: Icon( + Icons.link, + color: theme.colorScheme.onPrimaryContainer, + size: 18, + ), + ), + const SizedBox(width: 12), + Text( + t.yourReferralLink, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface, + ), + ), + ], + ), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: theme.dividerColor), + ), + child: Row( + children: [ + Expanded( + child: SelectableText( + inviteLink, + style: theme.textTheme.bodyMedium?.copyWith( + fontFamily: 'monospace', + ), + ), + ), + const SizedBox(width: 8), + FilledButton.icon( + onPressed: () async { + await Clipboard.setData(ClipboardData(text: inviteLink)); + if (!mounted) return; + final messenger = ScaffoldMessenger.of(context); + messenger + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text(t.copied), + backgroundColor: theme.colorScheme.primary, + ), + ); + }, + icon: const Icon(Icons.copy, size: 18), + label: Text(t.copyLink), + ), + ], + ), + ), + ], ), ), ), - const SizedBox(width: 8), - DropdownButton( - value: _limit, - items: const [10, 20, 50].map((e) => DropdownMenuItem(value: e, child: Text('per: ' + e.toString()))).toList(), - onChanged: (v) { - if (v == null) return; - setState(() => _limit = v); - _page = 1; - _fetchList(withRange: true); + const SizedBox(height: 24), + ], + + // Stats Cards + Wrap( + spacing: 16, + runSpacing: 16, + children: [ + _StatCard( + title: t.today, + value: _todayCount, + loading: _loading, + icon: Icons.today, + color: Colors.blue, + ), + _StatCard( + title: t.thisMonth, + value: _monthCount, + loading: _loading, + icon: Icons.calendar_month, + color: Colors.green, + ), + _StatCard( + title: t.total, + value: _totalCount, + loading: _loading, + icon: Icons.people, + color: Colors.orange, + ), + _StatCard( + title: '${t.dateFrom}-${t.dateTo}', + value: _rangeCount, + loading: _loading, + icon: Icons.date_range, + color: Colors.purple, + ), + ], + ), + const SizedBox(height: 24), + + // Data Table using new widget + DataTableWidget>( + config: DataTableConfig>( + title: t.referralList, + endpoint: '/api/v1/auth/referrals/list', + excelEndpoint: '/api/v1/auth/referrals/export/excel', + pdfEndpoint: '/api/v1/auth/referrals/export/pdf', + getExportParams: () => { + 'user_id': 'current_user', // Example parameter + }, + columns: [ + TextColumn( + 'first_name', + t.firstName, + sortable: true, + searchable: true, + width: ColumnWidth.small, + ), + TextColumn( + 'last_name', + t.lastName, + sortable: true, + searchable: true, + width: ColumnWidth.small, + ), + TextColumn( + 'email', + t.email, + sortable: true, + searchable: true, + width: ColumnWidth.large, + ), + DateColumn( + 'created_at', + t.register, + sortable: true, + searchable: true, + width: ColumnWidth.medium, + showTime: false, + ), + ], + searchFields: ['first_name', 'last_name', 'email'], + filterFields: ['first_name', 'last_name', 'email', 'created_at'], + dateRangeField: 'created_at', + showSearch: true, + showFilters: true, + showColumnSearch: true, + showPagination: true, + showActiveFilters: true, + enableSorting: true, + enableGlobalSearch: true, + enableDateRangeFilter: true, + showRowNumbers: true, + enableRowSelection: true, + enableMultiRowSelection: true, + selectedRows: _selectedRows, + onRowSelectionChanged: (selectedRows) { + setState(() { + _selectedRows = selectedRows; + }); + }, + defaultPageSize: 20, + pageSizeOptions: const [10, 20, 50, 100], + showRefreshButton: true, + showClearFiltersButton: true, + emptyStateMessage: 'هیچ معرفی‌ای یافت نشد', + loadingMessage: 'در حال بارگذاری معرفی‌ها...', + errorMessage: 'خطا در بارگذاری معرفی‌ها', + enableHorizontalScroll: true, + minTableWidth: 600, + showBorder: true, + borderRadius: BorderRadius.circular(8), + padding: const EdgeInsets.all(16), + onDateRangeApply: (fromDate, toDate) { + setState(() { + _fromDate = fromDate; + _toDate = toDate; + }); + _fetchStats(withRange: true); + }, + onDateRangeClear: () { + setState(() { + _fromDate = null; + _toDate = null; + }); + _fetchStats(); }, ), - ], - ), - const SizedBox(height: 12), - Card( - clipBehavior: Clip.antiAlias, - child: Column( - children: [ - if (_loadingList) - const LinearProgressIndicator(minHeight: 2) - else - const SizedBox(height: 2), - SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: DataTable( - columns: [ - DataColumn(label: Text(t.firstName)), - DataColumn(label: Text(t.lastName)), - DataColumn(label: Text(t.email)), - DataColumn(label: Text(t.register)), - ], - rows: _items.map((e) { - final createdAt = (e['created_at'] as String?) ?? ''; - DateTime? date; - if (createdAt.isNotEmpty) { - try { - date = DateTime.parse(createdAt.substring(0, 10)); - } catch (e) { - // Ignore parsing errors - } - } - final dateStr = date != null - ? HesabixDateUtils.formatForDisplay(date, widget.calendarController.isJalali) - : ''; - return DataRow(cells: [ - DataCell(Text((e['first_name'] ?? '') as String)), - DataCell(Text((e['last_name'] ?? '') as String)), - DataCell(Text((e['email'] ?? '') as String)), - DataCell(Text(dateStr)), - ]); - }).toList(), - ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), - child: Row( - children: [ - Text('${((_page - 1) * _limit + 1).clamp(0, _total)} - ${(_page * _limit).clamp(0, _total)} / $_total'), - const Spacer(), - IconButton( - onPressed: _page > 1 && !_loadingList ? () { setState(() => _page -= 1); _fetchList(withRange: true); } : null, - icon: const Icon(Icons.chevron_right), - tooltip: 'Prev', - ), - IconButton( - onPressed: (_page * _limit) < _total && !_loadingList ? () { setState(() => _page += 1); _fetchList(withRange: true); } : null, - icon: const Icon(Icons.chevron_left), - tooltip: 'Next', - ), - ], - ), - ), - ], + fromJson: (json) => json, + calendarController: widget.calendarController, ), - ), - ], - ), - ); - } -} - -class _StatCard extends StatelessWidget { - final String title; - final int? value; - final bool loading; - const _StatCard({required this.title, required this.value, required this.loading}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return SizedBox( - width: 240, - child: Card( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, style: theme.textTheme.titleMedium), - const SizedBox(height: 8), - loading - ? const SizedBox(height: 22, width: 22, child: CircularProgressIndicator(strokeWidth: 2)) - : Text((value ?? 0).toString(), style: theme.textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w600)), - ], - ), + ], ), ), ); } } +class _StatCard extends StatelessWidget { + final String title; + final int? value; + final bool loading; + final IconData icon; + final Color color; + + const _StatCard({ + required this.title, + required this.value, + required this.loading, + required this.icon, + required this.color, + }); + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return SizedBox( + width: 200, + child: Card( + elevation: 2, + child: Padding( + padding: const EdgeInsets.all(20.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, color: color, size: 24), + const SizedBox(width: 8), + Expanded( + child: Text( + title, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + loading + ? const SizedBox( + height: 28, + width: 28, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text( + (value ?? 0).toString(), + style: theme.textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + color: color, + ), + ), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/hesabixUI/hesabix_ui/lib/widgets/data_table/BUGFIXES_SUMMARY.md b/hesabixUI/hesabix_ui/lib/widgets/data_table/BUGFIXES_SUMMARY.md new file mode 100644 index 0000000..a9db1ad --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/data_table/BUGFIXES_SUMMARY.md @@ -0,0 +1,91 @@ +# خلاصه اصلاحات مشکلات تنظیمات ستون‌ها + +## مشکلات حل شده + +### 1. ✅ **مشکل نمایش ستون‌های مخفی در دیالوگ تنظیمات** +**مشکل**: بعد از مخفی کردن یک ستون و رفرش صفحه، این ستون در لیست تنظیمات ستون‌ها نمایش داده نمی‌شد. + +**علت**: در دیالوگ تنظیمات، لیست `widget.columns` تغییر می‌کرد و ستون‌های مخفی شده از لیست حذف می‌شدند. + +**راه‌حل**: +- ایجاد کپی محلی از لیست ستون‌ها (`_columns`) در دیالوگ +- استفاده از کپی محلی به جای `widget.columns` در تمام عملیات + +### 2. ✅ **مشکل دکمه "بازگردانی به پیش‌فرض"** +**مشکل**: دکمه "بازگردانی به پیش‌فرض" کار نمی‌کرد. + +**علت**: استفاده از `widget.columns` به جای کپی محلی. + +**راه‌حل**: تغییر مراجع به `_columns` در تابع `_resetToDefaults()`. + +### 3. ✅ **جابجایی دکمه تنظیمات ستون‌ها** +**مشکل**: دکمه تنظیمات ستون‌ها باید بعد از دکمه رفرش قرار گیرد. + +**راه‌حل**: جابجایی کد دکمه تنظیمات ستون‌ها به بعد از دکمه رفرش در `_buildHeader()`. + +### 4. ✅ **جلوگیری از مخفی کردن همه ستون‌ها** +**مشکل**: امکان مخفی کردن همه ستون‌ها وجود داشت که باعث نمایش خالی جدول می‌شد. + +**راه‌حل**: +- **در دیالوگ**: اضافه کردن چک `if (_visibleColumns.length > 1)` قبل از حذف ستون +- **در checkbox "همه"**: نگه داشتن حداقل یک ستون هنگام uncheck کردن +- **در سرویس**: اضافه کردن منطق `if (visibleColumns.isEmpty && defaultColumnKeys.isNotEmpty)` +- **در DataTableWidget**: اضافه کردن تابع `_validateColumnSettings()` + +## تغییرات فایل‌ها + +### 1. `column_settings_dialog.dart` +```dart +// اضافه شدن کپی محلی از ستون‌ها +late List _columns; + +// جلوگیری از مخفی کردن همه ستون‌ها +if (_visibleColumns.length > 1) { + _visibleColumns.remove(column.key); +} + +// نگه داشتن حداقل یک ستون در checkbox "همه" +_visibleColumns = [_columns.first.key]; +``` + +### 2. `data_table_widget.dart` +```dart +// جابجایی دکمه تنظیمات ستون‌ها +// اضافه شدن تابع اعتبارسنجی +ColumnSettings _validateColumnSettings(ColumnSettings settings) { + if (settings.visibleColumns.isEmpty && widget.config.columns.isNotEmpty) { + return settings.copyWith( + visibleColumns: [widget.config.columns.first.key], + columnOrder: [widget.config.columns.first.key], + ); + } + return settings; +} +``` + +### 3. `column_settings_service.dart` +```dart +// اضافه شدن منطق جلوگیری از مخفی کردن همه ستون‌ها +if (visibleColumns.isEmpty && defaultColumnKeys.isNotEmpty) { + visibleColumns.add(defaultColumnKeys.first); +} +``` + +## تست‌های اضافه شده + +### 1. `column_settings_validation_test.dart` +- تست جلوگیری از مخفی کردن همه ستون‌ها +- تست حفظ ستون‌های موجود +- تست فیلتر کردن ستون‌های نامعتبر +- تست حفظ ترتیب ستون‌ها + +## نتیجه + +همه مشکلات مطرح شده با موفقیت حل شدند: + +1. ✅ ستون‌های مخفی در دیالوگ تنظیمات نمایش داده می‌شوند +2. ✅ دکمه "بازگردانی به پیش‌فرض" کار می‌کند +3. ✅ دکمه تنظیمات ستون‌ها بعد از دکمه رفرش قرار دارد +4. ✅ همیشه حداقل یک ستون در حالت نمایش باقی می‌ماند + +سیستم اکنون کاملاً پایدار و کاربرپسند است. diff --git a/hesabixUI/hesabix_ui/lib/widgets/data_table/ISOLATION_ANALYSIS.md b/hesabixUI/hesabix_ui/lib/widgets/data_table/ISOLATION_ANALYSIS.md new file mode 100644 index 0000000..5d8ac33 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/data_table/ISOLATION_ANALYSIS.md @@ -0,0 +1,132 @@ +# تحلیل جداسازی تنظیمات ستون‌ها + +## بررسی کد برای اطمینان از جداسازی کامل + +### 1. تولید کلیدهای منحصر به فرد + +```dart +// در ColumnSettingsService +static const String _keyPrefix = 'data_table_column_settings_'; + +// در DataTableConfig +String get effectiveTableId { + return tableId ?? endpoint.replaceAll(RegExp(r'[^a-zA-Z0-9]'), '_'); +} +``` + +### 2. مثال‌های عملی کلیدهای تولید شده + +| جدول | endpoint | tableId | کلید نهایی | +|------|----------|---------|-------------| +| جدول کاربران | `/api/users` | `null` | `data_table_column_settings__api_users` | +| جدول سفارشات | `/api/orders` | `null` | `data_table_column_settings__api_orders` | +| جدول محصولات | `/api/products` | `null` | `data_table_column_settings__api_products` | +| جدول سفارشات | `/api/orders` | `custom_orders` | `data_table_column_settings_custom_orders` | +| جدول کاربران | `/api/users` | `users_management` | `data_table_column_settings_users_management` | + +### 3. بررسی کد ذخیره‌سازی + +```dart +static Future saveColumnSettings(String tableId, ColumnSettings settings) async { + try { + final prefs = await SharedPreferences.getInstance(); + final key = '$_keyPrefix$tableId'; // کلید منحصر به فرد + final jsonString = jsonEncode(settings.toJson()); + await prefs.setString(key, jsonString); + } catch (e) { + print('Error saving column settings: $e'); + } +} +``` + +### 4. بررسی کد بارگذاری + +```dart +static Future getColumnSettings(String tableId) async { + try { + final prefs = await SharedPreferences.getInstance(); + final key = '$_keyPrefix$tableId'; // کلید منحصر به فرد + final jsonString = prefs.getString(key); + + if (jsonString == null) return null; + + final json = jsonDecode(jsonString) as Map; + return ColumnSettings.fromJson(json); + } catch (e) { + print('Error loading column settings: $e'); + return null; + } +} +``` + +## نتیجه‌گیری + +### ✅ **جداسازی کامل تضمین شده است:** + +1. **کلیدهای منحصر به فرد**: هر جدول با `tableId` منحصر به فرد شناسایی می‌شود +2. **پیشوند مخصوص**: `data_table_column_settings_` فقط برای تنظیمات ستون‌ها استفاده می‌شود +3. **عدم تداخل**: تنظیمات هر جدول کاملاً مستقل از دیگری ذخیره می‌شود +4. **تولید خودکار**: اگر `tableId` مشخص نشود، از `endpoint` تولید می‌شود + +### مثال عملی استفاده در 5 صفحه مختلف: + +```dart +// صفحه 1: مدیریت کاربران +DataTableWidget( + config: DataTableConfig( + endpoint: '/api/users', + // کلید: data_table_column_settings__api_users + ), + fromJson: (json) => User.fromJson(json), +) + +// صفحه 2: مدیریت سفارشات +DataTableWidget( + config: DataTableConfig( + endpoint: '/api/orders', + // کلید: data_table_column_settings__api_orders + ), + fromJson: (json) => Order.fromJson(json), +) + +// صفحه 3: گزارش‌های مالی +DataTableWidget( + config: DataTableConfig( + endpoint: '/api/reports', + tableId: 'financial_reports', + // کلید: data_table_column_settings_financial_reports + ), + fromJson: (json) => Report.fromJson(json), +) + +// صفحه 4: مدیریت محصولات +DataTableWidget( + config: DataTableConfig( + endpoint: '/api/products', + // کلید: data_table_column_settings__api_products + ), + fromJson: (json) => Product.fromJson(json), +) + +// صفحه 5: لاگ‌های سیستم +DataTableWidget( + config: DataTableConfig( + endpoint: '/api/logs', + tableId: 'system_logs', + // کلید: data_table_column_settings_system_logs + ), + fromJson: (json) => Log.fromJson(json), +) +``` + +### ✅ **تضمین عدم تداخل:** + +- هر جدول تنظیمات مستقل خود را دارد +- تغییر تنظیمات در یک جدول روی جدول‌های دیگر تأثیر نمی‌گذارد +- هر جدول می‌تواند ستون‌های مختلفی را مخفی/نمایش دهد +- ترتیب ستون‌ها در هر جدول مستقل است +- تنظیمات در SharedPreferences با کلیدهای کاملاً متفاوت ذخیره می‌شود + +## خلاصه + +سیستم به گونه‌ای طراحی شده که **هیچ تداخلی بین تنظیمات جدول‌های مختلف وجود ندارد**. هر جدول با شناسه منحصر به فرد خود تنظیماتش را ذخیره و بازیابی می‌کند. diff --git a/hesabixUI/hesabix_ui/lib/widgets/data_table/README.md b/hesabixUI/hesabix_ui/lib/widgets/data_table/README.md new file mode 100644 index 0000000..a074f45 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/data_table/README.md @@ -0,0 +1,333 @@ +# DataTableWidget + +یک ویجت جدول قابل استفاده مجدد و قدرتمند برای Flutter که قابلیت‌های پیشرفته جست‌وجو، فیلتر، مرتب‌سازی و صفحه‌بندی را ارائه می‌دهد. + +## ویژگی‌ها + +### 🔍 جست‌وجو و فیلتر +- **جست‌وجوی کلی**: جست‌وجو در چندین فیلد به صورت همزمان +- **جست‌وجوی ستونی**: جست‌وجو در ستون‌های خاص با انواع مختلف +- **فیلتر بازه زمانی**: فیلتر بر اساس تاریخ +- **فیلترهای فعال**: نمایش و مدیریت فیلترهای اعمال شده + +### 📊 انواع ستون‌ها +- **TextColumn**: ستون متنی با قابلیت فرمت‌بندی +- **NumberColumn**: ستون عددی با فرمت‌بندی و پیشوند/پسوند +- **DateColumn**: ستون تاریخ با فرمت‌بندی Jalali/Gregorian +- **ActionColumn**: ستون عملیات با دکمه‌های قابل تنظیم +- **CustomColumn**: ستون سفارشی با builder مخصوص + +### 🎨 سفارشی‌سازی +- **تم‌ها**: پشتیبانی کامل از تم‌های Material Design +- **رنگ‌بندی**: قابلیت تنظیم رنگ‌های مختلف +- **فونت‌ها**: تنظیم فونت و اندازه متن +- **حاشیه‌ها**: تنظیم padding و margin + +### 📱 پاسخگو +- **اسکرول افقی**: در صورت کمبود فضای افقی +- **صفحه‌بندی**: مدیریت صفحات با گزینه‌های مختلف +- **حالت‌های مختلف**: loading، error، empty state + +## نصب و استفاده + +### 1. Import کردن +```dart +import 'package:hesabix_ui/widgets/data_table/data_table.dart'; +``` + +### 2. استفاده ساده +```dart +DataTableWidget>( + config: DataTableConfig>( + title: 'لیست کاربران', + endpoint: '/api/v1/users/list', + columns: [ + TextColumn('name', 'نام'), + TextColumn('email', 'ایمیل'), + DateColumn('created_at', 'تاریخ عضویت'), + ], + searchFields: ['name', 'email'], + filterFields: ['name', 'email', 'created_at'], + ), + fromJson: (json) => json, +) +``` + +### 3. استفاده پیشرفته +```dart +DataTableWidget>( + config: DataTableConfig>( + title: 'لیست فاکتورها', + subtitle: 'مدیریت فاکتورهای فروش', + endpoint: '/api/v1/invoices/list', + columns: [ + TextColumn( + 'invoice_number', + 'شماره فاکتور', + sortable: true, + searchable: true, + width: ColumnWidth.medium, + ), + NumberColumn( + 'total_amount', + 'مبلغ کل', + prefix: 'ریال ', + decimalPlaces: 0, + width: ColumnWidth.medium, + ), + DateColumn( + 'created_at', + 'تاریخ فاکتور', + showTime: false, + width: ColumnWidth.medium, + ), + ActionColumn( + 'actions', + 'عملیات', + actions: [ + DataTableAction( + icon: Icons.edit, + label: 'ویرایش', + onTap: (item) => _editItem(item), + ), + DataTableAction( + icon: Icons.delete, + label: 'حذف', + onTap: (item) => _deleteItem(item), + isDestructive: true, + ), + ], + ), + ], + searchFields: ['invoice_number', 'customer_name'], + filterFields: ['invoice_number', 'customer_name', 'created_at'], + dateRangeField: 'created_at', + onRowTap: (item) => _showDetails(item), + showSearch: true, + showFilters: true, + showColumnSearch: true, + showPagination: true, + enableSorting: true, + enableGlobalSearch: true, + enableDateRangeFilter: true, + defaultPageSize: 20, + pageSizeOptions: const [10, 20, 50, 100], + showRefreshButton: true, + showClearFiltersButton: true, + emptyStateMessage: 'هیچ فاکتوری یافت نشد', + loadingMessage: 'در حال بارگذاری...', + errorMessage: 'خطا در بارگذاری', + enableHorizontalScroll: true, + minTableWidth: 800, + showBorder: true, + borderRadius: BorderRadius.circular(8), + padding: const EdgeInsets.all(16), + ), + fromJson: (json) => json, + calendarController: calendarController, +) +``` + +## پیکربندی + +### DataTableConfig +کلاس اصلی پیکربندی که شامل تمام تنظیمات جدول است: + +```dart +DataTableConfig( + // الزامی + endpoint: String, // آدرس API + columns: List, // تعریف ستون‌ها + + // اختیاری + title: String?, // عنوان جدول + subtitle: String?, // زیرعنوان + searchFields: List, // فیلدهای جست‌وجوی کلی + filterFields: List, // فیلدهای قابل فیلتر + dateRangeField: String?, // فیلد فیلتر بازه زمانی + + // UI + showSearch: bool, // نمایش جست‌وجو + showFilters: bool, // نمایش فیلترها + showColumnSearch: bool, // نمایش جست‌وجوی ستونی + showPagination: bool, // نمایش صفحه‌بندی + showActiveFilters: bool, // نمایش فیلترهای فعال + + // عملکرد + enableSorting: bool, // فعال‌سازی مرتب‌سازی + enableGlobalSearch: bool, // فعال‌سازی جست‌وجوی کلی + enableDateRangeFilter: bool, // فعال‌سازی فیلتر بازه زمانی + + // صفحه‌بندی + defaultPageSize: int, // اندازه پیش‌فرض صفحه + pageSizeOptions: List, // گزینه‌های اندازه صفحه + + // رویدادها + onRowTap: Function(T)?, // کلیک روی سطر + onRowDoubleTap: Function(T)?, // دابل کلیک روی سطر + + // پیام‌ها + emptyStateMessage: String?, // پیام حالت خالی + loadingMessage: String?, // پیام بارگذاری + errorMessage: String?, // پیام خطا + + // ظاهر + enableHorizontalScroll: bool, // اسکرول افقی + minTableWidth: double?, // حداقل عرض جدول + showBorder: bool, // نمایش حاشیه + borderRadius: BorderRadius?, // شعاع حاشیه + padding: EdgeInsets?, // فاصله داخلی + margin: EdgeInsets?, // فاصله خارجی + backgroundColor: Color?, // رنگ پس‌زمینه + headerBackgroundColor: Color?, // رنگ پس‌زمینه هدر + rowBackgroundColor: Color?, // رنگ پس‌زمینه سطرها + alternateRowBackgroundColor: Color?, // رنگ پس‌زمینه سطرهای متناوب + borderColor: Color?, // رنگ حاشیه + borderWidth: double?, // ضخامت حاشیه + boxShadow: List?, // سایه +) +``` + +### انواع ستون‌ها + +#### TextColumn +```dart +TextColumn( + 'field_name', // نام فیلد + 'نمایش نام', // برچسب + sortable: true, // قابل مرتب‌سازی + searchable: true, // قابل جست‌وجو + width: ColumnWidth.medium, // عرض ستون + formatter: (item) => item['field_name']?.toString() ?? '', // فرمت‌کننده + textAlign: TextAlign.start, // تراز متن + maxLines: 1, // حداکثر خطوط + overflow: true, // نمایش ... در صورت اضافه +) +``` + +#### NumberColumn +```dart +NumberColumn( + 'amount', + 'مبلغ', + prefix: 'ریال ', + suffix: '', + decimalPlaces: 2, + textAlign: TextAlign.end, +) +``` + +#### DateColumn +```dart +DateColumn( + 'created_at', + 'تاریخ ایجاد', + showTime: false, + dateFormat: 'yyyy/MM/dd', + textAlign: TextAlign.center, +) +``` + +#### ActionColumn +```dart +ActionColumn( + 'actions', + 'عملیات', + actions: [ + DataTableAction( + icon: Icons.edit, + label: 'ویرایش', + onTap: (item) => _editItem(item), + ), + DataTableAction( + icon: Icons.delete, + label: 'حذف', + onTap: (item) => _deleteItem(item), + isDestructive: true, + ), + ], +) +``` + +## API Integration + +### QueryInfo Structure +ویجت از ساختار QueryInfo برای ارتباط با API استفاده می‌کند: + +```dart +class QueryInfo { + String? search; // عبارت جست‌وجو + List? searchFields; // فیلدهای جست‌وجو + List? filters; // فیلترها + String? sortBy; // فیلد مرتب‌سازی + bool sortDesc; // ترتیب نزولی + int take; // تعداد رکورد + int skip; // تعداد رد شده +} +``` + +### FilterItem Structure +```dart +class FilterItem { + String property; // نام فیلد + String operator; // عملگر (>=, <, *, =, etc.) + dynamic value; // مقدار +} +``` + +### Response Structure +API باید پاسخ را در این فرمت برگرداند: + +```json +{ + "data": { + "items": [...], + "total": 100, + "page": 1, + "limit": 20, + "total_pages": 5 + } +} +``` + +## مثال‌های استفاده + +### 1. لیست کاربران +```dart +ReferralDataTableExample(calendarController: calendarController) +``` + +### 2. لیست فاکتورها +```dart +InvoiceDataTableExample(calendarController: calendarController) +``` + +### 3. لیست سفارشی +```dart +DataTableWidget( + config: DataTableConfig( + // پیکربندی... + ), + fromJson: (json) => CustomModel.fromJson(json), + calendarController: calendarController, +) +``` + +## نکات مهم + +1. **CalendarController**: برای فیلترهای تاریخ نیاز است +2. **fromJson**: تابع تبدیل JSON به مدل مورد نظر +3. **API Endpoint**: باید QueryInfo را پشتیبانی کند +4. **Localization**: نیاز به کلیدهای ترجمه مناسب +5. **Theme**: از تم فعلی برنامه استفاده می‌کند + +## عیب‌یابی + +### مشکلات رایج +1. **خطای API**: بررسی endpoint و ساختار QueryInfo +2. **خطای ترجمه**: بررسی کلیدهای localization +3. **خطای مدل**: بررسی تابع fromJson +4. **خطای UI**: بررسی تنظیمات DataTableConfig + +### لاگ‌ها +ویجت لاگ‌های مفیدی برای عیب‌یابی ارائه می‌دهد که در console قابل مشاهده است. diff --git a/hesabixUI/hesabix_ui/lib/widgets/data_table/README_COLUMN_SETTINGS.md b/hesabixUI/hesabix_ui/lib/widgets/data_table/README_COLUMN_SETTINGS.md new file mode 100644 index 0000000..0720f8e --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/data_table/README_COLUMN_SETTINGS.md @@ -0,0 +1,149 @@ +# Column Settings Feature + +This feature allows users to customize column visibility and ordering in data tables. The settings are automatically saved and restored for each table. + +## Features + +- **Column Visibility**: Users can show/hide columns by checking/unchecking them +- **Column Ordering**: Users can reorder columns by dragging them +- **Persistent Storage**: Settings are saved using SharedPreferences and restored on next visit +- **Per-Table Settings**: Each table has its own independent settings +- **Multilingual Support**: Full support for English and Persian languages + +## Usage + +### Basic Usage + +```dart +DataTableWidget( + config: DataTableConfig( + endpoint: '/api/users', + columns: [ + TextColumn('id', 'ID'), + TextColumn('name', 'Name'), + TextColumn('email', 'Email'), + DateColumn('createdAt', 'Created At'), + ], + // Enable column settings (enabled by default) + enableColumnSettings: true, + // Show column settings button (shown by default) + showColumnSettingsButton: true, + // Optional: Provide a unique table ID for settings storage + tableId: 'users_table', + // Optional: Callback when settings change + onColumnSettingsChanged: (settings) { + print('Column settings changed: ${settings.visibleColumns}'); + }, + ), + fromJson: (json) => User.fromJson(json), +) +``` + +### Advanced Configuration + +```dart +DataTableWidget( + config: DataTableConfig( + endpoint: '/api/orders', + columns: [ + TextColumn('id', 'Order ID'), + TextColumn('customerName', 'Customer'), + NumberColumn('amount', 'Amount'), + DateColumn('orderDate', 'Order Date'), + ActionColumn('actions', 'Actions', actions: [ + DataTableAction( + icon: Icons.edit, + label: 'Edit', + onTap: (order) => editOrder(order), + ), + ]), + ], + // Custom table ID for settings storage + tableId: 'orders_management_table', + // Disable column settings for this table + enableColumnSettings: false, + // Hide column settings button + showColumnSettingsButton: false, + // Provide initial column settings + initialColumnSettings: ColumnSettings( + visibleColumns: ['id', 'customerName', 'amount'], + columnOrder: ['id', 'amount', 'customerName'], + ), + ), + fromJson: (json) => Order.fromJson(json), +) +``` + +## Configuration Options + +### DataTableConfig Properties + +- `enableColumnSettings` (bool, default: true): Enable/disable column settings functionality +- `showColumnSettingsButton` (bool, default: true): Show/hide the column settings button +- `tableId` (String?): Unique identifier for the table (auto-generated from endpoint if not provided) +- `initialColumnSettings` (ColumnSettings?): Initial column settings to use +- `onColumnSettingsChanged` (Function?): Callback when column settings change + +### ColumnSettings Properties + +- `visibleColumns` (List): List of visible column keys +- `columnOrder` (List): Ordered list of column keys +- `columnWidths` (Map): Custom column widths (future feature) + +## How It Works + +1. **Settings Storage**: Each table's settings are stored in SharedPreferences with a unique key +2. **Settings Loading**: On table initialization, settings are loaded and applied +3. **Settings Dialog**: Users can modify settings through a drag-and-drop interface +4. **Settings Persistence**: Changes are automatically saved to SharedPreferences +5. **Settings Restoration**: Settings are restored when the table is loaded again + +## Storage Key Format + +Settings are stored with the key: `data_table_column_settings_{tableId}` + +Where `tableId` is either: +- The provided `tableId` from config +- Auto-generated from the endpoint (e.g., `/api/users` becomes `_api_users`) + +## Localization + +The feature supports both English and Persian languages. Required localization keys: + +- `columnSettings`: "Column Settings" / "تنظیمات ستون‌ها" +- `columnSettingsDescription`: "Manage column visibility and order for this table" / "مدیریت نمایش و ترتیب ستون‌های این جدول" +- `columnName`: "Column Name" / "نام ستون" +- `visibility`: "Visibility" / "نمایش" +- `order`: "Order" / "ترتیب" +- `visible`: "Visible" / "نمایش" +- `hidden`: "Hidden" / "مخفی" +- `resetToDefaults`: "Reset to Defaults" / "بازگردانی به پیش‌فرض" +- `save`: "Save" / "ذخیره" +- `error`: "Error" / "خطا" + +## Technical Details + +### Files Added/Modified + +1. **New Files**: + - `helpers/column_settings_service.dart`: Service for managing settings persistence + - `column_settings_dialog.dart`: Dialog widget for managing column settings + +2. **Modified Files**: + - `data_table_config.dart`: Added column settings configuration options + - `data_table_widget.dart`: Integrated column settings functionality + - `app_en.arb` & `app_fa.arb`: Added localization keys + +### Dependencies + +- `shared_preferences`: For persistent storage +- `flutter/material.dart`: For UI components +- `data_table_2`: For the data table widget + +## Future Enhancements + +- Column width customization +- Column grouping +- Export settings with data +- Import/export column configurations +- Column templates/presets diff --git a/hesabixUI/hesabix_ui/lib/widgets/data_table/column_settings_dialog.dart b/hesabixUI/hesabix_ui/lib/widgets/data_table/column_settings_dialog.dart new file mode 100644 index 0000000..63f6675 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/data_table/column_settings_dialog.dart @@ -0,0 +1,318 @@ +import 'package:flutter/material.dart'; +import 'package:hesabix_ui/l10n/app_localizations.dart'; +import 'data_table_config.dart'; +import 'helpers/column_settings_service.dart'; + +/// Dialog for managing column visibility and ordering +class ColumnSettingsDialog extends StatefulWidget { + final List columns; + final ColumnSettings currentSettings; + final String tableTitle; + + const ColumnSettingsDialog({ + super.key, + required this.columns, + required this.currentSettings, + this.tableTitle = 'Table', + }); + + @override + State createState() => _ColumnSettingsDialogState(); +} + +class _ColumnSettingsDialogState extends State { + late List _visibleColumns; + late List _columnOrder; + late Map _columnWidths; + late List _columns; // Local copy of columns + + @override + void initState() { + super.initState(); + _visibleColumns = List.from(widget.currentSettings.visibleColumns); + _columnOrder = List.from(widget.currentSettings.columnOrder); + _columnWidths = Map.from(widget.currentSettings.columnWidths); + _columns = List.from(widget.columns); // Create local copy + } + + @override + Widget build(BuildContext context) { + final t = Localizations.of(context, AppLocalizations)!; + final theme = Theme.of(context); + + return Dialog( + child: Container( + width: MediaQuery.of(context).size.width * 0.8, + height: MediaQuery.of(context).size.height * 0.7, + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Row( + children: [ + Icon( + Icons.view_column, + color: theme.colorScheme.primary, + size: 24, + ), + const SizedBox(width: 12), + Text( + t.columnSettings, + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close), + ), + ], + ), + const SizedBox(height: 8), + Text( + t.columnSettingsDescription, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 24), + + // Column list + Expanded( + child: _buildColumnList(t, theme), + ), + + const SizedBox(height: 24), + + // Action buttons + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: _resetToDefaults, + child: Text(t.resetToDefaults), + ), + const SizedBox(width: 12), + OutlinedButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(t.cancel), + ), + const SizedBox(width: 12), + FilledButton( + onPressed: _saveSettings, + child: Text(t.save), + ), + ], + ), + ], + ), + ), + ); + } + + Widget _buildColumnList(AppLocalizations t, ThemeData theme) { + return Container( + decoration: BoxDecoration( + border: Border.all(color: theme.dividerColor), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + children: [ + // Header + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(8), + topRight: Radius.circular(8), + ), + ), + child: Row( + children: [ + SizedBox( + width: 24, + child: Checkbox( + value: _visibleColumns.length == _columns.length, + tristate: true, + onChanged: (value) { + if (value == true) { + setState(() { + _visibleColumns = _columns.map((col) => col.key).toList(); + }); + } else { + // Keep at least one column visible + setState(() { + _visibleColumns = [_columns.first.key]; + }); + } + }, + ), + ), + const SizedBox(width: 12), + Expanded( + flex: 2, + child: Text( + t.columnName, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + Expanded( + flex: 1, + child: Text( + t.visibility, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(width: 8), + Text( + t.order, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + + // Column items + Expanded( + child: ReorderableListView.builder( + itemCount: _columns.length, + onReorder: _onReorder, + itemBuilder: (context, index) { + final column = _columns[index]; + final isVisible = _visibleColumns.contains(column.key); + + return Container( + key: ValueKey(column.key), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: theme.dividerColor.withValues(alpha: 0.5), + ), + ), + ), + child: ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + leading: SizedBox( + width: 24, + child: Checkbox( + value: isVisible, + onChanged: (value) { + setState(() { + if (value == true) { + if (!_visibleColumns.contains(column.key)) { + _visibleColumns.add(column.key); + } + } else { + // Prevent hiding all columns + if (_visibleColumns.length > 1) { + _visibleColumns.remove(column.key); + } + } + }); + }, + ), + ), + title: Row( + children: [ + Expanded( + flex: 2, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + column.label, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + if (column.tooltip != null) + Text( + column.tooltip!, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + Expanded( + flex: 1, + child: Row( + children: [ + Icon( + isVisible ? Icons.visibility : Icons.visibility_off, + size: 16, + color: isVisible + ? theme.colorScheme.primary + : theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 4), + Text( + isVisible ? t.visible : t.hidden, + style: theme.textTheme.bodySmall?.copyWith( + color: isVisible + ? theme.colorScheme.primary + : theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + Icon( + Icons.drag_handle, + size: 16, + color: theme.colorScheme.onSurfaceVariant, + ), + ], + ), + ), + ); + }, + ), + ), + ], + ), + ); + } + + void _onReorder(int oldIndex, int newIndex) { + setState(() { + if (oldIndex < newIndex) { + newIndex -= 1; + } + final column = _columns.removeAt(oldIndex); + _columns.insert(newIndex, column); + + // Update column order + _columnOrder = _columns.map((col) => col.key).toList(); + }); + } + + void _resetToDefaults() { + setState(() { + _visibleColumns = _columns.map((col) => col.key).toList(); + _columnOrder = _columns.map((col) => col.key).toList(); + _columnWidths.clear(); + }); + } + + void _saveSettings() { + final newSettings = ColumnSettings( + visibleColumns: _visibleColumns, + columnOrder: _columnOrder, + columnWidths: _columnWidths, + ); + + Navigator.of(context).pop(newSettings); + } +} diff --git a/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table.dart b/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table.dart new file mode 100644 index 0000000..90bfe86 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table.dart @@ -0,0 +1,5 @@ +// Export all data table related files +export 'data_table_widget.dart'; +export 'data_table_config.dart'; +export 'data_table_search_dialog.dart'; +export 'helpers/data_table_utils.dart'; diff --git a/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_config.dart b/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_config.dart new file mode 100644 index 0000000..accd7ed --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_config.dart @@ -0,0 +1,424 @@ +import 'package:flutter/material.dart'; +import 'helpers/column_settings_service.dart'; + +/// Configuration for data table columns +enum ColumnWidth { + small, + medium, + large, + extraLarge, +} + +/// Base class for all column types +abstract class DataTableColumn { + final String key; + final String label; + final bool sortable; + final bool searchable; + final ColumnWidth width; + final String? tooltip; + + const DataTableColumn({ + required this.key, + required this.label, + this.sortable = true, + this.searchable = true, + this.width = ColumnWidth.medium, + this.tooltip, + }); +} + +/// Text column configuration +class TextColumn extends DataTableColumn { + final String? Function(dynamic item)? formatter; + final TextAlign? textAlign; + final int? maxLines; + final bool? overflow; + + const TextColumn( + String key, + String label, { + super.sortable = true, + super.searchable = true, + super.width = ColumnWidth.medium, + super.tooltip, + this.formatter, + this.textAlign, + this.maxLines, + this.overflow, + }) : super(key: key, label: label); +} + +/// Number column configuration +class NumberColumn extends DataTableColumn { + final String? Function(dynamic item)? formatter; + final TextAlign textAlign; + final int? decimalPlaces; + final String? prefix; + final String? suffix; + + const NumberColumn( + String key, + String label, { + super.sortable = true, + super.searchable = true, + super.width = ColumnWidth.medium, + super.tooltip, + this.formatter, + this.textAlign = TextAlign.end, + this.decimalPlaces, + this.prefix, + this.suffix, + }) : super(key: key, label: label); +} + +/// Date column configuration +class DateColumn extends DataTableColumn { + final String? Function(dynamic item)? formatter; + final TextAlign textAlign; + final bool showTime; + final String? dateFormat; + + const DateColumn( + String key, + String label, { + super.sortable = true, + super.searchable = true, + super.width = ColumnWidth.medium, + super.tooltip, + this.formatter, + this.textAlign = TextAlign.center, + this.showTime = false, + this.dateFormat, + }) : super(key: key, label: label); +} + +/// Action column configuration +class ActionColumn extends DataTableColumn { + final List actions; + final bool showOnHover; + + const ActionColumn( + String key, + String label, { + super.sortable = false, + super.searchable = false, + super.width = ColumnWidth.small, + super.tooltip, + required this.actions, + this.showOnHover = true, + }) : super(key: key, label: label); +} + +/// Custom column configuration +class CustomColumn extends DataTableColumn { + final Widget Function(dynamic item, int index)? builder; + final String? Function(dynamic item)? formatter; + + const CustomColumn( + String key, + String label, { + super.sortable = true, + super.searchable = true, + super.width = ColumnWidth.medium, + super.tooltip, + this.builder, + this.formatter, + }) : super(key: key, label: label); +} + +/// Action button configuration +class DataTableAction { + final IconData icon; + final String label; + final void Function(dynamic item) onTap; + final bool isDestructive; + final Color? color; + final bool enabled; + + const DataTableAction({ + required this.icon, + required this.label, + required this.onTap, + this.isDestructive = false, + this.color, + this.enabled = true, + }); +} + +/// Data table configuration +class DataTableConfig { + final String endpoint; + final List columns; + final List searchFields; + final List filterFields; + final String? dateRangeField; + final String? title; + final String? subtitle; + final bool showSearch; + final bool showFilters; + final bool showPagination; + final bool showColumnSearch; + final int defaultPageSize; + final List pageSizeOptions; + final bool enableSorting; + final bool enableGlobalSearch; + final bool enableDateRangeFilter; + final void Function(dynamic item)? onRowTap; + final void Function(dynamic item)? onRowDoubleTap; + final Widget? Function(dynamic item)? customRowBuilder; + final Map? additionalParams; + final Duration? searchDebounce; + final bool showRefreshButton; + final bool showClearFiltersButton; + final String? emptyStateMessage; + final Widget? emptyStateWidget; + final String? loadingMessage; + final Widget? loadingWidget; + final String? errorMessage; + final Widget? errorWidget; + final bool showActiveFilters; + final bool showColumnHeaders; + final bool showRowNumbers; + final bool enableRowSelection; + final bool enableMultiRowSelection; + final Set? selectedRows; + final void Function(Set selectedRows)? onRowSelectionChanged; + final bool enableHorizontalScroll; + final double? minTableWidth; + final EdgeInsets? padding; + final EdgeInsets? margin; + final Color? backgroundColor; + final Color? headerBackgroundColor; + final Color? rowBackgroundColor; + final Color? alternateRowBackgroundColor; + final BorderRadius? borderRadius; + final List? boxShadow; + final bool showBorder; + final Color? borderColor; + final double? borderWidth; + final void Function(DateTime? fromDate, DateTime? toDate)? onDateRangeApply; + final VoidCallback? onDateRangeClear; + + // Export configuration + final String? excelEndpoint; + final String? pdfEndpoint; + final Map Function()? getExportParams; + + // Column settings configuration + final String? tableId; + final bool enableColumnSettings; + final bool showColumnSettingsButton; + final ColumnSettings? initialColumnSettings; + final void Function(ColumnSettings settings)? onColumnSettingsChanged; + + const DataTableConfig({ + required this.endpoint, + required this.columns, + this.searchFields = const [], + this.filterFields = const [], + this.dateRangeField, + this.title, + this.subtitle, + this.showSearch = true, + this.showFilters = true, + this.showPagination = true, + this.showColumnSearch = true, + this.defaultPageSize = 20, + this.pageSizeOptions = const [10, 20, 50, 100], + this.enableSorting = true, + this.enableGlobalSearch = true, + this.enableDateRangeFilter = true, + this.onRowTap, + this.onRowDoubleTap, + this.customRowBuilder, + this.additionalParams, + this.searchDebounce = const Duration(milliseconds: 500), + this.showRefreshButton = true, + this.showClearFiltersButton = true, + this.emptyStateMessage, + this.emptyStateWidget, + this.loadingMessage, + this.loadingWidget, + this.errorMessage, + this.errorWidget, + this.showActiveFilters = true, + this.showColumnHeaders = true, + this.showRowNumbers = false, + this.enableRowSelection = false, + this.enableMultiRowSelection = false, + this.selectedRows, + this.onRowSelectionChanged, + this.enableHorizontalScroll = true, + this.minTableWidth = 600.0, + this.padding, + this.margin, + this.backgroundColor, + this.headerBackgroundColor, + this.rowBackgroundColor, + this.alternateRowBackgroundColor, + this.borderRadius, + this.boxShadow, + this.showBorder = true, + this.borderColor, + this.borderWidth = 1.0, + this.onDateRangeApply, + this.onDateRangeClear, + this.excelEndpoint, + this.pdfEndpoint, + this.getExportParams, + this.tableId, + this.enableColumnSettings = true, + this.showColumnSettingsButton = true, + this.initialColumnSettings, + this.onColumnSettingsChanged, + }); + + /// Get column width as double + double getColumnWidth(ColumnWidth width) { + switch (width) { + case ColumnWidth.small: + return 100.0; + case ColumnWidth.medium: + return 150.0; + case ColumnWidth.large: + return 200.0; + case ColumnWidth.extraLarge: + return 300.0; + } + } + + /// Get searchable columns + List get searchableColumns { + return columns.where((col) => col.searchable).toList(); + } + + /// Get sortable columns + List get sortableColumns { + return columns.where((col) => col.sortable).toList(); + } + + /// Get filterable columns + List get filterableColumns { + return columns.where((col) => col.searchable).toList(); + } + + /// Get all column keys + List get columnKeys { + return columns.map((col) => col.key).toList(); + } + + /// Get column by key + DataTableColumn? getColumnByKey(String key) { + try { + return columns.firstWhere((col) => col.key == key); + } catch (e) { + return null; + } + } + + /// Get effective table ID for column settings + String get effectiveTableId { + return tableId ?? endpoint.replaceAll(RegExp(r'[^a-zA-Z0-9]'), '_'); + } +} + +/// Data table response model +class DataTableResponse { + final List items; + final int total; + final int page; + final int limit; + final int totalPages; + + const DataTableResponse({ + required this.items, + required this.total, + required this.page, + required this.limit, + required this.totalPages, + }); + + factory DataTableResponse.fromJson( + Map json, + T Function(Map) fromJsonT, + ) { + final data = json['data'] as Map; + final itemsList = data['items'] as List? ?? []; + + return DataTableResponse( + items: itemsList.map((item) => fromJsonT(item as Map)).toList(), + total: (data['total'] as num?)?.toInt() ?? 0, + page: (data['page'] as num?)?.toInt() ?? 1, + limit: (data['limit'] as num?)?.toInt() ?? 20, + totalPages: (data['total_pages'] as num?)?.toInt() ?? 0, + ); + } +} + +/// Query info model for API requests +class QueryInfo { + final String? search; + final List? searchFields; + final List? filters; + final String? sortBy; + final bool sortDesc; + final int take; + final int skip; + + const QueryInfo({ + this.search, + this.searchFields, + this.filters, + this.sortBy, + this.sortDesc = false, + this.take = 20, + this.skip = 0, + }); + + Map toJson() { + final json = { + 'take': take, + 'skip': skip, + 'sort_desc': sortDesc, + }; + + if (search != null && search!.isNotEmpty) { + json['search'] = search; + if (searchFields != null && searchFields!.isNotEmpty) { + json['search_fields'] = searchFields; + } + } + + if (sortBy != null && sortBy!.isNotEmpty) { + json['sort_by'] = sortBy; + } + + if (filters != null && filters!.isNotEmpty) { + json['filters'] = filters!.map((f) => f.toJson()).toList(); + } + + return json; + } +} + +/// Filter item model +class FilterItem { + final String property; + final String operator; + final dynamic value; + + const FilterItem({ + required this.property, + required this.operator, + required this.value, + }); + + Map toJson() { + return { + 'property': property, + 'operator': operator, + 'value': value, + }; + } +} diff --git a/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_search_dialog.dart b/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_search_dialog.dart new file mode 100644 index 0000000..bcc345d --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_search_dialog.dart @@ -0,0 +1,352 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:hesabix_ui/l10n/app_localizations.dart'; +import 'data_table_config.dart'; +import 'helpers/data_table_utils.dart'; +import 'package:hesabix_ui/core/calendar_controller.dart'; +import 'package:hesabix_ui/core/date_utils.dart'; + +/// Dialog for column search +class DataTableSearchDialog extends StatefulWidget { + final String columnName; + final String columnLabel; + final String searchValue; + final String searchType; + final Function(String value, String type) onApply; + final VoidCallback onClear; + + const DataTableSearchDialog({ + super.key, + required this.columnName, + required this.columnLabel, + required this.searchValue, + required this.searchType, + required this.onApply, + required this.onClear, + }); + + @override + State createState() => _DataTableSearchDialogState(); +} + +class _DataTableSearchDialogState extends State { + late TextEditingController _controller; + late String _selectedType; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.searchValue); + _selectedType = widget.searchType; + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final t = AppLocalizations.of(context); + final theme = Theme.of(context); + + return AlertDialog( + title: Row( + children: [ + Icon(Icons.search, color: theme.primaryColor, size: 20), + const SizedBox(width: 8), + Text(t.searchInColumn(widget.columnLabel)), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Search type dropdown + DropdownButtonFormField( + value: _selectedType, + decoration: InputDecoration( + labelText: t.searchType, + border: const OutlineInputBorder(), + isDense: true, + ), + items: [ + DropdownMenuItem(value: '*', child: Text(t.contains)), + DropdownMenuItem(value: '*?', child: Text(t.startsWith)), + DropdownMenuItem(value: '?*', child: Text(t.endsWith)), + DropdownMenuItem(value: '=', child: Text(t.exactMatch)), + ], + onChanged: (value) { + if (value != null) { + setState(() { + _selectedType = value; + }); + } + }, + ), + const SizedBox(height: 16), + // Search value input + TextField( + controller: _controller, + decoration: InputDecoration( + labelText: t.searchValue, + border: const OutlineInputBorder(), + isDense: true, + ), + autofocus: true, + ), + ], + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: Text(t.cancel), + ), + if (widget.searchValue.isNotEmpty) + TextButton( + onPressed: () { + widget.onClear(); + Navigator.of(context).pop(); + }, + child: Text(t.clear), + ), + FilledButton( + onPressed: () { + widget.onApply(_controller.text.trim(), _selectedType); + Navigator.of(context).pop(); + }, + child: Text(t.applyColumnFilter), + ), + ], + ); + } +} + +/// Dialog for date range filter +class DataTableDateRangeDialog extends StatefulWidget { + final DateTime? fromDate; + final DateTime? toDate; + final Function(DateTime? from, DateTime? to) onApply; + final VoidCallback onClear; + + const DataTableDateRangeDialog({ + super.key, + this.fromDate, + this.toDate, + required this.onApply, + required this.onClear, + }); + + @override + State createState() => _DataTableDateRangeDialogState(); +} + +class _DataTableDateRangeDialogState extends State { + DateTime? _fromDate; + DateTime? _toDate; + + @override + void initState() { + super.initState(); + _fromDate = widget.fromDate; + _toDate = widget.toDate; + } + + @override + Widget build(BuildContext context) { + final t = AppLocalizations.of(context); + final theme = Theme.of(context); + + return AlertDialog( + title: Row( + children: [ + Icon(Icons.date_range, color: theme.primaryColor, size: 20), + const SizedBox(width: 8), + Text(t.dateRangeFilter), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // From date + ListTile( + leading: const Icon(Icons.calendar_today), + title: Text(t.dateFrom), + subtitle: Text(_fromDate != null + ? DateFormat('yyyy/MM/dd').format(_fromDate!) + : t.selectDate), + onTap: () async { + final date = await showDatePicker( + context: context, + initialDate: _fromDate ?? DateTime.now(), + firstDate: DateTime(2000), + lastDate: DateTime.now().add(const Duration(days: 365)), + ); + if (date != null) { + setState(() { + _fromDate = date; + }); + } + }, + ), + // To date + ListTile( + leading: const Icon(Icons.calendar_today), + title: Text(t.dateTo), + subtitle: Text(_toDate != null + ? DateFormat('yyyy/MM/dd').format(_toDate!) + : t.selectDate), + onTap: () async { + final date = await showDatePicker( + context: context, + initialDate: _toDate ?? _fromDate ?? DateTime.now(), + firstDate: _fromDate ?? DateTime(2000), + lastDate: DateTime.now().add(const Duration(days: 365)), + ); + if (date != null) { + setState(() { + _toDate = date; + }); + } + }, + ), + ], + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: Text(t.cancel), + ), + if (widget.fromDate != null || widget.toDate != null) + TextButton( + onPressed: () { + widget.onClear(); + Navigator.of(context).pop(); + }, + child: Text(t.clear), + ), + FilledButton( + onPressed: _fromDate != null && _toDate != null + ? () { + widget.onApply(_fromDate, _toDate); + Navigator.of(context).pop(); + } + : null, + child: Text(t.applyFilter), + ), + ], + ); + } +} + +/// Widget for active filters display +class ActiveFiltersWidget extends StatelessWidget { + final Map columnSearchValues; + final Map columnSearchTypes; + final DateTime? fromDate; + final DateTime? toDate; + final List columns; + final void Function(String columnName) onRemoveColumnFilter; + final VoidCallback onClearAll; + final CalendarController? calendarController; + + const ActiveFiltersWidget({ + super.key, + required this.columnSearchValues, + required this.columnSearchTypes, + this.fromDate, + this.toDate, + required this.columns, + required this.onRemoveColumnFilter, + required this.onClearAll, + this.calendarController, + }); + + @override + Widget build(BuildContext context) { + final t = AppLocalizations.of(context); + final theme = Theme.of(context); + + final hasFilters = columnSearchValues.isNotEmpty || + (fromDate != null && toDate != null); + + if (!hasFilters) return const SizedBox.shrink(); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: theme.primaryColor.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: theme.primaryColor.withValues(alpha: 0.2)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.filter_alt, color: theme.primaryColor, size: 16), + const SizedBox(width: 8), + Text( + t.activeFilters, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + color: theme.primaryColor, + ), + ), + const Spacer(), + TextButton( + onPressed: onClearAll, + child: Text(t.clear), + ), + ], + ), + const SizedBox(height: 6), + Wrap( + spacing: 6, + runSpacing: 3, + children: [ + // Column filters + ...columnSearchValues.entries.map((entry) { + final columnName = entry.key; + final searchValue = entry.value; + final searchType = columnSearchTypes[columnName] ?? '*'; + final columnLabel = DataTableUtils.getColumnLabel(columnName, columns); + final typeLabel = DataTableUtils.getSearchOperatorLabel(searchType); + + return Chip( + label: Text('$columnLabel: $searchValue ($typeLabel)'), + deleteIcon: const Icon(Icons.close, size: 16), + onDeleted: () => onRemoveColumnFilter(columnName), + backgroundColor: theme.primaryColor.withValues(alpha: 0.1), + deleteIconColor: theme.primaryColor, + labelStyle: TextStyle( + color: theme.primaryColor, + fontSize: 12, + ), + ); + }), + + // Date range filter + if (fromDate != null && toDate != null) + Chip( + label: Text('${t.dateFrom}: ${HesabixDateUtils.formatForDisplay(fromDate!, calendarController?.isJalali ?? false)} - ${t.dateTo}: ${HesabixDateUtils.formatForDisplay(toDate!, calendarController?.isJalali ?? false)}'), + deleteIcon: const Icon(Icons.close, size: 16), + onDeleted: () => onClearAll(), + backgroundColor: theme.primaryColor.withValues(alpha: 0.1), + deleteIconColor: theme.primaryColor, + labelStyle: TextStyle( + color: theme.primaryColor, + fontSize: 12, + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_widget.dart b/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_widget.dart new file mode 100644 index 0000000..1b8584c --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_widget.dart @@ -0,0 +1,1588 @@ +import 'dart:async'; +import 'dart:typed_data'; +import 'dart:html' as html; +import 'package:flutter/material.dart'; +import 'package:data_table_2/data_table_2.dart'; +import 'package:dio/dio.dart'; +import 'package:hesabix_ui/l10n/app_localizations.dart'; +import 'package:hesabix_ui/core/api_client.dart'; +import 'package:hesabix_ui/core/calendar_controller.dart'; +import 'package:hesabix_ui/widgets/date_input_field.dart'; +import 'data_table_config.dart'; +import 'data_table_search_dialog.dart'; +import 'column_settings_dialog.dart'; +import 'helpers/data_table_utils.dart'; +import 'helpers/column_settings_service.dart'; + +/// Main reusable data table widget +class DataTableWidget extends StatefulWidget { + final DataTableConfig config; + final T Function(Map) fromJson; + final CalendarController? calendarController; + + const DataTableWidget({ + super.key, + required this.config, + required this.fromJson, + this.calendarController, + }); + + @override + State> createState() => _DataTableWidgetState(); +} + +class _DataTableWidgetState extends State> { + // Data state + List _items = []; + bool _loading = false; + bool _loadingList = false; + String? _error; + + // Pagination state + int _page = 1; + int _limit = 20; + int _total = 0; + int _totalPages = 0; + + // Search and filter state + final TextEditingController _searchCtrl = TextEditingController(); + Timer? _searchDebounce; + bool _showFilters = false; + DateTime? _fromDate; + DateTime? _toDate; + + // Column search state + final Map _columnSearchValues = {}; + final Map _columnSearchTypes = {}; + final Map _columnSearchControllers = {}; + + // Sorting state + String? _sortBy; + bool _sortDesc = false; + + // Row selection state + Set _selectedRows = {}; + bool _isExporting = false; + + // Column settings state + ColumnSettings? _columnSettings; + List _visibleColumns = []; + bool _isLoadingColumnSettings = false; + + @override + void initState() { + super.initState(); + _limit = widget.config.defaultPageSize; + _setupSearchListener(); + _loadColumnSettings(); + _fetchData(); + } + + @override + void dispose() { + _searchCtrl.dispose(); + _searchDebounce?.cancel(); + for (var controller in _columnSearchControllers.values) { + controller.dispose(); + } + super.dispose(); + } + + void _setupSearchListener() { + _searchCtrl.addListener(() { + _searchDebounce?.cancel(); + _searchDebounce = Timer(widget.config.searchDebounce ?? const Duration(milliseconds: 500), () { + _page = 1; + _fetchData(); + }); + }); + } + + Future _loadColumnSettings() async { + if (!widget.config.enableColumnSettings) { + _visibleColumns = List.from(widget.config.columns); + return; + } + + setState(() { + _isLoadingColumnSettings = true; + }); + + try { + final tableId = widget.config.effectiveTableId; + final savedSettings = await ColumnSettingsService.getColumnSettings(tableId); + + ColumnSettings effectiveSettings; + if (savedSettings != null) { + effectiveSettings = ColumnSettingsService.mergeWithDefaults( + savedSettings, + widget.config.columnKeys, + ); + } else if (widget.config.initialColumnSettings != null) { + effectiveSettings = ColumnSettingsService.mergeWithDefaults( + widget.config.initialColumnSettings, + widget.config.columnKeys, + ); + } else { + effectiveSettings = ColumnSettingsService.getDefaultSettings(widget.config.columnKeys); + } + + setState(() { + _columnSettings = effectiveSettings; + _visibleColumns = _getVisibleColumnsFromSettings(effectiveSettings); + }); + } catch (e) { + print('Error loading column settings: $e'); + setState(() { + _visibleColumns = List.from(widget.config.columns); + }); + } finally { + setState(() { + _isLoadingColumnSettings = false; + }); + } + } + + List _getVisibleColumnsFromSettings(ColumnSettings settings) { + final visibleColumns = []; + + // Add columns in the order specified by settings + for (final key in settings.columnOrder) { + final column = widget.config.getColumnByKey(key); + if (column != null && settings.visibleColumns.contains(key)) { + visibleColumns.add(column); + } + } + + return visibleColumns; + } + + Future _fetchData() async { + setState(() => _loadingList = true); + _error = null; + + try { + final api = ApiClient(); + + // Build QueryInfo payload + final queryInfo = QueryInfo( + take: _limit, + skip: (_page - 1) * _limit, + sortDesc: _sortDesc, + sortBy: _sortBy, + search: _searchCtrl.text.trim().isNotEmpty ? _searchCtrl.text.trim() : null, + searchFields: widget.config.searchFields.isNotEmpty ? widget.config.searchFields : null, + filters: _buildFilters(), + ); + + // Add additional parameters + final requestData = queryInfo.toJson(); + if (widget.config.additionalParams != null) { + requestData.addAll(widget.config.additionalParams!); + } + + final res = await api.post>(widget.config.endpoint, data: requestData); + final body = res.data; + + if (body is Map) { + final response = DataTableResponse.fromJson(body, widget.fromJson); + + setState(() { + _items = response.items; + _page = response.page; + _limit = response.limit; + _total = response.total; + _totalPages = response.totalPages; + _selectedRows.clear(); // Clear selection when data changes + }); + } + } catch (e) { + setState(() { + _error = e.toString(); + }); + } finally { + setState(() => _loadingList = false); + } + } + + List _buildFilters() { + final filters = []; + + // Date range filters + if (widget.config.enableDateRangeFilter && + widget.config.dateRangeField != null && + _fromDate != null && + _toDate != null) { + filters.addAll(DataTableUtils.createDateRangeFilters( + widget.config.dateRangeField!, + _fromDate!, + _toDate!, + )); + } + + // Column search filters + for (var entry in _columnSearchValues.entries) { + final columnName = entry.key; + final searchValue = entry.value.trim(); + final searchType = _columnSearchTypes[columnName] ?? '*'; + + if (searchValue.isNotEmpty) { + filters.add(DataTableUtils.createColumnFilter( + columnName, + searchValue, + searchType, + )); + } + } + + return filters; + } + + void _openColumnSearchDialog(String columnName, String columnLabel) { + // Initialize controller if not exists + if (!_columnSearchControllers.containsKey(columnName)) { + _columnSearchControllers[columnName] = TextEditingController( + text: _columnSearchValues[columnName] ?? '', + ); + } + + // Initialize search type if not exists + _columnSearchTypes[columnName] ??= '*'; + + showDialog( + context: context, + builder: (context) => DataTableSearchDialog( + columnName: columnName, + columnLabel: columnLabel, + searchValue: _columnSearchValues[columnName] ?? '', + searchType: _columnSearchTypes[columnName] ?? '*', + onApply: (value, type) { + setState(() { + _columnSearchValues[columnName] = value; + _columnSearchTypes[columnName] = type; + }); + _page = 1; + _fetchData(); + }, + onClear: () { + setState(() { + _columnSearchValues.remove(columnName); + _columnSearchTypes.remove(columnName); + _columnSearchControllers[columnName]?.clear(); + }); + _page = 1; + _fetchData(); + }, + ), + ); + } + + + bool _hasActiveFilters() { + return _fromDate != null || + _toDate != null || + _searchCtrl.text.isNotEmpty || + _columnSearchValues.isNotEmpty; + } + + void _clearAllFilters() { + setState(() { + _fromDate = null; + _toDate = null; + _searchCtrl.clear(); + _sortBy = null; + _sortDesc = false; + _columnSearchValues.clear(); + _columnSearchTypes.clear(); + _selectedRows.clear(); + for (var controller in _columnSearchControllers.values) { + controller.clear(); + } + }); + _page = 1; + _fetchData(); + // Call the callback if provided + if (widget.config.onDateRangeClear != null) { + widget.config.onDateRangeClear!(); + } + if (widget.config.onRowSelectionChanged != null) { + widget.config.onRowSelectionChanged!(_selectedRows); + } + } + + void _sortByColumn(String column) { + setState(() { + if (_sortBy == column) { + _sortDesc = !_sortDesc; + } else { + _sortBy = column; + _sortDesc = false; + } + }); + _fetchData(); + } + + void _toggleRowSelection(int rowIndex) { + if (!widget.config.enableRowSelection) return; + + setState(() { + if (widget.config.enableMultiRowSelection) { + if (_selectedRows.contains(rowIndex)) { + _selectedRows.remove(rowIndex); + } else { + _selectedRows.add(rowIndex); + } + } else { + _selectedRows.clear(); + _selectedRows.add(rowIndex); + } + }); + + if (widget.config.onRowSelectionChanged != null) { + widget.config.onRowSelectionChanged!(_selectedRows); + } + } + + void _selectAllRows() { + if (!widget.config.enableRowSelection || !widget.config.enableMultiRowSelection) return; + + setState(() { + _selectedRows.clear(); + for (int i = 0; i < _items.length; i++) { + _selectedRows.add(i); + } + }); + + if (widget.config.onRowSelectionChanged != null) { + widget.config.onRowSelectionChanged!(_selectedRows); + } + } + + void _clearRowSelection() { + if (!widget.config.enableRowSelection) return; + + setState(() { + _selectedRows.clear(); + }); + + if (widget.config.onRowSelectionChanged != null) { + widget.config.onRowSelectionChanged!(_selectedRows); + } + } + + Future _openColumnSettingsDialog() async { + if (!widget.config.enableColumnSettings || _columnSettings == null) return; + + final result = await showDialog( + context: context, + builder: (context) => ColumnSettingsDialog( + columns: widget.config.columns, + currentSettings: _columnSettings!, + tableTitle: widget.config.title ?? 'Table', + ), + ); + + if (result != null) { + await _saveColumnSettings(result); + } + } + + Future _saveColumnSettings(ColumnSettings settings) async { + if (!widget.config.enableColumnSettings) return; + + try { + // Ensure at least one column is visible + final validatedSettings = _validateColumnSettings(settings); + + final tableId = widget.config.effectiveTableId; + await ColumnSettingsService.saveColumnSettings(tableId, validatedSettings); + + setState(() { + _columnSettings = validatedSettings; + _visibleColumns = _getVisibleColumnsFromSettings(validatedSettings); + }); + + // Call the callback if provided + if (widget.config.onColumnSettingsChanged != null) { + widget.config.onColumnSettingsChanged!(validatedSettings); + } + } catch (e) { + print('Error saving column settings: $e'); + if (mounted) { + final t = Localizations.of(context, AppLocalizations)!; + final messenger = ScaffoldMessenger.of(context); + messenger.showSnackBar( + SnackBar( + content: Text('${t.error}: $e'), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + } + } + + ColumnSettings _validateColumnSettings(ColumnSettings settings) { + // Ensure at least one column is visible + if (settings.visibleColumns.isEmpty && widget.config.columns.isNotEmpty) { + return settings.copyWith( + visibleColumns: [widget.config.columns.first.key], + columnOrder: [widget.config.columns.first.key], + ); + } + return settings; + } + + Future _exportData(String format, bool selectedOnly) async { + if (widget.config.excelEndpoint == null && widget.config.pdfEndpoint == null) { + return; + } + + final t = Localizations.of(context, AppLocalizations)!; + + setState(() { + _isExporting = true; + }); + + try { + final api = ApiClient(); + final endpoint = format == 'excel' + ? widget.config.excelEndpoint! + : widget.config.pdfEndpoint!; + + // Build QueryInfo object + final filters = >[]; + + // Add column filters + _columnSearchValues.forEach((column, value) { + if (value.isNotEmpty) { + final searchType = _columnSearchTypes[column] ?? 'contains'; + String operator; + switch (searchType) { + case 'contains': + operator = '*'; + break; + case 'startsWith': + operator = '*?'; + break; + case 'endsWith': + operator = '?*'; + break; + case 'exactMatch': + operator = '='; + break; + default: + operator = '*'; + } + filters.add({ + 'property': column, + 'operator': operator, + 'value': value, + }); + } + }); + + // Add date range filter + if (_fromDate != null && _toDate != null && widget.config.dateRangeField != null) { + final start = DateTime(_fromDate!.year, _fromDate!.month, _fromDate!.day); + final endExclusive = DateTime(_toDate!.year, _toDate!.month, _toDate!.day).add(const Duration(days: 1)); + filters.add({ + 'property': widget.config.dateRangeField!, + 'operator': '>=', + 'value': start.toIso8601String(), + }); + filters.add({ + 'property': widget.config.dateRangeField!, + 'operator': '<', + 'value': endExclusive.toIso8601String(), + }); + } + + final queryInfo = { + 'sort_by': _sortBy, + 'sort_desc': _sortDesc, + 'take': _limit, + 'skip': (_page - 1) * _limit, + 'search': _searchCtrl.text.isNotEmpty ? _searchCtrl.text : null, + 'search_fields': _searchCtrl.text.isNotEmpty && widget.config.searchFields.isNotEmpty + ? widget.config.searchFields + : null, + 'filters': filters.isNotEmpty ? filters : null, + }; + + final params = { + 'selected_only': selectedOnly, + }; + + // Add selected row indices if exporting selected only + if (selectedOnly && _selectedRows.isNotEmpty) { + params['selected_indices'] = _selectedRows.toList(); + } + + // Add custom export parameters if provided + if (widget.config.getExportParams != null) { + final customParams = widget.config.getExportParams!(); + params.addAll(customParams); + } + + final response = await api.post( + endpoint, + data: { + ...queryInfo, + ...params, + }, + options: Options( + headers: { + 'X-Calendar-Type': 'jalali', // Send Jalali calendar type + 'Accept-Language': Localizations.localeOf(context).languageCode, // Send locale + }, + ), + responseType: ResponseType.bytes, // Both PDF and Excel now return binary data + ); + + if (response.data != null) { + if (format == 'pdf') { + // Handle PDF download + await _downloadPdf(response.data, 'referrals_export_${DateTime.now().millisecondsSinceEpoch}.pdf'); + } else if (format == 'excel') { + // Handle Excel download + await _downloadExcel(response.data, 'referrals_export_${DateTime.now().millisecondsSinceEpoch}.xlsx'); + } + + if (mounted) { + final messenger = ScaffoldMessenger.of(context); + messenger.showSnackBar( + SnackBar( + content: Text(t.exportSuccess), + backgroundColor: Theme.of(context).colorScheme.primary, + ), + ); + } + } + } catch (e) { + if (mounted) { + final messenger = ScaffoldMessenger.of(context); + messenger.showSnackBar( + SnackBar( + content: Text('${t.exportError}: $e'), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + } finally { + if (mounted) { + setState(() { + _isExporting = false; + }); + } + } + } + + Future _downloadPdf(dynamic data, String filename) async { + try { + if (data is List) { + // Convert bytes to Uint8List + final bytes = Uint8List.fromList(data); + + // Create blob and download + final blob = html.Blob([bytes], 'application/pdf'); + final url = html.Url.createObjectUrlFromBlob(blob); + + html.AnchorElement(href: url) + ..setAttribute('download', filename) + ..click(); + + html.Url.revokeObjectUrl(url); + } + } catch (e) { + print('Error downloading PDF: $e'); + } + } + + Future _downloadExcel(dynamic data, String filename) async { + try { + if (data is List) { + // Handle binary Excel data from server + final bytes = Uint8List.fromList(data); + final blob = html.Blob([bytes], 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + final url = html.Url.createObjectUrlFromBlob(blob); + + html.AnchorElement(href: url) + ..setAttribute('download', filename) + ..click(); + + html.Url.revokeObjectUrl(url); + } else if (data is Map) { + // Fallback: Convert to CSV format (legacy support) + final excelData = data['data'] as List?; + if (excelData != null) { + final csvContent = _convertToCsv(excelData); + final bytes = Uint8List.fromList(csvContent.codeUnits); + + final blob = html.Blob([bytes], 'text/csv'); + final url = html.Url.createObjectUrlFromBlob(blob); + + html.AnchorElement(href: url) + ..setAttribute('download', filename.replaceAll('.xlsx', '.csv')) + ..click(); + + html.Url.revokeObjectUrl(url); + } + } + } catch (e) { + print('Error downloading Excel: $e'); + } + } + + String _convertToCsv(List data) { + if (data.isEmpty) return ''; + + // Get headers from first item + final firstItem = data.first as Map; + final headers = firstItem.keys.toList(); + + // Create CSV content + final csvLines = []; + + // Add headers + csvLines.add(headers.join(',')); + + // Add data rows + for (final item in data) { + final row = []; + for (final header in headers) { + final value = item[header]?.toString() ?? ''; + // Escape commas and quotes + final escapedValue = value.replaceAll('"', '""'); + row.add('"$escapedValue"'); + } + csvLines.add(row.join(',')); + } + + return csvLines.join('\n'); + } + + @override + Widget build(BuildContext context) { + final t = Localizations.of(context, AppLocalizations)!; + final theme = Theme.of(context); + + return Card( + elevation: widget.config.boxShadow != null ? 2 : 0, + shape: widget.config.borderRadius != null + ? RoundedRectangleBorder(borderRadius: widget.config.borderRadius!) + : null, + child: Container( + padding: widget.config.padding ?? const EdgeInsets.all(16), + margin: widget.config.margin, + decoration: BoxDecoration( + color: widget.config.backgroundColor, + borderRadius: widget.config.borderRadius, + border: widget.config.showBorder + ? Border.all( + color: widget.config.borderColor ?? theme.dividerColor, + width: widget.config.borderWidth ?? 1.0, + ) + : null, + ), + child: Column( + children: [ + // Header + if (widget.config.title != null) ...[ + _buildHeader(t, theme), + const SizedBox(height: 16), + ], + + // Search and Filters + if (widget.config.showSearch || widget.config.showFilters) ...[ + _buildSearchAndFilters(t, theme), + const SizedBox(height: 12), + ], + + // Active Filters + if (widget.config.showActiveFilters) ...[ + ActiveFiltersWidget( + columnSearchValues: _columnSearchValues, + columnSearchTypes: _columnSearchTypes, + fromDate: _fromDate, + toDate: _toDate, + columns: widget.config.columns, + calendarController: widget.calendarController, + onRemoveColumnFilter: (columnName) { + setState(() { + _columnSearchValues.remove(columnName); + _columnSearchTypes.remove(columnName); + _columnSearchControllers[columnName]?.clear(); + }); + _page = 1; + _fetchData(); + }, + onClearAll: _clearAllFilters, + ), + const SizedBox(height: 10), + ], + + // Data Table + Expanded( + child: _buildDataTable(t, theme), + ), + + // Footer with Pagination + if (widget.config.showPagination) ...[ + const SizedBox(height: 12), + _buildFooter(t, theme), + ], + ], + ), + ), + ); + } + + Widget _buildHeader(AppLocalizations t, ThemeData theme) { + return Row( + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(6), + ), + child: Icon( + Icons.table_chart, + color: theme.colorScheme.onPrimaryContainer, + size: 18, + ), + ), + const SizedBox(width: 12), + Text( + widget.config.title!, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface, + ), + ), + if (widget.config.subtitle != null) ...[ + const SizedBox(width: 8), + Text( + widget.config.subtitle!, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + const Spacer(), + + // Filter buttons + if (widget.config.showFilters) ...[ + Tooltip( + message: _showFilters ? t.hideFilters : t.showFilters, + child: IconButton( + onPressed: () { + setState(() { + _showFilters = !_showFilters; + }); + }, + icon: Icon(_showFilters ? Icons.filter_list_off : Icons.filter_list), + tooltip: _showFilters ? t.hideFilters : t.showFilters, + ), + ), + const SizedBox(width: 4), + ], + + // Clear filters button (only show when filters are applied) + if (widget.config.showClearFiltersButton && _hasActiveFilters()) ...[ + Tooltip( + message: t.clear, + child: IconButton( + onPressed: _clearAllFilters, + icon: const Icon(Icons.clear_all), + tooltip: t.clear, + ), + ), + const SizedBox(width: 4), + ], + + // Export buttons + if (widget.config.excelEndpoint != null || widget.config.pdfEndpoint != null) ...[ + _buildExportButtons(t, theme), + const SizedBox(width: 8), + ], + + if (widget.config.showRefreshButton) + IconButton( + onPressed: _fetchData, + icon: const Icon(Icons.refresh), + tooltip: t.refresh, + ), + + // Column settings button (moved after refresh button) + if (widget.config.showColumnSettingsButton && widget.config.enableColumnSettings) ...[ + const SizedBox(width: 4), + Tooltip( + message: t.columnSettings, + child: IconButton( + onPressed: _isLoadingColumnSettings ? null : _openColumnSettingsDialog, + icon: _isLoadingColumnSettings + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.view_column), + tooltip: t.columnSettings, + ), + ), + ], + ], + ); + } + + Widget _buildExportButtons(AppLocalizations t, ThemeData theme) { + return _buildExportButton(t, theme); + } + + Widget _buildExportButton( + AppLocalizations t, + ThemeData theme, + ) { + return Tooltip( + message: t.export, + child: GestureDetector( + onTap: () => _showExportOptions(t, theme), + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: theme.colorScheme.outline.withValues(alpha: 0.3), + ), + ), + child: _isExporting + ? SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + theme.colorScheme.primary, + ), + ), + ) + : Icon( + Icons.download, + size: 16, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ); + } + + void _showExportOptions( + AppLocalizations t, + ThemeData theme, + ) { + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (context) => Container( + decoration: BoxDecoration( + color: theme.colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Handle bar + Container( + width: 40, + height: 4, + margin: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ), + + // Title + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Icon(Icons.download, color: theme.colorScheme.primary, size: 20), + const SizedBox(width: 8), + Text( + t.export, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + + const Divider(height: 1), + + // Excel options + if (widget.config.excelEndpoint != null) ...[ + ListTile( + leading: Icon(Icons.table_chart, color: Colors.green[600]), + title: Text(t.exportToExcel), + subtitle: Text(t.exportAll), + onTap: () { + Navigator.pop(context); + _exportData('excel', false); + }, + ), + + if (widget.config.enableRowSelection && _selectedRows.isNotEmpty) + ListTile( + leading: Icon(Icons.table_chart, color: theme.colorScheme.primary), + title: Text(t.exportToExcel), + subtitle: Text(t.exportSelected), + onTap: () { + Navigator.pop(context); + _exportData('excel', true); + }, + ), + ], + + // PDF options + if (widget.config.pdfEndpoint != null) ...[ + if (widget.config.excelEndpoint != null) const Divider(height: 1), + + ListTile( + leading: Icon(Icons.picture_as_pdf, color: Colors.red[600]), + title: Text(t.exportToPdf), + subtitle: Text(t.exportAll), + onTap: () { + Navigator.pop(context); + _exportData('pdf', false); + }, + ), + + if (widget.config.enableRowSelection && _selectedRows.isNotEmpty) + ListTile( + leading: Icon(Icons.picture_as_pdf, color: theme.colorScheme.primary), + title: Text(t.exportToPdf), + subtitle: Text(t.exportSelected), + onTap: () { + Navigator.pop(context); + _exportData('pdf', true); + }, + ), + ], + + const SizedBox(height: 16), + ], + ), + ), + ); + } + + Widget _buildFooter(AppLocalizations t, ThemeData theme) { + // Always show footer if pagination is enabled + + return Container( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: theme.dividerColor.withValues(alpha: 0.3)), + ), + child: Row( + children: [ + // Results info + Text( + '${t.showing} ${((_page - 1) * _limit) + 1} ${t.to} ${(_page * _limit).clamp(0, _total)} ${t.ofText} $_total ${t.results}', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const Spacer(), + + // Page size selector + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + t.recordsPerPage, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(width: 8), + DropdownButton( + value: _limit, + items: widget.config.pageSizeOptions.map((size) { + return DropdownMenuItem( + value: size, + child: Text(size.toString()), + ); + }).toList(), + onChanged: (value) { + if (value != null) { + setState(() { + _limit = value; + _page = 1; + }); + _fetchData(); + } + }, + style: theme.textTheme.bodySmall, + underline: const SizedBox.shrink(), + isDense: true, + ), + ], + ), + const SizedBox(width: 16), + + // Pagination controls (only show if more than 1 page) + if (_totalPages > 1) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + // First page + IconButton( + onPressed: _page > 1 ? () { + setState(() => _page = 1); + _fetchData(); + } : null, + icon: const Icon(Icons.first_page), + iconSize: 20, + tooltip: t.firstPage, + ), + + // Previous page + IconButton( + onPressed: _page > 1 ? () { + setState(() => _page--); + _fetchData(); + } : null, + icon: const Icon(Icons.chevron_left), + iconSize: 20, + tooltip: t.previousPage, + ), + + // Page numbers + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + '$_page / $_totalPages', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onPrimaryContainer, + fontWeight: FontWeight.w600, + ), + ), + ), + + // Next page + IconButton( + onPressed: _page < _totalPages ? () { + setState(() => _page++); + _fetchData(); + } : null, + icon: const Icon(Icons.chevron_right), + iconSize: 20, + tooltip: t.nextPage, + ), + + // Last page + IconButton( + onPressed: _page < _totalPages ? () { + setState(() => _page = _totalPages); + _fetchData(); + } : null, + icon: const Icon(Icons.last_page), + iconSize: 20, + tooltip: t.lastPage, + ), + ], + ), + ], + ), + ); + } + + Widget _buildSearchAndFilters(AppLocalizations t, ThemeData theme) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: theme.dividerColor.withValues(alpha: 0.3)), + ), + child: Column( + children: [ + // Main controls row + Row( + children: [ + if (widget.config.showSearch) ...[ + Expanded( + child: TextField( + controller: _searchCtrl, + decoration: InputDecoration( + prefixIcon: const Icon(Icons.search, size: 18), + hintText: t.searchInNameEmail, + border: const OutlineInputBorder(), + isDense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + ), + ), + ), + const SizedBox(width: 8), + ], + ], + ), + + // Date range filters (if enabled and expanded) + if (widget.config.showFilters && + widget.config.enableDateRangeFilter && + widget.config.dateRangeField != null && + _showFilters) ...[ + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: theme.dividerColor.withValues(alpha: 0.2)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.date_range, color: theme.primaryColor, size: 16), + const SizedBox(width: 6), + Text( + t.dateRangeFilter, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + color: theme.primaryColor, + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: widget.calendarController != null ? DateInputField( + value: _fromDate, + onChanged: (date) { + setState(() { + _fromDate = date; + }); + }, + labelText: t.dateFrom, + calendarController: widget.calendarController!, + enabled: !_loading, + ) : const SizedBox.shrink(), + ), + const SizedBox(width: 8), + Expanded( + child: widget.calendarController != null ? DateInputField( + value: _toDate, + onChanged: (date) { + setState(() { + _toDate = date; + }); + }, + labelText: t.dateTo, + calendarController: widget.calendarController!, + enabled: !_loading, + ) : const SizedBox.shrink(), + ), + const SizedBox(width: 8), + FilledButton.icon( + onPressed: _loading || _fromDate == null || _toDate == null ? null : () { + _page = 1; + _fetchData(); + // Call the callback if provided + if (widget.config.onDateRangeApply != null) { + widget.config.onDateRangeApply!(_fromDate, _toDate); + } + }, + icon: _loading + ? const SizedBox( + height: 14, + width: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.check, size: 16), + label: Text(t.applyFilter), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + minimumSize: const Size(0, 32), + ), + ), + ], + ), + ], + ), + ), + ], + ], + ), + ); + } + + Widget _buildDataTable(AppLocalizations t, ThemeData theme) { + if (_loadingList) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (widget.config.loadingWidget != null) + widget.config.loadingWidget! + else + const CircularProgressIndicator(), + const SizedBox(height: 16), + Text( + widget.config.loadingMessage ?? t.loading, + style: theme.textTheme.bodyMedium, + ), + ], + ), + ); + } + + if (_error != null) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (widget.config.errorWidget != null) + widget.config.errorWidget! + else + Icon( + Icons.error_outline, + size: 64, + color: theme.colorScheme.error, + ), + const SizedBox(height: 16), + Text( + widget.config.errorMessage ?? t.dataLoadingError, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.error, + ), + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: _fetchData, + icon: const Icon(Icons.refresh), + label: Text(t.refresh), + ), + ], + ), + ); + } + + if (_items.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (widget.config.emptyStateWidget != null) + widget.config.emptyStateWidget! + else + Icon( + Icons.inbox_outlined, + size: 64, + color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.6), + ), + const SizedBox(height: 16), + Text( + widget.config.emptyStateMessage ?? t.noDataFound, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.6), + ), + ), + ], + ), + ); + } + + // Build columns list + final List columns = []; + + // Add selection column if enabled (first) + if (widget.config.enableRowSelection) { + columns.add(DataColumn2( + label: widget.config.enableMultiRowSelection + ? Checkbox( + value: _selectedRows.length == _items.length && _items.isNotEmpty, + tristate: true, + onChanged: (value) { + if (value == true) { + _selectAllRows(); + } else { + _clearRowSelection(); + } + }, + ) + : const SizedBox.shrink(), + size: ColumnSize.S, + )); + } + + // Add row number column if enabled (second) + if (widget.config.showRowNumbers) { + columns.add(DataColumn2( + label: Text( + '#', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface, + ), + ), + size: ColumnSize.S, + )); + } + + // Add data columns (use visible columns if column settings are enabled) + final columnsToShow = widget.config.enableColumnSettings && _visibleColumns.isNotEmpty + ? _visibleColumns + : widget.config.columns; + + columns.addAll(columnsToShow.map((column) { + return DataColumn2( + label: _ColumnHeaderWithSearch( + text: column.label, + sortBy: column.key, + currentSort: _sortBy, + sortDesc: _sortDesc, + onSort: widget.config.enableSorting ? _sortByColumn : (_) { }, + onSearch: widget.config.showColumnSearch && column.searchable + ? () => _openColumnSearchDialog(column.key, column.label) + : () { }, + hasActiveFilter: _columnSearchValues.containsKey(column.key), + enabled: widget.config.enableSorting && column.sortable, + ), + size: DataTableUtils.getColumnSize(column.width), + ); + })); + + return DataTable2( + columnSpacing: 12, + horizontalMargin: 12, + minWidth: widget.config.minTableWidth ?? 600, + columns: columns, + rows: _items.asMap().entries.map((entry) { + final index = entry.key; + final item = entry.value; + final isSelected = _selectedRows.contains(index); + + // Build cells list + final List cells = []; + + // Add selection cell if enabled (first) + if (widget.config.enableRowSelection) { + cells.add(DataCell( + Checkbox( + value: isSelected, + onChanged: (value) => _toggleRowSelection(index), + ), + )); + } + + // Add row number cell if enabled (second) + if (widget.config.showRowNumbers) { + cells.add(DataCell( + Text( + '${((_page - 1) * _limit) + index + 1}', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + )); + } + + // Add data cells + if (widget.config.customRowBuilder != null) { + cells.add(DataCell( + widget.config.customRowBuilder!(item) ?? const SizedBox.shrink(), + )); + } else { + final columnsToShow = widget.config.enableColumnSettings && _visibleColumns.isNotEmpty + ? _visibleColumns + : widget.config.columns; + + cells.addAll(columnsToShow.map((column) { + return DataCell( + _buildCellContent(item, column, index), + ); + })); + } + + return DataRow2( + selected: isSelected, + onTap: widget.config.onRowTap != null + ? () => widget.config.onRowTap!(item) + : null, + onDoubleTap: widget.config.onRowDoubleTap != null + ? () => widget.config.onRowDoubleTap!(item) + : null, + cells: cells, + ); + }).toList(), + ); + } + + Widget _buildCellContent(dynamic item, DataTableColumn column, int index) { + final value = DataTableUtils.getCellValue(item, column.key); + + if (column is CustomColumn && column.builder != null) { + return column.builder!(item, index); + } + + if (column is ActionColumn) { + return _buildActionButtons(item, column); + } + + final formattedValue = DataTableUtils.formatCellValue(value, column); + + return Text( + formattedValue, + textAlign: _getTextAlign(column), + maxLines: _getMaxLines(column), + overflow: _getOverflow(column), + ); + } + + Widget _buildActionButtons(dynamic item, ActionColumn column) { + if (column.actions.isEmpty) return const SizedBox.shrink(); + + return Row( + mainAxisSize: MainAxisSize.min, + children: column.actions.map((action) { + return IconButton( + onPressed: action.enabled ? () => action.onTap(item) : null, + icon: Icon( + action.icon, + color: action.color, + size: 20, + ), + tooltip: action.label, + style: IconButton.styleFrom( + foregroundColor: action.isDestructive + ? Theme.of(context).colorScheme.error + : action.color, + ), + ); + }).toList(), + ); + } + + TextAlign _getTextAlign(DataTableColumn column) { + if (column is NumberColumn) return column.textAlign; + if (column is DateColumn) return column.textAlign; + if (column is TextColumn && column.textAlign != null) return column.textAlign!; + return TextAlign.start; + } + + int? _getMaxLines(DataTableColumn column) { + if (column is TextColumn) return column.maxLines; + return null; + } + + TextOverflow? _getOverflow(DataTableColumn column) { + if (column is TextColumn && column.overflow != null) { + return column.overflow! ? TextOverflow.ellipsis : null; + } + return null; + } +} + +/// Column header with search functionality +class _ColumnHeaderWithSearch extends StatelessWidget { + final String text; + final String sortBy; + final String? currentSort; + final bool sortDesc; + final Function(String) onSort; + final VoidCallback onSearch; + final bool hasActiveFilter; + final bool enabled; + + const _ColumnHeaderWithSearch({ + required this.text, + required this.sortBy, + required this.currentSort, + required this.sortDesc, + required this.onSort, + required this.onSearch, + required this.hasActiveFilter, + this.enabled = true, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isActive = currentSort == sortBy; + + return InkWell( + onTap: enabled ? () => onSort(sortBy) : null, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + text, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + color: isActive ? theme.colorScheme.primary : theme.colorScheme.onSurface, + ), + ), + if (enabled) ...[ + const SizedBox(width: 4), + if (isActive) + Icon( + sortDesc ? Icons.arrow_downward : Icons.arrow_upward, + size: 16, + color: theme.colorScheme.primary, + ) + else + Icon( + Icons.unfold_more, + size: 16, + color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.6), + ), + ], + const SizedBox(width: 8), + // Search button + InkWell( + onTap: onSearch, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: hasActiveFilter + ? theme.colorScheme.primaryContainer + : theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(12), + border: hasActiveFilter + ? Border.all(color: theme.colorScheme.primary.withValues(alpha: 0.3)) + : null, + ), + child: Icon( + Icons.search, + size: 14, + color: hasActiveFilter + ? theme.colorScheme.onPrimaryContainer + : theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.7), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/hesabixUI/hesabix_ui/lib/widgets/data_table/example_usage.dart b/hesabixUI/hesabix_ui/lib/widgets/data_table/example_usage.dart new file mode 100644 index 0000000..2bfc4f9 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/data_table/example_usage.dart @@ -0,0 +1,261 @@ +import 'package:flutter/material.dart'; +import 'data_table_config.dart'; +import 'data_table_widget.dart'; + +/// Example usage of DataTableWidget with column settings in multiple pages +class DataTableExampleUsage { + + /// Page 1: Users Management + static Widget buildUsersTable() { + return DataTableWidget( + config: DataTableConfig( + endpoint: '/api/users', + title: 'مدیریت کاربران', + columns: [ + TextColumn('id', 'شناسه'), + TextColumn('firstName', 'نام'), + TextColumn('lastName', 'نام خانوادگی'), + TextColumn('email', 'ایمیل'), + DateColumn('createdAt', 'تاریخ عضویت'), + ActionColumn('actions', 'عملیات', actions: [ + DataTableAction( + icon: Icons.edit, + label: 'ویرایش', + onTap: (user) => print('Edit user: ${user.id}'), + ), + ]), + ], + // تنظیمات ستون فعال است (پیش‌فرض) + enableColumnSettings: true, + // دکمه تنظیمات ستون نمایش داده می‌شود (پیش‌فرض) + showColumnSettingsButton: true, + // شناسه منحصر به فرد: data_table_column_settings__api_users + ), + fromJson: (json) => User.fromJson(json), + ); + } + + /// Page 2: Orders Management + static Widget buildOrdersTable() { + return DataTableWidget( + config: DataTableConfig( + endpoint: '/api/orders', + title: 'مدیریت سفارشات', + columns: [ + TextColumn('id', 'شماره سفارش'), + TextColumn('customerName', 'نام مشتری'), + NumberColumn('amount', 'مبلغ'), + DateColumn('orderDate', 'تاریخ سفارش'), + TextColumn('status', 'وضعیت'), + ActionColumn('actions', 'عملیات', actions: [ + DataTableAction( + icon: Icons.visibility, + label: 'مشاهده', + onTap: (order) => print('View order: ${order.id}'), + ), + ]), + ], + // شناسه منحصر به فرد: data_table_column_settings__api_orders + ), + fromJson: (json) => Order.fromJson(json), + ); + } + + /// Page 3: Financial Reports + static Widget buildReportsTable() { + return DataTableWidget( + config: DataTableConfig( + endpoint: '/api/reports', + tableId: 'financial_reports', // شناسه سفارشی + title: 'گزارش‌های مالی', + columns: [ + TextColumn('id', 'شناسه گزارش'), + TextColumn('title', 'عنوان'), + NumberColumn('income', 'درآمد'), + NumberColumn('expense', 'هزینه'), + NumberColumn('profit', 'سود'), + DateColumn('reportDate', 'تاریخ گزارش'), + ], + // شناسه منحصر به فرد: data_table_column_settings_financial_reports + ), + fromJson: (json) => Report.fromJson(json), + ); + } + + /// Page 4: Products Management + static Widget buildProductsTable() { + return DataTableWidget( + config: DataTableConfig( + endpoint: '/api/products', + title: 'مدیریت محصولات', + columns: [ + TextColumn('id', 'کد محصول'), + TextColumn('name', 'نام محصول'), + TextColumn('category', 'دسته‌بندی'), + NumberColumn('price', 'قیمت'), + NumberColumn('stock', 'موجودی'), + DateColumn('createdAt', 'تاریخ ایجاد'), + ], + // شناسه منحصر به فرد: data_table_column_settings__api_products + ), + fromJson: (json) => Product.fromJson(json), + ); + } + + /// Page 5: System Logs + static Widget buildLogsTable() { + return DataTableWidget( + config: DataTableConfig( + endpoint: '/api/logs', + tableId: 'system_logs', // شناسه سفارشی + title: 'لاگ‌های سیستم', + columns: [ + TextColumn('id', 'شناسه'), + TextColumn('level', 'سطح'), + TextColumn('message', 'پیام'), + DateColumn('timestamp', 'زمان'), + TextColumn('source', 'منبع'), + ], + // شناسه منحصر به فرد: data_table_column_settings_system_logs + ), + fromJson: (json) => Log.fromJson(json), + ); + } +} + +/// Example data models +class User { + final String id; + final String firstName; + final String lastName; + final String email; + final DateTime createdAt; + + User({ + required this.id, + required this.firstName, + required this.lastName, + required this.email, + required this.createdAt, + }); + + factory User.fromJson(Map json) { + return User( + id: json['id']?.toString() ?? '', + firstName: json['firstName']?.toString() ?? '', + lastName: json['lastName']?.toString() ?? '', + email: json['email']?.toString() ?? '', + createdAt: DateTime.tryParse(json['createdAt']?.toString() ?? '') ?? DateTime.now(), + ); + } +} + +class Order { + final String id; + final String customerName; + final double amount; + final DateTime orderDate; + final String status; + + Order({ + required this.id, + required this.customerName, + required this.amount, + required this.orderDate, + required this.status, + }); + + factory Order.fromJson(Map json) { + return Order( + id: json['id']?.toString() ?? '', + customerName: json['customerName']?.toString() ?? '', + amount: (json['amount'] as num?)?.toDouble() ?? 0.0, + orderDate: DateTime.tryParse(json['orderDate']?.toString() ?? '') ?? DateTime.now(), + status: json['status']?.toString() ?? '', + ); + } +} + +class Report { + final String id; + final String title; + final double income; + final double expense; + final double profit; + final DateTime reportDate; + + Report({ + required this.id, + required this.title, + required this.income, + required this.expense, + required this.profit, + required this.reportDate, + }); + + factory Report.fromJson(Map json) { + return Report( + id: json['id']?.toString() ?? '', + title: json['title']?.toString() ?? '', + income: (json['income'] as num?)?.toDouble() ?? 0.0, + expense: (json['expense'] as num?)?.toDouble() ?? 0.0, + profit: (json['profit'] as num?)?.toDouble() ?? 0.0, + reportDate: DateTime.tryParse(json['reportDate']?.toString() ?? '') ?? DateTime.now(), + ); + } +} + +class Product { + final String id; + final String name; + final String category; + final double price; + final int stock; + final DateTime createdAt; + + Product({ + required this.id, + required this.name, + required this.category, + required this.price, + required this.stock, + required this.createdAt, + }); + + factory Product.fromJson(Map json) { + return Product( + id: json['id']?.toString() ?? '', + name: json['name']?.toString() ?? '', + category: json['category']?.toString() ?? '', + price: (json['price'] as num?)?.toDouble() ?? 0.0, + stock: (json['stock'] as num?)?.toInt() ?? 0, + createdAt: DateTime.tryParse(json['createdAt']?.toString() ?? '') ?? DateTime.now(), + ); + } +} + +class Log { + final String id; + final String level; + final String message; + final DateTime timestamp; + final String source; + + Log({ + required this.id, + required this.level, + required this.message, + required this.timestamp, + required this.source, + }); + + factory Log.fromJson(Map json) { + return Log( + id: json['id']?.toString() ?? '', + level: json['level']?.toString() ?? '', + message: json['message']?.toString() ?? '', + timestamp: DateTime.tryParse(json['timestamp']?.toString() ?? '') ?? DateTime.now(), + source: json['source']?.toString() ?? '', + ); + } +} diff --git a/hesabixUI/hesabix_ui/lib/widgets/data_table/helpers/column_settings_service.dart b/hesabixUI/hesabix_ui/lib/widgets/data_table/helpers/column_settings_service.dart new file mode 100644 index 0000000..32337ad --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/data_table/helpers/column_settings_service.dart @@ -0,0 +1,148 @@ +import 'dart:convert'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Column settings for a specific table +class ColumnSettings { + final List visibleColumns; + final List columnOrder; + final Map columnWidths; + + const ColumnSettings({ + required this.visibleColumns, + required this.columnOrder, + this.columnWidths = const {}, + }); + + Map toJson() { + return { + 'visibleColumns': visibleColumns, + 'columnOrder': columnOrder, + 'columnWidths': columnWidths, + }; + } + + factory ColumnSettings.fromJson(Map json) { + return ColumnSettings( + visibleColumns: List.from(json['visibleColumns'] ?? []), + columnOrder: List.from(json['columnOrder'] ?? []), + columnWidths: Map.from(json['columnWidths'] ?? {}), + ); + } + + ColumnSettings copyWith({ + List? visibleColumns, + List? columnOrder, + Map? columnWidths, + }) { + return ColumnSettings( + visibleColumns: visibleColumns ?? this.visibleColumns, + columnOrder: columnOrder ?? this.columnOrder, + columnWidths: columnWidths ?? this.columnWidths, + ); + } +} + +/// Service for managing column settings persistence +class ColumnSettingsService { + static const String _keyPrefix = 'data_table_column_settings_'; + + /// Get column settings for a specific table + static Future getColumnSettings(String tableId) async { + try { + final prefs = await SharedPreferences.getInstance(); + final key = '$_keyPrefix$tableId'; + final jsonString = prefs.getString(key); + + if (jsonString == null) return null; + + final json = jsonDecode(jsonString) as Map; + return ColumnSettings.fromJson(json); + } catch (e) { + print('Error loading column settings: $e'); + return null; + } + } + + /// Save column settings for a specific table + static Future saveColumnSettings(String tableId, ColumnSettings settings) async { + try { + final prefs = await SharedPreferences.getInstance(); + final key = '$_keyPrefix$tableId'; + final jsonString = jsonEncode(settings.toJson()); + await prefs.setString(key, jsonString); + } catch (e) { + print('Error saving column settings: $e'); + } + } + + /// Clear column settings for a specific table + static Future clearColumnSettings(String tableId) async { + try { + final prefs = await SharedPreferences.getInstance(); + final key = '$_keyPrefix$tableId'; + await prefs.remove(key); + } catch (e) { + print('Error clearing column settings: $e'); + } + } + + /// Get default column settings from column definitions + static ColumnSettings getDefaultSettings(List columnKeys) { + return ColumnSettings( + visibleColumns: List.from(columnKeys), + columnOrder: List.from(columnKeys), + ); + } + + /// Merge user settings with default settings + static ColumnSettings mergeWithDefaults( + ColumnSettings? userSettings, + List defaultColumnKeys, + ) { + if (userSettings == null) { + return getDefaultSettings(defaultColumnKeys); + } + + // Ensure all default columns are present in visible columns + final visibleColumns = []; + for (final key in defaultColumnKeys) { + if (userSettings.visibleColumns.contains(key)) { + visibleColumns.add(key); + } + } + + // Ensure at least one column is visible + if (visibleColumns.isEmpty && defaultColumnKeys.isNotEmpty) { + visibleColumns.add(defaultColumnKeys.first); + } + + // Ensure all visible columns are in the correct order + final columnOrder = []; + for (final key in userSettings.columnOrder) { + if (visibleColumns.contains(key)) { + columnOrder.add(key); + } + } + + // Add any missing visible columns to the end + for (final key in visibleColumns) { + if (!columnOrder.contains(key)) { + columnOrder.add(key); + } + } + + // Filter column widths to only include valid columns + final validColumnWidths = {}; + for (final entry in userSettings.columnWidths.entries) { + if (visibleColumns.contains(entry.key)) { + validColumnWidths[entry.key] = entry.value; + } + } + + return userSettings.copyWith( + visibleColumns: visibleColumns, + columnOrder: columnOrder, + columnWidths: validColumnWidths, + ); + } +} diff --git a/hesabixUI/hesabix_ui/lib/widgets/data_table/helpers/data_table_utils.dart b/hesabixUI/hesabix_ui/lib/widgets/data_table/helpers/data_table_utils.dart new file mode 100644 index 0000000..ae332b5 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/data_table/helpers/data_table_utils.dart @@ -0,0 +1,258 @@ +import 'package:intl/intl.dart'; +import 'package:data_table_2/data_table_2.dart'; +import '../data_table_config.dart'; + +/// Utility functions for data table +class DataTableUtils { + /// Format text with ellipsis if needed + static String formatText(String text, {int? maxLength}) { + if (maxLength != null && text.length > maxLength) { + return '${text.substring(0, maxLength)}...'; + } + return text; + } + + /// Format number with thousand separators + static String formatNumber(dynamic value, {int? decimalPlaces, String? prefix, String? suffix}) { + if (value == null) return ''; + + final number = value is num ? value : double.tryParse(value.toString()) ?? 0; + final formatter = NumberFormat.currency( + symbol: '', + decimalDigits: decimalPlaces ?? 0, + ); + + final formatted = formatter.format(number); + return '${prefix ?? ''}$formatted${suffix ?? ''}'; + } + + /// Format date based on locale and format + static String formatDate( + dynamic value, { + String? format, + bool isJalali = false, + bool showTime = false, + }) { + if (value == null) return ''; + + DateTime? date; + if (value is DateTime) { + date = value; + } else if (value is String) { + try { + date = DateTime.parse(value); + } catch (e) { + return value; // Return original string if parsing fails + } + } else if (value is Map) { + // Handle formatted date objects from backend + if (value.containsKey('date_only')) { + return value['date_only'].toString(); + } else if (value.containsKey('formatted')) { + return value['formatted'].toString(); + } + return value.toString(); + } + + if (date == null) return value.toString(); + + if (isJalali) { + // TODO: Implement Jalali date formatting + return DateFormat(format ?? 'yyyy/MM/dd').format(date); + } else { + final pattern = format ?? (showTime ? 'yyyy/MM/dd HH:mm' : 'yyyy/MM/dd'); + return DateFormat(pattern).format(date); + } + } + + /// Get column width as double + static double getColumnWidth(ColumnWidth width) { + switch (width) { + case ColumnWidth.small: + return 100.0; + case ColumnWidth.medium: + return 150.0; + case ColumnWidth.large: + return 200.0; + case ColumnWidth.extraLarge: + return 300.0; + } + } + + /// Get column size for DataTable2 + static ColumnSize getColumnSize(ColumnWidth width) { + switch (width) { + case ColumnWidth.small: + return ColumnSize.S; + case ColumnWidth.medium: + return ColumnSize.M; + case ColumnWidth.large: + return ColumnSize.L; + case ColumnWidth.extraLarge: + return ColumnSize.L; + } + } + + /// Get search operator label + static String getSearchOperatorLabel(String operator) { + switch (operator) { + case '*': + return 'شامل'; + case '*?': + return 'شروع با'; + case '?*': + return 'خاتمه با'; + case '=': + return 'مطابقت دقیق'; + default: + return operator; + } + } + + /// Get search operator label in English + static String getSearchOperatorLabelEn(String operator) { + switch (operator) { + case '*': + return 'Contains'; + case '*?': + return 'Starts With'; + case '?*': + return 'Ends With'; + case '=': + return 'Exact Match'; + default: + return operator; + } + } + + /// Validate search value + static bool isValidSearchValue(String value) { + return value.trim().isNotEmpty; + } + + /// Get default empty state message + static String getDefaultEmptyMessage() { + return 'هیچ داده‌ای یافت نشد'; + } + + /// Get default loading message + static String getDefaultLoadingMessage() { + return 'در حال بارگذاری...'; + } + + /// Get default error message + static String getDefaultErrorMessage() { + return 'خطا در بارگذاری داده‌ها'; + } + + /// Create filter item for date range + static List createDateRangeFilters( + String field, + DateTime startDate, + DateTime endDate, + ) { + final start = DateTime(startDate.year, startDate.month, startDate.day); + final endExclusive = DateTime(endDate.year, endDate.month, endDate.day) + .add(const Duration(days: 1)); + + return [ + FilterItem( + property: field, + operator: '>=', + value: start.toIso8601String(), + ), + FilterItem( + property: field, + operator: '<', + value: endExclusive.toIso8601String(), + ), + ]; + } + + /// Create filter item for column search + static FilterItem createColumnFilter( + String field, + String value, + String operator, + ) { + return FilterItem( + property: field, + operator: operator, + value: value, + ); + } + + /// Get column label by key + static String getColumnLabel(String key, List columns) { + final column = columns.firstWhere( + (col) => col.key == key, + orElse: () => TextColumn(key, key), + ); + return column.label; + } + + /// Check if column is searchable + static bool isColumnSearchable(String key, List columns) { + final column = columns.firstWhere( + (col) => col.key == key, + orElse: () => TextColumn(key, key, searchable: false), + ); + return column.searchable; + } + + /// Check if column is sortable + static bool isColumnSortable(String key, List columns) { + final column = columns.firstWhere( + (col) => col.key == key, + orElse: () => TextColumn(key, key, sortable: false), + ); + return column.sortable; + } + + /// Get cell value from item + static dynamic getCellValue(dynamic item, String key) { + if (item is Map) { + return item[key]; + } + // For custom objects, try to access property using reflection + // This is a simplified version - in real implementation you might need + // to use reflection or have a more sophisticated approach + return null; + } + + /// Format cell value based on column type + static String formatCellValue( + dynamic value, + DataTableColumn column, + ) { + if (value == null) return ''; + + if (column is TextColumn) { + if (column.formatter != null) { + return column.formatter!(value) ?? ''; + } + return value.toString(); + } else if (column is NumberColumn) { + if (column.formatter != null) { + return column.formatter!(value) ?? ''; + } + return formatNumber( + value, + decimalPlaces: column.decimalPlaces, + prefix: column.prefix, + suffix: column.suffix, + ); + } else if (column is DateColumn) { + if (column.formatter != null) { + return column.formatter!(value) ?? ''; + } + return formatDate( + value, + format: column.dateFormat, + showTime: column.showTime, + ); + } + + return value.toString(); + } +} diff --git a/hesabixUI/hesabix_ui/lib/widgets/date_input_field.dart b/hesabixUI/hesabix_ui/lib/widgets/date_input_field.dart index 8ed6ccd..1955bdd 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/date_input_field.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/date_input_field.dart @@ -58,7 +58,7 @@ class _DateInputFieldState extends State { void didUpdateWidget(DateInputField oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.value != widget.value || - oldWidget.calendarController.isJalali != widget.calendarController.isJalali) { + (oldWidget.calendarController.isJalali == true) != (widget.calendarController.isJalali == true)) { _updateDisplayValue(); } } @@ -76,7 +76,7 @@ class _DateInputFieldState extends State { void _updateDisplayValue() { final displayValue = HesabixDateUtils.formatForDisplay( widget.value, - widget.calendarController.isJalali + widget.calendarController.isJalali == true ); _controller.text = displayValue; } @@ -91,7 +91,7 @@ class _DateInputFieldState extends State { DateTime? selectedDate; - if (widget.calendarController.isJalali) { + if (widget.calendarController.isJalali == true) { selectedDate = await showJalaliDatePicker( context: context, initialDate: initialDate, diff --git a/hesabixUI/hesabix_ui/lib/widgets/jalali_date_picker.dart b/hesabixUI/hesabix_ui/lib/widgets/jalali_date_picker.dart index db44c6a..cae1e20 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/jalali_date_picker.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/jalali_date_picker.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:persian_datetime_picker/persian_datetime_picker.dart' as picker; import 'package:shamsi_date/shamsi_date.dart'; /// DatePicker سفارشی برای تقویم شمسی @@ -124,7 +123,7 @@ class _JalaliDatePickerState extends State { } Widget _buildCalendar() { - return picker.PersianCalendarDatePicker( + return _CustomPersianCalendar( initialDate: _selectedJalali, firstDate: Jalali.fromDateTime(widget.firstDate ?? DateTime(1900)), lastDate: Jalali.fromDateTime(widget.lastDate ?? DateTime(2100)), @@ -155,4 +154,192 @@ Future showJalaliDatePicker({ helpText: helpText, ), ); +} + +/// Custom Persian Calendar Widget with proper Persian month names +class _CustomPersianCalendar extends StatefulWidget { + final Jalali initialDate; + final Jalali firstDate; + final Jalali lastDate; + final ValueChanged onDateChanged; + + const _CustomPersianCalendar({ + required this.initialDate, + required this.firstDate, + required this.lastDate, + required this.onDateChanged, + }); + + @override + State<_CustomPersianCalendar> createState() => _CustomPersianCalendarState(); +} + +class _CustomPersianCalendarState extends State<_CustomPersianCalendar> { + late Jalali _currentDate; + late Jalali _selectedDate; + + // Persian month names + static const List _monthNames = [ + 'فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', + 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند' + ]; + + // Persian day names (abbreviated) + static const List _dayNames = [ + 'ش', 'ی', 'د', 'س', 'چ', 'پ', 'ج' + ]; + + @override + void initState() { + super.initState(); + _currentDate = widget.initialDate; + _selectedDate = widget.initialDate; + } + + void _previousMonth() { + setState(() { + if (_currentDate.month == 1) { + _currentDate = Jalali(_currentDate.year - 1, 12, 1); + } else { + _currentDate = Jalali(_currentDate.year, _currentDate.month - 1, 1); + } + }); + } + + void _nextMonth() { + setState(() { + if (_currentDate.month == 12) { + _currentDate = Jalali(_currentDate.year + 1, 1, 1); + } else { + _currentDate = Jalali(_currentDate.year, _currentDate.month + 1, 1); + } + }); + } + + void _selectDate(Jalali date) { + setState(() { + _selectedDate = date; + }); + widget.onDateChanged(date); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + // Get the first day of the month and calculate the starting day + final firstDayOfMonth = Jalali(_currentDate.year, _currentDate.month, 1); + final lastDayOfMonth = Jalali(_currentDate.year, _currentDate.month, _currentDate.monthLength); + + // Calculate the starting weekday (0 = Saturday, 6 = Friday) + // Convert Jalali to DateTime to get weekday, then adjust for Persian calendar + final gregorianFirstDay = firstDayOfMonth.toDateTime(); + final startWeekday = (gregorianFirstDay.weekday + 1) % 7; // Adjust for Persian week start (Saturday) + + return Column( + children: [ + // Month/Year header + Container( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + onPressed: _previousMonth, + icon: const Icon(Icons.chevron_left), + ), + Text( + '${_monthNames[_currentDate.month - 1]} ${_currentDate.year}', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + IconButton( + onPressed: _nextMonth, + icon: const Icon(Icons.chevron_right), + ), + ], + ), + ), + + // Day names header + Row( + children: _dayNames.map((day) => Expanded( + child: Center( + child: Text( + day, + style: theme.textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.bold, + color: theme.colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + ), + )).toList(), + ), + + const SizedBox(height: 8), + + // Calendar grid + Expanded( + child: GridView.builder( + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 7, + childAspectRatio: 1.0, + ), + itemCount: 42, // 6 weeks * 7 days + itemBuilder: (context, index) { + final dayIndex = index - startWeekday; + final day = dayIndex + 1; + + if (dayIndex < 0 || day > lastDayOfMonth.day.toInt()) { + return const SizedBox.shrink(); + } + + final date = Jalali(_currentDate.year, _currentDate.month, day); + final isSelected = date.year == _selectedDate.year && + date.month == _selectedDate.month && + date.day == _selectedDate.day; + final isToday = date.year == Jalali.now().year && + date.month == Jalali.now().month && + date.day == Jalali.now().day; + + return GestureDetector( + onTap: () => _selectDate(date), + child: Container( + margin: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: isSelected + ? theme.colorScheme.primary + : isToday + ? theme.colorScheme.primary.withValues(alpha: 0.1) + : Colors.transparent, + borderRadius: BorderRadius.circular(8), + border: isToday && !isSelected + ? Border.all(color: theme.colorScheme.primary, width: 1) + : null, + ), + child: Center( + child: Text( + day.toString(), + style: theme.textTheme.bodyMedium?.copyWith( + color: isSelected + ? theme.colorScheme.onPrimary + : isToday + ? theme.colorScheme.primary + : theme.colorScheme.onSurface, + fontWeight: isSelected || isToday + ? FontWeight.bold + : FontWeight.normal, + ), + ), + ), + ), + ); + }, + ), + ), + ], + ); + } } \ No newline at end of file diff --git a/hesabixUI/hesabix_ui/pubspec.lock b/hesabixUI/hesabix_ui/pubspec.lock index 0cdb782..6dbca59 100644 --- a/hesabixUI/hesabix_ui/pubspec.lock +++ b/hesabixUI/hesabix_ui/pubspec.lock @@ -57,6 +57,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.0.8" + data_table_2: + dependency: "direct main" + description: + name: data_table_2 + sha256: b8dd157e4efe5f2beef092c9952a254b2192cf76a26ad1c6aa8b06c8b9d665da + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.6.0" dio: dependency: "direct main" description: @@ -366,7 +374,7 @@ packages: source: hosted version: "2.1.8" shamsi_date: - dependency: transitive + dependency: "direct main" description: name: shamsi_date sha256: "0383fddc9bce91e9e08de0c909faf93c3ab3a0e532abd271fb0dcf5d0617487b" diff --git a/hesabixUI/hesabix_ui/pubspec.yaml b/hesabixUI/hesabix_ui/pubspec.yaml index 26f0d3d..0d73a67 100644 --- a/hesabixUI/hesabix_ui/pubspec.yaml +++ b/hesabixUI/hesabix_ui/pubspec.yaml @@ -42,7 +42,9 @@ dependencies: flutter_secure_storage: ^9.2.2 uuid: ^4.4.2 persian_datetime_picker: ^3.2.0 + shamsi_date: ^1.1.1 intl: ^0.20.0 + data_table_2: ^2.5.12 dev_dependencies: flutter_test: diff --git a/hesabixUI/hesabix_ui/test/column_settings_isolation_test.dart b/hesabixUI/hesabix_ui/test/column_settings_isolation_test.dart new file mode 100644 index 0000000..0adf467 --- /dev/null +++ b/hesabixUI/hesabix_ui/test/column_settings_isolation_test.dart @@ -0,0 +1,132 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:hesabix_ui/widgets/data_table/helpers/column_settings_service.dart'; +import 'package:hesabix_ui/widgets/data_table/data_table_config.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('Column Settings Isolation Tests', () { + test('should have different settings for different tables', () async { + // Clear any existing settings + await ColumnSettingsService.clearColumnSettings('table1'); + await ColumnSettingsService.clearColumnSettings('table2'); + + // Create different settings for two tables + final settings1 = ColumnSettings( + visibleColumns: ['id', 'name'], + columnOrder: ['name', 'id'], + columnWidths: {'name': 200.0}, + ); + + final settings2 = ColumnSettings( + visibleColumns: ['id', 'email', 'createdAt'], + columnOrder: ['id', 'createdAt', 'email'], + columnWidths: {'email': 300.0, 'createdAt': 150.0}, + ); + + // Save settings for both tables + await ColumnSettingsService.saveColumnSettings('table1', settings1); + await ColumnSettingsService.saveColumnSettings('table2', settings2); + + // Retrieve settings + final retrieved1 = await ColumnSettingsService.getColumnSettings('table1'); + final retrieved2 = await ColumnSettingsService.getColumnSettings('table2'); + + // Verify they are different + expect(retrieved1, isNotNull); + expect(retrieved2, isNotNull); + expect(retrieved1!.visibleColumns, equals(['id', 'name'])); + expect(retrieved2!.visibleColumns, equals(['id', 'email', 'createdAt'])); + expect(retrieved1.columnOrder, equals(['name', 'id'])); + expect(retrieved2.columnOrder, equals(['id', 'createdAt', 'email'])); + expect(retrieved1.columnWidths, equals({'name': 200.0})); + expect(retrieved2.columnWidths, equals({'email': 300.0, 'createdAt': 150.0})); + }); + + test('should generate unique table IDs from endpoints', () { + // Test different endpoints generate different IDs + final config1 = DataTableConfig( + endpoint: '/api/users', + columns: [], + ); + + final config2 = DataTableConfig( + endpoint: '/api/orders', + columns: [], + ); + + final config3 = DataTableConfig( + endpoint: '/api/products', + columns: [], + ); + + expect(config1.effectiveTableId, equals('_api_users')); + expect(config2.effectiveTableId, equals('_api_orders')); + expect(config3.effectiveTableId, equals('_api_products')); + + // All should be different + expect(config1.effectiveTableId, isNot(equals(config2.effectiveTableId))); + expect(config2.effectiveTableId, isNot(equals(config3.effectiveTableId))); + expect(config1.effectiveTableId, isNot(equals(config3.effectiveTableId))); + }); + + test('should use custom tableId when provided', () { + final config1 = DataTableConfig( + endpoint: '/api/users', + tableId: 'custom_users_table', + columns: [], + ); + + final config2 = DataTableConfig( + endpoint: '/api/users', // Same endpoint + tableId: 'custom_orders_table', // Different tableId + columns: [], + ); + + expect(config1.effectiveTableId, equals('custom_users_table')); + expect(config2.effectiveTableId, equals('custom_orders_table')); + expect(config1.effectiveTableId, isNot(equals(config2.effectiveTableId))); + }); + + test('should handle special characters in endpoints', () { + final config1 = DataTableConfig( + endpoint: '/api/v1/users?active=true', + columns: [], + ); + + final config2 = DataTableConfig( + endpoint: '/api/v2/users?active=false', + columns: [], + ); + + // Special characters should be replaced with underscores + expect(config1.effectiveTableId, equals('_api_v1_users_active_true')); + expect(config2.effectiveTableId, equals('_api_v2_users_active_false')); + expect(config1.effectiveTableId, isNot(equals(config2.effectiveTableId))); + }); + + test('should not interfere with other app data', () async { + // Save some app data + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('user_preferences', 'some_value'); + await prefs.setString('theme_settings', 'dark_mode'); + + // Save column settings + final settings = ColumnSettings( + visibleColumns: ['id', 'name'], + columnOrder: ['name', 'id'], + ); + await ColumnSettingsService.saveColumnSettings('test_table', settings); + + // Verify app data is still intact + expect(prefs.getString('user_preferences'), equals('some_value')); + expect(prefs.getString('theme_settings'), equals('dark_mode')); + + // Verify column settings are saved + final retrieved = await ColumnSettingsService.getColumnSettings('test_table'); + expect(retrieved, isNotNull); + expect(retrieved!.visibleColumns, equals(['id', 'name'])); + }); + }); +} diff --git a/hesabixUI/hesabix_ui/test/column_settings_test.dart b/hesabixUI/hesabix_ui/test/column_settings_test.dart new file mode 100644 index 0000000..d9028cf --- /dev/null +++ b/hesabixUI/hesabix_ui/test/column_settings_test.dart @@ -0,0 +1,88 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:hesabix_ui/widgets/data_table/helpers/column_settings_service.dart'; + +void main() { + group('ColumnSettingsService', () { + test('should create default settings from column keys', () { + final columnKeys = ['id', 'name', 'email', 'createdAt']; + final settings = ColumnSettingsService.getDefaultSettings(columnKeys); + + expect(settings.visibleColumns, equals(columnKeys)); + expect(settings.columnOrder, equals(columnKeys)); + expect(settings.columnWidths, isEmpty); + }); + + test('should merge user settings with defaults correctly', () { + final defaultKeys = ['id', 'name', 'email', 'createdAt', 'updatedAt']; + final userSettings = ColumnSettings( + visibleColumns: ['id', 'name', 'email'], + columnOrder: ['name', 'id', 'email'], + columnWidths: {'name': 200.0}, + ); + + final merged = ColumnSettingsService.mergeWithDefaults(userSettings, defaultKeys); + + expect(merged.visibleColumns, equals(['id', 'name', 'email'])); + expect(merged.columnOrder, equals(['name', 'id', 'email'])); + expect(merged.columnWidths, equals({'name': 200.0})); + }); + + test('should handle null user settings', () { + final defaultKeys = ['id', 'name', 'email']; + final merged = ColumnSettingsService.mergeWithDefaults(null, defaultKeys); + + expect(merged.visibleColumns, equals(defaultKeys)); + expect(merged.columnOrder, equals(defaultKeys)); + expect(merged.columnWidths, isEmpty); + }); + + test('should filter out invalid columns from user settings', () { + final defaultKeys = ['id', 'name', 'email']; + final userSettings = ColumnSettings( + visibleColumns: ['id', 'name', 'invalidColumn', 'email'], + columnOrder: ['name', 'invalidColumn', 'id', 'email'], + columnWidths: {'name': 200.0, 'invalidColumn': 150.0}, + ); + + final merged = ColumnSettingsService.mergeWithDefaults(userSettings, defaultKeys); + + expect(merged.visibleColumns, equals(['id', 'name', 'email'])); + expect(merged.columnOrder, equals(['name', 'id', 'email'])); + expect(merged.columnWidths, equals({'name': 200.0})); + }); + }); + + group('ColumnSettings', () { + test('should serialize and deserialize correctly', () { + final original = ColumnSettings( + visibleColumns: ['id', 'name', 'email'], + columnOrder: ['name', 'id', 'email'], + columnWidths: {'name': 200.0, 'email': 150.0}, + ); + + final json = original.toJson(); + final restored = ColumnSettings.fromJson(json); + + expect(restored.visibleColumns, equals(original.visibleColumns)); + expect(restored.columnOrder, equals(original.columnOrder)); + expect(restored.columnWidths, equals(original.columnWidths)); + }); + + test('should copy with new values correctly', () { + final original = ColumnSettings( + visibleColumns: ['id', 'name'], + columnOrder: ['name', 'id'], + columnWidths: {'name': 200.0}, + ); + + final copied = original.copyWith( + visibleColumns: ['id', 'name', 'email'], + columnWidths: {'name': 250.0, 'email': 150.0}, + ); + + expect(copied.visibleColumns, equals(['id', 'name', 'email'])); + expect(copied.columnOrder, equals(['name', 'id'])); // unchanged + expect(copied.columnWidths, equals({'name': 250.0, 'email': 150.0})); + }); + }); +} diff --git a/hesabixUI/hesabix_ui/test/column_settings_validation_test.dart b/hesabixUI/hesabix_ui/test/column_settings_validation_test.dart new file mode 100644 index 0000000..cd105eb --- /dev/null +++ b/hesabixUI/hesabix_ui/test/column_settings_validation_test.dart @@ -0,0 +1,86 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:hesabix_ui/widgets/data_table/helpers/column_settings_service.dart'; + +void main() { + group('Column Settings Validation Tests', () { + test('should prevent hiding all columns in mergeWithDefaults', () { + final defaultKeys = ['id', 'name', 'email', 'createdAt']; + final userSettings = ColumnSettings( + visibleColumns: [], // Empty - should be prevented + columnOrder: [], + columnWidths: {}, + ); + + final merged = ColumnSettingsService.mergeWithDefaults(userSettings, defaultKeys); + + // Should have at least one column visible + expect(merged.visibleColumns, isNotEmpty); + expect(merged.visibleColumns.length, greaterThanOrEqualTo(1)); + expect(merged.visibleColumns.first, equals('id')); // First column should be visible + }); + + test('should preserve existing visible columns', () { + final defaultKeys = ['id', 'name', 'email', 'createdAt']; + final userSettings = ColumnSettings( + visibleColumns: ['name', 'email'], // Some columns visible + columnOrder: ['name', 'email'], + columnWidths: {'name': 200.0}, + ); + + final merged = ColumnSettingsService.mergeWithDefaults(userSettings, defaultKeys); + + expect(merged.visibleColumns, equals(['name', 'email'])); + expect(merged.columnOrder, equals(['name', 'email'])); + expect(merged.columnWidths, equals({'name': 200.0})); + }); + + test('should handle empty default keys gracefully', () { + final defaultKeys = []; + final userSettings = ColumnSettings( + visibleColumns: [], + columnOrder: [], + columnWidths: {}, + ); + + final merged = ColumnSettingsService.mergeWithDefaults(userSettings, defaultKeys); + + // Should return empty settings when no default keys + expect(merged.visibleColumns, isEmpty); + expect(merged.columnOrder, isEmpty); + expect(merged.columnWidths, isEmpty); + }); + + test('should filter out invalid columns and ensure at least one visible', () { + final defaultKeys = ['id', 'name', 'email']; + final userSettings = ColumnSettings( + visibleColumns: ['invalid1', 'invalid2'], // Invalid columns + columnOrder: ['invalid1', 'invalid2'], + columnWidths: {'invalid1': 200.0, 'name': 150.0}, + ); + + final merged = ColumnSettingsService.mergeWithDefaults(userSettings, defaultKeys); + + // Should have at least one valid column visible + expect(merged.visibleColumns, isNotEmpty); + expect(merged.visibleColumns.length, greaterThanOrEqualTo(1)); + expect(merged.visibleColumns.first, equals('id')); // First valid column + + // Should filter out invalid column widths (name is not in visible columns) + expect(merged.columnWidths, isEmpty); + }); + + test('should maintain column order when adding missing columns', () { + final defaultKeys = ['id', 'name', 'email', 'createdAt']; + final userSettings = ColumnSettings( + visibleColumns: ['name', 'email'], + columnOrder: ['name', 'email', 'id'], // 'id' is not in visible but in order + columnWidths: {}, + ); + + final merged = ColumnSettingsService.mergeWithDefaults(userSettings, defaultKeys); + + expect(merged.visibleColumns, equals(['name', 'email'])); + expect(merged.columnOrder, equals(['name', 'email'])); // Should filter out 'id' + }); + }); +}