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