almost finish persons

This commit is contained in:
Hesabix 2025-09-29 19:19:24 +03:30
parent a409202f6f
commit a371499e31
15 changed files with 1080 additions and 152 deletions

View file

@ -713,9 +713,37 @@ async def import_persons_excel(
from openpyxl import load_workbook
from fastapi import HTTPException
import logging
import zipfile
logger = logging.getLogger(__name__)
def validate_excel_file(content: bytes) -> bool:
"""
Validate if the content is a valid Excel file
"""
try:
# Check if it starts with PK signature (zip file)
if not content.startswith(b'PK'):
return False
# Try to open as zip file
with zipfile.ZipFile(io.BytesIO(content), 'r') as zip_file:
file_list = zip_file.namelist()
# Check for Excel structure (xl/ folder for .xlsx files)
excel_structure = any(f.startswith('xl/') for f in file_list)
if excel_structure:
return True
# Check for older Excel format (.xls) - this would be a different structure
# But since we only support .xlsx, we'll return False for .xls
return False
except zipfile.BadZipFile:
logger.error("File is not a valid zip file")
return False
except Exception as e:
logger.error(f"Error validating Excel file: {str(e)}")
return False
try:
# Convert dry_run string to boolean
dry_run_bool = dry_run.lower() in ('true', '1', 'yes', 'on')
@ -730,18 +758,27 @@ async def import_persons_excel(
content = await file.read()
logger.info(f"File content size: {len(content)} bytes")
# Log first few bytes for debugging
logger.info(f"File header (first 20 bytes): {content[:20].hex()}")
logger.info(f"File header (first 20 bytes as text): {content[:20]}")
# Check if content is empty or too small
if len(content) < 100:
logger.error(f"File too small: {len(content)} bytes")
raise HTTPException(status_code=400, detail="فایل خیلی کوچک است یا خالی است")
# Check if it's a valid Excel file by looking at the first few bytes
if not content.startswith(b'PK'):
logger.error("File does not start with PK signature (not a valid Excel file)")
# Validate Excel file format
if not validate_excel_file(content):
logger.error("File is not a valid Excel file")
raise HTTPException(status_code=400, detail="فرمت فایل معتبر نیست. فایل Excel معتبر نیست")
try:
# Try to load the workbook with additional error handling
wb = load_workbook(filename=io.BytesIO(content), data_only=True)
logger.info(f"Successfully loaded workbook with {len(wb.worksheets)} worksheets")
except zipfile.BadZipFile as e:
logger.error(f"Bad zip file error: {str(e)}")
raise HTTPException(status_code=400, detail="فایل Excel خراب است یا فرمت آن معتبر نیست")
except Exception as e:
logger.error(f"Error loading workbook: {str(e)}")
raise HTTPException(status_code=400, detail=f"امکان خواندن فایل وجود ندارد: {str(e)}")
@ -835,21 +872,29 @@ async def import_persons_excel(
for data in valid_items:
existing = find_existing(db, data)
match_value = None
try:
match_value = data.get(match_by)
except Exception:
match_value = None
if existing is None:
# create
try:
create_person(db, business_id, PersonCreateRequest(**data))
inserted += 1
except Exception:
except Exception as e:
logger.error(f"Create person failed for data={data}: {str(e)}")
skipped += 1
else:
if conflict_policy == 'insert':
logger.info(f"Skipping existing person (match_by={match_by}, value={match_value}) due to conflict_policy=insert")
skipped += 1
elif conflict_policy in ('update', 'upsert'):
try:
update_person(db, existing.id, business_id, PersonUpdateRequest(**data))
updated += 1
except Exception:
except Exception as e:
logger.error(f"Update person failed for id={existing.id}, data={data}: {str(e)}")
skipped += 1
summary = {

View file

@ -29,7 +29,6 @@ class PersonBankAccountUpdateRequest(BaseModel):
account_number: Optional[str] = Field(default=None, max_length=50, description="شماره حساب")
card_number: Optional[str] = Field(default=None, max_length=20, description="شماره کارت")
sheba_number: Optional[str] = Field(default=None, max_length=30, description="شماره شبا")
is_active: Optional[bool] = Field(default=None, description="وضعیت فعال بودن")
class PersonBankAccountResponse(BaseModel):
@ -40,7 +39,6 @@ class PersonBankAccountResponse(BaseModel):
account_number: Optional[str] = Field(default=None, description="شماره حساب")
card_number: Optional[str] = Field(default=None, description="شماره کارت")
sheba_number: Optional[str] = Field(default=None, description="شماره شبا")
is_active: bool = Field(..., description="وضعیت فعال بودن")
created_at: str = Field(..., description="تاریخ ایجاد")
updated_at: str = Field(..., description="تاریخ آخرین بروزرسانی")
@ -142,8 +140,6 @@ class PersonUpdateRequest(BaseModel):
email: Optional[str] = Field(default=None, max_length=255, description="پست الکترونیکی")
website: Optional[str] = Field(default=None, max_length=255, description="وب‌سایت")
# وضعیت
is_active: Optional[bool] = Field(default=None, description="وضعیت فعال بودن")
# سهام
share_count: Optional[int] = Field(default=None, ge=1, description="تعداد سهام (برای سهامدار)")
# پورسانت
@ -210,9 +206,6 @@ class PersonResponse(BaseModel):
email: Optional[str] = Field(default=None, description="پست الکترونیکی")
website: Optional[str] = Field(default=None, description="وب‌سایت")
# وضعیت
is_active: bool = Field(..., description="وضعیت فعال بودن")
# زمان‌بندی
created_at: str = Field(..., description="تاریخ ایجاد")
updated_at: str = Field(..., description="تاریخ آخرین بروزرسانی")

View file

@ -245,15 +245,17 @@ def create_app() -> FastAPI:
async def smart_number_normalizer(request: Request, call_next):
"""Middleware هوشمند برای تبدیل اعداد فارسی/عربی به انگلیسی"""
if SmartNormalizerConfig.ENABLED and request.method in ["POST", "PUT", "PATCH"]:
# خواندن body درخواست
body = await request.body()
if body:
# تبدیل اعداد در JSON
normalized_body = smart_normalize_json(body)
if normalized_body != body:
# ایجاد request جدید با body تبدیل شده
request._body = normalized_body
# فقط برای درخواست‌های JSON اعمال شود تا فایل‌های باینری/چندبخشی خراب نشوند
content_type = request.headers.get("Content-Type", "").lower()
if content_type.startswith("application/json"):
# خواندن body درخواست
body = await request.body()
if body:
# تبدیل اعداد در JSON
normalized_body = smart_normalize_json(body)
if normalized_body != body:
# ایجاد request جدید با body تبدیل شده
request._body = normalized_body
response = await call_next(request)
return response

View file

@ -34,6 +34,9 @@ def create_person(db: Session, business_id: int, person_data: PersonCreateReques
t = person_data.person_type
types_list = [t.value if hasattr(t, 'value') else str(t)]
# نوع تکی برای استفاده‌های بعدی (قبل از هر استفاده تعریف شود)
incoming_single_type = getattr(person_data, 'person_type', None)
# اعتبارسنجی سهام برای سهامدار
is_shareholder = False
if types_list:
@ -50,7 +53,6 @@ def create_person(db: Session, business_id: int, person_data: PersonCreateReques
# ایجاد شخص
# نگاشت person_type دریافتی از اسکیما به Enum مدل
incoming_single_type = getattr(person_data, 'person_type', None)
mapped_single_type = None
if incoming_single_type is not None:
try:

View file

@ -124,6 +124,7 @@ migrations/versions/20250927_000019_seed_accounts_chart.py
migrations/versions/20250927_000020_add_share_count_and_shareholder_type.py
migrations/versions/20250927_000021_update_person_type_enum_to_persian.py
migrations/versions/20250927_000022_add_person_commission_fields.py
migrations/versions/20250928_000023_remove_person_is_active_force.py
migrations/versions/4b2ea782bcb3_merge_heads.py
migrations/versions/5553f8745c6e_add_support_tables.py
migrations/versions/d3e84892c1c2_sync_person_type_enum_values_callable_.py

View file

@ -0,0 +1,56 @@
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision = '20250928_000023_remove_person_is_active_force'
down_revision = '4b2ea782bcb3'
branch_labels = None
depends_on = None
def upgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
tables = set(inspector.get_table_names())
# Drop is_active from persons if exists
if 'persons' in tables:
columns = {col['name'] for col in inspector.get_columns('persons')}
if 'is_active' in columns:
with op.batch_alter_table('persons') as batch_op:
try:
batch_op.drop_column('is_active')
except Exception:
pass
# Drop is_active from person_bank_accounts if exists
if 'person_bank_accounts' in tables:
columns = {col['name'] for col in inspector.get_columns('person_bank_accounts')}
if 'is_active' in columns:
with op.batch_alter_table('person_bank_accounts') as batch_op:
try:
batch_op.drop_column('is_active')
except Exception:
pass
def downgrade() -> None:
# Recreate columns with safe defaults if needed
bind = op.get_bind()
inspector = inspect(bind)
tables = set(inspector.get_table_names())
if 'persons' in tables:
columns = {col['name'] for col in inspector.get_columns('persons')}
if 'is_active' not in columns:
with op.batch_alter_table('persons') as batch_op:
batch_op.add_column(sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('1')))
if 'person_bank_accounts' in tables:
columns = {col['name'] for col in inspector.get_columns('person_bank_accounts')}
if 'is_active' not in columns:
with op.batch_alter_table('person_bank_accounts') as batch_op:
batch_op.add_column(sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('1')))

View file

@ -166,6 +166,7 @@
"exportSuccess": "Export completed successfully",
"exportError": "Export error",
"export": "Export",
"importFromExcel": "Import from Excel",
"rowNumber": "Row",
"firstName": "First Name",
"lastName": "Last Name",
@ -489,6 +490,7 @@
"printDocuments": "Print Documents",
"people": "People",
"peopleList": "People List",
"personCode": "Person Code",
"receipts": "Receipts",
"payments": "Payments",
"receiptsAndPayments": "Receipts and Payments",
@ -858,6 +860,59 @@
"buy": "Buy",
"templates": "Templates",
"history": "History",
"business": "Business"
"business": "Business",
"shareCount": "Share Count",
"commissionSalePercentLabel": "Commission Sale Percent",
"commissionSalesReturnPercentLabel": "Commission Sales Return Percent",
"commissionSalesAmountLabel": "Commission Sales Amount",
"commissionSalesReturnAmountLabel": "Commission Sales Return Amount",
"importPersonsFromExcel": "Import Persons from Excel",
"selectedFile": "Selected file",
"noFileSelected": "No file selected",
"chooseFile": "Choose file",
"matchBy": "Match by",
"code": "code",
"conflictPolicy": "Conflict policy",
"policyInsertOnly": "Insert-only",
"policyUpdateExisting": "Update existing",
"policyUpsert": "Upsert",
"dryRun": "Dry run",
"dryRunValidateOnly": "Dry run (validate only)",
"downloadTemplate": "Download template",
"reviewDryRun": "Review (Dry run)",
"import": "Import",
"importReal": "Import (real)",
"templateDownloaded": "Template downloaded",
"pickFileError": "Error picking file",
"templateDownloadError": "Error downloading template",
"importError": "Import error",
"result": "Result",
"valid": "Valid",
"invalid": "Invalid",
"inserted": "Inserted",
"updated": "Updated",
"skipped": "Skipped",
"yes": "Yes",
"no": "No",
"row": "Row",
"onlyForMarketerSeller": "This section is shown only for marketer/seller",
"percentFromSales": "Percent from sales",
"percentFromSalesReturn": "Percent from sales return",
"salesAmount": "Sales amount",
"salesReturnAmount": "Sales return amount",
"mustBeBetweenZeroAndHundred": "Must be between 0 and 100",
"mustBePositiveNumber": "Must be a positive number",
"personCodeOptional": "Person code (optional)",
"uniqueCodeNumeric": "Unique code (numeric)",
"automatic": "Automatic",
"manual": "Manual",
"personCodeRequired": "Person code is required",
"codeMustBeNumeric": "Code must be numeric",
"integerNoDecimal": "Integer number (no decimals)",
"shareholderShareCountRequired": "For shareholder, share count is required",
"noBankAccountsAdded": "No bank accounts added",
"commissionExcludeDiscounts": "Exclude discounts from commission",
"commissionExcludeAdditionsDeductions": "Exclude additions/deductions from commission",
"commissionPostInInvoiceDocument": "Post commission in invoice accounting document"
}

View file

@ -123,7 +123,7 @@
"hideFilters": "مخفی کردن فیلترها",
"showFilters": "نمایش فیلترها",
"clear": "پاک کردن",
"searchInNameEmail": "جستجو در نام، نام خانوادگی و ایمیل...",
"searchInNameEmail": "جستجو",
"recordsPerPage": "تعداد در صفحه",
"records": "رکورد",
"test": "تست",
@ -165,6 +165,7 @@
"exportSuccess": "خروجی با موفقیت انجام شد",
"exportError": "خطا در خروجی",
"export": "خروجی",
"importFromExcel": "ایمپورت از اکسل",
"rowNumber": "ردیف",
"firstName": "نام",
"lastName": "نام خانوادگی",
@ -488,6 +489,7 @@
"printDocuments": "چاپ اسناد",
"people": "اشخاص",
"peopleList": "لیست اشخاص",
"personCode": "کد شخص",
"receipts": "دریافت‌ها",
"payments": "پرداخت‌ها",
"receiptsAndPayments": "دریافت و پرداخت",
@ -737,7 +739,6 @@
"eventHistory": "تاریخچه رویدادها",
"usersAndPermissions": "کاربران و دسترسی‌ها",
"storageSpace": "فضای ذخیره‌سازی",
"viewStorage": "مشاهده فضای ذخیره‌سازی",
"deleteFiles": "حذف فایل‌ها",
"smsPanel": "پنل پیامک",
"viewSmsHistory": "مشاهده تاریخچه پیامک‌ها",
@ -780,6 +781,7 @@
"accountManagement": "مدیریت حساب کاربری",
"persons": "اشخاص",
"personsList": "لیست اشخاص",
"personCode": "کد شخص",
"addPerson": "افزودن شخص",
"editPerson": "ویرایش شخص",
"personDetails": "جزئیات شخص",
@ -857,6 +859,59 @@
"buy": "خرید",
"templates": "قالب‌ها",
"history": "تاریخچه",
"business": "کسب و کار"
"business": "کسب و کار",
"shareCount": "تعداد سهام",
"commissionSalePercentLabel": "درصد پورسانت فروش",
"commissionSalesReturnPercentLabel": "درصد پورسانت برگشت از فروش",
"commissionSalesAmountLabel": "مبلغ پورسانت فروش",
"commissionSalesReturnAmountLabel": "مبلغ پورسانت برگشت از فروش",
"importPersonsFromExcel": "ایمپورت اشخاص از اکسل",
"selectedFile": "فایل انتخاب‌شده",
"noFileSelected": "هیچ فایلی انتخاب نشده",
"chooseFile": "انتخاب فایل",
"matchBy": "معیار تطبیق",
"code": "کد",
"conflictPolicy": "سیاست تداخل",
"policyInsertOnly": "فقط ایجاد",
"policyUpdateExisting": "به‌روزرسانی موجود",
"policyUpsert": "آپسرت",
"dryRun": "اجرای آزمایشی",
"dryRunValidateOnly": "اجرای آزمایشی (فقط اعتبارسنجی)",
"downloadTemplate": "دانلود تمپلیت",
"reviewDryRun": "بررسی (اجرای آزمایشی)",
"import": "ایمپورت",
"importReal": "ایمپورت واقعی",
"templateDownloaded": "تمپلیت دانلود شد",
"pickFileError": "خطا در انتخاب فایل",
"templateDownloadError": "خطا در دانلود تمپلیت",
"importError": "خطا در ایمپورت",
"result": "نتیجه",
"valid": "معتبر",
"invalid": "نامعتبر",
"inserted": "ایجاد شده",
"updated": "به‌روزرسانی",
"skipped": "رد شده",
"yes": "بله",
"no": "خیر",
"row": "ردیف",
"onlyForMarketerSeller": "این بخش فقط برای بازاریاب/فروشنده نمایش داده می‌شود",
"percentFromSales": "درصد از فروش",
"percentFromSalesReturn": "درصد از برگشت از فروش",
"salesAmount": "مبلغ فروش",
"salesReturnAmount": "مبلغ برگشت از فروش",
"mustBeBetweenZeroAndHundred": "باید بین 0 تا 100 باشد",
"mustBePositiveNumber": "باید عدد مثبت باشد",
"personCodeOptional": "کد شخص (اختیاری)",
"uniqueCodeNumeric": "کد یکتا (عددی)",
"automatic": "اتوماتیک",
"manual": "دستی",
"personCodeRequired": "کد شخص الزامی است",
"codeMustBeNumeric": "کد باید عددی باشد",
"integerNoDecimal": "عدد صحیح بدون اعشار",
"shareholderShareCountRequired": "برای سهامدار، تعداد سهام الزامی است",
"noBankAccountsAdded": "هیچ حساب بانکی اضافه نشده است",
"commissionExcludeDiscounts": "عدم محاسبه تخفیف",
"commissionExcludeAdditionsDeductions": "عدم محاسبه اضافات و کسورات فاکتور",
"commissionPostInInvoiceDocument": "ثبت پورسانت در سند حسابداری فاکتور"
}

View file

@ -1022,6 +1022,12 @@ abstract class AppLocalizations {
/// **'Export'**
String get export;
/// No description provided for @importFromExcel.
///
/// In en, this message translates to:
/// **'Import from Excel'**
String get importFromExcel;
/// No description provided for @rowNumber.
///
/// In en, this message translates to:
@ -2744,6 +2750,12 @@ abstract class AppLocalizations {
/// **'People List'**
String get peopleList;
/// No description provided for @personCode.
///
/// In en, this message translates to:
/// **'Person Code'**
String get personCode;
/// No description provided for @receipts.
///
/// In en, this message translates to:
@ -4627,6 +4639,324 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Business'**
String get business;
/// No description provided for @shareCount.
///
/// In en, this message translates to:
/// **'Share Count'**
String get shareCount;
/// No description provided for @commissionSalePercentLabel.
///
/// In en, this message translates to:
/// **'Commission Sale Percent'**
String get commissionSalePercentLabel;
/// No description provided for @commissionSalesReturnPercentLabel.
///
/// In en, this message translates to:
/// **'Commission Sales Return Percent'**
String get commissionSalesReturnPercentLabel;
/// No description provided for @commissionSalesAmountLabel.
///
/// In en, this message translates to:
/// **'Commission Sales Amount'**
String get commissionSalesAmountLabel;
/// No description provided for @commissionSalesReturnAmountLabel.
///
/// In en, this message translates to:
/// **'Commission Sales Return Amount'**
String get commissionSalesReturnAmountLabel;
/// No description provided for @importPersonsFromExcel.
///
/// In en, this message translates to:
/// **'Import Persons from Excel'**
String get importPersonsFromExcel;
/// No description provided for @selectedFile.
///
/// In en, this message translates to:
/// **'Selected file'**
String get selectedFile;
/// No description provided for @noFileSelected.
///
/// In en, this message translates to:
/// **'No file selected'**
String get noFileSelected;
/// No description provided for @chooseFile.
///
/// In en, this message translates to:
/// **'Choose file'**
String get chooseFile;
/// No description provided for @matchBy.
///
/// In en, this message translates to:
/// **'Match by'**
String get matchBy;
/// No description provided for @code.
///
/// In en, this message translates to:
/// **'code'**
String get code;
/// No description provided for @conflictPolicy.
///
/// In en, this message translates to:
/// **'Conflict policy'**
String get conflictPolicy;
/// No description provided for @policyInsertOnly.
///
/// In en, this message translates to:
/// **'Insert-only'**
String get policyInsertOnly;
/// No description provided for @policyUpdateExisting.
///
/// In en, this message translates to:
/// **'Update existing'**
String get policyUpdateExisting;
/// No description provided for @policyUpsert.
///
/// In en, this message translates to:
/// **'Upsert'**
String get policyUpsert;
/// No description provided for @dryRun.
///
/// In en, this message translates to:
/// **'Dry run'**
String get dryRun;
/// No description provided for @dryRunValidateOnly.
///
/// In en, this message translates to:
/// **'Dry run (validate only)'**
String get dryRunValidateOnly;
/// No description provided for @downloadTemplate.
///
/// In en, this message translates to:
/// **'Download template'**
String get downloadTemplate;
/// No description provided for @reviewDryRun.
///
/// In en, this message translates to:
/// **'Review (Dry run)'**
String get reviewDryRun;
/// No description provided for @import.
///
/// In en, this message translates to:
/// **'Import'**
String get import;
/// No description provided for @importReal.
///
/// In en, this message translates to:
/// **'Import (real)'**
String get importReal;
/// No description provided for @templateDownloaded.
///
/// In en, this message translates to:
/// **'Template downloaded'**
String get templateDownloaded;
/// No description provided for @pickFileError.
///
/// In en, this message translates to:
/// **'Error picking file'**
String get pickFileError;
/// No description provided for @templateDownloadError.
///
/// In en, this message translates to:
/// **'Error downloading template'**
String get templateDownloadError;
/// No description provided for @importError.
///
/// In en, this message translates to:
/// **'Import error'**
String get importError;
/// No description provided for @result.
///
/// In en, this message translates to:
/// **'Result'**
String get result;
/// No description provided for @valid.
///
/// In en, this message translates to:
/// **'Valid'**
String get valid;
/// No description provided for @invalid.
///
/// In en, this message translates to:
/// **'Invalid'**
String get invalid;
/// No description provided for @inserted.
///
/// In en, this message translates to:
/// **'Inserted'**
String get inserted;
/// No description provided for @updated.
///
/// In en, this message translates to:
/// **'Updated'**
String get updated;
/// No description provided for @skipped.
///
/// In en, this message translates to:
/// **'Skipped'**
String get skipped;
/// No description provided for @yes.
///
/// In en, this message translates to:
/// **'Yes'**
String get yes;
/// No description provided for @no.
///
/// In en, this message translates to:
/// **'No'**
String get no;
/// No description provided for @row.
///
/// In en, this message translates to:
/// **'Row'**
String get row;
/// No description provided for @onlyForMarketerSeller.
///
/// In en, this message translates to:
/// **'This section is shown only for marketer/seller'**
String get onlyForMarketerSeller;
/// No description provided for @percentFromSales.
///
/// In en, this message translates to:
/// **'Percent from sales'**
String get percentFromSales;
/// No description provided for @percentFromSalesReturn.
///
/// In en, this message translates to:
/// **'Percent from sales return'**
String get percentFromSalesReturn;
/// No description provided for @salesAmount.
///
/// In en, this message translates to:
/// **'Sales amount'**
String get salesAmount;
/// No description provided for @salesReturnAmount.
///
/// In en, this message translates to:
/// **'Sales return amount'**
String get salesReturnAmount;
/// No description provided for @mustBeBetweenZeroAndHundred.
///
/// In en, this message translates to:
/// **'Must be between 0 and 100'**
String get mustBeBetweenZeroAndHundred;
/// No description provided for @mustBePositiveNumber.
///
/// In en, this message translates to:
/// **'Must be a positive number'**
String get mustBePositiveNumber;
/// No description provided for @personCodeOptional.
///
/// In en, this message translates to:
/// **'Person code (optional)'**
String get personCodeOptional;
/// No description provided for @uniqueCodeNumeric.
///
/// In en, this message translates to:
/// **'Unique code (numeric)'**
String get uniqueCodeNumeric;
/// No description provided for @automatic.
///
/// In en, this message translates to:
/// **'Automatic'**
String get automatic;
/// No description provided for @manual.
///
/// In en, this message translates to:
/// **'Manual'**
String get manual;
/// No description provided for @personCodeRequired.
///
/// In en, this message translates to:
/// **'Person code is required'**
String get personCodeRequired;
/// No description provided for @codeMustBeNumeric.
///
/// In en, this message translates to:
/// **'Code must be numeric'**
String get codeMustBeNumeric;
/// No description provided for @integerNoDecimal.
///
/// In en, this message translates to:
/// **'Integer number (no decimals)'**
String get integerNoDecimal;
/// No description provided for @shareholderShareCountRequired.
///
/// In en, this message translates to:
/// **'For shareholder, share count is required'**
String get shareholderShareCountRequired;
/// No description provided for @noBankAccountsAdded.
///
/// In en, this message translates to:
/// **'No bank accounts added'**
String get noBankAccountsAdded;
/// No description provided for @commissionExcludeDiscounts.
///
/// In en, this message translates to:
/// **'Exclude discounts from commission'**
String get commissionExcludeDiscounts;
/// No description provided for @commissionExcludeAdditionsDeductions.
///
/// In en, this message translates to:
/// **'Exclude additions/deductions from commission'**
String get commissionExcludeAdditionsDeductions;
/// No description provided for @commissionPostInInvoiceDocument.
///
/// In en, this message translates to:
/// **'Post commission in invoice accounting document'**
String get commissionPostInInvoiceDocument;
}
class _AppLocalizationsDelegate

View file

@ -485,6 +485,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get export => 'Export';
@override
String get importFromExcel => 'Import from Excel';
@override
String get rowNumber => 'Row';
@ -1373,6 +1376,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get peopleList => 'People List';
@override
String get personCode => 'Person Code';
@override
String get receipts => 'Receipts';
@ -2332,4 +2338,169 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get business => 'Business';
@override
String get shareCount => 'Share Count';
@override
String get commissionSalePercentLabel => 'Commission Sale Percent';
@override
String get commissionSalesReturnPercentLabel =>
'Commission Sales Return Percent';
@override
String get commissionSalesAmountLabel => 'Commission Sales Amount';
@override
String get commissionSalesReturnAmountLabel =>
'Commission Sales Return Amount';
@override
String get importPersonsFromExcel => 'Import Persons from Excel';
@override
String get selectedFile => 'Selected file';
@override
String get noFileSelected => 'No file selected';
@override
String get chooseFile => 'Choose file';
@override
String get matchBy => 'Match by';
@override
String get code => 'code';
@override
String get conflictPolicy => 'Conflict policy';
@override
String get policyInsertOnly => 'Insert-only';
@override
String get policyUpdateExisting => 'Update existing';
@override
String get policyUpsert => 'Upsert';
@override
String get dryRun => 'Dry run';
@override
String get dryRunValidateOnly => 'Dry run (validate only)';
@override
String get downloadTemplate => 'Download template';
@override
String get reviewDryRun => 'Review (Dry run)';
@override
String get import => 'Import';
@override
String get importReal => 'Import (real)';
@override
String get templateDownloaded => 'Template downloaded';
@override
String get pickFileError => 'Error picking file';
@override
String get templateDownloadError => 'Error downloading template';
@override
String get importError => 'Import error';
@override
String get result => 'Result';
@override
String get valid => 'Valid';
@override
String get invalid => 'Invalid';
@override
String get inserted => 'Inserted';
@override
String get updated => 'Updated';
@override
String get skipped => 'Skipped';
@override
String get yes => 'Yes';
@override
String get no => 'No';
@override
String get row => 'Row';
@override
String get onlyForMarketerSeller =>
'This section is shown only for marketer/seller';
@override
String get percentFromSales => 'Percent from sales';
@override
String get percentFromSalesReturn => 'Percent from sales return';
@override
String get salesAmount => 'Sales amount';
@override
String get salesReturnAmount => 'Sales return amount';
@override
String get mustBeBetweenZeroAndHundred => 'Must be between 0 and 100';
@override
String get mustBePositiveNumber => 'Must be a positive number';
@override
String get personCodeOptional => 'Person code (optional)';
@override
String get uniqueCodeNumeric => 'Unique code (numeric)';
@override
String get automatic => 'Automatic';
@override
String get manual => 'Manual';
@override
String get personCodeRequired => 'Person code is required';
@override
String get codeMustBeNumeric => 'Code must be numeric';
@override
String get integerNoDecimal => 'Integer number (no decimals)';
@override
String get shareholderShareCountRequired =>
'For shareholder, share count is required';
@override
String get noBankAccountsAdded => 'No bank accounts added';
@override
String get commissionExcludeDiscounts => 'Exclude discounts from commission';
@override
String get commissionExcludeAdditionsDeductions =>
'Exclude additions/deductions from commission';
@override
String get commissionPostInInvoiceDocument =>
'Post commission in invoice accounting document';
}

View file

@ -362,7 +362,7 @@ class AppLocalizationsFa extends AppLocalizations {
String get clear => 'پاک کردن';
@override
String get searchInNameEmail => 'جستجو در نام، نام خانوادگی و ایمیل...';
String get searchInNameEmail => 'جستجو';
@override
String get recordsPerPage => 'سطر در هر صفحه';
@ -484,6 +484,9 @@ class AppLocalizationsFa extends AppLocalizations {
@override
String get export => 'خروجی';
@override
String get importFromExcel => 'ایمپورت از اکسل';
@override
String get rowNumber => 'ردیف';
@ -1362,6 +1365,9 @@ class AppLocalizationsFa extends AppLocalizations {
@override
String get peopleList => 'لیست اشخاص';
@override
String get personCode => 'کد شخص';
@override
String get receipts => 'دریافت‌ها';
@ -2010,7 +2016,7 @@ class AppLocalizationsFa extends AppLocalizations {
String get eventHistory => 'تاریخچه رویدادها';
@override
String get viewStorage => 'مشاهده فضای ذخیره‌سازی';
String get viewStorage => 'View Storage';
@override
String get deleteFiles => 'فایل‌ها';
@ -2316,4 +2322,167 @@ class AppLocalizationsFa extends AppLocalizations {
@override
String get business => 'کسب و کار';
@override
String get shareCount => 'تعداد سهام';
@override
String get commissionSalePercentLabel => 'درصد پورسانت فروش';
@override
String get commissionSalesReturnPercentLabel => 'درصد پورسانت برگشت از فروش';
@override
String get commissionSalesAmountLabel => 'مبلغ پورسانت فروش';
@override
String get commissionSalesReturnAmountLabel => 'مبلغ پورسانت برگشت از فروش';
@override
String get importPersonsFromExcel => 'ایمپورت اشخاص از اکسل';
@override
String get selectedFile => 'فایل انتخاب‌شده';
@override
String get noFileSelected => 'هیچ فایلی انتخاب نشده';
@override
String get chooseFile => 'انتخاب فایل';
@override
String get matchBy => 'معیار تطبیق';
@override
String get code => 'کد';
@override
String get conflictPolicy => 'سیاست تداخل';
@override
String get policyInsertOnly => 'فقط ایجاد';
@override
String get policyUpdateExisting => 'به‌روزرسانی موجود';
@override
String get policyUpsert => 'آپسرت';
@override
String get dryRun => 'اجرای آزمایشی';
@override
String get dryRunValidateOnly => 'اجرای آزمایشی (فقط اعتبارسنجی)';
@override
String get downloadTemplate => 'دانلود تمپلیت';
@override
String get reviewDryRun => 'بررسی (اجرای آزمایشی)';
@override
String get import => 'ایمپورت';
@override
String get importReal => 'ایمپورت واقعی';
@override
String get templateDownloaded => 'تمپلیت دانلود شد';
@override
String get pickFileError => 'خطا در انتخاب فایل';
@override
String get templateDownloadError => 'خطا در دانلود تمپلیت';
@override
String get importError => 'خطا در ایمپورت';
@override
String get result => 'نتیجه';
@override
String get valid => 'معتبر';
@override
String get invalid => 'نامعتبر';
@override
String get inserted => 'ایجاد شده';
@override
String get updated => 'به‌روزرسانی';
@override
String get skipped => 'رد شده';
@override
String get yes => 'بله';
@override
String get no => 'خیر';
@override
String get row => 'ردیف';
@override
String get onlyForMarketerSeller =>
'این بخش فقط برای بازاریاب/فروشنده نمایش داده می‌شود';
@override
String get percentFromSales => 'درصد از فروش';
@override
String get percentFromSalesReturn => 'درصد از برگشت از فروش';
@override
String get salesAmount => 'مبلغ فروش';
@override
String get salesReturnAmount => 'مبلغ برگشت از فروش';
@override
String get mustBeBetweenZeroAndHundred => 'باید بین 0 تا 100 باشد';
@override
String get mustBePositiveNumber => 'باید عدد مثبت باشد';
@override
String get personCodeOptional => 'کد شخص (اختیاری)';
@override
String get uniqueCodeNumeric => 'کد یکتا (عددی)';
@override
String get automatic => 'اتوماتیک';
@override
String get manual => 'دستی';
@override
String get personCodeRequired => 'کد شخص الزامی است';
@override
String get codeMustBeNumeric => 'کد باید عددی باشد';
@override
String get integerNoDecimal => 'عدد صحیح بدون اعشار';
@override
String get shareholderShareCountRequired =>
'برای سهامدار، تعداد سهام الزامی است';
@override
String get noBankAccountsAdded => 'هیچ حساب بانکی اضافه نشده است';
@override
String get commissionExcludeDiscounts => 'عدم محاسبه تخفیف';
@override
String get commissionExcludeAdditionsDeductions =>
'عدم محاسبه اضافات و کسورات فاکتور';
@override
String get commissionPostInInvoiceDocument =>
'ثبت پورسانت در سند حسابداری فاکتور';
}

View file

@ -65,7 +65,7 @@ class _PersonsPageState extends State<PersonsPage> {
columns: [
NumberColumn(
'code',
'کد شخص',
t.personCode,
width: ColumnWidth.small,
formatter: (person) => (person.code?.toString() ?? '-'),
textAlign: TextAlign.center,
@ -92,6 +92,16 @@ class _PersonsPageState extends State<PersonsPage> {
'person_type',
t.personType,
width: ColumnWidth.medium,
filterType: ColumnFilterType.multiSelect,
filterOptions: [
FilterOption(value: 'مشتری', label: t.personTypeCustomer),
FilterOption(value: 'بازاریاب', label: t.personTypeMarketer),
FilterOption(value: 'کارمند', label: t.personTypeEmployee),
FilterOption(value: 'تامین‌کننده', label: t.personTypeSupplier),
FilterOption(value: 'همکار', label: t.personTypePartner),
FilterOption(value: 'فروشنده', label: t.personTypeSeller),
FilterOption(value: 'سهامدار', label: 'سهامدار'),
],
formatter: (person) => (person.personTypes.isNotEmpty
? person.personTypes.map((e) => e.persianName).join('، ')
: person.personType.persianName),
@ -122,39 +132,39 @@ class _PersonsPageState extends State<PersonsPage> {
),
DateColumn(
'created_at',
'تاریخ ایجاد',
t.createdAt,
width: ColumnWidth.medium,
),
NumberColumn(
'share_count',
'تعداد سهام',
t.shareCount,
width: ColumnWidth.small,
textAlign: TextAlign.center,
decimalPlaces: 0,
),
NumberColumn(
'commission_sale_percent',
'درصد پورسانت فروش',
t.commissionSalePercentLabel,
width: ColumnWidth.medium,
decimalPlaces: 2,
suffix: '٪',
),
NumberColumn(
'commission_sales_return_percent',
'درصد پورسانت برگشت از فروش',
t.commissionSalesReturnPercentLabel,
width: ColumnWidth.medium,
decimalPlaces: 2,
suffix: '٪',
),
NumberColumn(
'commission_sales_amount',
'مبلغ پورسانت فروش',
t.commissionSalesAmountLabel,
width: ColumnWidth.large,
decimalPlaces: 0,
),
NumberColumn(
'commission_sales_return_amount',
'مبلغ پورسانت برگشت از فروش',
t.commissionSalesReturnAmountLabel,
width: ColumnWidth.large,
decimalPlaces: 0,
),
@ -226,7 +236,7 @@ class _PersonsPageState extends State<PersonsPage> {
),
ActionColumn(
'actions',
'عملیات',
t.actions,
actions: [
DataTableAction(
icon: Icons.edit,
@ -255,7 +265,6 @@ class _PersonsPageState extends State<PersonsPage> {
filterFields: [
'person_type',
'person_types',
'is_active',
'country',
'province',
],
@ -274,25 +283,42 @@ class _PersonsPageState extends State<PersonsPage> {
),
),
),
Tooltip(
message: 'ایمپورت اشخاص از اکسل',
child: IconButton(
onPressed: () async {
final ok = await showDialog<bool>(
context: context,
builder: (context) => PersonImportDialog(businessId: widget.businessId),
);
if (ok == true) {
final state = _personsTableKey.currentState;
try {
// ignore: avoid_dynamic_calls
(state as dynamic)?.refresh();
} catch (_) {}
}
},
icon: const Icon(Icons.upload_file),
),
),
Builder(builder: (context) {
final theme = Theme.of(context);
return Tooltip(
message: t.importFromExcel,
child: GestureDetector(
onTap: () async {
final ok = await showDialog<bool>(
context: context,
builder: (context) => PersonImportDialog(businessId: widget.businessId),
);
if (ok == true) {
final state = _personsTableKey.currentState;
try {
// ignore: avoid_dynamic_calls
(state as dynamic)?.refresh();
} catch (_) {}
}
},
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: theme.colorScheme.outline.withValues(alpha: 0.3),
),
),
child: Icon(
Icons.upload_file,
size: 16,
color: theme.colorScheme.onSurfaceVariant,
),
),
),
);
}),
],
);
}

View file

@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'helpers/file_saver.dart';
// // import 'dart:html' as html; // Not available on Linux // Not available on Linux
@ -672,18 +673,24 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
// Platform-specific download functions for Linux
Future<void> _downloadPdf(dynamic data, String filename) async {
// For Linux desktop, we'll save to Downloads folder
print('Download PDF: $filename (Linux desktop - save to Downloads folder)');
// TODO: Implement proper file saving for Linux
await _saveBytesToDownloads(data, filename);
}
Future<void> _downloadExcel(dynamic data, String filename) async {
// For Linux desktop, we'll save to Downloads folder
print('Download Excel: $filename (Linux desktop - save to Downloads folder)');
// TODO: Implement proper file saving for Linux
await _saveBytesToDownloads(data, filename);
}
double _measureTextWidth(String text, TextStyle style) {
final painter = TextPainter(
text: TextSpan(text: text, style: style),
textDirection: TextDirection.ltr,
maxLines: 1,
)
..layout(minWidth: 0, maxWidth: double.infinity);
return painter.width;
}
@override
Widget build(BuildContext context) {
final t = Localizations.of<AppLocalizations>(context, AppLocalizations)!;
@ -1331,6 +1338,15 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
final dataColumnsToShow = columnsToShow.where((c) => c is! ActionColumn).toList();
columns.addAll(dataColumnsToShow.map((column) {
final headerTextStyle = theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
) ?? const TextStyle(fontSize: 14, fontWeight: FontWeight.w600);
final double baseWidth = DataTableUtils.getColumnWidth(column.width);
final double affordancePadding = 48.0;
final double headerTextWidth = _measureTextWidth(column.label, headerTextStyle) + affordancePadding;
final double computedWidth = math.max(baseWidth, headerTextWidth);
return DataColumn2(
label: _ColumnHeaderWithSearch(
text: column.label,
@ -1345,19 +1361,31 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
enabled: widget.config.enableSorting && column.sortable,
),
size: DataTableUtils.getColumnSize(column.width),
fixedWidth: DataTableUtils.getColumnWidth(column.width),
fixedWidth: computedWidth,
);
}));
return Scrollbar(
controller: _horizontalScrollController,
thumbVisibility: true,
child: DataTable2(
columnSpacing: 8,
horizontalMargin: 8,
minWidth: widget.config.minTableWidth ?? 600,
horizontalScrollController: _horizontalScrollController,
columns: columns,
child: DataTableTheme(
data: DataTableThemeData(
headingRowColor: MaterialStatePropertyAll(
theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.6),
),
headingTextStyle: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
color: theme.colorScheme.onSurface,
),
dividerThickness: 0.6,
),
child: DataTable2(
columnSpacing: 8,
horizontalMargin: 8,
minWidth: widget.config.minTableWidth ?? 600,
horizontalScrollController: _horizontalScrollController,
headingRowHeight: 44,
columns: columns,
rows: _items.asMap().entries.map((entry) {
final index = entry.key;
final item = entry.value;
@ -1433,6 +1461,7 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
);
}).toList(),
),
),
);
}

View file

@ -407,7 +407,7 @@ class _PersonFormDialogState extends State<PersonFormDialog> {
),
];
if (hasCommissionTab) {
tabs.add(const Tab(text: 'پورسانت'));
tabs.add(Tab(text: t.commissionSalePercentLabel));
views.add(
SingleChildScrollView(
child: Padding(
@ -467,11 +467,12 @@ class _PersonFormDialogState extends State<PersonFormDialog> {
}
Widget _buildCommissionTab() {
final t = AppLocalizations.of(context);
final isMarketer = _selectedPersonTypes.contains(PersonType.marketer);
final isSeller = _selectedPersonTypes.contains(PersonType.seller);
if (!isMarketer && !isSeller) {
return Center(
child: Text('این بخش فقط برای بازاریاب/فروشنده نمایش داده می‌شود'),
child: Text(t.onlyForMarketerSeller),
);
}
@ -482,8 +483,8 @@ class _PersonFormDialogState extends State<PersonFormDialog> {
Expanded(
child: TextFormField(
controller: _commissionSalePercentController,
decoration: const InputDecoration(
labelText: 'درصد از فروش',
decoration: InputDecoration(
labelText: t.percentFromSales,
suffixText: '%',
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
@ -491,7 +492,7 @@ class _PersonFormDialogState extends State<PersonFormDialog> {
validator: (v) {
if ((isMarketer || isSeller) && (v != null && v.isNotEmpty)) {
final num? val = num.tryParse(v);
if (val == null || val < 0 || val > 100) return 'باید بین 0 تا 100 باشد';
if (val == null || val < 0 || val > 100) return t.mustBeBetweenZeroAndHundred;
}
return null;
},
@ -501,8 +502,8 @@ class _PersonFormDialogState extends State<PersonFormDialog> {
Expanded(
child: TextFormField(
controller: _commissionSalesReturnPercentController,
decoration: const InputDecoration(
labelText: 'درصد از برگشت از فروش',
decoration: InputDecoration(
labelText: t.percentFromSalesReturn,
suffixText: '%',
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
@ -510,7 +511,7 @@ class _PersonFormDialogState extends State<PersonFormDialog> {
validator: (v) {
if ((isMarketer || isSeller) && (v != null && v.isNotEmpty)) {
final num? val = num.tryParse(v);
if (val == null || val < 0 || val > 100) return 'باید بین 0 تا 100 باشد';
if (val == null || val < 0 || val > 100) return t.mustBeBetweenZeroAndHundred;
}
return null;
},
@ -524,15 +525,15 @@ class _PersonFormDialogState extends State<PersonFormDialog> {
Expanded(
child: TextFormField(
controller: _commissionSalesAmountController,
decoration: const InputDecoration(
labelText: 'مبلغ فروش',
decoration: InputDecoration(
labelText: t.salesAmount,
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'[0-9.,]'))],
validator: (v) {
if (v != null && v.isNotEmpty) {
final num? val = num.tryParse(v);
if (val == null || val < 0) return 'باید عدد مثبت باشد';
if (val == null || val < 0) return t.mustBePositiveNumber;
}
return null;
},
@ -542,15 +543,15 @@ class _PersonFormDialogState extends State<PersonFormDialog> {
Expanded(
child: TextFormField(
controller: _commissionSalesReturnAmountController,
decoration: const InputDecoration(
labelText: 'مبلغ برگشت از فروش',
decoration: InputDecoration(
labelText: t.salesReturnAmount,
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'[0-9.,]'))],
validator: (v) {
if (v != null && v.isNotEmpty) {
final num? val = num.tryParse(v);
if (val == null || val < 0) return 'باید عدد مثبت باشد';
if (val == null || val < 0) return t.mustBePositiveNumber;
}
return null;
},
@ -563,7 +564,7 @@ class _PersonFormDialogState extends State<PersonFormDialog> {
children: [
Expanded(
child: SwitchListTile(
title: const Text('عدم محاسبه تخفیف'),
title: Text(t.commissionExcludeDiscounts),
value: _commissionExcludeDiscounts,
onChanged: (v) { setState(() { _commissionExcludeDiscounts = v; }); },
),
@ -571,7 +572,7 @@ class _PersonFormDialogState extends State<PersonFormDialog> {
const SizedBox(width: 16),
Expanded(
child: SwitchListTile(
title: const Text('عدم محاسبه اضافات و کسورات فاکتور'),
title: Text(t.commissionExcludeAdditionsDeductions),
value: _commissionExcludeAdditionsDeductions,
onChanged: (v) { setState(() { _commissionExcludeAdditionsDeductions = v; }); },
),
@ -580,7 +581,7 @@ class _PersonFormDialogState extends State<PersonFormDialog> {
),
const SizedBox(height: 12),
SwitchListTile(
title: const Text('ثبت پورسانت در سند حسابداری فاکتور'),
title: Text(t.commissionPostInInvoiceDocument),
value: _commissionPostInInvoiceDocument,
onChanged: (v) { setState(() { _commissionPostInInvoiceDocument = v; }); },
),
@ -608,8 +609,8 @@ class _PersonFormDialogState extends State<PersonFormDialog> {
controller: _codeController,
readOnly: _autoGenerateCode,
decoration: InputDecoration(
labelText: 'کد شخص (اختیاری)',
hintText: 'کد یکتا (عددی)',
labelText: t.personCodeOptional,
hintText: t.uniqueCodeNumeric,
suffixIcon: Container(
margin: const EdgeInsets.symmetric(vertical: 6, horizontal: 6),
padding: const EdgeInsets.all(2),
@ -626,14 +627,14 @@ class _PersonFormDialogState extends State<PersonFormDialog> {
_autoGenerateCode = (index == 0);
});
},
children: const [
children: [
Padding(
padding: EdgeInsets.symmetric(horizontal: 6),
child: Text('اتوماتیک'),
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Text(t.automatic),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 6),
child: Text('دستی'),
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Text(t.manual),
),
],
),
@ -643,10 +644,10 @@ class _PersonFormDialogState extends State<PersonFormDialog> {
validator: (value) {
if (!_autoGenerateCode) {
if (value == null || value.trim().isEmpty) {
return 'کد شخص الزامی است';
return t.personCodeRequired;
}
if (int.tryParse(value.trim()) == null) {
return 'کد باید عددی باشد';
return t.codeMustBeNumeric;
}
}
return null;
@ -684,15 +685,15 @@ class _PersonFormDialogState extends State<PersonFormDialog> {
Expanded(
child: TextFormField(
controller: _shareCountController,
decoration: const InputDecoration(
labelText: 'تعداد سهام',
hintText: 'عدد صحیح بدون اعشار',
decoration: InputDecoration(
labelText: t.shareCount,
hintText: t.integerNoDecimal,
),
keyboardType: TextInputType.number,
validator: (value) {
if (_selectedPersonTypes.contains(PersonType.shareholder)) {
if (value == null || value.trim().isEmpty) {
return 'برای سهامدار، تعداد سهام الزامی است';
return t.shareholderShareCountRequired;
}
final parsed = int.tryParse(value.trim());
if (parsed == null || parsed <= 0) {
@ -974,10 +975,7 @@ class _PersonFormDialogState extends State<PersonFormDialog> {
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: Text(
'هیچ حساب بانکی اضافه نشده است',
style: TextStyle(color: Colors.grey.shade600),
),
child: Text(t.noBankAccountsAdded, style: TextStyle(color: Colors.grey.shade600)),
),
)
else
@ -1007,10 +1005,7 @@ class _PersonFormDialogState extends State<PersonFormDialog> {
Expanded(
child: TextFormField(
initialValue: bankAccount.bankName,
decoration: InputDecoration(
labelText: t.bankName,
hintText: t.bankName,
),
decoration: InputDecoration(labelText: t.bankName, hintText: t.bankName),
onChanged: (value) {
_updateBankAccount(index, bankAccount.copyWith(bankName: value));
},
@ -1029,10 +1024,7 @@ class _PersonFormDialogState extends State<PersonFormDialog> {
Expanded(
child: TextFormField(
initialValue: bankAccount.accountNumber ?? '',
decoration: InputDecoration(
labelText: t.accountNumber,
hintText: t.accountNumber,
),
decoration: InputDecoration(labelText: t.accountNumber, hintText: t.accountNumber),
onChanged: (value) {
_updateBankAccount(index, bankAccount.copyWith(accountNumber: value.isEmpty ? null : value));
},
@ -1042,10 +1034,7 @@ class _PersonFormDialogState extends State<PersonFormDialog> {
Expanded(
child: TextFormField(
initialValue: bankAccount.cardNumber ?? '',
decoration: InputDecoration(
labelText: t.cardNumber,
hintText: t.cardNumber,
),
decoration: InputDecoration(labelText: t.cardNumber, hintText: t.cardNumber),
onChanged: (value) {
_updateBankAccount(index, bankAccount.copyWith(cardNumber: value.isEmpty ? null : value));
},
@ -1056,10 +1045,7 @@ class _PersonFormDialogState extends State<PersonFormDialog> {
const SizedBox(height: 16),
TextFormField(
initialValue: bankAccount.shebaNumber ?? '',
decoration: InputDecoration(
labelText: t.shebaNumber,
hintText: t.shebaNumber,
),
decoration: InputDecoration(labelText: t.shebaNumber, hintText: t.shebaNumber),
onChanged: (value) {
_updateBankAccount(index, bankAccount.copyWith(shebaNumber: value.isEmpty ? null : value));
},

View file

@ -1,6 +1,7 @@
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'file_picker_bridge.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart';
import '../../core/api_client.dart';
import '../data_table/helpers/file_saver.dart';
@ -45,8 +46,9 @@ class _PersonImportDialogState extends State<PersonImportDialog> {
Future<void> _pickFile() async {
if (!_isInitialized) {
if (mounted) {
final t = AppLocalizations.of(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('لطفاً صبر کنید تا دیالوگ کاملاً بارگذاری شود')),
SnackBar(content: Text(t.loading)),
);
}
return;
@ -62,8 +64,9 @@ class _PersonImportDialogState extends State<PersonImportDialog> {
}
} catch (e) {
if (mounted) {
final t = AppLocalizations.of(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('خطا در انتخاب فایل: $e')),
SnackBar(content: Text('${t.pickFileError}: $e')),
);
}
}
@ -96,14 +99,16 @@ class _PersonImportDialogState extends State<PersonImportDialog> {
}
await FileSaver.saveBytes((res.data as List<int>), filename);
if (mounted) {
final t = AppLocalizations.of(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('تمپلیت دانلود شد: $filename')),
SnackBar(content: Text('${t.templateDownloaded}: $filename')),
);
}
} catch (e) {
if (mounted) {
final t = AppLocalizations.of(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('خطا در دانلود تمپلیت: $e')),
SnackBar(content: Text('${t.templateDownloadError}: $e')),
);
}
} finally {
@ -145,8 +150,9 @@ class _PersonImportDialogState extends State<PersonImportDialog> {
}
} catch (e) {
if (mounted) {
final t = AppLocalizations.of(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('خطا در ایمپورت: $e')),
SnackBar(content: Text('${t.importError}: $e')),
);
}
} finally {
@ -156,8 +162,9 @@ class _PersonImportDialogState extends State<PersonImportDialog> {
@override
Widget build(BuildContext context) {
final t = AppLocalizations.of(context);
return AlertDialog(
title: const Text('ایمپورت اشخاص از اکسل'),
title: Text(t.importPersonsFromExcel),
content: SizedBox(
width: 560,
child: Column(
@ -168,9 +175,9 @@ class _PersonImportDialogState extends State<PersonImportDialog> {
child: TextField(
controller: _pathCtrl,
readOnly: true,
decoration: const InputDecoration(
labelText: 'فایل انتخاب‌شده',
hintText: 'هیچ فایلی انتخاب نشده',
decoration: InputDecoration(
labelText: t.selectedFile,
hintText: t.noFileSelected,
isDense: true,
),
),
@ -179,7 +186,7 @@ class _PersonImportDialogState extends State<PersonImportDialog> {
OutlinedButton.icon(
onPressed: (_loading || !_isInitialized) ? null : _pickFile,
icon: const Icon(Icons.attach_file),
label: const Text('انتخاب فایل'),
label: Text(t.chooseFile),
),
]),
const SizedBox(height: 8),
@ -189,10 +196,10 @@ class _PersonImportDialogState extends State<PersonImportDialog> {
child: DropdownButtonFormField<String>(
value: _matchBy,
isDense: true,
items: const [
DropdownMenuItem(value: 'code', child: Text('match by: code')),
DropdownMenuItem(value: 'national_id', child: Text('match by: national_id')),
DropdownMenuItem(value: 'email', child: Text('match by: email')),
items: [
DropdownMenuItem(value: 'code', child: Text('${t.matchBy}: ${t.code}')),
DropdownMenuItem(value: 'national_id', child: Text('${t.matchBy}: ${t.personNationalId}')),
DropdownMenuItem(value: 'email', child: Text('${t.matchBy}: ${t.personEmail}')),
],
onChanged: (v) => setState(() => _matchBy = v ?? 'code'),
decoration: const InputDecoration(isDense: true),
@ -203,10 +210,10 @@ class _PersonImportDialogState extends State<PersonImportDialog> {
child: DropdownButtonFormField<String>(
value: _conflictPolicy,
isDense: true,
items: const [
DropdownMenuItem(value: 'insert', child: Text('policy: insert-only')),
DropdownMenuItem(value: 'update', child: Text('policy: update-existing')),
DropdownMenuItem(value: 'upsert', child: Text('policy: upsert')),
items: [
DropdownMenuItem(value: 'insert', child: Text('${t.conflictPolicy}: ${t.policyInsertOnly}')),
DropdownMenuItem(value: 'update', child: Text('${t.conflictPolicy}: ${t.policyUpdateExisting}')),
DropdownMenuItem(value: 'upsert', child: Text('${t.conflictPolicy}: ${t.policyUpsert}')),
],
onChanged: (v) => setState(() => _conflictPolicy = v ?? 'upsert'),
decoration: const InputDecoration(isDense: true),
@ -221,7 +228,7 @@ class _PersonImportDialogState extends State<PersonImportDialog> {
value: _dryRun,
onChanged: (v) => setState(() => _dryRun = v ?? true),
),
const Text('Dry run (فقط اعتبارسنجی)')
Text(t.dryRunValidateOnly)
],
),
const SizedBox(height: 8),
@ -230,13 +237,13 @@ class _PersonImportDialogState extends State<PersonImportDialog> {
OutlinedButton.icon(
onPressed: _loading ? null : _downloadTemplate,
icon: const Icon(Icons.download),
label: const Text('دانلود تمپلیت'),
label: Text(t.downloadTemplate),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: _loading ? null : () => _runImport(dryRun: _dryRun),
icon: _loading ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) : const Icon(Icons.play_arrow),
label: Text(_dryRun ? 'بررسی (Dry run)' : 'ایمپورت'),
label: Text(_dryRun ? t.reviewDryRun : t.import),
),
const SizedBox(width: 8),
if (_dryRun)
@ -247,7 +254,7 @@ class _PersonImportDialogState extends State<PersonImportDialog> {
await _runImport(dryRun: false);
},
icon: const Icon(Icons.cloud_upload),
label: const Text('ایمپورت واقعی'),
label: Text(t.importReal),
)
],
),
@ -255,7 +262,7 @@ class _PersonImportDialogState extends State<PersonImportDialog> {
const SizedBox(height: 12),
Align(
alignment: Alignment.centerRight,
child: Text('نتیجه:', style: Theme.of(context).textTheme.titleSmall),
child: Text('${t.result}:', style: Theme.of(context).textTheme.titleSmall),
),
const SizedBox(height: 8),
_ResultSummary(result: _result!),
@ -266,7 +273,7 @@ class _PersonImportDialogState extends State<PersonImportDialog> {
actions: [
TextButton(
onPressed: _loading ? null : () => Navigator.of(context).pop(false),
child: const Text('بستن'),
child: Text(t.close),
),
],
);
@ -279,6 +286,7 @@ class _ResultSummary extends StatelessWidget {
@override
Widget build(BuildContext context) {
final t = AppLocalizations.of(context);
final data = result['data'] as Map<String, dynamic>?;
final summary = (data?['summary'] as Map<String, dynamic>?) ?? {};
final errors = (data?['errors'] as List?)?.cast<Map<String, dynamic>>() ?? const [];
@ -290,13 +298,13 @@ class _ResultSummary extends StatelessWidget {
spacing: 12,
runSpacing: 4,
children: [
_chip('کل', summary['total']),
_chip('معتبر', summary['valid']),
_chip('نامعتبر', summary['invalid']),
_chip('ایجاد شده', summary['inserted']),
_chip('به‌روزرسانی', summary['updated']),
_chip('رد شده', summary['skipped']),
_chip('Dry run', summary['dry_run'] == true ? 'بله' : 'خیر'),
_chip(t.total, summary['total']),
_chip(t.valid, summary['valid']),
_chip(t.invalid, summary['invalid']),
_chip(t.inserted, summary['inserted']),
_chip(t.updated, summary['updated']),
_chip(t.skipped, summary['skipped']),
_chip(t.dryRun, summary['dry_run'] == true ? t.yes : t.no),
],
),
const SizedBox(height: 8),
@ -310,8 +318,8 @@ class _ResultSummary extends StatelessWidget {
return ListTile(
dense: true,
leading: const Icon(Icons.error_outline, color: Colors.red),
title: Text('ردیف ${e['row']}'),
subtitle: Text(((e['errors'] as List?)?.join('، ')) ?? ''),
title: Text('${t.row} ${e['row']}'),
subtitle: Text(((e['errors'] as List?)?.join(', ')) ?? ''),
);
},
),