more progress in base system and table design
This commit is contained in:
parent
46925f4b22
commit
f1a5bb4c41
9
hesabixAPI/=3.1.0
Normal file
9
hesabixAPI/=3.1.0
Normal 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
|
||||
|
|
@ -1,13 +1,16 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from adapters.db.session import get_db
|
||||
from app.core.responses import success_response, format_datetime_fields
|
||||
from app.services.captcha_service import create_captcha
|
||||
from app.services.auth_service import register_user, login_user, create_password_reset, reset_password, change_password, referral_stats, referral_list
|
||||
from .schemas import RegisterRequest, LoginRequest, ForgotPasswordRequest, ResetPasswordRequest, ChangePasswordRequest, CreateApiKeyRequest
|
||||
from app.services.auth_service import register_user, login_user, create_password_reset, reset_password, change_password, referral_stats
|
||||
from app.services.pdf import PDFService
|
||||
from .schemas import RegisterRequest, LoginRequest, ForgotPasswordRequest, ResetPasswordRequest, ChangePasswordRequest, CreateApiKeyRequest, QueryInfo, FilterItem
|
||||
from app.core.auth_dependency import get_current_user, AuthContext
|
||||
from app.services.api_key_service import list_personal_keys, create_personal_key, revoke_key
|
||||
|
||||
|
|
@ -96,13 +99,13 @@ def reset_password_endpoint(payload: ResetPasswordRequest, db: Session = Depends
|
|||
|
||||
|
||||
@router.get("/api-keys", summary="List personal API keys")
|
||||
def list_keys(ctx: AuthContext = Depends(get_current_user), db: Session = Depends(get_db)) -> dict:
|
||||
def list_keys(request: Request, ctx: AuthContext = Depends(get_current_user), db: Session = Depends(get_db)) -> dict:
|
||||
items = list_personal_keys(db, ctx.user.id)
|
||||
return success_response(items)
|
||||
|
||||
|
||||
@router.post("/api-keys", summary="Create personal API key")
|
||||
def create_key(payload: CreateApiKeyRequest, ctx: AuthContext = Depends(get_current_user), db: Session = Depends(get_db)) -> dict:
|
||||
def create_key(request: Request, payload: CreateApiKeyRequest, ctx: AuthContext = Depends(get_current_user), db: Session = Depends(get_db)) -> dict:
|
||||
id_, api_key = create_personal_key(db, ctx.user.id, payload.name, payload.scopes, None)
|
||||
return success_response({"id": id_, "api_key": api_key})
|
||||
|
||||
|
|
@ -124,13 +127,13 @@ def change_password_endpoint(request: Request, payload: ChangePasswordRequest, c
|
|||
|
||||
|
||||
@router.delete("/api-keys/{key_id}", summary="Revoke API key")
|
||||
def delete_key(key_id: int, ctx: AuthContext = Depends(get_current_user), db: Session = Depends(get_db)) -> dict:
|
||||
def delete_key(request: Request, key_id: int, ctx: AuthContext = Depends(get_current_user), db: Session = Depends(get_db)) -> dict:
|
||||
revoke_key(db, ctx.user.id, key_id)
|
||||
return success_response({"ok": True})
|
||||
|
||||
|
||||
@router.get("/referrals/stats", summary="Referral stats for current user")
|
||||
def get_referral_stats(ctx: AuthContext = Depends(get_current_user), db: Session = Depends(get_db), start: str | None = None, end: str | None = None) -> dict:
|
||||
def get_referral_stats(request: Request, ctx: AuthContext = Depends(get_current_user), db: Session = Depends(get_db), start: str | None = None, end: str | None = None) -> dict:
|
||||
from datetime import datetime
|
||||
start_dt = datetime.fromisoformat(start) if start else None
|
||||
end_dt = datetime.fromisoformat(end) if end else None
|
||||
|
|
@ -138,19 +141,292 @@ def get_referral_stats(ctx: AuthContext = Depends(get_current_user), db: Session
|
|||
return success_response(stats)
|
||||
|
||||
|
||||
@router.get("/referrals/list", summary="Referral list for current user")
|
||||
def get_referral_list(
|
||||
@router.post("/referrals/list", summary="Referral list with advanced filtering")
|
||||
def get_referral_list_advanced(
|
||||
request: Request,
|
||||
query_info: QueryInfo,
|
||||
ctx: AuthContext = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
start: str | None = None,
|
||||
end: str | None = None,
|
||||
search: str | None = None,
|
||||
page: int = 1,
|
||||
limit: int = 20,
|
||||
db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
from datetime import datetime
|
||||
start_dt = datetime.fromisoformat(start) if start else None
|
||||
end_dt = datetime.fromisoformat(end) if end else None
|
||||
resp = referral_list(db=db, user_id=ctx.user.id, start=start_dt, end=end_dt, search=search, page=page, limit=limit)
|
||||
return success_response(resp)
|
||||
"""
|
||||
دریافت لیست معرفیها با قابلیت فیلتر پیشرفته
|
||||
|
||||
پارامترهای QueryInfo:
|
||||
- sort_by: فیلد مرتبسازی (مثال: created_at, first_name, last_name, email)
|
||||
- sort_desc: ترتیب نزولی (true/false)
|
||||
- take: تعداد رکورد در هر صفحه (پیشفرض: 10)
|
||||
- skip: تعداد رکورد صرفنظر شده (پیشفرض: 0)
|
||||
- search: عبارت جستجو
|
||||
- search_fields: فیلدهای جستجو (مثال: ["first_name", "last_name", "email"])
|
||||
- filters: آرایه فیلترها با ساختار:
|
||||
[
|
||||
{
|
||||
"property": "created_at",
|
||||
"operator": ">=",
|
||||
"value": "2024-01-01T00:00:00"
|
||||
},
|
||||
{
|
||||
"property": "first_name",
|
||||
"operator": "*",
|
||||
"value": "احمد"
|
||||
}
|
||||
]
|
||||
"""
|
||||
from adapters.db.repositories.user_repo import UserRepository
|
||||
from adapters.db.models.user import User
|
||||
from datetime import datetime
|
||||
|
||||
# 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"
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,25 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
|
||||
|
||||
class FilterItem(BaseModel):
|
||||
property: str = Field(..., description="نام فیلد مورد نظر برای اعمال فیلتر")
|
||||
operator: str = Field(..., description="نوع عملگر: =, >, >=, <, <=, !=, *, ?*, *?, in")
|
||||
value: Any = Field(..., description="مقدار مورد نظر")
|
||||
|
||||
|
||||
class QueryInfo(BaseModel):
|
||||
sort_by: str | None = Field(default=None, description="نام فیلد مورد نظر برای مرتب سازی")
|
||||
sort_desc: bool = Field(default=False, description="false = مرتب سازی صعودی، true = مرتب سازی نزولی")
|
||||
take: int = Field(default=10, ge=1, le=1000, description="حداکثر تعداد رکورد بازگشتی")
|
||||
skip: int = Field(default=0, ge=0, description="تعداد رکوردی که از ابتدای لیست صرف نظر می شود")
|
||||
search: str | None = Field(default=None, description="عبارت جستجو")
|
||||
search_fields: list[str] | None = Field(default=None, description="آرایه ای از فیلدهایی که جستجو در آن انجام می گیرد")
|
||||
filters: list[FilterItem] | None = Field(default=None, description="آرایه ای از اشیا برای اعمال فیلتر بر روی لیست")
|
||||
|
||||
|
||||
class CaptchaSolve(BaseModel):
|
||||
captcha_id: str = Field(..., min_length=8)
|
||||
captcha_code: str = Field(..., min_length=3, max_length=8)
|
||||
|
|
|
|||
144
hesabixAPI/adapters/api/v1/users.py
Normal file
144
hesabixAPI/adapters/api/v1/users.py
Normal 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)
|
||||
|
||||
|
||||
55
hesabixAPI/adapters/db/repositories/base_repo.py
Normal file
55
hesabixAPI/adapters/db/repositories/base_repo.py
Normal 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
|
||||
|
|
@ -6,11 +6,13 @@ from sqlalchemy import select, func, and_, or_
|
|||
from sqlalchemy.orm import Session
|
||||
|
||||
from adapters.db.models.user import User
|
||||
from adapters.db.repositories.base_repo import BaseRepository
|
||||
from adapters.api.v1.schemas import QueryInfo
|
||||
|
||||
|
||||
class UserRepository:
|
||||
class UserRepository(BaseRepository[User]):
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
super().__init__(db, User)
|
||||
|
||||
def get_by_email(self, email: str) -> Optional[User]:
|
||||
stmt = select(User).where(User.email == email)
|
||||
|
|
@ -72,4 +74,19 @@ class UserRepository:
|
|||
stmt = stmt.order_by(User.created_at.desc()).offset(offset).limit(limit)
|
||||
return self.db.execute(stmt).scalars().all()
|
||||
|
||||
def to_dict(self, user: User) -> dict:
|
||||
"""تبدیل User object به dictionary برای API response"""
|
||||
return {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"mobile": user.mobile,
|
||||
"first_name": user.first_name,
|
||||
"last_name": user.last_name,
|
||||
"is_active": user.is_active,
|
||||
"referral_code": user.referral_code,
|
||||
"referred_by_user_id": user.referred_by_user_id,
|
||||
"created_at": user.created_at,
|
||||
"updated_at": user.updated_at,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Depends, Header
|
||||
from fastapi import Depends, Header, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from adapters.db.session import get_db
|
||||
|
|
@ -10,15 +9,99 @@ from adapters.db.repositories.api_key_repo import ApiKeyRepository
|
|||
from adapters.db.models.user import User
|
||||
from app.core.security import hash_api_key
|
||||
from app.core.responses import ApiError
|
||||
from app.core.i18n import negotiate_locale, Translator
|
||||
from app.core.calendar import get_calendar_type_from_header, CalendarType
|
||||
|
||||
|
||||
class AuthContext:
|
||||
def __init__(self, user: User, api_key_id: int) -> None:
|
||||
"""کلاس مرکزی برای نگهداری اطلاعات کاربر کنونی و تنظیمات"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
user: User,
|
||||
api_key_id: int,
|
||||
language: str = "fa",
|
||||
calendar_type: CalendarType = "jalali",
|
||||
timezone: Optional[str] = None,
|
||||
business_id: Optional[int] = None,
|
||||
fiscal_year_id: Optional[int] = None
|
||||
) -> None:
|
||||
self.user = user
|
||||
self.api_key_id = api_key_id
|
||||
self.language = language
|
||||
self.calendar_type = calendar_type
|
||||
self.timezone = timezone
|
||||
self.business_id = business_id
|
||||
self.fiscal_year_id = fiscal_year_id
|
||||
|
||||
# ایجاد translator برای زبان تشخیص داده شده
|
||||
self._translator = Translator(language)
|
||||
|
||||
def get_translator(self) -> Translator:
|
||||
"""دریافت translator برای ترجمه"""
|
||||
return self._translator
|
||||
|
||||
def get_calendar_type(self) -> CalendarType:
|
||||
"""دریافت نوع تقویم"""
|
||||
return self.calendar_type
|
||||
|
||||
def get_user_id(self) -> int:
|
||||
"""دریافت ID کاربر"""
|
||||
return self.user.id
|
||||
|
||||
def get_user_email(self) -> Optional[str]:
|
||||
"""دریافت ایمیل کاربر"""
|
||||
return self.user.email
|
||||
|
||||
def get_user_mobile(self) -> Optional[str]:
|
||||
"""دریافت شماره موبایل کاربر"""
|
||||
return self.user.mobile
|
||||
|
||||
def get_user_name(self) -> str:
|
||||
"""دریافت نام کامل کاربر"""
|
||||
first_name = self.user.first_name or ""
|
||||
last_name = self.user.last_name or ""
|
||||
return f"{first_name} {last_name}".strip()
|
||||
|
||||
def get_referral_code(self) -> Optional[str]:
|
||||
"""دریافت کد معرف کاربر"""
|
||||
return getattr(self.user, "referral_code", None)
|
||||
|
||||
def is_user_active(self) -> bool:
|
||||
"""بررسی فعال بودن کاربر"""
|
||||
return self.user.is_active
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""تبدیل به dictionary برای استفاده در API"""
|
||||
return {
|
||||
"user": {
|
||||
"id": self.user.id,
|
||||
"first_name": self.user.first_name,
|
||||
"last_name": self.user.last_name,
|
||||
"email": self.user.email,
|
||||
"mobile": self.user.mobile,
|
||||
"referral_code": getattr(self.user, "referral_code", None),
|
||||
"is_active": self.user.is_active,
|
||||
"created_at": self.user.created_at.isoformat() if self.user.created_at else None,
|
||||
"updated_at": self.user.updated_at.isoformat() if self.user.updated_at else None,
|
||||
},
|
||||
"api_key_id": self.api_key_id,
|
||||
"settings": {
|
||||
"language": self.language,
|
||||
"calendar_type": self.calendar_type,
|
||||
"timezone": self.timezone,
|
||||
"business_id": self.business_id,
|
||||
"fiscal_year_id": self.fiscal_year_id,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def get_current_user(authorization: Optional[str] = Header(default=None), db: Session = Depends(get_db)) -> AuthContext:
|
||||
def get_current_user(
|
||||
request: Request,
|
||||
authorization: Optional[str] = Header(default=None),
|
||||
db: Session = Depends(get_db)
|
||||
) -> AuthContext:
|
||||
"""دریافت اطلاعات کامل کاربر کنونی و تنظیمات از درخواست"""
|
||||
if not authorization or not authorization.startswith("ApiKey "):
|
||||
raise ApiError("UNAUTHORIZED", "Missing or invalid API key", http_status=401)
|
||||
|
||||
|
|
@ -34,6 +117,68 @@ def get_current_user(authorization: Optional[str] = Header(default=None), db: Se
|
|||
if not user or not user.is_active:
|
||||
raise ApiError("UNAUTHORIZED", "Invalid API key", http_status=401)
|
||||
|
||||
return AuthContext(user=user, api_key_id=obj.id)
|
||||
# تشخیص زبان از هدر Accept-Language
|
||||
language = _detect_language(request)
|
||||
|
||||
# تشخیص نوع تقویم از هدر X-Calendar-Type
|
||||
calendar_type = _detect_calendar_type(request)
|
||||
|
||||
# تشخیص منطقه زمانی از هدر X-Timezone (اختیاری)
|
||||
timezone = _detect_timezone(request)
|
||||
|
||||
# تشخیص کسب و کار از هدر X-Business-ID (آینده)
|
||||
business_id = _detect_business_id(request)
|
||||
|
||||
# تشخیص سال مالی از هدر X-Fiscal-Year-ID (آینده)
|
||||
fiscal_year_id = _detect_fiscal_year_id(request)
|
||||
|
||||
return AuthContext(
|
||||
user=user,
|
||||
api_key_id=obj.id,
|
||||
language=language,
|
||||
calendar_type=calendar_type,
|
||||
timezone=timezone,
|
||||
business_id=business_id,
|
||||
fiscal_year_id=fiscal_year_id
|
||||
)
|
||||
|
||||
|
||||
def _detect_language(request: Request) -> str:
|
||||
"""تشخیص زبان از هدر Accept-Language"""
|
||||
accept_language = request.headers.get("Accept-Language")
|
||||
return negotiate_locale(accept_language)
|
||||
|
||||
|
||||
def _detect_calendar_type(request: Request) -> CalendarType:
|
||||
"""تشخیص نوع تقویم از هدر X-Calendar-Type"""
|
||||
calendar_header = request.headers.get("X-Calendar-Type")
|
||||
return get_calendar_type_from_header(calendar_header)
|
||||
|
||||
|
||||
def _detect_timezone(request: Request) -> Optional[str]:
|
||||
"""تشخیص منطقه زمانی از هدر X-Timezone"""
|
||||
return request.headers.get("X-Timezone")
|
||||
|
||||
|
||||
def _detect_business_id(request: Request) -> Optional[int]:
|
||||
"""تشخیص ID کسب و کار از هدر X-Business-ID (آینده)"""
|
||||
business_id_str = request.headers.get("X-Business-ID")
|
||||
if business_id_str:
|
||||
try:
|
||||
return int(business_id_str)
|
||||
except ValueError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _detect_fiscal_year_id(request: Request) -> Optional[int]:
|
||||
"""تشخیص ID سال مالی از هدر X-Fiscal-Year-ID (آینده)"""
|
||||
fiscal_year_id_str = request.headers.get("X-Fiscal-Year-ID")
|
||||
if fiscal_year_id_str:
|
||||
try:
|
||||
return int(fiscal_year_id_str)
|
||||
except ValueError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ class CalendarConverter:
|
|||
"date_only": jalali.strftime("%Y/%m/%d"),
|
||||
"time_only": jalali.strftime("%H:%M:%S"),
|
||||
"is_leap_year": jalali.isleap(),
|
||||
"month_days": jalali.days_in_month,
|
||||
"month_days": jdatetime.j_days_in_month[jalali.month - 1],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
|
|
|
|||
|
|
@ -46,3 +46,8 @@ async def locale_dependency(request: Request) -> Translator:
|
|||
return Translator(lang)
|
||||
|
||||
|
||||
def get_translator(locale: str = "fa") -> Translator:
|
||||
"""Get translator for the given locale"""
|
||||
return Translator(locale)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,11 @@ def format_datetime_fields(data: Any, request: Request) -> Any:
|
|||
for key, value in data.items():
|
||||
if isinstance(value, datetime):
|
||||
formatted_data[key] = CalendarConverter.format_datetime(value, calendar_type)
|
||||
formatted_data[f"{key}_raw"] = value.isoformat() # Keep original for reference
|
||||
# Convert raw date to the same calendar type as the formatted date
|
||||
if calendar_type == "jalali":
|
||||
formatted_data[f"{key}_raw"] = CalendarConverter.to_jalali(value)["formatted"]
|
||||
else:
|
||||
formatted_data[f"{key}_raw"] = value.isoformat()
|
||||
elif isinstance(value, (dict, list)):
|
||||
formatted_data[key] = format_datetime_fields(value, request)
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from app.core.settings import get_settings
|
|||
from app.core.logging import configure_logging
|
||||
from adapters.api.v1.health import router as health_router
|
||||
from adapters.api.v1.auth import router as auth_router
|
||||
from adapters.api.v1.users import router as users_router
|
||||
from app.core.i18n import negotiate_locale, Translator
|
||||
from app.core.error_handlers import register_error_handlers
|
||||
from app.core.smart_normalizer import smart_normalize_json, SmartNormalizerConfig
|
||||
|
|
@ -60,6 +61,7 @@ def create_app() -> FastAPI:
|
|||
|
||||
application.include_router(health_router, prefix=settings.api_v1_prefix)
|
||||
application.include_router(auth_router, prefix=settings.api_v1_prefix)
|
||||
application.include_router(users_router, prefix=settings.api_v1_prefix)
|
||||
|
||||
register_error_handlers(application)
|
||||
|
||||
|
|
|
|||
148
hesabixAPI/app/services/pdf/README.md
Normal file
148
hesabixAPI/app/services/pdf/README.md
Normal 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
|
||||
6
hesabixAPI/app/services/pdf/__init__.py
Normal file
6
hesabixAPI/app/services/pdf/__init__.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
"""
|
||||
PDF Service Package
|
||||
"""
|
||||
from .base_pdf_service import PDFService
|
||||
|
||||
__all__ = ['PDFService']
|
||||
135
hesabixAPI/app/services/pdf/base_pdf_service.py
Normal file
135
hesabixAPI/app/services/pdf/base_pdf_service.py
Normal 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())
|
||||
3
hesabixAPI/app/services/pdf/modules/__init__.py
Normal file
3
hesabixAPI/app/services/pdf/modules/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
"""
|
||||
PDF Modules Package
|
||||
"""
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
"""
|
||||
Marketing PDF Module
|
||||
"""
|
||||
from .marketing_module import MarketingPDFModule
|
||||
|
||||
__all__ = ['MarketingPDFModule']
|
||||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
162
hesabixAPI/app/services/query_service.py
Normal file
162
hesabixAPI/app/services/query_service.py
Normal 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
|
||||
|
|
@ -19,6 +19,8 @@ Requires-Dist: pillow>=10.3.0
|
|||
Requires-Dist: phonenumbers>=8.13.40
|
||||
Requires-Dist: Babel>=2.15.0
|
||||
Requires-Dist: jdatetime>=4.1.0
|
||||
Requires-Dist: weasyprint>=62.3
|
||||
Requires-Dist: jinja2>=3.1.0
|
||||
Provides-Extra: dev
|
||||
Requires-Dist: pytest>=8.2.0; extra == "dev"
|
||||
Requires-Dist: httpx>=0.27.0; extra == "dev"
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ adapters/api/v1/__init__.py
|
|||
adapters/api/v1/auth.py
|
||||
adapters/api/v1/health.py
|
||||
adapters/api/v1/schemas.py
|
||||
adapters/api/v1/users.py
|
||||
adapters/db/__init__.py
|
||||
adapters/db/session.py
|
||||
adapters/db/models/__init__.py
|
||||
|
|
@ -14,6 +15,7 @@ adapters/db/models/captcha.py
|
|||
adapters/db/models/password_reset.py
|
||||
adapters/db/models/user.py
|
||||
adapters/db/repositories/api_key_repo.py
|
||||
adapters/db/repositories/base_repo.py
|
||||
adapters/db/repositories/password_reset_repo.py
|
||||
adapters/db/repositories/user_repo.py
|
||||
app/__init__.py
|
||||
|
|
@ -33,6 +35,9 @@ app/core/smart_normalizer.py
|
|||
app/services/api_key_service.py
|
||||
app/services/auth_service.py
|
||||
app/services/captcha_service.py
|
||||
app/services/pdf_service.py
|
||||
app/services/query_service.py
|
||||
app/services/user_context_service.py
|
||||
hesabix_api.egg-info/PKG-INFO
|
||||
hesabix_api.egg-info/SOURCES.txt
|
||||
hesabix_api.egg-info/dependency_links.txt
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ pillow>=10.3.0
|
|||
phonenumbers>=8.13.40
|
||||
Babel>=2.15.0
|
||||
jdatetime>=4.1.0
|
||||
weasyprint>=62.3
|
||||
jinja2>=3.1.0
|
||||
|
||||
[dev]
|
||||
pytest>=8.2.0
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -82,3 +82,123 @@ msgstr "Gregorian"
|
|||
|
||||
msgid "JALALI"
|
||||
msgstr "Jalali"
|
||||
|
||||
msgid "rowNumber"
|
||||
msgstr "Row"
|
||||
|
||||
msgid "firstName"
|
||||
msgstr "First Name"
|
||||
|
||||
msgid "lastName"
|
||||
msgstr "Last Name"
|
||||
|
||||
msgid "registrationDate"
|
||||
msgstr "Registration Date"
|
||||
|
||||
msgid "selectedRange"
|
||||
msgstr "Selected Range"
|
||||
|
||||
msgid "page"
|
||||
msgstr "Page"
|
||||
|
||||
msgid "equals"
|
||||
msgstr "equals"
|
||||
|
||||
msgid "greater_than"
|
||||
msgstr "greater than"
|
||||
|
||||
msgid "greater_equal"
|
||||
msgstr "greater or equal"
|
||||
|
||||
msgid "less_than"
|
||||
msgstr "less than"
|
||||
|
||||
msgid "less_equal"
|
||||
msgstr "less or equal"
|
||||
|
||||
msgid "not_equals"
|
||||
msgstr "not equals"
|
||||
|
||||
msgid "contains"
|
||||
msgstr "contains"
|
||||
|
||||
msgid "starts_with"
|
||||
msgstr "starts with"
|
||||
|
||||
msgid "ends_with"
|
||||
msgstr "ends with"
|
||||
|
||||
msgid "in_list"
|
||||
msgstr "in list"
|
||||
|
||||
msgid "active"
|
||||
msgstr "Active"
|
||||
|
||||
msgid "inactive"
|
||||
msgstr "Inactive"
|
||||
|
||||
msgid "allFields"
|
||||
msgstr "All Fields"
|
||||
|
||||
msgid "in"
|
||||
msgstr "in"
|
||||
|
||||
msgid "reportDate"
|
||||
msgstr "Report Date"
|
||||
|
||||
msgid "totalRecords"
|
||||
msgstr "Total Records"
|
||||
|
||||
msgid "displayedRecords"
|
||||
msgstr "Displayed Records"
|
||||
|
||||
msgid "outputType"
|
||||
msgstr "Output Type"
|
||||
|
||||
msgid "selectedOnly"
|
||||
msgstr "Selected Only"
|
||||
|
||||
msgid "reportGeneratedOn"
|
||||
msgstr "Report generated on"
|
||||
|
||||
msgid "at"
|
||||
msgstr "at"
|
||||
|
||||
msgid "hesabixAccountingSystem"
|
||||
msgstr "Hesabix Accounting System"
|
||||
|
||||
msgid "marketingReport"
|
||||
msgstr "Marketing Report"
|
||||
|
||||
msgid "referralList"
|
||||
msgstr "Referral List"
|
||||
|
||||
msgid "thisMonth"
|
||||
msgstr "This Month"
|
||||
|
||||
msgid "today"
|
||||
msgstr "Today"
|
||||
|
||||
msgid "total"
|
||||
msgstr "Total"
|
||||
|
||||
msgid "email"
|
||||
msgstr "Email"
|
||||
|
||||
msgid "ofText"
|
||||
msgstr "of"
|
||||
|
||||
msgid "noDataFound"
|
||||
msgstr "No data found"
|
||||
|
||||
msgid "activeFilters"
|
||||
msgstr "Active Filters"
|
||||
|
||||
msgid "search"
|
||||
msgstr "Search"
|
||||
|
||||
msgid "referralCode"
|
||||
msgstr "Referral Code"
|
||||
|
||||
msgid "status"
|
||||
msgstr "Status"
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -83,4 +83,124 @@ msgstr "میلادی"
|
|||
msgid "JALALI"
|
||||
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 "وضعیت"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,10 @@ dependencies = [
|
|||
"pillow>=10.3.0",
|
||||
"phonenumbers>=8.13.40",
|
||||
"Babel>=2.15.0",
|
||||
"jdatetime>=4.1.0"
|
||||
"jdatetime>=4.1.0",
|
||||
"weasyprint>=62.3",
|
||||
"jinja2>=3.1.0",
|
||||
"openpyxl>=3.1.0"
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
|
|
|||
72
hesabixAPI/templates/README.md
Normal file
72
hesabixAPI/templates/README.md
Normal 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
|
||||
331
hesabixAPI/templates/pdf/marketing_referrals.html
Normal file
331
hesabixAPI/templates/pdf/marketing_referrals.html
Normal 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>
|
||||
|
|
@ -97,12 +97,20 @@ class ApiClient {
|
|||
return ApiClient._(dio);
|
||||
}
|
||||
|
||||
Future<Response<T>> get<T>(String path, {Map<String, dynamic>? query, Options? options, CancelToken? cancelToken}) {
|
||||
return _dio.get<T>(path, queryParameters: query, options: options, cancelToken: cancelToken);
|
||||
Future<Response<T>> get<T>(String path, {Map<String, dynamic>? query, Options? options, CancelToken? cancelToken, ResponseType? responseType}) {
|
||||
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}) {
|
||||
return _dio.post<T>(path, data: data, queryParameters: query, options: options, cancelToken: cancelToken);
|
||||
Future<Response<T>> post<T>(String path, {Object? data, Map<String, dynamic>? query, Options? options, CancelToken? cancelToken, ResponseType? responseType}) {
|
||||
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}) {
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ class ReferralStore {
|
|||
static Future<void> captureFromCurrentUrl() async {
|
||||
try {
|
||||
String? ref = Uri.base.queryParameters['ref'];
|
||||
// اگر در hash بود (مثلاً #/login?ref=CODE) از fragment بخوان
|
||||
// اگر در hash بود (مثلاً /login?ref=CODE) از fragment بخوان
|
||||
if (ref == null || ref.trim().isEmpty) {
|
||||
final frag = Uri.base.fragment; // مثل '/login?ref=CODE'
|
||||
if (frag.isNotEmpty) {
|
||||
|
|
@ -58,7 +58,7 @@ class ReferralStore {
|
|||
static String buildInviteLink(String referralCode) {
|
||||
final origin = Uri.base.origin; // دامنه پویا
|
||||
// استفاده از Hash URL Strategy برای سازگاری کامل با Flutter Web
|
||||
return '$origin/#/login?ref=$referralCode';
|
||||
return '$origin/login?ref=$referralCode';
|
||||
}
|
||||
|
||||
static Future<void> saveUserReferralCode(String? code) async {
|
||||
|
|
|
|||
|
|
@ -47,7 +47,17 @@
|
|||
"menu": "Menu"
|
||||
,
|
||||
"ok": "OK",
|
||||
"cancel": "Cancel"
|
||||
"cancel": "Cancel",
|
||||
"columnSettings": "Column Settings",
|
||||
"columnSettingsDescription": "Manage column visibility and order for this table",
|
||||
"columnName": "Column Name",
|
||||
"visibility": "Visibility",
|
||||
"order": "Order",
|
||||
"visible": "Visible",
|
||||
"hidden": "Hidden",
|
||||
"resetToDefaults": "Reset to Defaults",
|
||||
"save": "Save",
|
||||
"error": "Error"
|
||||
,
|
||||
"newBusiness": "New business",
|
||||
"businesses": "Businesses",
|
||||
|
|
@ -80,6 +90,71 @@
|
|||
"calendar": "Calendar",
|
||||
"gregorian": "Gregorian",
|
||||
"jalali": "Jalali",
|
||||
"calendarType": "Calendar Type"
|
||||
"calendarType": "Calendar Type",
|
||||
"dataLoadingError": "Error loading data",
|
||||
"refresh": "Refresh",
|
||||
"yourReferralLink": "Your referral link",
|
||||
"filtersAndSearch": "Filters and search",
|
||||
"hideFilters": "Hide filters",
|
||||
"showFilters": "Show filters",
|
||||
"clear": "Clear",
|
||||
"searchInNameEmail": "Search in name, last name and email...",
|
||||
"recordsPerPage": "Records per page",
|
||||
"records": "records",
|
||||
"test": "Test",
|
||||
"user": "User",
|
||||
"showingRecords": "Showing {start} to {end} of {total} records",
|
||||
"previousPage": "Previous page",
|
||||
"nextPage": "Next page",
|
||||
"pageOf": "{current} of {total}",
|
||||
"referralList": "Referral List",
|
||||
"dateRangeFilter": "Date Range Filter",
|
||||
"columnSearch": "Column Search",
|
||||
"searchInColumn": "Search in {column}",
|
||||
"searchType": "Search Type",
|
||||
"contains": "Contains",
|
||||
"startsWith": "Starts With",
|
||||
"endsWith": "Ends With",
|
||||
"exactMatch": "Exact Match",
|
||||
"searchValue": "Search Value",
|
||||
"applyColumnFilter": "Apply Column Filter",
|
||||
"clearColumnFilter": "Clear Column Filter",
|
||||
"activeFilters": "Active Filters",
|
||||
"selectDate": "Select Date",
|
||||
"noDataFound": "No data found",
|
||||
"marketingReportSubtitle": "Manage and analyze user referrals",
|
||||
"showing": "Showing",
|
||||
"to": "to",
|
||||
"ofText": "of",
|
||||
"results": "results",
|
||||
"recordsPerPage": "Records per page",
|
||||
"firstPage": "First page",
|
||||
"previousPage": "Previous page",
|
||||
"nextPage": "Next page",
|
||||
"lastPage": "Last page",
|
||||
"exportToExcel": "Export to Excel",
|
||||
"exportToPdf": "Export to PDF",
|
||||
"exportSelected": "Export Selected",
|
||||
"exportAll": "Export All",
|
||||
"exporting": "Exporting...",
|
||||
"exportSuccess": "Export completed successfully",
|
||||
"exportError": "Export error",
|
||||
"export": "Export",
|
||||
"rowNumber": "Row",
|
||||
"firstName": "First Name",
|
||||
"lastName": "Last Name",
|
||||
"registrationDate": "Registration Date",
|
||||
"selectedRange": "Selected Range",
|
||||
"page": "Page",
|
||||
"equals": "equals",
|
||||
"greater_than": "greater than",
|
||||
"greater_equal": "greater or equal",
|
||||
"less_than": "less than",
|
||||
"less_equal": "less or equal",
|
||||
"not_equals": "not equals",
|
||||
"contains": "contains",
|
||||
"starts_with": "starts with",
|
||||
"ends_with": "ends with",
|
||||
"in_list": "in list"
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -52,7 +52,17 @@
|
|||
"changePassword": "تغییر کلمه عبور",
|
||||
"marketing": "بازاریابی",
|
||||
"ok": "تایید",
|
||||
"cancel": "انصراف"
|
||||
"cancel": "انصراف",
|
||||
"columnSettings": "تنظیمات ستونها",
|
||||
"columnSettingsDescription": "مدیریت نمایش و ترتیب ستونهای این جدول",
|
||||
"columnName": "نام ستون",
|
||||
"visibility": "نمایش",
|
||||
"order": "ترتیب",
|
||||
"visible": "نمایش",
|
||||
"hidden": "مخفی",
|
||||
"resetToDefaults": "بازگردانی به پیشفرض",
|
||||
"save": "ذخیره",
|
||||
"error": "خطا"
|
||||
,
|
||||
"marketingReport": "گزارش بازاریابی",
|
||||
"today": "امروز",
|
||||
|
|
@ -79,6 +89,71 @@
|
|||
"calendar": "تقویم",
|
||||
"gregorian": "میلادی",
|
||||
"jalali": "شمسی",
|
||||
"calendarType": "نوع تقویم"
|
||||
"calendarType": "نوع تقویم",
|
||||
"dataLoadingError": "خطا در بارگذاری دادهها",
|
||||
"refresh": "بروزرسانی",
|
||||
"yourReferralLink": "لینک معرفی شما",
|
||||
"filtersAndSearch": "فیلترها و جستجو",
|
||||
"hideFilters": "مخفی کردن فیلترها",
|
||||
"showFilters": "نمایش فیلترها",
|
||||
"clear": "پاک کردن",
|
||||
"searchInNameEmail": "جستجو در نام، نام خانوادگی و ایمیل...",
|
||||
"recordsPerPage": "تعداد در صفحه",
|
||||
"records": "رکورد",
|
||||
"test": "تست",
|
||||
"user": "کاربر",
|
||||
"showingRecords": "نمایش {start} تا {end} از {total} رکورد",
|
||||
"previousPage": "صفحه قبل",
|
||||
"nextPage": "صفحه بعد",
|
||||
"pageOf": "{current} از {total}",
|
||||
"referralList": "لیست معرفیها",
|
||||
"dateRangeFilter": "فیلتر بازه زمانی",
|
||||
"columnSearch": "جستجو در ستون",
|
||||
"searchInColumn": "جستجو در {column}",
|
||||
"searchType": "نوع جستجو",
|
||||
"contains": "شامل",
|
||||
"startsWith": "شروع با",
|
||||
"endsWith": "خاتمه با",
|
||||
"exactMatch": "مطابقت دقیق",
|
||||
"searchValue": "مقدار جستجو",
|
||||
"applyColumnFilter": "اعمال فیلتر ستون",
|
||||
"clearColumnFilter": "پاک کردن فیلتر ستون",
|
||||
"activeFilters": "فیلترهای فعال",
|
||||
"selectDate": "انتخاب تاریخ",
|
||||
"noDataFound": "هیچ دادهای یافت نشد",
|
||||
"marketingReportSubtitle": "مدیریت و تحلیل معرفیهای کاربران",
|
||||
"showing": "نمایش",
|
||||
"to": "تا",
|
||||
"ofText": "از",
|
||||
"results": "نتیجه",
|
||||
"recordsPerPage": "سطر در هر صفحه",
|
||||
"firstPage": "صفحه اول",
|
||||
"previousPage": "صفحه قبل",
|
||||
"nextPage": "صفحه بعد",
|
||||
"lastPage": "صفحه آخر",
|
||||
"exportToExcel": "خروجی اکسل",
|
||||
"exportToPdf": "خروجی PDF",
|
||||
"exportSelected": "خروجی انتخاب شدهها",
|
||||
"exportAll": "خروجی همه",
|
||||
"exporting": "در حال خروجی...",
|
||||
"exportSuccess": "خروجی با موفقیت انجام شد",
|
||||
"exportError": "خطا در خروجی",
|
||||
"export": "خروجی",
|
||||
"rowNumber": "ردیف",
|
||||
"firstName": "نام",
|
||||
"lastName": "نام خانوادگی",
|
||||
"registrationDate": "تاریخ ثبت",
|
||||
"selectedRange": "بازه انتخابی",
|
||||
"page": "صفحه",
|
||||
"equals": "برابر",
|
||||
"greater_than": "بزرگتر از",
|
||||
"greater_equal": "بزرگتر یا برابر",
|
||||
"less_than": "کوچکتر از",
|
||||
"less_equal": "کوچکتر یا برابر",
|
||||
"not_equals": "مخالف",
|
||||
"contains": "شامل",
|
||||
"starts_with": "شروع با",
|
||||
"ends_with": "پایان با",
|
||||
"in_list": "در لیست"
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -167,13 +167,13 @@ abstract class AppLocalizations {
|
|||
/// No description provided for @firstName.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'First name'**
|
||||
/// **'First Name'**
|
||||
String get firstName;
|
||||
|
||||
/// No description provided for @lastName.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Last name'**
|
||||
/// **'Last Name'**
|
||||
String get lastName;
|
||||
|
||||
/// No description provided for @email.
|
||||
|
|
@ -350,6 +350,66 @@ abstract class AppLocalizations {
|
|||
/// **'Cancel'**
|
||||
String get cancel;
|
||||
|
||||
/// No description provided for @columnSettings.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Column Settings'**
|
||||
String get columnSettings;
|
||||
|
||||
/// No description provided for @columnSettingsDescription.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Manage column visibility and order for this table'**
|
||||
String get columnSettingsDescription;
|
||||
|
||||
/// No description provided for @columnName.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Column Name'**
|
||||
String get columnName;
|
||||
|
||||
/// No description provided for @visibility.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Visibility'**
|
||||
String get visibility;
|
||||
|
||||
/// No description provided for @order.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Order'**
|
||||
String get order;
|
||||
|
||||
/// No description provided for @visible.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Visible'**
|
||||
String get visible;
|
||||
|
||||
/// No description provided for @hidden.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Hidden'**
|
||||
String get hidden;
|
||||
|
||||
/// No description provided for @resetToDefaults.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Reset to Defaults'**
|
||||
String get resetToDefaults;
|
||||
|
||||
/// No description provided for @save.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Save'**
|
||||
String get save;
|
||||
|
||||
/// No description provided for @error.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Error'**
|
||||
String get error;
|
||||
|
||||
/// No description provided for @newBusiness.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
@ -535,6 +595,354 @@ abstract class AppLocalizations {
|
|||
/// In en, this message translates to:
|
||||
/// **'Calendar Type'**
|
||||
String get calendarType;
|
||||
|
||||
/// No description provided for @dataLoadingError.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Error loading data'**
|
||||
String get dataLoadingError;
|
||||
|
||||
/// No description provided for @yourReferralLink.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Your referral link'**
|
||||
String get yourReferralLink;
|
||||
|
||||
/// No description provided for @filtersAndSearch.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Filters and search'**
|
||||
String get filtersAndSearch;
|
||||
|
||||
/// No description provided for @hideFilters.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Hide filters'**
|
||||
String get hideFilters;
|
||||
|
||||
/// No description provided for @showFilters.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Show filters'**
|
||||
String get showFilters;
|
||||
|
||||
/// No description provided for @clear.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Clear'**
|
||||
String get clear;
|
||||
|
||||
/// No description provided for @searchInNameEmail.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Search in name, last name and email...'**
|
||||
String get searchInNameEmail;
|
||||
|
||||
/// No description provided for @recordsPerPage.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Records per page'**
|
||||
String get recordsPerPage;
|
||||
|
||||
/// No description provided for @records.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'records'**
|
||||
String get records;
|
||||
|
||||
/// No description provided for @test.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Test'**
|
||||
String get test;
|
||||
|
||||
/// No description provided for @user.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'User'**
|
||||
String get user;
|
||||
|
||||
/// No description provided for @showingRecords.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Showing {start} to {end} of {total} records'**
|
||||
String showingRecords(Object end, Object start, Object total);
|
||||
|
||||
/// No description provided for @previousPage.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Previous page'**
|
||||
String get previousPage;
|
||||
|
||||
/// No description provided for @nextPage.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Next page'**
|
||||
String get nextPage;
|
||||
|
||||
/// No description provided for @pageOf.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{current} of {total}'**
|
||||
String pageOf(Object current, Object total);
|
||||
|
||||
/// No description provided for @referralList.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Referral List'**
|
||||
String get referralList;
|
||||
|
||||
/// No description provided for @dateRangeFilter.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Date Range Filter'**
|
||||
String get dateRangeFilter;
|
||||
|
||||
/// No description provided for @columnSearch.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Column Search'**
|
||||
String get columnSearch;
|
||||
|
||||
/// No description provided for @searchInColumn.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Search in {column}'**
|
||||
String searchInColumn(Object column);
|
||||
|
||||
/// No description provided for @searchType.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Search Type'**
|
||||
String get searchType;
|
||||
|
||||
/// No description provided for @contains.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'contains'**
|
||||
String get contains;
|
||||
|
||||
/// No description provided for @startsWith.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Starts With'**
|
||||
String get startsWith;
|
||||
|
||||
/// No description provided for @endsWith.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Ends With'**
|
||||
String get endsWith;
|
||||
|
||||
/// No description provided for @exactMatch.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Exact Match'**
|
||||
String get exactMatch;
|
||||
|
||||
/// No description provided for @searchValue.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Search Value'**
|
||||
String get searchValue;
|
||||
|
||||
/// No description provided for @applyColumnFilter.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Apply Column Filter'**
|
||||
String get applyColumnFilter;
|
||||
|
||||
/// No description provided for @clearColumnFilter.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Clear Column Filter'**
|
||||
String get clearColumnFilter;
|
||||
|
||||
/// No description provided for @activeFilters.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Active Filters'**
|
||||
String get activeFilters;
|
||||
|
||||
/// No description provided for @selectDate.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Select Date'**
|
||||
String get selectDate;
|
||||
|
||||
/// No description provided for @noDataFound.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No data found'**
|
||||
String get noDataFound;
|
||||
|
||||
/// No description provided for @marketingReportSubtitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Manage and analyze user referrals'**
|
||||
String get marketingReportSubtitle;
|
||||
|
||||
/// No description provided for @showing.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Showing'**
|
||||
String get showing;
|
||||
|
||||
/// No description provided for @to.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'to'**
|
||||
String get to;
|
||||
|
||||
/// No description provided for @ofText.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'of'**
|
||||
String get ofText;
|
||||
|
||||
/// No description provided for @results.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'results'**
|
||||
String get results;
|
||||
|
||||
/// No description provided for @firstPage.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'First page'**
|
||||
String get firstPage;
|
||||
|
||||
/// No description provided for @lastPage.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Last page'**
|
||||
String get lastPage;
|
||||
|
||||
/// No description provided for @exportToExcel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Export to Excel'**
|
||||
String get exportToExcel;
|
||||
|
||||
/// No description provided for @exportToPdf.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Export to PDF'**
|
||||
String get exportToPdf;
|
||||
|
||||
/// No description provided for @exportSelected.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Export Selected'**
|
||||
String get exportSelected;
|
||||
|
||||
/// No description provided for @exportAll.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Export All'**
|
||||
String get exportAll;
|
||||
|
||||
/// No description provided for @exporting.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Exporting...'**
|
||||
String get exporting;
|
||||
|
||||
/// No description provided for @exportSuccess.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Export completed successfully'**
|
||||
String get exportSuccess;
|
||||
|
||||
/// No description provided for @exportError.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Export error'**
|
||||
String get exportError;
|
||||
|
||||
/// No description provided for @export.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Export'**
|
||||
String get export;
|
||||
|
||||
/// No description provided for @rowNumber.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Row'**
|
||||
String get rowNumber;
|
||||
|
||||
/// No description provided for @registrationDate.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Registration Date'**
|
||||
String get registrationDate;
|
||||
|
||||
/// No description provided for @selectedRange.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Selected Range'**
|
||||
String get selectedRange;
|
||||
|
||||
/// No description provided for @page.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Page'**
|
||||
String get page;
|
||||
|
||||
/// No description provided for @equals.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'equals'**
|
||||
String get equals;
|
||||
|
||||
/// No description provided for @greater_than.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'greater than'**
|
||||
String get greater_than;
|
||||
|
||||
/// No description provided for @greater_equal.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'greater or equal'**
|
||||
String get greater_equal;
|
||||
|
||||
/// No description provided for @less_than.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'less than'**
|
||||
String get less_than;
|
||||
|
||||
/// No description provided for @less_equal.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'less or equal'**
|
||||
String get less_equal;
|
||||
|
||||
/// No description provided for @not_equals.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'not equals'**
|
||||
String get not_equals;
|
||||
|
||||
/// No description provided for @starts_with.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'starts with'**
|
||||
String get starts_with;
|
||||
|
||||
/// No description provided for @ends_with.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'ends with'**
|
||||
String get ends_with;
|
||||
|
||||
/// No description provided for @in_list.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'in list'**
|
||||
String get in_list;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
|
|
|||
|
|
@ -42,10 +42,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
String get forgotPassword => 'Forgot password';
|
||||
|
||||
@override
|
||||
String get firstName => 'First name';
|
||||
String get firstName => 'First Name';
|
||||
|
||||
@override
|
||||
String get lastName => 'Last name';
|
||||
String get lastName => 'Last Name';
|
||||
|
||||
@override
|
||||
String get email => 'Email';
|
||||
|
|
@ -136,6 +136,37 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get cancel => 'Cancel';
|
||||
|
||||
@override
|
||||
String get columnSettings => 'Column Settings';
|
||||
|
||||
@override
|
||||
String get columnSettingsDescription =>
|
||||
'Manage column visibility and order for this table';
|
||||
|
||||
@override
|
||||
String get columnName => 'Column Name';
|
||||
|
||||
@override
|
||||
String get visibility => 'Visibility';
|
||||
|
||||
@override
|
||||
String get order => 'Order';
|
||||
|
||||
@override
|
||||
String get visible => 'Visible';
|
||||
|
||||
@override
|
||||
String get hidden => 'Hidden';
|
||||
|
||||
@override
|
||||
String get resetToDefaults => 'Reset to Defaults';
|
||||
|
||||
@override
|
||||
String get save => 'Save';
|
||||
|
||||
@override
|
||||
String get error => 'Error';
|
||||
|
||||
@override
|
||||
String get newBusiness => 'New business';
|
||||
|
||||
|
|
@ -232,4 +263,184 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
|
||||
@override
|
||||
String get calendarType => 'Calendar Type';
|
||||
|
||||
@override
|
||||
String get dataLoadingError => 'Error loading data';
|
||||
|
||||
@override
|
||||
String get yourReferralLink => 'Your referral link';
|
||||
|
||||
@override
|
||||
String get filtersAndSearch => 'Filters and search';
|
||||
|
||||
@override
|
||||
String get hideFilters => 'Hide filters';
|
||||
|
||||
@override
|
||||
String get showFilters => 'Show filters';
|
||||
|
||||
@override
|
||||
String get clear => 'Clear';
|
||||
|
||||
@override
|
||||
String get searchInNameEmail => 'Search in name, last name and email...';
|
||||
|
||||
@override
|
||||
String get recordsPerPage => 'Records per page';
|
||||
|
||||
@override
|
||||
String get records => 'records';
|
||||
|
||||
@override
|
||||
String get test => 'Test';
|
||||
|
||||
@override
|
||||
String get user => 'User';
|
||||
|
||||
@override
|
||||
String showingRecords(Object end, Object start, Object total) {
|
||||
return 'Showing $start to $end of $total records';
|
||||
}
|
||||
|
||||
@override
|
||||
String get previousPage => 'Previous page';
|
||||
|
||||
@override
|
||||
String get nextPage => 'Next page';
|
||||
|
||||
@override
|
||||
String pageOf(Object current, Object total) {
|
||||
return '$current of $total';
|
||||
}
|
||||
|
||||
@override
|
||||
String get referralList => 'Referral List';
|
||||
|
||||
@override
|
||||
String get dateRangeFilter => 'Date Range Filter';
|
||||
|
||||
@override
|
||||
String get columnSearch => 'Column Search';
|
||||
|
||||
@override
|
||||
String searchInColumn(Object column) {
|
||||
return 'Search in $column';
|
||||
}
|
||||
|
||||
@override
|
||||
String get searchType => 'Search Type';
|
||||
|
||||
@override
|
||||
String get contains => 'contains';
|
||||
|
||||
@override
|
||||
String get startsWith => 'Starts With';
|
||||
|
||||
@override
|
||||
String get endsWith => 'Ends With';
|
||||
|
||||
@override
|
||||
String get exactMatch => 'Exact Match';
|
||||
|
||||
@override
|
||||
String get searchValue => 'Search Value';
|
||||
|
||||
@override
|
||||
String get applyColumnFilter => 'Apply Column Filter';
|
||||
|
||||
@override
|
||||
String get clearColumnFilter => 'Clear Column Filter';
|
||||
|
||||
@override
|
||||
String get activeFilters => 'Active Filters';
|
||||
|
||||
@override
|
||||
String get selectDate => 'Select Date';
|
||||
|
||||
@override
|
||||
String get noDataFound => 'No data found';
|
||||
|
||||
@override
|
||||
String get marketingReportSubtitle => 'Manage and analyze user referrals';
|
||||
|
||||
@override
|
||||
String get showing => 'Showing';
|
||||
|
||||
@override
|
||||
String get to => 'to';
|
||||
|
||||
@override
|
||||
String get ofText => 'of';
|
||||
|
||||
@override
|
||||
String get results => 'results';
|
||||
|
||||
@override
|
||||
String get firstPage => 'First page';
|
||||
|
||||
@override
|
||||
String get lastPage => 'Last page';
|
||||
|
||||
@override
|
||||
String get exportToExcel => 'Export to Excel';
|
||||
|
||||
@override
|
||||
String get exportToPdf => 'Export to PDF';
|
||||
|
||||
@override
|
||||
String get exportSelected => 'Export Selected';
|
||||
|
||||
@override
|
||||
String get exportAll => 'Export All';
|
||||
|
||||
@override
|
||||
String get exporting => 'Exporting...';
|
||||
|
||||
@override
|
||||
String get exportSuccess => 'Export completed successfully';
|
||||
|
||||
@override
|
||||
String get exportError => 'Export error';
|
||||
|
||||
@override
|
||||
String get export => 'Export';
|
||||
|
||||
@override
|
||||
String get rowNumber => 'Row';
|
||||
|
||||
@override
|
||||
String get registrationDate => 'Registration Date';
|
||||
|
||||
@override
|
||||
String get selectedRange => 'Selected Range';
|
||||
|
||||
@override
|
||||
String get page => 'Page';
|
||||
|
||||
@override
|
||||
String get equals => 'equals';
|
||||
|
||||
@override
|
||||
String get greater_than => 'greater than';
|
||||
|
||||
@override
|
||||
String get greater_equal => 'greater or equal';
|
||||
|
||||
@override
|
||||
String get less_than => 'less than';
|
||||
|
||||
@override
|
||||
String get less_equal => 'less or equal';
|
||||
|
||||
@override
|
||||
String get not_equals => 'not equals';
|
||||
|
||||
@override
|
||||
String get starts_with => 'starts with';
|
||||
|
||||
@override
|
||||
String get ends_with => 'ends with';
|
||||
|
||||
@override
|
||||
String get in_list => 'in list';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ class AppLocalizationsFa extends AppLocalizations {
|
|||
String get captcha => 'کد امنیتی';
|
||||
|
||||
@override
|
||||
String get refresh => 'تازهسازی';
|
||||
String get refresh => 'بروزرسانی';
|
||||
|
||||
@override
|
||||
String get captchaRequired => 'کد امنیتی الزامی است.';
|
||||
|
|
@ -136,6 +136,37 @@ class AppLocalizationsFa extends AppLocalizations {
|
|||
@override
|
||||
String get cancel => 'انصراف';
|
||||
|
||||
@override
|
||||
String get columnSettings => 'تنظیمات ستونها';
|
||||
|
||||
@override
|
||||
String get columnSettingsDescription =>
|
||||
'مدیریت نمایش و ترتیب ستونهای این جدول';
|
||||
|
||||
@override
|
||||
String get columnName => 'نام ستون';
|
||||
|
||||
@override
|
||||
String get visibility => 'نمایش';
|
||||
|
||||
@override
|
||||
String get order => 'ترتیب';
|
||||
|
||||
@override
|
||||
String get visible => 'نمایش';
|
||||
|
||||
@override
|
||||
String get hidden => 'مخفی';
|
||||
|
||||
@override
|
||||
String get resetToDefaults => 'بازگردانی به پیشفرض';
|
||||
|
||||
@override
|
||||
String get save => 'ذخیره';
|
||||
|
||||
@override
|
||||
String get error => 'خطا';
|
||||
|
||||
@override
|
||||
String get newBusiness => 'کسبوکار جدید';
|
||||
|
||||
|
|
@ -231,4 +262,184 @@ class AppLocalizationsFa extends AppLocalizations {
|
|||
|
||||
@override
|
||||
String get calendarType => 'نوع تقویم';
|
||||
|
||||
@override
|
||||
String get dataLoadingError => 'خطا در بارگذاری دادهها';
|
||||
|
||||
@override
|
||||
String get yourReferralLink => 'لینک معرفی شما';
|
||||
|
||||
@override
|
||||
String get filtersAndSearch => 'فیلترها و جستجو';
|
||||
|
||||
@override
|
||||
String get hideFilters => 'مخفی کردن فیلترها';
|
||||
|
||||
@override
|
||||
String get showFilters => 'نمایش فیلترها';
|
||||
|
||||
@override
|
||||
String get clear => 'پاک کردن';
|
||||
|
||||
@override
|
||||
String get searchInNameEmail => 'جستجو در نام، نام خانوادگی و ایمیل...';
|
||||
|
||||
@override
|
||||
String get recordsPerPage => 'سطر در هر صفحه';
|
||||
|
||||
@override
|
||||
String get records => 'رکورد';
|
||||
|
||||
@override
|
||||
String get test => 'تست';
|
||||
|
||||
@override
|
||||
String get user => 'کاربر';
|
||||
|
||||
@override
|
||||
String showingRecords(Object end, Object start, Object total) {
|
||||
return 'نمایش $start تا $end از $total رکورد';
|
||||
}
|
||||
|
||||
@override
|
||||
String get previousPage => 'صفحه قبل';
|
||||
|
||||
@override
|
||||
String get nextPage => 'صفحه بعد';
|
||||
|
||||
@override
|
||||
String pageOf(Object current, Object total) {
|
||||
return '$current از $total';
|
||||
}
|
||||
|
||||
@override
|
||||
String get referralList => 'لیست معرفیها';
|
||||
|
||||
@override
|
||||
String get dateRangeFilter => 'فیلتر بازه زمانی';
|
||||
|
||||
@override
|
||||
String get columnSearch => 'جستجو در ستون';
|
||||
|
||||
@override
|
||||
String searchInColumn(Object column) {
|
||||
return 'جستجو در $column';
|
||||
}
|
||||
|
||||
@override
|
||||
String get searchType => 'نوع جستجو';
|
||||
|
||||
@override
|
||||
String get contains => 'شامل';
|
||||
|
||||
@override
|
||||
String get startsWith => 'شروع با';
|
||||
|
||||
@override
|
||||
String get endsWith => 'خاتمه با';
|
||||
|
||||
@override
|
||||
String get exactMatch => 'مطابقت دقیق';
|
||||
|
||||
@override
|
||||
String get searchValue => 'مقدار جستجو';
|
||||
|
||||
@override
|
||||
String get applyColumnFilter => 'اعمال فیلتر ستون';
|
||||
|
||||
@override
|
||||
String get clearColumnFilter => 'پاک کردن فیلتر ستون';
|
||||
|
||||
@override
|
||||
String get activeFilters => 'فیلترهای فعال';
|
||||
|
||||
@override
|
||||
String get selectDate => 'انتخاب تاریخ';
|
||||
|
||||
@override
|
||||
String get noDataFound => 'هیچ دادهای یافت نشد';
|
||||
|
||||
@override
|
||||
String get marketingReportSubtitle => 'مدیریت و تحلیل معرفیهای کاربران';
|
||||
|
||||
@override
|
||||
String get showing => 'نمایش';
|
||||
|
||||
@override
|
||||
String get to => 'تا';
|
||||
|
||||
@override
|
||||
String get ofText => 'از';
|
||||
|
||||
@override
|
||||
String get results => 'نتیجه';
|
||||
|
||||
@override
|
||||
String get firstPage => 'صفحه اول';
|
||||
|
||||
@override
|
||||
String get lastPage => 'صفحه آخر';
|
||||
|
||||
@override
|
||||
String get exportToExcel => 'خروجی اکسل';
|
||||
|
||||
@override
|
||||
String get exportToPdf => 'خروجی PDF';
|
||||
|
||||
@override
|
||||
String get exportSelected => 'خروجی انتخاب شدهها';
|
||||
|
||||
@override
|
||||
String get exportAll => 'خروجی همه';
|
||||
|
||||
@override
|
||||
String get exporting => 'در حال خروجی...';
|
||||
|
||||
@override
|
||||
String get exportSuccess => 'خروجی با موفقیت انجام شد';
|
||||
|
||||
@override
|
||||
String get exportError => 'خطا در خروجی';
|
||||
|
||||
@override
|
||||
String get export => 'خروجی';
|
||||
|
||||
@override
|
||||
String get rowNumber => 'ردیف';
|
||||
|
||||
@override
|
||||
String get registrationDate => 'تاریخ ثبت';
|
||||
|
||||
@override
|
||||
String get selectedRange => 'بازه انتخابی';
|
||||
|
||||
@override
|
||||
String get page => 'صفحه';
|
||||
|
||||
@override
|
||||
String get equals => 'برابر';
|
||||
|
||||
@override
|
||||
String get greater_than => 'بزرگتر از';
|
||||
|
||||
@override
|
||||
String get greater_equal => 'بزرگتر یا برابر';
|
||||
|
||||
@override
|
||||
String get less_than => 'کوچکتر از';
|
||||
|
||||
@override
|
||||
String get less_equal => 'کوچکتر یا برابر';
|
||||
|
||||
@override
|
||||
String get not_equals => 'مخالف';
|
||||
|
||||
@override
|
||||
String get starts_with => 'شروع با';
|
||||
|
||||
@override
|
||||
String get ends_with => 'پایان با';
|
||||
|
||||
@override
|
||||
String get in_list => 'در لیست';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import 'package:flutter_localizations/flutter_localizations.dart';
|
|||
import 'package:flutter_web_plugins/url_strategy.dart';
|
||||
|
||||
import 'pages/login_page.dart';
|
||||
import 'pages/home_page.dart';
|
||||
import 'pages/profile/profile_shell.dart';
|
||||
import 'pages/profile/profile_dashboard_page.dart';
|
||||
import 'pages/profile/new_business_page.dart';
|
||||
|
|
@ -89,20 +88,164 @@ class _MyAppState extends State<MyApp> {
|
|||
// Root of application with GoRouter
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// اگر هنوز loading است، یک router ساده با loading page بساز
|
||||
if (_controller == null || _calendarController == null || _themeController == null || _authStore == null) {
|
||||
return const MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('Loading...'),
|
||||
],
|
||||
final loadingRouter = GoRouter(
|
||||
redirect: (context, state) {
|
||||
// در حین loading، هیچ redirect نکن - URL را حفظ کن
|
||||
return null;
|
||||
},
|
||||
routes: <RouteBase>[
|
||||
GoRoute(
|
||||
path: '/',
|
||||
builder: (context, state) => const Scaffold(
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('Loading...'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// برای سایر مسیرها هم loading page نمایش بده
|
||||
GoRoute(
|
||||
path: '/login',
|
||||
builder: (context, state) => const Scaffold(
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('Loading...'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/user/profile/dashboard',
|
||||
builder: (context, state) => const Scaffold(
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('Loading...'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/user/profile/marketing',
|
||||
builder: (context, state) => const Scaffold(
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('Loading...'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/user/profile/new-business',
|
||||
builder: (context, state) => const Scaffold(
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('Loading...'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/user/profile/businesses',
|
||||
builder: (context, state) => const Scaffold(
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('Loading...'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/user/profile/support',
|
||||
builder: (context, state) => const Scaffold(
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('Loading...'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/user/profile/change-password',
|
||||
builder: (context, state) => const Scaffold(
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('Loading...'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Catch-all route برای هر URL دیگر
|
||||
GoRoute(
|
||||
path: '/:path(.*)',
|
||||
builder: (context, state) => const Scaffold(
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('Loading...'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
return MaterialApp.router(
|
||||
title: 'Hesabix',
|
||||
routerConfig: loadingRouter,
|
||||
locale: const Locale('en'),
|
||||
supportedLocales: const [Locale('en'), Locale('fa')],
|
||||
localizationsDelegates: const [
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -143,6 +286,7 @@ class _MyAppState extends State<MyApp> {
|
|||
}
|
||||
|
||||
// برای سایر صفحات (شامل صفحات profile)، redirect نکن (بماند)
|
||||
// این مهم است: اگر کاربر در صفحات profile است، بماند
|
||||
return null;
|
||||
},
|
||||
routes: <RouteBase>[
|
||||
|
|
|
|||
|
|
@ -86,8 +86,8 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
|
|||
if (body is! Map<String, dynamic>) return;
|
||||
final data = body['data'];
|
||||
if (data is! Map<String, dynamic>) return;
|
||||
final String? id = data['captcha_id'] as String?;
|
||||
final String? imgB64 = data['image_base64'] as String?;
|
||||
final String? id = data['captcha_id']?.toString();
|
||||
final String? imgB64 = data['image_base64']?.toString();
|
||||
final int? ttl = (data['ttl_seconds'] as num?)?.toInt();
|
||||
if (id == null || imgB64 == null) return;
|
||||
Uint8List bytes;
|
||||
|
|
@ -236,12 +236,12 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
|
|||
final inner = body['data'];
|
||||
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) {
|
||||
await widget.authStore.saveApiKey(apiKey);
|
||||
// ذخیره کد بازاریابی کاربر برای صفحه Marketing
|
||||
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));
|
||||
}
|
||||
|
||||
|
|
@ -323,7 +323,7 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
|
|||
final inner = body['data'];
|
||||
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) {
|
||||
await widget.authStore.saveApiKey(apiKey);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,10 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hesabix_ui/l10n/app_localizations.dart';
|
||||
import '../../core/referral_store.dart';
|
||||
import '../../core/api_client.dart';
|
||||
import '../../core/calendar_controller.dart';
|
||||
import '../../core/date_utils.dart';
|
||||
import '../../widgets/jalali_date_picker.dart';
|
||||
import '../../widgets/date_input_field.dart';
|
||||
import '../../widgets/data_table/data_table.dart';
|
||||
|
||||
class MarketingPage extends StatefulWidget {
|
||||
final CalendarController calendarController;
|
||||
|
|
@ -27,35 +23,13 @@ class _MarketingPageState extends State<MarketingPage> {
|
|||
int? _rangeCount;
|
||||
DateTime? _fromDate;
|
||||
DateTime? _toDate;
|
||||
// list state
|
||||
bool _loadingList = false;
|
||||
int _page = 1;
|
||||
int _limit = 10;
|
||||
int _total = 0;
|
||||
List<Map<String, dynamic>> _items = const [];
|
||||
final TextEditingController _searchCtrl = TextEditingController();
|
||||
Timer? _searchDebounce;
|
||||
Set<int> _selectedRows = <int>{};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadReferralCode();
|
||||
_fetchStats();
|
||||
_fetchList();
|
||||
_searchCtrl.addListener(() {
|
||||
_searchDebounce?.cancel();
|
||||
_searchDebounce = Timer(const Duration(milliseconds: 400), () {
|
||||
_page = 1;
|
||||
_fetchList(withRange: true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchCtrl.dispose();
|
||||
_searchDebounce?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadReferralCode() async {
|
||||
|
|
@ -72,7 +46,6 @@ class _MarketingPageState extends State<MarketingPage> {
|
|||
final api = ApiClient();
|
||||
final params = <String, dynamic>{};
|
||||
if (withRange && _fromDate != null && _toDate != null) {
|
||||
// use ISO8601 date-time boundaries: start at 00:00, end next day 00:00
|
||||
final start = DateTime(_fromDate!.year, _fromDate!.month, _fromDate!.day);
|
||||
final endExclusive = DateTime(_toDate!.year, _toDate!.month, _toDate!.day).add(const Duration(days: 1));
|
||||
params['start'] = start.toIso8601String();
|
||||
|
|
@ -92,225 +65,285 @@ class _MarketingPageState extends State<MarketingPage> {
|
|||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// silent fail: نمایش خطا ضروری نیست
|
||||
// silent fail
|
||||
} finally {
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
final t = AppLocalizations.of(context);
|
||||
final t = Localizations.of<AppLocalizations>(context, AppLocalizations)!;
|
||||
final code = _referralCode;
|
||||
final inviteLink = (code == null || code.isEmpty) ? null : ReferralStore.buildInviteLink(code);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(t.marketingReport, style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 12),
|
||||
if (code == null || code.isEmpty) Text(t.loading, style: Theme.of(context).textTheme.bodyMedium),
|
||||
if (inviteLink != null) ...[
|
||||
Row(
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: theme.dividerColor.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.analytics,
|
||||
size: 24,
|
||||
color: theme.colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: SelectableText(inviteLink)),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () async {
|
||||
await Clipboard.setData(ClipboardData(text: inviteLink));
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(SnackBar(content: Text(t.copied)));
|
||||
},
|
||||
icon: const Icon(Icons.link),
|
||||
label: Text(t.copyLink),
|
||||
),
|
||||
Text(
|
||||
t.marketingReport,
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
t.marketingReportSubtitle,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
_StatCard(title: t.today, value: _todayCount, loading: _loading),
|
||||
_StatCard(title: t.thisMonth, value: _monthCount, loading: _loading),
|
||||
_StatCard(title: t.total, value: _totalCount, loading: _loading),
|
||||
_StatCard(title: '${t.dateFrom}-${t.dateTo}', value: _rangeCount, loading: _loading),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: DateInputField(
|
||||
value: _fromDate,
|
||||
onChanged: (date) {
|
||||
setState(() {
|
||||
_fromDate = date;
|
||||
});
|
||||
},
|
||||
labelText: t.dateFrom,
|
||||
calendarController: widget.calendarController,
|
||||
enabled: !_loading,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: DateInputField(
|
||||
value: _toDate,
|
||||
onChanged: (date) {
|
||||
setState(() {
|
||||
_toDate = date;
|
||||
});
|
||||
},
|
||||
labelText: t.dateTo,
|
||||
calendarController: widget.calendarController,
|
||||
enabled: !_loading,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton(
|
||||
onPressed: _loading || _fromDate == null || _toDate == null ? null : _applyFilters,
|
||||
child: _loading ? const SizedBox(height: 18, width: 18, child: CircularProgressIndicator(strokeWidth: 2)) : Text(t.applyFilter),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _searchCtrl,
|
||||
decoration: InputDecoration(
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
hintText: t.email,
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Referral Link Card
|
||||
if (inviteLink != null) ...[
|
||||
Card(
|
||||
elevation: 2,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.link,
|
||||
color: theme.colorScheme.onPrimaryContainer,
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
t.yourReferralLink,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: theme.dividerColor),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SelectableText(
|
||||
inviteLink,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton.icon(
|
||||
onPressed: () async {
|
||||
await Clipboard.setData(ClipboardData(text: inviteLink));
|
||||
if (!mounted) return;
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
messenger
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(t.copied),
|
||||
backgroundColor: theme.colorScheme.primary,
|
||||
),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.copy, size: 18),
|
||||
label: Text(t.copyLink),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
DropdownButton<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: 24),
|
||||
],
|
||||
|
||||
// Stats Cards
|
||||
Wrap(
|
||||
spacing: 16,
|
||||
runSpacing: 16,
|
||||
children: [
|
||||
_StatCard(
|
||||
title: t.today,
|
||||
value: _todayCount,
|
||||
loading: _loading,
|
||||
icon: Icons.today,
|
||||
color: Colors.blue,
|
||||
),
|
||||
_StatCard(
|
||||
title: t.thisMonth,
|
||||
value: _monthCount,
|
||||
loading: _loading,
|
||||
icon: Icons.calendar_month,
|
||||
color: Colors.green,
|
||||
),
|
||||
_StatCard(
|
||||
title: t.total,
|
||||
value: _totalCount,
|
||||
loading: _loading,
|
||||
icon: Icons.people,
|
||||
color: Colors.orange,
|
||||
),
|
||||
_StatCard(
|
||||
title: '${t.dateFrom}-${t.dateTo}',
|
||||
value: _rangeCount,
|
||||
loading: _loading,
|
||||
icon: Icons.date_range,
|
||||
color: Colors.purple,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Data Table using new widget
|
||||
DataTableWidget<Map<String, dynamic>>(
|
||||
config: DataTableConfig<Map<String, dynamic>>(
|
||||
title: t.referralList,
|
||||
endpoint: '/api/v1/auth/referrals/list',
|
||||
excelEndpoint: '/api/v1/auth/referrals/export/excel',
|
||||
pdfEndpoint: '/api/v1/auth/referrals/export/pdf',
|
||||
getExportParams: () => {
|
||||
'user_id': 'current_user', // Example parameter
|
||||
},
|
||||
columns: [
|
||||
TextColumn(
|
||||
'first_name',
|
||||
t.firstName,
|
||||
sortable: true,
|
||||
searchable: true,
|
||||
width: ColumnWidth.small,
|
||||
),
|
||||
TextColumn(
|
||||
'last_name',
|
||||
t.lastName,
|
||||
sortable: true,
|
||||
searchable: true,
|
||||
width: ColumnWidth.small,
|
||||
),
|
||||
TextColumn(
|
||||
'email',
|
||||
t.email,
|
||||
sortable: true,
|
||||
searchable: true,
|
||||
width: ColumnWidth.large,
|
||||
),
|
||||
DateColumn(
|
||||
'created_at',
|
||||
t.register,
|
||||
sortable: true,
|
||||
searchable: true,
|
||||
width: ColumnWidth.medium,
|
||||
showTime: false,
|
||||
),
|
||||
],
|
||||
searchFields: ['first_name', 'last_name', 'email'],
|
||||
filterFields: ['first_name', 'last_name', 'email', 'created_at'],
|
||||
dateRangeField: 'created_at',
|
||||
showSearch: true,
|
||||
showFilters: true,
|
||||
showColumnSearch: true,
|
||||
showPagination: true,
|
||||
showActiveFilters: true,
|
||||
enableSorting: true,
|
||||
enableGlobalSearch: true,
|
||||
enableDateRangeFilter: true,
|
||||
showRowNumbers: true,
|
||||
enableRowSelection: true,
|
||||
enableMultiRowSelection: true,
|
||||
selectedRows: _selectedRows,
|
||||
onRowSelectionChanged: (selectedRows) {
|
||||
setState(() {
|
||||
_selectedRows = selectedRows;
|
||||
});
|
||||
},
|
||||
defaultPageSize: 20,
|
||||
pageSizeOptions: const [10, 20, 50, 100],
|
||||
showRefreshButton: true,
|
||||
showClearFiltersButton: true,
|
||||
emptyStateMessage: 'هیچ معرفیای یافت نشد',
|
||||
loadingMessage: 'در حال بارگذاری معرفیها...',
|
||||
errorMessage: 'خطا در بارگذاری معرفیها',
|
||||
enableHorizontalScroll: true,
|
||||
minTableWidth: 600,
|
||||
showBorder: true,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
padding: const EdgeInsets.all(16),
|
||||
onDateRangeApply: (fromDate, toDate) {
|
||||
setState(() {
|
||||
_fromDate = fromDate;
|
||||
_toDate = toDate;
|
||||
});
|
||||
_fetchStats(withRange: true);
|
||||
},
|
||||
onDateRangeClear: () {
|
||||
setState(() {
|
||||
_fromDate = null;
|
||||
_toDate = null;
|
||||
});
|
||||
_fetchStats();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
children: [
|
||||
if (_loadingList)
|
||||
const LinearProgressIndicator(minHeight: 2)
|
||||
else
|
||||
const SizedBox(height: 2),
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: DataTable(
|
||||
columns: [
|
||||
DataColumn(label: Text(t.firstName)),
|
||||
DataColumn(label: Text(t.lastName)),
|
||||
DataColumn(label: Text(t.email)),
|
||||
DataColumn(label: Text(t.register)),
|
||||
],
|
||||
rows: _items.map((e) {
|
||||
final createdAt = (e['created_at'] as String?) ?? '';
|
||||
DateTime? date;
|
||||
if (createdAt.isNotEmpty) {
|
||||
try {
|
||||
date = DateTime.parse(createdAt.substring(0, 10));
|
||||
} catch (e) {
|
||||
// Ignore parsing errors
|
||||
}
|
||||
}
|
||||
final dateStr = date != null
|
||||
? HesabixDateUtils.formatForDisplay(date, widget.calendarController.isJalali)
|
||||
: '';
|
||||
return DataRow(cells: [
|
||||
DataCell(Text((e['first_name'] ?? '') as String)),
|
||||
DataCell(Text((e['last_name'] ?? '') as String)),
|
||||
DataCell(Text((e['email'] ?? '') as String)),
|
||||
DataCell(Text(dateStr)),
|
||||
]);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Text('${((_page - 1) * _limit + 1).clamp(0, _total)} - ${(_page * _limit).clamp(0, _total)} / $_total'),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
onPressed: _page > 1 && !_loadingList ? () { setState(() => _page -= 1); _fetchList(withRange: true); } : null,
|
||||
icon: const Icon(Icons.chevron_right),
|
||||
tooltip: 'Prev',
|
||||
),
|
||||
IconButton(
|
||||
onPressed: (_page * _limit) < _total && !_loadingList ? () { setState(() => _page += 1); _fetchList(withRange: true); } : null,
|
||||
icon: const Icon(Icons.chevron_left),
|
||||
tooltip: 'Next',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
fromJson: (json) => json,
|
||||
calendarController: widget.calendarController,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -320,24 +353,58 @@ class _StatCard extends StatelessWidget {
|
|||
final String title;
|
||||
final int? value;
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return SizedBox(
|
||||
width: 240,
|
||||
width: 200,
|
||||
child: Card(
|
||||
elevation: 2,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
padding: const EdgeInsets.all(20.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Icon(icon, color: color, size: 24),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
loading
|
||||
? const SizedBox(height: 22, width: 22, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: Text((value ?? 0).toString(), style: theme.textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w600)),
|
||||
? const SizedBox(
|
||||
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 {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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. ✅ همیشه حداقل یک ستون در حالت نمایش باقی میماند
|
||||
|
||||
سیستم اکنون کاملاً پایدار و کاربرپسند است.
|
||||
|
|
@ -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 با کلیدهای کاملاً متفاوت ذخیره میشود
|
||||
|
||||
## خلاصه
|
||||
|
||||
سیستم به گونهای طراحی شده که **هیچ تداخلی بین تنظیمات جدولهای مختلف وجود ندارد**. هر جدول با شناسه منحصر به فرد خود تنظیماتش را ذخیره و بازیابی میکند.
|
||||
333
hesabixUI/hesabix_ui/lib/widgets/data_table/README.md
Normal file
333
hesabixUI/hesabix_ui/lib/widgets/data_table/README.md
Normal 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 قابل مشاهده است.
|
||||
|
|
@ -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
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
1588
hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_widget.dart
Normal file
1588
hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_widget.dart
Normal file
File diff suppressed because it is too large
Load diff
261
hesabixUI/hesabix_ui/lib/widgets/data_table/example_usage.dart
Normal file
261
hesabixUI/hesabix_ui/lib/widgets/data_table/example_usage.dart
Normal 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() ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -58,7 +58,7 @@ class _DateInputFieldState extends State<DateInputField> {
|
|||
void didUpdateWidget(DateInputField oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.value != widget.value ||
|
||||
oldWidget.calendarController.isJalali != widget.calendarController.isJalali) {
|
||||
(oldWidget.calendarController.isJalali == true) != (widget.calendarController.isJalali == true)) {
|
||||
_updateDisplayValue();
|
||||
}
|
||||
}
|
||||
|
|
@ -76,7 +76,7 @@ class _DateInputFieldState extends State<DateInputField> {
|
|||
void _updateDisplayValue() {
|
||||
final displayValue = HesabixDateUtils.formatForDisplay(
|
||||
widget.value,
|
||||
widget.calendarController.isJalali
|
||||
widget.calendarController.isJalali == true
|
||||
);
|
||||
_controller.text = displayValue;
|
||||
}
|
||||
|
|
@ -91,7 +91,7 @@ class _DateInputFieldState extends State<DateInputField> {
|
|||
|
||||
DateTime? selectedDate;
|
||||
|
||||
if (widget.calendarController.isJalali) {
|
||||
if (widget.calendarController.isJalali == true) {
|
||||
selectedDate = await showJalaliDatePicker(
|
||||
context: context,
|
||||
initialDate: initialDate,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:persian_datetime_picker/persian_datetime_picker.dart' as picker;
|
||||
import 'package:shamsi_date/shamsi_date.dart';
|
||||
|
||||
/// DatePicker سفارشی برای تقویم شمسی
|
||||
|
|
@ -124,7 +123,7 @@ class _JalaliDatePickerState extends State<JalaliDatePicker> {
|
|||
}
|
||||
|
||||
Widget _buildCalendar() {
|
||||
return picker.PersianCalendarDatePicker(
|
||||
return _CustomPersianCalendar(
|
||||
initialDate: _selectedJalali,
|
||||
firstDate: Jalali.fromDateTime(widget.firstDate ?? DateTime(1900)),
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -57,6 +57,14 @@ packages:
|
|||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.0.8"
|
||||
data_table_2:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: data_table_2
|
||||
sha256: b8dd157e4efe5f2beef092c9952a254b2192cf76a26ad1c6aa8b06c8b9d665da
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.6.0"
|
||||
dio:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -366,7 +374,7 @@ packages:
|
|||
source: hosted
|
||||
version: "2.1.8"
|
||||
shamsi_date:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: shamsi_date
|
||||
sha256: "0383fddc9bce91e9e08de0c909faf93c3ab3a0e532abd271fb0dcf5d0617487b"
|
||||
|
|
|
|||
|
|
@ -42,7 +42,9 @@ dependencies:
|
|||
flutter_secure_storage: ^9.2.2
|
||||
uuid: ^4.4.2
|
||||
persian_datetime_picker: ^3.2.0
|
||||
shamsi_date: ^1.1.1
|
||||
intl: ^0.20.0
|
||||
data_table_2: ^2.5.12
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
|
|
|||
132
hesabixUI/hesabix_ui/test/column_settings_isolation_test.dart
Normal file
132
hesabixUI/hesabix_ui/test/column_settings_isolation_test.dart
Normal 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']));
|
||||
});
|
||||
});
|
||||
}
|
||||
88
hesabixUI/hesabix_ui/test/column_settings_test.dart
Normal file
88
hesabixUI/hesabix_ui/test/column_settings_test.dart
Normal 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}));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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'
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue