diff --git a/hesabixAPI/adapters/api/v1/persons.py b/hesabixAPI/adapters/api/v1/persons.py index 87e9741..e6cc3c4 100644 --- a/hesabixAPI/adapters/api/v1/persons.py +++ b/hesabixAPI/adapters/api/v1/persons.py @@ -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 = { diff --git a/hesabixAPI/adapters/api/v1/schema_models/person.py b/hesabixAPI/adapters/api/v1/schema_models/person.py index 31ca7b4..3772a4b 100644 --- a/hesabixAPI/adapters/api/v1/schema_models/person.py +++ b/hesabixAPI/adapters/api/v1/schema_models/person.py @@ -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="تاریخ آخرین بروزرسانی") diff --git a/hesabixAPI/app/main.py b/hesabixAPI/app/main.py index 093bdec..693edbb 100644 --- a/hesabixAPI/app/main.py +++ b/hesabixAPI/app/main.py @@ -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 diff --git a/hesabixAPI/app/services/person_service.py b/hesabixAPI/app/services/person_service.py index 6c26dba..2c8615a 100644 --- a/hesabixAPI/app/services/person_service.py +++ b/hesabixAPI/app/services/person_service.py @@ -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: diff --git a/hesabixAPI/hesabix_api.egg-info/SOURCES.txt b/hesabixAPI/hesabix_api.egg-info/SOURCES.txt index 7360a45..9f20a17 100644 --- a/hesabixAPI/hesabix_api.egg-info/SOURCES.txt +++ b/hesabixAPI/hesabix_api.egg-info/SOURCES.txt @@ -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 diff --git a/hesabixAPI/migrations/versions/20250928_000023_remove_person_is_active_force.py b/hesabixAPI/migrations/versions/20250928_000023_remove_person_is_active_force.py new file mode 100644 index 0000000..8b45be2 --- /dev/null +++ b/hesabixAPI/migrations/versions/20250928_000023_remove_person_is_active_force.py @@ -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'))) + + diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_en.arb b/hesabixUI/hesabix_ui/lib/l10n/app_en.arb index e049df4..0d463e3 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_en.arb +++ b/hesabixUI/hesabix_ui/lib/l10n/app_en.arb @@ -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" } diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_fa.arb b/hesabixUI/hesabix_ui/lib/l10n/app_fa.arb index 96ae54e..0f32852 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_fa.arb +++ b/hesabixUI/hesabix_ui/lib/l10n/app_fa.arb @@ -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": "ثبت پورسانت در سند حسابداری فاکتور" } diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_localizations.dart b/hesabixUI/hesabix_ui/lib/l10n/app_localizations.dart index eae3198..efc0c7c 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_localizations.dart +++ b/hesabixUI/hesabix_ui/lib/l10n/app_localizations.dart @@ -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 diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_localizations_en.dart b/hesabixUI/hesabix_ui/lib/l10n/app_localizations_en.dart index c29a554..2a275cd 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_localizations_en.dart +++ b/hesabixUI/hesabix_ui/lib/l10n/app_localizations_en.dart @@ -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'; } diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_localizations_fa.dart b/hesabixUI/hesabix_ui/lib/l10n/app_localizations_fa.dart index 8553251..23abb09 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_localizations_fa.dart +++ b/hesabixUI/hesabix_ui/lib/l10n/app_localizations_fa.dart @@ -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 => + 'ثبت پورسانت در سند حسابداری فاکتور'; } diff --git a/hesabixUI/hesabix_ui/lib/pages/business/persons_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/persons_page.dart index 61bb635..4921c06 100644 --- a/hesabixUI/hesabix_ui/lib/pages/business/persons_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/persons_page.dart @@ -65,7 +65,7 @@ class _PersonsPageState extends State { columns: [ NumberColumn( 'code', - 'کد شخص', + t.personCode, width: ColumnWidth.small, formatter: (person) => (person.code?.toString() ?? '-'), textAlign: TextAlign.center, @@ -92,6 +92,16 @@ class _PersonsPageState extends State { '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 { ), 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 { ), ActionColumn( 'actions', - 'عملیات', + t.actions, actions: [ DataTableAction( icon: Icons.edit, @@ -255,7 +265,6 @@ class _PersonsPageState extends State { filterFields: [ 'person_type', 'person_types', - 'is_active', 'country', 'province', ], @@ -274,25 +283,42 @@ class _PersonsPageState extends State { ), ), ), - Tooltip( - message: 'ایمپورت اشخاص از اکسل', - child: IconButton( - onPressed: () async { - final ok = await showDialog( - 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( + 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, + ), + ), + ), + ); + }), ], ); } diff --git a/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_widget.dart b/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_widget.dart index d23e774..d99499b 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_widget.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_widget.dart @@ -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 extends State> { // Platform-specific download functions for Linux Future _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 _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(context, AppLocalizations)!; @@ -1331,6 +1338,15 @@ class _DataTableWidgetState extends State> { 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 extends State> { 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 extends State> { ); }).toList(), ), + ), ); } diff --git a/hesabixUI/hesabix_ui/lib/widgets/person/person_form_dialog.dart b/hesabixUI/hesabix_ui/lib/widgets/person/person_form_dialog.dart index dd4880b..55e5c28 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/person/person_form_dialog.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/person/person_form_dialog.dart @@ -407,7 +407,7 @@ class _PersonFormDialogState extends State { ), ]; 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 { } 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { ), 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 { 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 { _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 { 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 { 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 { 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 { 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 { 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 { 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 { 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)); }, diff --git a/hesabixUI/hesabix_ui/lib/widgets/person/person_import_dialog.dart b/hesabixUI/hesabix_ui/lib/widgets/person/person_import_dialog.dart index 2195af9..ee3e3de 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/person/person_import_dialog.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/person/person_import_dialog.dart @@ -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 { Future _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 { } } 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 { } await FileSaver.saveBytes((res.data as List), 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 { } } 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 { @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 { 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 { 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 { child: DropdownButtonFormField( 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 { child: DropdownButtonFormField( 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 { 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 { 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 { 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 { 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 { 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?; final summary = (data?['summary'] as Map?) ?? {}; final errors = (data?['errors'] as List?)?.cast>() ?? 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(', ')) ?? ''), ); }, ),