more progress in base system and table design

This commit is contained in:
Hesabix 2025-09-19 04:35:13 +03:30
parent 46925f4b22
commit f1a5bb4c41
70 changed files with 8645 additions and 325 deletions

9
hesabixAPI/=3.1.0 Normal file
View file

@ -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

View file

@ -1,13 +1,16 @@
from __future__ import annotations from __future__ import annotations
import datetime
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Request
from fastapi.responses import Response
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from adapters.db.session import get_db from adapters.db.session import get_db
from app.core.responses import success_response, format_datetime_fields from app.core.responses import success_response, format_datetime_fields
from app.services.captcha_service import create_captcha 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 app.services.auth_service import register_user, login_user, create_password_reset, reset_password, change_password, referral_stats
from .schemas import RegisterRequest, LoginRequest, ForgotPasswordRequest, ResetPasswordRequest, ChangePasswordRequest, CreateApiKeyRequest 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.core.auth_dependency import get_current_user, AuthContext
from app.services.api_key_service import list_personal_keys, create_personal_key, revoke_key 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") @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) items = list_personal_keys(db, ctx.user.id)
return success_response(items) return success_response(items)
@router.post("/api-keys", summary="Create personal API key") @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) id_, api_key = create_personal_key(db, ctx.user.id, payload.name, payload.scopes, None)
return success_response({"id": id_, "api_key": api_key}) 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") @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) revoke_key(db, ctx.user.id, key_id)
return success_response({"ok": True}) return success_response({"ok": True})
@router.get("/referrals/stats", summary="Referral stats for current user") @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 from datetime import datetime
start_dt = datetime.fromisoformat(start) if start else None start_dt = datetime.fromisoformat(start) if start else None
end_dt = datetime.fromisoformat(end) if end 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) return success_response(stats)
@router.get("/referrals/list", summary="Referral list for current user") @router.post("/referrals/list", summary="Referral list with advanced filtering")
def get_referral_list( def get_referral_list_advanced(
request: Request,
query_info: QueryInfo,
ctx: AuthContext = Depends(get_current_user), ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db)
start: str | None = None,
end: str | None = None,
search: str | None = None,
page: int = 1,
limit: int = 20,
) -> dict: ) -> dict:
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) پارامترهای QueryInfo:
return success_response(resp) - 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
# 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"
}
)

View file

@ -1,8 +1,25 @@
from __future__ import annotations from __future__ import annotations
from typing import Any
from pydantic import BaseModel, EmailStr, Field 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): class CaptchaSolve(BaseModel):
captcha_id: str = Field(..., min_length=8) captcha_id: str = Field(..., min_length=8)
captcha_code: str = Field(..., min_length=3, max_length=8) captcha_code: str = Field(..., min_length=3, max_length=8)

View file

@ -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)

View file

@ -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

View file

@ -6,11 +6,13 @@ from sqlalchemy import select, func, and_, or_
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from adapters.db.models.user import User 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: def __init__(self, db: Session) -> None:
self.db = db super().__init__(db, User)
def get_by_email(self, email: str) -> Optional[User]: def get_by_email(self, email: str) -> Optional[User]:
stmt = select(User).where(User.email == email) stmt = select(User).where(User.email == email)
@ -72,4 +74,19 @@ class UserRepository:
stmt = stmt.order_by(User.created_at.desc()).offset(offset).limit(limit) stmt = stmt.order_by(User.created_at.desc()).offset(offset).limit(limit)
return self.db.execute(stmt).scalars().all() 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,
}

View file

@ -1,8 +1,7 @@
from __future__ import annotations from __future__ import annotations
from typing import Optional from typing import Optional
from fastapi import Depends, Header, Request
from fastapi import Depends, Header
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from adapters.db.session import get_db 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 adapters.db.models.user import User
from app.core.security import hash_api_key from app.core.security import hash_api_key
from app.core.responses import ApiError 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: 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.user = user
self.api_key_id = api_key_id 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 "): if not authorization or not authorization.startswith("ApiKey "):
raise ApiError("UNAUTHORIZED", "Missing or invalid API key", http_status=401) 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: if not user or not user.is_active:
raise ApiError("UNAUTHORIZED", "Invalid API key", http_status=401) 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

View file

@ -41,7 +41,7 @@ class CalendarConverter:
"date_only": jalali.strftime("%Y/%m/%d"), "date_only": jalali.strftime("%Y/%m/%d"),
"time_only": jalali.strftime("%H:%M:%S"), "time_only": jalali.strftime("%H:%M:%S"),
"is_leap_year": jalali.isleap(), "is_leap_year": jalali.isleap(),
"month_days": jalali.days_in_month, "month_days": jdatetime.j_days_in_month[jalali.month - 1],
} }
@staticmethod @staticmethod

View file

@ -46,3 +46,8 @@ async def locale_dependency(request: Request) -> Translator:
return Translator(lang) return Translator(lang)
def get_translator(locale: str = "fa") -> Translator:
"""Get translator for the given locale"""
return Translator(locale)

View file

@ -29,7 +29,11 @@ def format_datetime_fields(data: Any, request: Request) -> Any:
for key, value in data.items(): for key, value in data.items():
if isinstance(value, datetime): if isinstance(value, datetime):
formatted_data[key] = CalendarConverter.format_datetime(value, calendar_type) 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)): elif isinstance(value, (dict, list)):
formatted_data[key] = format_datetime_fields(value, request) formatted_data[key] = format_datetime_fields(value, request)
else: else:

View file

@ -5,6 +5,7 @@ from app.core.settings import get_settings
from app.core.logging import configure_logging from app.core.logging import configure_logging
from adapters.api.v1.health import router as health_router from adapters.api.v1.health import router as health_router
from adapters.api.v1.auth import router as auth_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.i18n import negotiate_locale, Translator
from app.core.error_handlers import register_error_handlers from app.core.error_handlers import register_error_handlers
from app.core.smart_normalizer import smart_normalize_json, SmartNormalizerConfig 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(health_router, prefix=settings.api_v1_prefix)
application.include_router(auth_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) register_error_handlers(application)

View file

@ -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
<!-- In your template -->
<h1>{{ t('yourTranslationKey') }}</h1>
<div>{{ t('anotherKey') }}</div>
```
### 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

View file

@ -0,0 +1,6 @@
"""
PDF Service Package
"""
from .base_pdf_service import PDFService
__all__ = ['PDFService']

View file

@ -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())

View file

@ -0,0 +1,3 @@
"""
PDF Modules Package
"""

View file

@ -0,0 +1,6 @@
"""
Marketing PDF Module
"""
from .marketing_module import MarketingPDFModule
__all__ = ['MarketingPDFModule']

View file

@ -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

View file

@ -0,0 +1,331 @@
<!DOCTYPE html>
<html lang="{{ locale }}" dir="{{ 'rtl' if locale == 'fa' else 'ltr' }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ t('marketingReport') }} - {{ t('referralList') }}</title>
<style>
@page {
size: A4;
margin: 2cm;
@top-center {
content: "{{ t('marketingReport') }} - {{ t('referralList') }}";
font-family: 'Vazirmatn', Arial, sans-serif;
font-size: 14px;
color: #333;
}
@bottom-center {
content: "{{ t('page') }} " counter(page) " {{ t('ofText') }} " counter(pages);
font-family: 'Vazirmatn', Arial, sans-serif;
font-size: 12px;
color: #666;
}
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: {{ 'Vazirmatn' if locale == 'fa' else 'Arial' }}, Arial, sans-serif;
font-size: 12px;
line-height: 1.6;
color: #333;
direction: {{ 'rtl' if locale == 'fa' else 'ltr' }};
text-align: {{ 'right' if locale == 'fa' else 'left' }};
}
.header {
text-align: center;
margin-bottom: 30px;
padding: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 10px;
}
.header h1 {
font-size: 24px;
margin-bottom: 10px;
font-weight: bold;
}
.header .subtitle {
font-size: 14px;
opacity: 0.9;
}
.report-info {
display: flex;
justify-content: space-between;
margin-bottom: 20px;
padding: 15px;
background-color: #f8f9fa;
border-radius: 8px;
border-right: 4px solid #667eea;
}
.report-info .info-item {
text-align: center;
}
.report-info .info-item .label {
font-size: 11px;
color: #666;
margin-bottom: 5px;
}
.report-info .info-item .value {
font-size: 16px;
font-weight: bold;
color: #333;
}
.filters-info {
margin-bottom: 20px;
padding: 15px;
background-color: #e3f2fd;
border-radius: 8px;
border-right: 4px solid #2196f3;
}
.filters-info h3 {
font-size: 14px;
margin-bottom: 10px;
color: #1976d2;
}
.filter-item {
display: inline-block;
margin-{{ 'left' if locale == 'fa' else 'right' }}: 15px;
margin-bottom: 5px;
padding: 5px 10px;
background-color: white;
border-radius: 15px;
font-size: 11px;
color: #1976d2;
border: 1px solid #bbdefb;
}
.table-container {
margin-top: 20px;
}
table {
width: 100%;
border-collapse: collapse;
margin-bottom: 20px;
background-color: white;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
border-radius: 8px;
overflow: hidden;
}
th {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 12px 8px;
text-align: center;
font-weight: bold;
font-size: 12px;
border: none;
}
td {
padding: 10px 8px;
text-align: center;
border-bottom: 1px solid #e0e0e0;
font-size: 11px;
}
tr:nth-child(even) {
background-color: #f8f9fa;
}
tr:hover {
background-color: #e3f2fd;
}
.row-number {
font-weight: bold;
color: #667eea;
}
.name-cell {
text-align: {{ 'right' if locale == 'fa' else 'left' }};
font-weight: 500;
}
.email-cell {
text-align: {{ 'right' if locale == 'fa' else 'left' }};
color: #666;
}
.date-cell {
color: #666;
font-size: 10px;
}
.footer {
margin-top: 30px;
padding: 20px;
text-align: center;
background-color: #f8f9fa;
border-radius: 8px;
border-top: 3px solid #667eea;
}
.footer .generated-info {
font-size: 11px;
color: #666;
margin-bottom: 10px;
}
.footer .company-info {
font-size: 12px;
color: #333;
font-weight: 500;
}
.no-data {
text-align: center;
padding: 40px;
color: #666;
font-size: 14px;
}
.no-data .icon {
font-size: 48px;
margin-bottom: 15px;
color: #ccc;
}
.stats-summary {
display: flex;
justify-content: space-around;
margin-bottom: 20px;
padding: 15px;
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
color: white;
border-radius: 8px;
}
.stats-summary .stat-item {
text-align: center;
}
.stats-summary .stat-item .number {
font-size: 20px;
font-weight: bold;
margin-bottom: 5px;
}
.stats-summary .stat-item .label {
font-size: 11px;
opacity: 0.9;
}
</style>
</head>
<body>
<div class="header">
<h1>{{ t('marketingReport') }}</h1>
<div class="subtitle">{{ t('referralList') }}</div>
</div>
{% if stats %}
<div class="stats-summary">
<div class="stat-item">
<div class="number">{{ stats.today or 0 }}</div>
<div class="label">{{ t('today') }}</div>
</div>
<div class="stat-item">
<div class="number">{{ stats.this_month or 0 }}</div>
<div class="label">{{ t('thisMonth') }}</div>
</div>
<div class="stat-item">
<div class="number">{{ stats.total or 0 }}</div>
<div class="label">{{ t('total') }}</div>
</div>
{% if stats.range %}
<div class="stat-item">
<div class="number">{{ stats.range }}</div>
<div class="label">{{ t('selectedRange') }}</div>
</div>
{% endif %}
</div>
{% endif %}
<div class="report-info">
<div class="info-item">
<div class="label">{{ t('reportDate') }}</div>
<div class="value">{{ report_date }}</div>
</div>
<div class="info-item">
<div class="label">{{ t('totalRecords') }}</div>
<div class="value">{{ total_count }}</div>
</div>
<div class="info-item">
<div class="label">{{ t('displayedRecords') }}</div>
<div class="value">{{ items|length }}</div>
</div>
{% if selected_only %}
<div class="info-item">
<div class="label">{{ t('outputType') }}</div>
<div class="value">{{ t('selectedOnly') }}</div>
</div>
{% endif %}
</div>
{% if filters %}
<div class="filters-info">
<h3>{{ t('activeFilters') }}:</h3>
{% for filter in filters %}
<span class="filter-item">{{ filter }}</span>
{% endfor %}
</div>
{% endif %}
<div class="table-container">
{% if items %}
<table>
<thead>
<tr>
<th style="width: 5%;">{{ t('rowNumber') }}</th>
<th style="width: 25%;">{{ t('firstName') }}</th>
<th style="width: 25%;">{{ t('lastName') }}</th>
<th style="width: 30%;">{{ t('email') }}</th>
<th style="width: 15%;">{{ t('registrationDate') }}</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td class="row-number">{{ loop.index }}</td>
<td class="name-cell">{{ item.first_name or '-' }}</td>
<td class="name-cell">{{ item.last_name or '-' }}</td>
<td class="email-cell">{{ item.email or '-' }}</td>
<td class="date-cell">{{ item.formatted_created_at }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="no-data">
<div class="icon">📊</div>
<div>{{ t('noDataFound') }}</div>
</div>
{% endif %}
</div>
<div class="footer">
<div class="generated-info">
{{ t('reportGeneratedOn') }} {{ report_date }} {{ t('at') }} {{ report_time }}
</div>
<div class="company-info">
{{ t('hesabixAccountingSystem') }}
</div>
</div>
</body>
</html>

View file

@ -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

View file

@ -19,6 +19,8 @@ Requires-Dist: pillow>=10.3.0
Requires-Dist: phonenumbers>=8.13.40 Requires-Dist: phonenumbers>=8.13.40
Requires-Dist: Babel>=2.15.0 Requires-Dist: Babel>=2.15.0
Requires-Dist: jdatetime>=4.1.0 Requires-Dist: jdatetime>=4.1.0
Requires-Dist: weasyprint>=62.3
Requires-Dist: jinja2>=3.1.0
Provides-Extra: dev Provides-Extra: dev
Requires-Dist: pytest>=8.2.0; extra == "dev" Requires-Dist: pytest>=8.2.0; extra == "dev"
Requires-Dist: httpx>=0.27.0; extra == "dev" Requires-Dist: httpx>=0.27.0; extra == "dev"

View file

@ -6,6 +6,7 @@ adapters/api/v1/__init__.py
adapters/api/v1/auth.py adapters/api/v1/auth.py
adapters/api/v1/health.py adapters/api/v1/health.py
adapters/api/v1/schemas.py adapters/api/v1/schemas.py
adapters/api/v1/users.py
adapters/db/__init__.py adapters/db/__init__.py
adapters/db/session.py adapters/db/session.py
adapters/db/models/__init__.py adapters/db/models/__init__.py
@ -14,6 +15,7 @@ adapters/db/models/captcha.py
adapters/db/models/password_reset.py adapters/db/models/password_reset.py
adapters/db/models/user.py adapters/db/models/user.py
adapters/db/repositories/api_key_repo.py adapters/db/repositories/api_key_repo.py
adapters/db/repositories/base_repo.py
adapters/db/repositories/password_reset_repo.py adapters/db/repositories/password_reset_repo.py
adapters/db/repositories/user_repo.py adapters/db/repositories/user_repo.py
app/__init__.py app/__init__.py
@ -33,6 +35,9 @@ app/core/smart_normalizer.py
app/services/api_key_service.py app/services/api_key_service.py
app/services/auth_service.py app/services/auth_service.py
app/services/captcha_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/PKG-INFO
hesabix_api.egg-info/SOURCES.txt hesabix_api.egg-info/SOURCES.txt
hesabix_api.egg-info/dependency_links.txt hesabix_api.egg-info/dependency_links.txt

View file

@ -12,6 +12,8 @@ pillow>=10.3.0
phonenumbers>=8.13.40 phonenumbers>=8.13.40
Babel>=2.15.0 Babel>=2.15.0
jdatetime>=4.1.0 jdatetime>=4.1.0
weasyprint>=62.3
jinja2>=3.1.0
[dev] [dev]
pytest>=8.2.0 pytest>=8.2.0

View file

@ -82,3 +82,123 @@ msgstr "Gregorian"
msgid "JALALI" msgid "JALALI"
msgstr "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"

View file

@ -83,4 +83,124 @@ msgstr "میلادی"
msgid "JALALI" msgid "JALALI"
msgstr "شمسی" msgstr "شمسی"
msgid "rowNumber"
msgstr "ردیف"
msgid "firstName"
msgstr "نام"
msgid "lastName"
msgstr "نام خانوادگی"
msgid "registrationDate"
msgstr "تاریخ ثبت"
msgid "selectedRange"
msgstr "بازه انتخابی"
msgid "page"
msgstr "صفحه"
msgid "equals"
msgstr "برابر"
msgid "greater_than"
msgstr "بزرگتر از"
msgid "greater_equal"
msgstr "بزرگتر یا برابر"
msgid "less_than"
msgstr "کوچکتر از"
msgid "less_equal"
msgstr "کوچکتر یا برابر"
msgid "not_equals"
msgstr "مخالف"
msgid "contains"
msgstr "شامل"
msgid "starts_with"
msgstr "شروع با"
msgid "ends_with"
msgstr "پایان با"
msgid "in_list"
msgstr "در لیست"
msgid "active"
msgstr "فعال"
msgid "inactive"
msgstr "غیرفعال"
msgid "allFields"
msgstr "همه فیلدها"
msgid "in"
msgstr "در"
msgid "reportDate"
msgstr "تاریخ گزارش"
msgid "totalRecords"
msgstr "تعداد کل رکوردها"
msgid "displayedRecords"
msgstr "تعداد نمایش داده شده"
msgid "outputType"
msgstr "نوع خروجی"
msgid "selectedOnly"
msgstr "انتخاب شده‌ها"
msgid "reportGeneratedOn"
msgstr "گزارش در تاریخ"
msgid "at"
msgstr "و ساعت"
msgid "hesabixAccountingSystem"
msgstr "سیستم حسابداری حسابیکس - Hesabix Accounting System"
msgid "marketingReport"
msgstr "گزارش بازاریابی"
msgid "referralList"
msgstr "لیست معرفی‌ها"
msgid "thisMonth"
msgstr "این ماه"
msgid "today"
msgstr "امروز"
msgid "total"
msgstr "کل"
msgid "email"
msgstr "ایمیل"
msgid "ofText"
msgstr "از"
msgid "noDataFound"
msgstr "هیچ داده‌ای برای نمایش وجود ندارد"
msgid "activeFilters"
msgstr "فیلترهای فعال"
msgid "search"
msgstr "جستجو"
msgid "referralCode"
msgstr "کد معرف"
msgid "status"
msgstr "وضعیت"

View file

@ -22,7 +22,10 @@ dependencies = [
"pillow>=10.3.0", "pillow>=10.3.0",
"phonenumbers>=8.13.40", "phonenumbers>=8.13.40",
"Babel>=2.15.0", "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] [project.optional-dependencies]

View file

@ -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

View file

@ -0,0 +1,331 @@
<!DOCTYPE html>
<html lang="fa" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>گزارش بازاریابی - لیست معرفی‌ها</title>
<style>
@page {
size: A4;
margin: 2cm;
@top-center {
content: "گزارش بازاریابی - لیست معرفی‌ها";
font-family: 'Vazirmatn', Arial, sans-serif;
font-size: 14px;
color: #333;
}
@bottom-center {
content: "صفحه " counter(page) " از " counter(pages);
font-family: 'Vazirmatn', Arial, sans-serif;
font-size: 12px;
color: #666;
}
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Vazirmatn', Arial, sans-serif;
font-size: 12px;
line-height: 1.6;
color: #333;
direction: rtl;
text-align: right;
}
.header {
text-align: center;
margin-bottom: 30px;
padding: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 10px;
}
.header h1 {
font-size: 24px;
margin-bottom: 10px;
font-weight: bold;
}
.header .subtitle {
font-size: 14px;
opacity: 0.9;
}
.report-info {
display: flex;
justify-content: space-between;
margin-bottom: 20px;
padding: 15px;
background-color: #f8f9fa;
border-radius: 8px;
border-right: 4px solid #667eea;
}
.report-info .info-item {
text-align: center;
}
.report-info .info-item .label {
font-size: 11px;
color: #666;
margin-bottom: 5px;
}
.report-info .info-item .value {
font-size: 16px;
font-weight: bold;
color: #333;
}
.filters-info {
margin-bottom: 20px;
padding: 15px;
background-color: #e3f2fd;
border-radius: 8px;
border-right: 4px solid #2196f3;
}
.filters-info h3 {
font-size: 14px;
margin-bottom: 10px;
color: #1976d2;
}
.filter-item {
display: inline-block;
margin-left: 15px;
margin-bottom: 5px;
padding: 5px 10px;
background-color: white;
border-radius: 15px;
font-size: 11px;
color: #1976d2;
border: 1px solid #bbdefb;
}
.table-container {
margin-top: 20px;
}
table {
width: 100%;
border-collapse: collapse;
margin-bottom: 20px;
background-color: white;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
border-radius: 8px;
overflow: hidden;
}
th {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 12px 8px;
text-align: center;
font-weight: bold;
font-size: 12px;
border: none;
}
td {
padding: 10px 8px;
text-align: center;
border-bottom: 1px solid #e0e0e0;
font-size: 11px;
}
tr:nth-child(even) {
background-color: #f8f9fa;
}
tr:hover {
background-color: #e3f2fd;
}
.row-number {
font-weight: bold;
color: #667eea;
}
.name-cell {
text-align: right;
font-weight: 500;
}
.email-cell {
text-align: right;
color: #666;
}
.date-cell {
color: #666;
font-size: 10px;
}
.footer {
margin-top: 30px;
padding: 20px;
text-align: center;
background-color: #f8f9fa;
border-radius: 8px;
border-top: 3px solid #667eea;
}
.footer .generated-info {
font-size: 11px;
color: #666;
margin-bottom: 10px;
}
.footer .company-info {
font-size: 12px;
color: #333;
font-weight: 500;
}
.no-data {
text-align: center;
padding: 40px;
color: #666;
font-size: 14px;
}
.no-data .icon {
font-size: 48px;
margin-bottom: 15px;
color: #ccc;
}
.stats-summary {
display: flex;
justify-content: space-around;
margin-bottom: 20px;
padding: 15px;
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
color: white;
border-radius: 8px;
}
.stats-summary .stat-item {
text-align: center;
}
.stats-summary .stat-item .number {
font-size: 20px;
font-weight: bold;
margin-bottom: 5px;
}
.stats-summary .stat-item .label {
font-size: 11px;
opacity: 0.9;
}
</style>
</head>
<body>
<div class="header">
<h1>گزارش بازاریابی</h1>
<div class="subtitle">لیست معرفی‌های کاربران</div>
</div>
{% if stats %}
<div class="stats-summary">
<div class="stat-item">
<div class="number">{{ stats.today or 0 }}</div>
<div class="label">امروز</div>
</div>
<div class="stat-item">
<div class="number">{{ stats.this_month or 0 }}</div>
<div class="label">این ماه</div>
</div>
<div class="stat-item">
<div class="number">{{ stats.total or 0 }}</div>
<div class="label">کل</div>
</div>
{% if stats.range %}
<div class="stat-item">
<div class="number">{{ stats.range }}</div>
<div class="label">بازه انتخابی</div>
</div>
{% endif %}
</div>
{% endif %}
<div class="report-info">
<div class="info-item">
<div class="label">تاریخ گزارش</div>
<div class="value">{{ report_date }}</div>
</div>
<div class="info-item">
<div class="label">تعداد کل رکوردها</div>
<div class="value">{{ total_count }}</div>
</div>
<div class="info-item">
<div class="label">تعداد نمایش داده شده</div>
<div class="value">{{ items|length }}</div>
</div>
{% if selected_only %}
<div class="info-item">
<div class="label">نوع خروجی</div>
<div class="value">انتخاب شده‌ها</div>
</div>
{% endif %}
</div>
{% if filters %}
<div class="filters-info">
<h3>فیلترهای اعمال شده:</h3>
{% for filter in filters %}
<span class="filter-item">{{ filter }}</span>
{% endfor %}
</div>
{% endif %}
<div class="table-container">
{% if items %}
<table>
<thead>
<tr>
<th style="width: 5%;">ردیف</th>
<th style="width: 25%;">نام</th>
<th style="width: 25%;">نام خانوادگی</th>
<th style="width: 30%;">ایمیل</th>
<th style="width: 15%;">تاریخ ثبت</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td class="row-number">{{ loop.index }}</td>
<td class="name-cell">{{ item.first_name or '-' }}</td>
<td class="name-cell">{{ item.last_name or '-' }}</td>
<td class="email-cell">{{ item.email or '-' }}</td>
<td class="date-cell">{{ item.created_at }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="no-data">
<div class="icon">📊</div>
<div>هیچ داده‌ای برای نمایش وجود ندارد</div>
</div>
{% endif %}
</div>
<div class="footer">
<div class="generated-info">
گزارش در تاریخ {{ report_date }} و ساعت {{ report_time }} تولید شده است
</div>
<div class="company-info">
سیستم حسابداری حسابیکس - Hesabix Accounting System
</div>
</div>
</body>
</html>

View file

@ -97,12 +97,20 @@ class ApiClient {
return ApiClient._(dio); return ApiClient._(dio);
} }
Future<Response<T>> get<T>(String path, {Map<String, dynamic>? query, Options? options, CancelToken? cancelToken}) { Future<Response<T>> get<T>(String path, {Map<String, dynamic>? query, Options? options, CancelToken? cancelToken, ResponseType? responseType}) {
return _dio.get<T>(path, queryParameters: query, options: options, cancelToken: cancelToken); final requestOptions = options ?? Options();
if (responseType != null) {
requestOptions.responseType = responseType;
}
return _dio.get<T>(path, queryParameters: query, options: requestOptions, cancelToken: cancelToken);
} }
Future<Response<T>> post<T>(String path, {Object? data, Map<String, dynamic>? query, Options? options, CancelToken? cancelToken}) { Future<Response<T>> post<T>(String path, {Object? data, Map<String, dynamic>? query, Options? options, CancelToken? cancelToken, ResponseType? responseType}) {
return _dio.post<T>(path, data: data, queryParameters: query, options: options, cancelToken: cancelToken); final requestOptions = options ?? Options();
if (responseType != null) {
requestOptions.responseType = responseType;
}
return _dio.post<T>(path, data: data, queryParameters: query, options: requestOptions, cancelToken: cancelToken);
} }
Future<Response<T>> put<T>(String path, {Object? data, Map<String, dynamic>? query, Options? options, CancelToken? cancelToken}) { Future<Response<T>> put<T>(String path, {Object? data, Map<String, dynamic>? query, Options? options, CancelToken? cancelToken}) {

View file

@ -14,7 +14,7 @@ class ReferralStore {
static Future<void> captureFromCurrentUrl() async { static Future<void> captureFromCurrentUrl() async {
try { try {
String? ref = Uri.base.queryParameters['ref']; String? ref = Uri.base.queryParameters['ref'];
// اگر در hash بود (مثلاً #/login?ref=CODE) از fragment بخوان // اگر در hash بود (مثلاً /login?ref=CODE) از fragment بخوان
if (ref == null || ref.trim().isEmpty) { if (ref == null || ref.trim().isEmpty) {
final frag = Uri.base.fragment; // مثل '/login?ref=CODE' final frag = Uri.base.fragment; // مثل '/login?ref=CODE'
if (frag.isNotEmpty) { if (frag.isNotEmpty) {
@ -58,7 +58,7 @@ class ReferralStore {
static String buildInviteLink(String referralCode) { static String buildInviteLink(String referralCode) {
final origin = Uri.base.origin; // دامنه پویا final origin = Uri.base.origin; // دامنه پویا
// استفاده از Hash URL Strategy برای سازگاری کامل با Flutter Web // استفاده از Hash URL Strategy برای سازگاری کامل با Flutter Web
return '$origin/#/login?ref=$referralCode'; return '$origin/login?ref=$referralCode';
} }
static Future<void> saveUserReferralCode(String? code) async { static Future<void> saveUserReferralCode(String? code) async {

View file

@ -47,7 +47,17 @@
"menu": "Menu" "menu": "Menu"
, ,
"ok": "OK", "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", "newBusiness": "New business",
"businesses": "Businesses", "businesses": "Businesses",
@ -80,6 +90,71 @@
"calendar": "Calendar", "calendar": "Calendar",
"gregorian": "Gregorian", "gregorian": "Gregorian",
"jalali": "Jalali", "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"
} }

View file

@ -52,7 +52,17 @@
"changePassword": "تغییر کلمه عبور", "changePassword": "تغییر کلمه عبور",
"marketing": "بازاریابی", "marketing": "بازاریابی",
"ok": "تایید", "ok": "تایید",
"cancel": "انصراف" "cancel": "انصراف",
"columnSettings": "تنظیمات ستون‌ها",
"columnSettingsDescription": "مدیریت نمایش و ترتیب ستون‌های این جدول",
"columnName": "نام ستون",
"visibility": "نمایش",
"order": "ترتیب",
"visible": "نمایش",
"hidden": "مخفی",
"resetToDefaults": "بازگردانی به پیش‌فرض",
"save": "ذخیره",
"error": "خطا"
, ,
"marketingReport": "گزارش بازاریابی", "marketingReport": "گزارش بازاریابی",
"today": "امروز", "today": "امروز",
@ -79,6 +89,71 @@
"calendar": "تقویم", "calendar": "تقویم",
"gregorian": "میلادی", "gregorian": "میلادی",
"jalali": "شمسی", "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": "در لیست"
} }

View file

@ -167,13 +167,13 @@ abstract class AppLocalizations {
/// No description provided for @firstName. /// No description provided for @firstName.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'First name'** /// **'First Name'**
String get firstName; String get firstName;
/// No description provided for @lastName. /// No description provided for @lastName.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Last name'** /// **'Last Name'**
String get lastName; String get lastName;
/// No description provided for @email. /// No description provided for @email.
@ -350,6 +350,66 @@ abstract class AppLocalizations {
/// **'Cancel'** /// **'Cancel'**
String get 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. /// No description provided for @newBusiness.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@ -535,6 +595,354 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Calendar Type'** /// **'Calendar Type'**
String get calendarType; 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 class _AppLocalizationsDelegate

View file

@ -42,10 +42,10 @@ class AppLocalizationsEn extends AppLocalizations {
String get forgotPassword => 'Forgot password'; String get forgotPassword => 'Forgot password';
@override @override
String get firstName => 'First name'; String get firstName => 'First Name';
@override @override
String get lastName => 'Last name'; String get lastName => 'Last Name';
@override @override
String get email => 'Email'; String get email => 'Email';
@ -136,6 +136,37 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get cancel => 'Cancel'; 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 @override
String get newBusiness => 'New business'; String get newBusiness => 'New business';
@ -232,4 +263,184 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get calendarType => 'Calendar Type'; 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';
} }

View file

@ -88,7 +88,7 @@ class AppLocalizationsFa extends AppLocalizations {
String get captcha => 'کد امنیتی'; String get captcha => 'کد امنیتی';
@override @override
String get refresh => 'تازه‌سازی'; String get refresh => 'بروزرسانی';
@override @override
String get captchaRequired => 'کد امنیتی الزامی است.'; String get captchaRequired => 'کد امنیتی الزامی است.';
@ -136,6 +136,37 @@ class AppLocalizationsFa extends AppLocalizations {
@override @override
String get cancel => 'انصراف'; 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 @override
String get newBusiness => 'کسب‌وکار جدید'; String get newBusiness => 'کسب‌وکار جدید';
@ -231,4 +262,184 @@ class AppLocalizationsFa extends AppLocalizations {
@override @override
String get calendarType => 'نوع تقویم'; 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 => 'در لیست';
} }

View file

@ -4,7 +4,6 @@ import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_web_plugins/url_strategy.dart'; import 'package:flutter_web_plugins/url_strategy.dart';
import 'pages/login_page.dart'; import 'pages/login_page.dart';
import 'pages/home_page.dart';
import 'pages/profile/profile_shell.dart'; import 'pages/profile/profile_shell.dart';
import 'pages/profile/profile_dashboard_page.dart'; import 'pages/profile/profile_dashboard_page.dart';
import 'pages/profile/new_business_page.dart'; import 'pages/profile/new_business_page.dart';
@ -89,9 +88,17 @@ class _MyAppState extends State<MyApp> {
// Root of application with GoRouter // Root of application with GoRouter
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// اگر هنوز loading است، یک router ساده با loading page بساز
if (_controller == null || _calendarController == null || _themeController == null || _authStore == null) { if (_controller == null || _calendarController == null || _themeController == null || _authStore == null) {
return const MaterialApp( final loadingRouter = GoRouter(
home: Scaffold( redirect: (context, state) {
// در حین loading، هیچ redirect نکن - URL را حفظ کن
return null;
},
routes: <RouteBase>[
GoRoute(
path: '/',
builder: (context, state) => const Scaffold(
body: Center( body: Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
@ -103,6 +110,142 @@ class _MyAppState extends State<MyApp> {
), ),
), ),
), ),
),
// برای سایر مسیرها هم 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<MyApp> {
} }
// برای سایر صفحات (شامل صفحات profile)، redirect نکن (بماند) // برای سایر صفحات (شامل صفحات profile)، redirect نکن (بماند)
// این مهم است: اگر کاربر در صفحات profile است، بماند
return null; return null;
}, },
routes: <RouteBase>[ routes: <RouteBase>[

View file

@ -86,8 +86,8 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
if (body is! Map<String, dynamic>) return; if (body is! Map<String, dynamic>) return;
final data = body['data']; final data = body['data'];
if (data is! Map<String, dynamic>) return; if (data is! Map<String, dynamic>) return;
final String? id = data['captcha_id'] as String?; final String? id = data['captcha_id']?.toString();
final String? imgB64 = data['image_base64'] as String?; final String? imgB64 = data['image_base64']?.toString();
final int? ttl = (data['ttl_seconds'] as num?)?.toInt(); final int? ttl = (data['ttl_seconds'] as num?)?.toInt();
if (id == null || imgB64 == null) return; if (id == null || imgB64 == null) return;
Uint8List bytes; Uint8List bytes;
@ -236,12 +236,12 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
final inner = body['data']; final inner = body['data'];
if (inner is Map<String, dynamic>) data = inner; if (inner is Map<String, dynamic>) 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) { if (apiKey != null && apiKey.isNotEmpty) {
await widget.authStore.saveApiKey(apiKey); await widget.authStore.saveApiKey(apiKey);
// ذخیره کد بازاریابی کاربر برای صفحه Marketing // ذخیره کد بازاریابی کاربر برای صفحه Marketing
final user = data?['user'] as Map<String, dynamic>?; final user = data?['user'] as Map<String, dynamic>?;
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)); unawaited(ReferralStore.saveUserReferralCode(myRef));
} }
@ -323,7 +323,7 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
final inner = body['data']; final inner = body['data'];
if (inner is Map<String, dynamic>) data = inner; if (inner is Map<String, dynamic>) 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) { if (apiKey != null && apiKey.isNotEmpty) {
await widget.authStore.saveApiKey(apiKey); await widget.authStore.saveApiKey(apiKey);
} }

View file

@ -1,14 +1,10 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart'; import 'package:hesabix_ui/l10n/app_localizations.dart';
import '../../core/referral_store.dart'; import '../../core/referral_store.dart';
import '../../core/api_client.dart'; import '../../core/api_client.dart';
import '../../core/calendar_controller.dart'; import '../../core/calendar_controller.dart';
import '../../core/date_utils.dart'; import '../../widgets/data_table/data_table.dart';
import '../../widgets/jalali_date_picker.dart';
import '../../widgets/date_input_field.dart';
class MarketingPage extends StatefulWidget { class MarketingPage extends StatefulWidget {
final CalendarController calendarController; final CalendarController calendarController;
@ -27,35 +23,13 @@ class _MarketingPageState extends State<MarketingPage> {
int? _rangeCount; int? _rangeCount;
DateTime? _fromDate; DateTime? _fromDate;
DateTime? _toDate; DateTime? _toDate;
// list state Set<int> _selectedRows = <int>{};
bool _loadingList = false;
int _page = 1;
int _limit = 10;
int _total = 0;
List<Map<String, dynamic>> _items = const [];
final TextEditingController _searchCtrl = TextEditingController();
Timer? _searchDebounce;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_loadReferralCode(); _loadReferralCode();
_fetchStats(); _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<void> _loadReferralCode() async { Future<void> _loadReferralCode() async {
@ -72,7 +46,6 @@ class _MarketingPageState extends State<MarketingPage> {
final api = ApiClient(); final api = ApiClient();
final params = <String, dynamic>{}; final params = <String, dynamic>{};
if (withRange && _fromDate != null && _toDate != null) { 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 start = DateTime(_fromDate!.year, _fromDate!.month, _fromDate!.day);
final endExclusive = DateTime(_toDate!.year, _toDate!.month, _toDate!.day).add(const Duration(days: 1)); final endExclusive = DateTime(_toDate!.year, _toDate!.month, _toDate!.day).add(const Duration(days: 1));
params['start'] = start.toIso8601String(); params['start'] = start.toIso8601String();
@ -92,226 +65,286 @@ class _MarketingPageState extends State<MarketingPage> {
} }
} }
} catch (_) { } catch (_) {
// silent fail: نمایش خطا ضروری نیست // silent fail
} finally { } finally {
if (mounted) setState(() => _loading = false); if (mounted) setState(() => _loading = false);
} }
} }
Future<void> _fetchList({bool withRange = false}) async {
setState(() => _loadingList = true);
try {
final api = ApiClient();
final params = <String, dynamic>{
'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<Map<String, dynamic>>('/api/v1/auth/referrals/list', query: params);
final body = res.data;
if (body is Map<String, dynamic>) {
final data = body['data'];
if (data is Map<String, dynamic>) {
final items = (data['items'] as List?)?.cast<Map<String, dynamic>>() ?? 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final t = AppLocalizations.of(context); final t = Localizations.of<AppLocalizations>(context, AppLocalizations)!;
final code = _referralCode; final code = _referralCode;
final inviteLink = (code == null || code.isEmpty) ? null : ReferralStore.buildInviteLink(code); final inviteLink = (code == null || code.isEmpty) ? null : ReferralStore.buildInviteLink(code);
return Padding( final theme = Theme.of(context);
return Scaffold(
body: SingleChildScrollView(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(t.marketingReport, style: Theme.of(context).textTheme.titleLarge), // Header
const SizedBox(height: 12), Container(
if (code == null || code.isEmpty) Text(t.loading, style: Theme.of(context).textTheme.bodyMedium), 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: [
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: 24),
// Referral Link Card
if (inviteLink != null) ...[ if (inviteLink != null) ...[
Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row( Row(
children: [ children: [
Expanded(child: SelectableText(inviteLink)), 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), const SizedBox(width: 8),
OutlinedButton.icon( FilledButton.icon(
onPressed: () async { onPressed: () async {
await Clipboard.setData(ClipboardData(text: inviteLink)); await Clipboard.setData(ClipboardData(text: inviteLink));
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context) final messenger = ScaffoldMessenger.of(context);
messenger
..hideCurrentSnackBar() ..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(t.copied))); ..showSnackBar(
SnackBar(
content: Text(t.copied),
backgroundColor: theme.colorScheme.primary,
),
);
}, },
icon: const Icon(Icons.link), icon: const Icon(Icons.copy, size: 18),
label: Text(t.copyLink), label: Text(t.copyLink),
), ),
], ],
), ),
),
], ],
const SizedBox(height: 16), ),
),
),
const SizedBox(height: 24),
],
// Stats Cards
Wrap( Wrap(
spacing: 12, spacing: 16,
runSpacing: 12, runSpacing: 16,
children: [ children: [
_StatCard(title: t.today, value: _todayCount, loading: _loading), _StatCard(
_StatCard(title: t.thisMonth, value: _monthCount, loading: _loading), title: t.today,
_StatCard(title: t.total, value: _totalCount, loading: _loading), value: _todayCount,
_StatCard(title: '${t.dateFrom}-${t.dateTo}', value: _rangeCount, loading: _loading), loading: _loading,
], icon: Icons.today,
color: Colors.blue,
), ),
const SizedBox(height: 16), _StatCard(
Row( title: t.thisMonth,
children: [ value: _monthCount,
Expanded( loading: _loading,
child: DateInputField( icon: Icons.calendar_month,
value: _fromDate, color: Colors.green,
onChanged: (date) {
setState(() {
_fromDate = date;
});
},
labelText: t.dateFrom,
calendarController: widget.calendarController,
enabled: !_loading,
), ),
_StatCard(
title: t.total,
value: _totalCount,
loading: _loading,
icon: Icons.people,
color: Colors.orange,
), ),
const SizedBox(width: 8), _StatCard(
Expanded( title: '${t.dateFrom}-${t.dateTo}',
child: DateInputField( value: _rangeCount,
value: _toDate, loading: _loading,
onChanged: (date) { icon: Icons.date_range,
setState(() { color: Colors.purple,
_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), const SizedBox(height: 24),
Row(
children: [ // Data Table using new widget
Expanded( DataTableWidget<Map<String, dynamic>>(
child: TextField( config: DataTableConfig<Map<String, dynamic>>(
controller: _searchCtrl, title: t.referralList,
decoration: InputDecoration( endpoint: '/api/v1/auth/referrals/list',
prefixIcon: const Icon(Icons.search), excelEndpoint: '/api/v1/auth/referrals/export/excel',
hintText: t.email, pdfEndpoint: '/api/v1/auth/referrals/export/pdf',
border: const OutlineInputBorder(), getExportParams: () => {
isDense: true, 'user_id': 'current_user', // Example parameter
),
),
),
const SizedBox(width: 8),
DropdownButton<int>(
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: 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: [ columns: [
DataColumn(label: Text(t.firstName)), TextColumn(
DataColumn(label: Text(t.lastName)), 'first_name',
DataColumn(label: Text(t.email)), t.firstName,
DataColumn(label: Text(t.register)), 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,
),
], ],
rows: _items.map((e) { searchFields: ['first_name', 'last_name', 'email'],
final createdAt = (e['created_at'] as String?) ?? ''; filterFields: ['first_name', 'last_name', 'email', 'created_at'],
DateTime? date; dateRangeField: 'created_at',
if (createdAt.isNotEmpty) { showSearch: true,
try { showFilters: true,
date = DateTime.parse(createdAt.substring(0, 10)); showColumnSearch: true,
} catch (e) { showPagination: true,
// Ignore parsing errors showActiveFilters: true,
} enableSorting: true,
} enableGlobalSearch: true,
final dateStr = date != null enableDateRangeFilter: true,
? HesabixDateUtils.formatForDisplay(date, widget.calendarController.isJalali) showRowNumbers: true,
: ''; enableRowSelection: true,
return DataRow(cells: [ enableMultiRowSelection: true,
DataCell(Text((e['first_name'] ?? '') as String)), selectedRows: _selectedRows,
DataCell(Text((e['last_name'] ?? '') as String)), onRowSelectionChanged: (selectedRows) {
DataCell(Text((e['email'] ?? '') as String)), setState(() {
DataCell(Text(dateStr)), _selectedRows = selectedRows;
]); });
}).toList(), },
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();
},
), ),
), fromJson: (json) => json,
Padding( calendarController: widget.calendarController,
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',
), ),
], ],
), ),
), ),
],
),
),
],
),
); );
} }
} }
@ -320,24 +353,58 @@ class _StatCard extends StatelessWidget {
final String title; final String title;
final int? value; final int? value;
final bool loading; final bool loading;
const _StatCard({required this.title, required this.value, required this.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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
return SizedBox( return SizedBox(
width: 240, width: 200,
child: Card( child: Card(
elevation: 2,
child: Padding( child: Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(20.0),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(title, style: theme.textTheme.titleMedium), Row(
const SizedBox(height: 8), 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 loading
? const SizedBox(height: 22, width: 22, child: CircularProgressIndicator(strokeWidth: 2)) ? const SizedBox(
: Text((value ?? 0).toString(), style: theme.textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w600)), height: 28,
width: 28,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(
(value ?? 0).toString(),
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: color,
),
),
], ],
), ),
), ),
@ -345,5 +412,3 @@ class _StatCard extends StatelessWidget {
); );
} }
} }

View file

@ -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<DataTableColumn> _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. ✅ همیشه حداقل یک ستون در حالت نمایش باقی می‌ماند
سیستم اکنون کاملاً پایدار و کاربرپسند است.

View file

@ -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<void> 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<ColumnSettings?> 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<String, dynamic>;
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<User>(
config: DataTableConfig<User>(
endpoint: '/api/users',
// کلید: data_table_column_settings__api_users
),
fromJson: (json) => User.fromJson(json),
)
// صفحه 2: مدیریت سفارشات
DataTableWidget<Order>(
config: DataTableConfig<Order>(
endpoint: '/api/orders',
// کلید: data_table_column_settings__api_orders
),
fromJson: (json) => Order.fromJson(json),
)
// صفحه 3: گزارش‌های مالی
DataTableWidget<Report>(
config: DataTableConfig<Report>(
endpoint: '/api/reports',
tableId: 'financial_reports',
// کلید: data_table_column_settings_financial_reports
),
fromJson: (json) => Report.fromJson(json),
)
// صفحه 4: مدیریت محصولات
DataTableWidget<Product>(
config: DataTableConfig<Product>(
endpoint: '/api/products',
// کلید: data_table_column_settings__api_products
),
fromJson: (json) => Product.fromJson(json),
)
// صفحه 5: لاگ‌های سیستم
DataTableWidget<Log>(
config: DataTableConfig<Log>(
endpoint: '/api/logs',
tableId: 'system_logs',
// کلید: data_table_column_settings_system_logs
),
fromJson: (json) => Log.fromJson(json),
)
```
### ✅ **تضمین عدم تداخل:**
- هر جدول تنظیمات مستقل خود را دارد
- تغییر تنظیمات در یک جدول روی جدول‌های دیگر تأثیر نمی‌گذارد
- هر جدول می‌تواند ستون‌های مختلفی را مخفی/نمایش دهد
- ترتیب ستون‌ها در هر جدول مستقل است
- تنظیمات در SharedPreferences با کلیدهای کاملاً متفاوت ذخیره می‌شود
## خلاصه
سیستم به گونه‌ای طراحی شده که **هیچ تداخلی بین تنظیمات جدول‌های مختلف وجود ندارد**. هر جدول با شناسه منحصر به فرد خود تنظیماتش را ذخیره و بازیابی می‌کند.

View file

@ -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<Map<String, dynamic>>(
config: DataTableConfig<Map<String, dynamic>>(
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<Map<String, dynamic>>(
config: DataTableConfig<Map<String, dynamic>>(
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<T>(
// الزامی
endpoint: String, // آدرس API
columns: List<DataTableColumn>, // تعریف ستون‌ها
// اختیاری
title: String?, // عنوان جدول
subtitle: String?, // زیرعنوان
searchFields: List<String>, // فیلدهای جست‌وجوی کلی
filterFields: List<String>, // فیلدهای قابل فیلتر
dateRangeField: String?, // فیلد فیلتر بازه زمانی
// UI
showSearch: bool, // نمایش جست‌وجو
showFilters: bool, // نمایش فیلترها
showColumnSearch: bool, // نمایش جست‌وجوی ستونی
showPagination: bool, // نمایش صفحه‌بندی
showActiveFilters: bool, // نمایش فیلترهای فعال
// عملکرد
enableSorting: bool, // فعال‌سازی مرتب‌سازی
enableGlobalSearch: bool, // فعال‌سازی جست‌وجوی کلی
enableDateRangeFilter: bool, // فعال‌سازی فیلتر بازه زمانی
// صفحه‌بندی
defaultPageSize: int, // اندازه پیش‌فرض صفحه
pageSizeOptions: List<int>, // گزینه‌های اندازه صفحه
// رویدادها
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<BoxShadow>?, // سایه
)
```
### انواع ستون‌ها
#### 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<String>? searchFields; // فیلدهای جست‌وجو
List<FilterItem>? 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<CustomModel>(
config: DataTableConfig<CustomModel>(
// پیکربندی...
),
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 قابل مشاهده است.

View file

@ -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<User>(
config: DataTableConfig<User>(
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<Order>(
config: DataTableConfig<Order>(
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<String>): List of visible column keys
- `columnOrder` (List<String>): Ordered list of column keys
- `columnWidths` (Map<String, double>): 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

View file

@ -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<DataTableColumn> columns;
final ColumnSettings currentSettings;
final String tableTitle;
const ColumnSettingsDialog({
super.key,
required this.columns,
required this.currentSettings,
this.tableTitle = 'Table',
});
@override
State<ColumnSettingsDialog> createState() => _ColumnSettingsDialogState();
}
class _ColumnSettingsDialogState extends State<ColumnSettingsDialog> {
late List<String> _visibleColumns;
late List<String> _columnOrder;
late Map<String, double> _columnWidths;
late List<DataTableColumn> _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<AppLocalizations>(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);
}
}

View file

@ -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';

View file

@ -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<DataTableAction> 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<T> {
final String endpoint;
final List<DataTableColumn> columns;
final List<String> searchFields;
final List<String> 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<int> 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<String, dynamic>? 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<int>? selectedRows;
final void Function(Set<int> 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>? 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<String, dynamic> 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<DataTableColumn> get searchableColumns {
return columns.where((col) => col.searchable).toList();
}
/// Get sortable columns
List<DataTableColumn> get sortableColumns {
return columns.where((col) => col.sortable).toList();
}
/// Get filterable columns
List<DataTableColumn> get filterableColumns {
return columns.where((col) => col.searchable).toList();
}
/// Get all column keys
List<String> 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<T> {
final List<T> 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<String, dynamic> json,
T Function(Map<String, dynamic>) fromJsonT,
) {
final data = json['data'] as Map<String, dynamic>;
final itemsList = data['items'] as List? ?? [];
return DataTableResponse<T>(
items: itemsList.map((item) => fromJsonT(item as Map<String, dynamic>)).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<String>? searchFields;
final List<FilterItem>? 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<String, dynamic> toJson() {
final json = <String, dynamic>{
'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<String, dynamic> toJson() {
return {
'property': property,
'operator': operator,
'value': value,
};
}
}

View file

@ -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<DataTableSearchDialog> createState() => _DataTableSearchDialogState();
}
class _DataTableSearchDialogState extends State<DataTableSearchDialog> {
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<String>(
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<DataTableDateRangeDialog> createState() => _DataTableDateRangeDialogState();
}
class _DataTableDateRangeDialogState extends State<DataTableDateRangeDialog> {
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<String, String> columnSearchValues;
final Map<String, String> columnSearchTypes;
final DateTime? fromDate;
final DateTime? toDate;
final List<DataTableColumn> 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,
),
),
],
),
],
),
);
}
}

File diff suppressed because it is too large Load diff

View file

@ -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<User>(
config: DataTableConfig<User>(
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<Order>(
config: DataTableConfig<Order>(
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<Report>(
config: DataTableConfig<Report>(
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<Product>(
config: DataTableConfig<Product>(
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<Log>(
config: DataTableConfig<Log>(
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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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() ?? '',
);
}
}

View file

@ -0,0 +1,148 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
/// Column settings for a specific table
class ColumnSettings {
final List<String> visibleColumns;
final List<String> columnOrder;
final Map<String, double> columnWidths;
const ColumnSettings({
required this.visibleColumns,
required this.columnOrder,
this.columnWidths = const {},
});
Map<String, dynamic> toJson() {
return {
'visibleColumns': visibleColumns,
'columnOrder': columnOrder,
'columnWidths': columnWidths,
};
}
factory ColumnSettings.fromJson(Map<String, dynamic> json) {
return ColumnSettings(
visibleColumns: List<String>.from(json['visibleColumns'] ?? []),
columnOrder: List<String>.from(json['columnOrder'] ?? []),
columnWidths: Map<String, double>.from(json['columnWidths'] ?? {}),
);
}
ColumnSettings copyWith({
List<String>? visibleColumns,
List<String>? columnOrder,
Map<String, double>? 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<ColumnSettings?> 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<String, dynamic>;
return ColumnSettings.fromJson(json);
} catch (e) {
print('Error loading column settings: $e');
return null;
}
}
/// Save column settings for a specific table
static Future<void> 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<void> 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<String> columnKeys) {
return ColumnSettings(
visibleColumns: List.from(columnKeys),
columnOrder: List.from(columnKeys),
);
}
/// Merge user settings with default settings
static ColumnSettings mergeWithDefaults(
ColumnSettings? userSettings,
List<String> defaultColumnKeys,
) {
if (userSettings == null) {
return getDefaultSettings(defaultColumnKeys);
}
// Ensure all default columns are present in visible columns
final visibleColumns = <String>[];
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 = <String>[];
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 = <String, double>{};
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,
);
}
}

View file

@ -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<String, dynamic>) {
// 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<FilterItem> 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<DataTableColumn> 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<DataTableColumn> 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<DataTableColumn> 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<String, dynamic>) {
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();
}
}

View file

@ -58,7 +58,7 @@ class _DateInputFieldState extends State<DateInputField> {
void didUpdateWidget(DateInputField oldWidget) { void didUpdateWidget(DateInputField oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (oldWidget.value != widget.value || if (oldWidget.value != widget.value ||
oldWidget.calendarController.isJalali != widget.calendarController.isJalali) { (oldWidget.calendarController.isJalali == true) != (widget.calendarController.isJalali == true)) {
_updateDisplayValue(); _updateDisplayValue();
} }
} }
@ -76,7 +76,7 @@ class _DateInputFieldState extends State<DateInputField> {
void _updateDisplayValue() { void _updateDisplayValue() {
final displayValue = HesabixDateUtils.formatForDisplay( final displayValue = HesabixDateUtils.formatForDisplay(
widget.value, widget.value,
widget.calendarController.isJalali widget.calendarController.isJalali == true
); );
_controller.text = displayValue; _controller.text = displayValue;
} }
@ -91,7 +91,7 @@ class _DateInputFieldState extends State<DateInputField> {
DateTime? selectedDate; DateTime? selectedDate;
if (widget.calendarController.isJalali) { if (widget.calendarController.isJalali == true) {
selectedDate = await showJalaliDatePicker( selectedDate = await showJalaliDatePicker(
context: context, context: context,
initialDate: initialDate, initialDate: initialDate,

View file

@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:persian_datetime_picker/persian_datetime_picker.dart' as picker;
import 'package:shamsi_date/shamsi_date.dart'; import 'package:shamsi_date/shamsi_date.dart';
/// DatePicker سفارشی برای تقویم شمسی /// DatePicker سفارشی برای تقویم شمسی
@ -124,7 +123,7 @@ class _JalaliDatePickerState extends State<JalaliDatePicker> {
} }
Widget _buildCalendar() { Widget _buildCalendar() {
return picker.PersianCalendarDatePicker( return _CustomPersianCalendar(
initialDate: _selectedJalali, initialDate: _selectedJalali,
firstDate: Jalali.fromDateTime(widget.firstDate ?? DateTime(1900)), firstDate: Jalali.fromDateTime(widget.firstDate ?? DateTime(1900)),
lastDate: Jalali.fromDateTime(widget.lastDate ?? DateTime(2100)), lastDate: Jalali.fromDateTime(widget.lastDate ?? DateTime(2100)),
@ -156,3 +155,191 @@ Future<DateTime?> showJalaliDatePicker({
), ),
); );
} }
/// Custom Persian Calendar Widget with proper Persian month names
class _CustomPersianCalendar extends StatefulWidget {
final Jalali initialDate;
final Jalali firstDate;
final Jalali lastDate;
final ValueChanged<Jalali> 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<String> _monthNames = [
'فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور',
'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند'
];
// Persian day names (abbreviated)
static const List<String> _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,
),
),
),
),
);
},
),
),
],
);
}
}

View file

@ -57,6 +57,14 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "1.0.8" 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: dio:
dependency: "direct main" dependency: "direct main"
description: description:
@ -366,7 +374,7 @@ packages:
source: hosted source: hosted
version: "2.1.8" version: "2.1.8"
shamsi_date: shamsi_date:
dependency: transitive dependency: "direct main"
description: description:
name: shamsi_date name: shamsi_date
sha256: "0383fddc9bce91e9e08de0c909faf93c3ab3a0e532abd271fb0dcf5d0617487b" sha256: "0383fddc9bce91e9e08de0c909faf93c3ab3a0e532abd271fb0dcf5d0617487b"

View file

@ -42,7 +42,9 @@ dependencies:
flutter_secure_storage: ^9.2.2 flutter_secure_storage: ^9.2.2
uuid: ^4.4.2 uuid: ^4.4.2
persian_datetime_picker: ^3.2.0 persian_datetime_picker: ^3.2.0
shamsi_date: ^1.1.1
intl: ^0.20.0 intl: ^0.20.0
data_table_2: ^2.5.12
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

View file

@ -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<String>(
endpoint: '/api/users',
columns: [],
);
final config2 = DataTableConfig<String>(
endpoint: '/api/orders',
columns: [],
);
final config3 = DataTableConfig<String>(
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<String>(
endpoint: '/api/users',
tableId: 'custom_users_table',
columns: [],
);
final config2 = DataTableConfig<String>(
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<String>(
endpoint: '/api/v1/users?active=true',
columns: [],
);
final config2 = DataTableConfig<String>(
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']));
});
});
}

View file

@ -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}));
});
});
}

View file

@ -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 = <String>[];
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'
});
});
}