diff --git a/hesabixAPI/adapters/api/v1/receipts_payments.py b/hesabixAPI/adapters/api/v1/receipts_payments.py index 6535c67..6de6d29 100644 --- a/hesabixAPI/adapters/api/v1/receipts_payments.py +++ b/hesabixAPI/adapters/api/v1/receipts_payments.py @@ -65,6 +65,7 @@ async def list_receipts_payments_endpoint( for key in ["document_type", "from_date", "to_date"]: if key in body_json: query_dict[key] = body_json[key] + print(f"API - پارامتر {key}: {body_json[key]}") except Exception: pass @@ -108,6 +109,7 @@ async def create_receipt_payment_endpoint( "document_type": "receipt" | "payment", "document_date": "2025-01-15T10:30:00", "currency_id": 1, + "description": "توضیحات کلی سند (اختیاری)", "person_lines": [ { "person_id": 123, @@ -435,6 +437,259 @@ async def export_receipts_payments_excel( ) +@router.get( + "/receipts-payments/{document_id}/pdf", + summary="خروجی PDF تک سند دریافت/پرداخت", + description="خروجی PDF یک سند دریافت یا پرداخت", +) +async def export_single_receipt_payment_pdf( + document_id: int, + request: Request, + auth_context: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +): + """خروجی PDF تک سند دریافت/پرداخت""" + from weasyprint import HTML, CSS + from weasyprint.text.fonts import FontConfiguration + from app.core.i18n import negotiate_locale + from html import escape + + # دریافت سند + result = get_receipt_payment(db, document_id) + if not result: + raise ApiError( + "DOCUMENT_NOT_FOUND", + "Receipt/Payment document not found", + http_status=404 + ) + + # بررسی دسترسی + business_id = result.get("business_id") + if business_id and not auth_context.can_access_business(business_id): + raise ApiError("FORBIDDEN", "Access denied", http_status=403) + + # دریافت اطلاعات کسب‌وکار + business_name = "" + try: + b = db.query(Business).filter(Business.id == business_id).first() + if b is not None: + business_name = b.name or "" + except Exception: + business_name = "" + + # Locale handling + locale = negotiate_locale(request.headers.get("Accept-Language")) + is_fa = locale == 'fa' + + # آماده‌سازی داده‌ها + doc_type_name = result.get("document_type_name", "") + doc_code = result.get("code", "") + doc_date = result.get("document_date", "") + total_amount = result.get("total_amount", 0) + description = result.get("description", "") + person_lines = result.get("person_lines", []) + account_lines = result.get("account_lines", []) + + # تاریخ تولید + now = datetime.datetime.now().strftime('%Y/%m/%d %H:%M') + title_text = f"سند {doc_type_name}" if is_fa else f"{doc_type_name} Document" + label_biz = "کسب و کار" if is_fa else "Business" + label_date = "تاریخ تولید" if is_fa else "Generated Date" + footer_text = f"تولید شده در {now}" if is_fa else f"Generated at {now}" + + # ایجاد HTML برای PDF + html_content = f""" + + + + + {title_text} + + + +
+
+
{title_text}
+
{label_biz}: {escape(business_name)}
+
+
{label_date}: {escape(now)}
+
+ +
+
+
کد سند:
+
{escape(doc_code)}
+
+
+
نوع سند:
+
{escape(doc_type_name)}
+
+
+
تاریخ سند:
+
{escape(doc_date)}
+
+
+
مبلغ کل:
+
{escape(str(total_amount))} ریال
+
+ {f'
توضیحات:
{escape(description or "")}
' if description else ''} +
+ +
+
خطوط اشخاص
+ + + + + + + + + + {''.join([f'' for line in person_lines])} + +
نام شخصمبلغتوضیحات
{escape(line.get("person_name") or "نامشخص")}{escape(str(line.get("amount", 0)))} ریال{escape(line.get("description") or "")}
+
+ +
+
خطوط حساب‌ها
+ + + + + + + + + + + + {''.join([f'' for line in account_lines])} + +
نام حسابکد حسابنوع تراکنشمبلغتوضیحات
{escape(line.get("account_name") or "")}{escape(line.get("account_code") or "")}{escape(line.get("transaction_type") or "")}{escape(str(line.get("amount", 0)))} ریال{escape(line.get("description") or "")}
+
+ + + + + """ + + font_config = FontConfiguration() + pdf_bytes = HTML(string=html_content).write_pdf(font_config=font_config) + + # Build filename + def slugify(text: str) -> str: + return re.sub(r"[^A-Za-z0-9_-]+", "_", text).strip("_") + + filename = f"receipt_payment_{slugify(doc_code)}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf" + + return Response( + content=pdf_bytes, + media_type="application/pdf", + headers={ + "Content-Disposition": f"attachment; filename={filename}", + "Content-Length": str(len(pdf_bytes)), + "Access-Control-Expose-Headers": "Content-Disposition", + }, + ) + + @router.post( "/businesses/{business_id}/receipts-payments/export/pdf", summary="خروجی PDF لیست اسناد دریافت و پرداخت", diff --git a/hesabixAPI/adapters/db/models/document.py b/hesabixAPI/adapters/db/models/document.py index f5cfffe..bc87899 100644 --- a/hesabixAPI/adapters/db/models/document.py +++ b/hesabixAPI/adapters/db/models/document.py @@ -2,7 +2,7 @@ from __future__ import annotations from datetime import date, datetime -from sqlalchemy import String, Integer, DateTime, Boolean, ForeignKey, JSON, Date, UniqueConstraint +from sqlalchemy import String, Integer, DateTime, Boolean, ForeignKey, JSON, Date, UniqueConstraint, Text from sqlalchemy.orm import Mapped, mapped_column, relationship from adapters.db.session import Base @@ -24,6 +24,7 @@ class Document(Base): document_date: Mapped[date] = mapped_column(Date, nullable=False) document_type: Mapped[str] = mapped_column(String(50), nullable=False) is_proforma: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) extra_info: Mapped[dict | None] = mapped_column(JSON, nullable=True) developer_settings: Mapped[dict | None] = mapped_column(JSON, nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) diff --git a/hesabixAPI/app/services/receipt_payment_service.py b/hesabixAPI/app/services/receipt_payment_service.py index 1537f01..3f2e51c 100644 --- a/hesabixAPI/app/services/receipt_payment_service.py +++ b/hesabixAPI/app/services/receipt_payment_service.py @@ -24,6 +24,7 @@ from adapters.db.models.currency import Currency from adapters.db.models.user import User from adapters.db.models.fiscal_year import FiscalYear from app.core.responses import ApiError +import jdatetime # تنظیم لاگر logger = logging.getLogger(__name__) @@ -43,16 +44,57 @@ ACCOUNT_TYPE_CHECK_PAYABLE = "check" # اسناد پرداختنی (چک پرد def _parse_iso_date(dt: str | datetime | date) -> date: - """تبدیل تاریخ به فرمت date""" + """تبدیل تاریخ به فرمت date - پشتیبانی از تاریخ‌های شمسی و میلادی""" if isinstance(dt, date): return dt if isinstance(dt, datetime): return dt.date() + + dt_str = str(dt).strip() + try: - parsed = datetime.fromisoformat(str(dt).replace('Z', '+00:00')) + # ابتدا سعی کن ISO8601 را پردازش کنی + dt_str_clean = dt_str.replace('Z', '+00:00') + parsed = datetime.fromisoformat(dt_str_clean) return parsed.date() except Exception: - raise ApiError("INVALID_DATE", f"Invalid date: {dt}", http_status=400) + pass + + try: + # بررسی فرمت YYYY-MM-DD (میلادی) + if len(dt_str) == 10 and dt_str.count('-') == 2: + return datetime.strptime(dt_str, '%Y-%m-%d').date() + except Exception: + pass + + try: + # بررسی فرمت YYYY/MM/DD (ممکن است شمسی باشد) + if len(dt_str) == 10 and dt_str.count('/') == 2: + parts = dt_str.split('/') + if len(parts) == 3: + year, month, day = parts + try: + year_int = int(year) + month_int = int(month) + day_int = int(day) + + # اگر سال بزرگتر از 1500 باشد، احتمالاً شمسی است + if year_int > 1500: + # تبدیل شمسی به میلادی + jalali_date = jdatetime.date(year_int, month_int, day_int) + gregorian_date = jalali_date.togregorian() + return gregorian_date + else: + # احتمالاً میلادی است + return datetime.strptime(dt_str, '%Y/%m/%d').date() + except (ValueError, jdatetime.JalaliDateError): + # اگر تبدیل شمسی ناموفق بود، سعی کن میلادی کنی + return datetime.strptime(dt_str, '%Y/%m/%d').date() + except Exception: + pass + + # اگر هیچ فرمتی کار نکرد، خطا بده + raise ApiError("INVALID_DATE", f"Invalid date format: {dt}", http_status=400) def _get_current_fiscal_year(db: Session, business_id: int) -> FiscalYear: @@ -241,6 +283,7 @@ def create_receipt_payment( created_by_user_id=user_id, registered_at=datetime.utcnow(), is_proforma=False, + description=data.get("description"), extra_info=data.get("extra_info"), ) db.add(document) @@ -602,8 +645,12 @@ def list_receipts_payments( # فیلتر بر اساس نوع doc_type = query.get("document_type") + logger.info(f"فیلتر نوع سند: {doc_type}") if doc_type: q = q.filter(Document.document_type == doc_type) + logger.info(f"فیلتر نوع سند اعمال شد: {doc_type}") + else: + logger.info("فیلتر نوع سند اعمال نشد - نمایش همه انواع") # فیلتر بر اساس تاریخ from_date = query.get("from_date") @@ -613,14 +660,18 @@ def list_receipts_payments( try: from_dt = _parse_iso_date(from_date) q = q.filter(Document.document_date >= from_dt) - except Exception: + logger.info(f"فیلتر تاریخ از: {from_date} -> {from_dt}") + except Exception as e: + logger.warning(f"خطا در پردازش تاریخ از: {from_date}, خطا: {e}") pass if to_date: try: to_dt = _parse_iso_date(to_date) q = q.filter(Document.document_date <= to_dt) - except Exception: + logger.info(f"فیلتر تاریخ تا: {to_date} -> {to_dt}") + except Exception as e: + logger.warning(f"خطا در پردازش تاریخ تا: {to_date}, خطا: {e}") pass # جستجو @@ -822,6 +873,8 @@ def update_receipt_payment( document.currency_id = int(currency_id) if isinstance(data.get("extra_info"), dict) or data.get("extra_info") is None: document.extra_info = data.get("extra_info") + if isinstance(data.get("description"), str) or data.get("description") is None: + document.description = data.get("description") # تعیین نوع دریافت/پرداخت برای محاسبات بدهکار/بستانکار is_receipt = (document.document_type == DOCUMENT_TYPE_RECEIPT) @@ -1130,6 +1183,7 @@ def document_to_dict(db: Session, document: Document) -> Dict[str, Any]: "created_by_user_id": document.created_by_user_id, "created_by_name": created_by_name, "is_proforma": document.is_proforma, + "description": document.description, "extra_info": document.extra_info, "person_lines": person_lines, "account_lines": account_lines, diff --git a/hesabixAPI/hesabix_api.egg-info/SOURCES.txt b/hesabixAPI/hesabix_api.egg-info/SOURCES.txt index f5ef058..f373042 100644 --- a/hesabixAPI/hesabix_api.egg-info/SOURCES.txt +++ b/hesabixAPI/hesabix_api.egg-info/SOURCES.txt @@ -14,6 +14,7 @@ adapters/api/v1/categories.py adapters/api/v1/checks.py adapters/api/v1/currencies.py adapters/api/v1/customers.py +adapters/api/v1/fiscal_years.py adapters/api/v1/health.py adapters/api/v1/invoices.py adapters/api/v1/persons.py @@ -199,6 +200,7 @@ migrations/versions/4b2ea782bcb3_merge_heads.py migrations/versions/5553f8745c6e_add_support_tables.py migrations/versions/7891282548e9_merge_tax_types_and_unit_fields_heads.py migrations/versions/7ecb63029764_merge_heads.py +migrations/versions/9a06b0cb880a_add_description_to_documents.py migrations/versions/9f9786ae7191_create_tax_units_table.py migrations/versions/a1443c153b47_merge_heads.py migrations/versions/ac9e4b3dcffc_add_fiscal_year_to_documents.py diff --git a/hesabixAPI/migrations/versions/9a06b0cb880a_add_description_to_documents.py b/hesabixAPI/migrations/versions/9a06b0cb880a_add_description_to_documents.py new file mode 100644 index 0000000..f87363e --- /dev/null +++ b/hesabixAPI/migrations/versions/9a06b0cb880a_add_description_to_documents.py @@ -0,0 +1,28 @@ +"""add_description_to_documents + +Revision ID: 9a06b0cb880a +Revises: ac9e4b3dcffc +Create Date: 2025-10-16 17:26:22.681359 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '9a06b0cb880a' +down_revision = 'ac9e4b3dcffc' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('documents', sa.Column('description', sa.Text(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('documents', 'description') + # ### end Alembic commands ### diff --git a/hesabixUI/hesabix_ui/lib/core/api_client.dart b/hesabixUI/hesabix_ui/lib/core/api_client.dart index e221704..2566b2c 100644 --- a/hesabixUI/hesabix_ui/lib/core/api_client.dart +++ b/hesabixUI/hesabix_ui/lib/core/api_client.dart @@ -193,6 +193,20 @@ class ApiClient { }, ); } + + // Download PDF API + Future> downloadPdf(String path) async { + final response = await get>( + path, + responseType: ResponseType.bytes, + options: Options( + headers: { + 'Accept': 'application/pdf', + }, + ), + ); + return response.data ?? []; + } } // Utilities diff --git a/hesabixUI/hesabix_ui/lib/main.dart b/hesabixUI/hesabix_ui/lib/main.dart index a86d561..c09279e 100644 --- a/hesabixUI/hesabix_ui/lib/main.dart +++ b/hesabixUI/hesabix_ui/lib/main.dart @@ -39,6 +39,7 @@ import 'pages/business/petty_cash_page.dart'; import 'pages/business/checks_page.dart'; import 'pages/business/check_form_page.dart'; import 'pages/business/receipts_payments_list_page.dart'; +import 'pages/business/transfers_page.dart'; import 'pages/error_404_page.dart'; import 'core/locale_controller.dart'; import 'core/calendar_controller.dart'; @@ -816,6 +817,26 @@ class _MyAppState extends State { ); }, ), + GoRoute( + path: 'transfers', + name: 'business_transfers', + builder: (context, state) { + final businessId = int.parse(state.pathParameters['business_id']!); + return BusinessShell( + businessId: businessId, + authStore: _authStore!, + localeController: controller, + calendarController: _calendarController!, + themeController: themeController, + child: TransfersPage( + businessId: businessId, + calendarController: _calendarController!, + authStore: _authStore!, + apiClient: ApiClient(), + ), + ); + }, + ), GoRoute( path: 'checks', name: 'business_checks', diff --git a/hesabixUI/hesabix_ui/lib/models/receipt_payment_document.dart b/hesabixUI/hesabix_ui/lib/models/receipt_payment_document.dart index fcd6170..8fe4fa5 100644 --- a/hesabixUI/hesabix_ui/lib/models/receipt_payment_document.dart +++ b/hesabixUI/hesabix_ui/lib/models/receipt_payment_document.dart @@ -116,6 +116,7 @@ class ReceiptPaymentDocument { final int createdByUserId; final String? createdByName; final bool isProforma; + final String? description; final Map? extraInfo; final List personLines; final List accountLines; @@ -135,6 +136,7 @@ class ReceiptPaymentDocument { required this.createdByUserId, this.createdByName, required this.isProforma, + this.description, this.extraInfo, required this.personLines, required this.accountLines, @@ -156,6 +158,7 @@ class ReceiptPaymentDocument { createdByUserId: json['created_by_user_id'] ?? 0, createdByName: json['created_by_name'], isProforma: json['is_proforma'] ?? false, + description: json['description'], extraInfo: json['extra_info'], personLines: (json['person_lines'] as List?) ?.map((item) => PersonLine.fromJson(item)) @@ -182,6 +185,7 @@ class ReceiptPaymentDocument { 'created_by_user_id': createdByUserId, 'created_by_name': createdByName, 'is_proforma': isProforma, + 'description': description, 'extra_info': extraInfo, 'person_lines': personLines.map((item) => item.toJson()).toList(), 'account_lines': accountLines.map((item) => item.toJson()).toList(), diff --git a/hesabixUI/hesabix_ui/lib/pages/business/receipts_payments_list_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/receipts_payments_list_page.dart index 4c6d933..a5d54e9 100644 --- a/hesabixUI/hesabix_ui/lib/pages/business/receipts_payments_list_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/receipts_payments_list_page.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:dio/dio.dart'; import 'package:hesabix_ui/l10n/app_localizations.dart'; import 'package:hesabix_ui/core/calendar_controller.dart'; @@ -19,6 +20,7 @@ import 'package:hesabix_ui/models/invoice_transaction.dart'; import 'package:hesabix_ui/models/invoice_type_model.dart'; import 'package:hesabix_ui/models/person_model.dart'; import 'package:hesabix_ui/models/business_dashboard_models.dart'; +import 'dart:html' as html; /// صفحه لیست اسناد دریافت و پرداخت با ویجت جدول class ReceiptsPaymentsListPage extends StatefulWidget { @@ -164,7 +166,7 @@ class _ReceiptsPaymentsListPageState extends State { icon: const Icon(Icons.upload_outlined), ), ], - selected: {_selectedDocumentType}, + selected: _selectedDocumentType != null ? {_selectedDocumentType} : {}, onSelectionChanged: (set) { setState(() { _selectedDocumentType = set.first; @@ -251,9 +253,10 @@ class _ReceiptsPaymentsListPageState extends State { ], getExportParams: () => { 'business_id': widget.businessId, - if (_selectedDocumentType != null) 'document_type': _selectedDocumentType, - if (_fromDate != null) 'from_date': _fromDate!.toIso8601String(), - if (_toDate != null) 'to_date': _toDate!.toIso8601String(), + // همیشه document_type را ارسال کن، حتی اگر null باشد + 'document_type': _selectedDocumentType, + if (_fromDate != null) 'from_date': _fromDate!.toUtc().toIso8601String(), + if (_toDate != null) 'to_date': _toDate!.toUtc().toIso8601String(), }, columns: [ // کد سند @@ -297,6 +300,14 @@ class _ReceiptsPaymentsListPageState extends State { formatter: (item) => item.personNames ?? 'نامشخص', ), + // توضیحات + TextColumn( + 'description', + 'توضیحات', + width: ColumnWidth.large, + formatter: (item) => item.description ?? '', + ), + // تعداد حساب‌ها NumberColumn( 'account_lines_count', @@ -368,9 +379,10 @@ class _ReceiptsPaymentsListPageState extends State { }); }, additionalParams: { - if (_selectedDocumentType != null) 'document_type': _selectedDocumentType, - if (_fromDate != null) 'from_date': _fromDate!.toIso8601String(), - if (_toDate != null) 'to_date': _toDate!.toIso8601String(), + // همیشه document_type را ارسال کن، حتی اگر null باشد + 'document_type': _selectedDocumentType, + if (_fromDate != null) 'from_date': _fromDate!.toUtc().toIso8601String(), + if (_toDate != null) 'to_date': _toDate!.toUtc().toIso8601String(), }, onRowTap: (item) => _onView(item), onRowDoubleTap: (item) => _onEdit(item), @@ -400,13 +412,34 @@ class _ReceiptsPaymentsListPageState extends State { } /// مشاهده جزئیات سند - void _onView(ReceiptPaymentDocument document) { - // TODO: باز کردن صفحه جزئیات سند - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('مشاهده سند ${document.code}'), - ), - ); + void _onView(ReceiptPaymentDocument document) async { + try { + // دریافت جزئیات کامل سند + final fullDoc = await _service.getById(document.id); + if (fullDoc == null) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('سند یافت نشد')), + ); + return; + } + + // نمایش دیالوگ مشاهده جزئیات + await showDialog( + context: context, + builder: (_) => ReceiptPaymentViewDialog( + document: fullDoc, + calendarController: widget.calendarController, + businessId: widget.businessId, + apiClient: widget.apiClient, + ), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('خطا در بارگذاری جزئیات: $e')), + ); + } } /// ویرایش سند @@ -663,6 +696,7 @@ class _BulkSettlementDialogState extends State { late DateTime _docDate; late bool _isReceipt; int? _selectedCurrencyId; + final TextEditingController _descriptionController = TextEditingController(); final List<_PersonLine> _personLines = <_PersonLine>[]; final List _centerTransactions = []; @@ -675,6 +709,7 @@ class _BulkSettlementDialogState extends State { _isReceipt = initial.isReceipt; _docDate = initial.documentDate; _selectedCurrencyId = initial.currencyId; + _descriptionController.text = initial.description ?? ''; // تبدیل خطوط اشخاص _personLines.clear(); for (final pl in initial.personLines) { @@ -724,6 +759,12 @@ class _BulkSettlementDialogState extends State { } } + @override + void dispose() { + _descriptionController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { final t = AppLocalizations.of(context); @@ -784,6 +825,18 @@ class _BulkSettlementDialogState extends State { ], ), ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: TextField( + controller: _descriptionController, + decoration: const InputDecoration( + labelText: 'توضیحات کلی سند', + hintText: 'توضیحات اختیاری...', + border: OutlineInputBorder(), + ), + maxLines: 2, + ), + ), const Divider(height: 1), Expanded( child: Row( @@ -917,6 +970,7 @@ class _BulkSettlementDialogState extends State { documentId: widget.initialDocument!.id, documentDate: _docDate, currencyId: _selectedCurrencyId!, + description: _descriptionController.text.trim().isNotEmpty ? _descriptionController.text.trim() : null, personLines: personLinesData, accountLines: accountLinesData, ); @@ -927,6 +981,7 @@ class _BulkSettlementDialogState extends State { documentType: _isReceipt ? 'receipt' : 'payment', documentDate: _docDate, currencyId: _selectedCurrencyId!, + description: _descriptionController.text.trim().isNotEmpty ? _descriptionController.text.trim() : null, personLines: personLinesData, accountLines: accountLinesData, ); @@ -1182,3 +1237,432 @@ class _PersonLine { } } +/// دیالوگ مشاهده جزئیات سند دریافت/پرداخت +class ReceiptPaymentViewDialog extends StatefulWidget { + final ReceiptPaymentDocument document; + final CalendarController calendarController; + final int businessId; + final ApiClient apiClient; + + const ReceiptPaymentViewDialog({ + super.key, + required this.document, + required this.calendarController, + required this.businessId, + required this.apiClient, + }); + + @override + State createState() => _ReceiptPaymentViewDialogState(); +} + +class _ReceiptPaymentViewDialogState extends State { + bool _isGeneratingPdf = false; + + @override + Widget build(BuildContext context) { + final t = AppLocalizations.of(context); + final doc = widget.document; + + return Dialog( + insetPadding: const EdgeInsets.all(16), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 1000, maxHeight: 800), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // هدر دیالوگ + _buildHeader(t, doc), + + // محتوای اصلی + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // اطلاعات کلی سند + _buildDocumentInfo(t, doc), + + const SizedBox(height: 24), + + // خطوط اشخاص + _buildPersonLines(t, doc), + + const SizedBox(height: 24), + + // خطوط حساب‌ها + _buildAccountLines(t, doc), + ], + ), + ), + ), + + // دکمه‌های پایین + _buildFooter(t), + ], + ), + ), + ); + } + + Widget _buildHeader(AppLocalizations t, ReceiptPaymentDocument doc) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(12), + topRight: Radius.circular(12), + ), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'جزئیات سند ${doc.documentTypeName}', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + ), + const SizedBox(height: 4), + Text( + 'کد سند: ${doc.code}', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + ), + ], + ), + ), + IconButton( + onPressed: () => Navigator.pop(context), + icon: const Icon(Icons.close), + tooltip: 'بستن', + ), + ], + ), + ); + } + + Widget _buildDocumentInfo(AppLocalizations t, ReceiptPaymentDocument doc) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'اطلاعات کلی سند', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 12), + _buildInfoRow('نوع سند', doc.documentTypeName), + _buildInfoRow('تاریخ سند', HesabixDateUtils.formatForDisplay(doc.documentDate, widget.calendarController.isJalali)), + _buildInfoRow('تاریخ ثبت', HesabixDateUtils.formatForDisplay(doc.registeredAt, widget.calendarController.isJalali)), + _buildInfoRow('ارز', doc.currencyCode ?? 'نامشخص'), + _buildInfoRow('ایجادکننده', doc.createdByName ?? 'نامشخص'), + _buildInfoRow('مبلغ کل', formatWithThousands(doc.totalAmount) + ' ریال'), + if (doc.description != null && doc.description!.isNotEmpty) + _buildInfoRow('توضیحات', doc.description!), + ], + ), + ), + ); + } + + Widget _buildInfoRow(String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 120, + child: Text( + '$label:', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + ), + Expanded( + child: Text( + value, + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ], + ), + ); + } + + Widget _buildPersonLines(AppLocalizations t, ReceiptPaymentDocument doc) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'خطوط اشخاص (${doc.personLinesCount})', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 12), + if (doc.personLines.isEmpty) + const Text('هیچ خط شخصی یافت نشد') + else + ...doc.personLines.map((line) => _buildPersonLineItem(line)), + ], + ), + ), + ); + } + + Widget _buildPersonLineItem(PersonLine line) { + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + border: Border.all(color: Theme.of(context).colorScheme.outline), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + line.personName ?? 'نامشخص', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + if (line.description != null && line.description!.isNotEmpty) + Text( + line.description!, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + Text( + formatWithThousands(line.amount) + ' ریال', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + } + + Widget _buildAccountLines(AppLocalizations t, ReceiptPaymentDocument doc) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'خطوط حساب‌ها (${doc.accountLinesCount})', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 12), + if (doc.accountLines.isEmpty) + const Text('هیچ خط حسابی یافت نشد') + else + ...doc.accountLines.map((line) => _buildAccountLineItem(line)), + ], + ), + ), + ); + } + + Widget _buildAccountLineItem(AccountLine line) { + final isCommission = line.extraInfo?['is_commission_line'] == true; + + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + border: Border.all( + color: isCommission + ? Theme.of(context).colorScheme.error + : Theme.of(context).colorScheme.outline, + ), + borderRadius: BorderRadius.circular(8), + color: isCommission + ? Theme.of(context).colorScheme.errorContainer.withOpacity(0.1) + : null, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + line.accountName, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + Text( + 'کد: ${line.accountCode}', + style: Theme.of(context).textTheme.bodySmall, + ), + if (line.transactionType != null) + Text( + 'نوع: ${_getTransactionTypeName(line.transactionType!)}', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + Text( + formatWithThousands(line.amount) + ' ریال', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + ], + ), + if (line.description != null && line.description!.isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + line.description!, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + if (isCommission) ...[ + const SizedBox(height: 4), + Text( + 'کارمزد', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.error, + fontWeight: FontWeight.w500, + ), + ), + ], + ], + ), + ); + } + + String _getTransactionTypeName(String type) { + switch (type) { + case 'bank': + return 'بانک'; + case 'cash_register': + return 'صندوق'; + case 'petty_cash': + return 'تنخواهگردان'; + case 'check': + return 'چک'; + case 'person': + return 'شخص'; + default: + return type; + } + } + + Widget _buildFooter(AppLocalizations t) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(12), + bottomRight: Radius.circular(12), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(t.close), + ), + const SizedBox(width: 8), + FilledButton.icon( + onPressed: _isGeneratingPdf ? null : _generatePdf, + icon: _isGeneratingPdf + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.picture_as_pdf), + label: Text(_isGeneratingPdf ? 'در حال تولید...' : 'خروجی PDF'), + ), + ], + ), + ); + } + + Future _generatePdf() async { + setState(() { + _isGeneratingPdf = true; + }); + + try { + // ایجاد PDF از سند + final pdfBytes = await widget.apiClient.downloadPdf( + '/receipts-payments/${widget.document.id}/pdf', + ); + + // ذخیره فایل + await _savePdfFile(pdfBytes, widget.document.code); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('فایل PDF با موفقیت تولید شد'), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('خطا در تولید PDF: $e'), + backgroundColor: Colors.red, + ), + ); + } + } finally { + if (mounted) { + setState(() { + _isGeneratingPdf = false; + }); + } + } + } + + Future _savePdfFile(List bytes, String filename) async { + try { + // استفاده از dart:html برای دانلود فایل در وب + final blob = html.Blob([bytes]); + final url = html.Url.createObjectUrlFromBlob(blob); + html.AnchorElement(href: url) + ..setAttribute('download', filename.endsWith('.pdf') ? filename : '$filename.pdf') + ..click(); + html.Url.revokeObjectUrl(url); + + print('✅ PDF downloaded successfully: $filename'); + } catch (e) { + print('❌ Error downloading PDF: $e'); + rethrow; + } + } +} + diff --git a/hesabixUI/hesabix_ui/lib/pages/business/receipts_payments_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/receipts_payments_page.dart index c844781..057ba31 100644 --- a/hesabixUI/hesabix_ui/lib/pages/business/receipts_payments_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/receipts_payments_page.dart @@ -216,6 +216,7 @@ class _BulkSettlementDialogState extends State<_BulkSettlementDialog> { late DateTime _docDate; late bool _isReceipt; int? _selectedCurrencyId; + final TextEditingController _descriptionController = TextEditingController(); final List<_PersonLine> _personLines = <_PersonLine>[]; final List _centerTransactions = []; @@ -231,6 +232,12 @@ class _BulkSettlementDialogState extends State<_BulkSettlementDialog> { } } + @override + void dispose() { + _descriptionController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { final t = AppLocalizations.of(context); @@ -290,6 +297,18 @@ class _BulkSettlementDialogState extends State<_BulkSettlementDialog> { ], ), ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: TextField( + controller: _descriptionController, + decoration: const InputDecoration( + labelText: 'توضیحات کلی سند', + hintText: 'توضیحات اختیاری...', + border: OutlineInputBorder(), + ), + maxLines: 2, + ), + ), const Divider(height: 1), Expanded( child: Row( @@ -423,6 +442,7 @@ class _BulkSettlementDialogState extends State<_BulkSettlementDialog> { documentType: _isReceipt ? 'receipt' : 'payment', documentDate: _docDate, currencyId: _selectedCurrencyId!, + description: _descriptionController.text.trim().isNotEmpty ? _descriptionController.text.trim() : null, personLines: personLinesData, accountLines: accountLinesData, ); diff --git a/hesabixUI/hesabix_ui/lib/pages/business/transfers_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/transfers_page.dart new file mode 100644 index 0000000..0196ca8 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/pages/business/transfers_page.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import '../../core/auth_store.dart'; +import '../../core/calendar_controller.dart'; +import '../../core/api_client.dart'; +import '../../widgets/transfer/transfer_form_dialog.dart'; +import 'package:hesabix_ui/l10n/app_localizations.dart'; + +class TransfersPage extends StatefulWidget { + final int businessId; + final AuthStore authStore; + final CalendarController calendarController; + final ApiClient apiClient; + + const TransfersPage({ + super.key, + required this.businessId, + required this.authStore, + required this.calendarController, + required this.apiClient, + }); + + @override + State createState() => _TransfersPageState(); +} + +class _TransfersPageState extends State { + Future _showAddTransferDialog() async { + final result = await showDialog( + context: context, + builder: (context) => TransferFormDialog( + businessId: widget.businessId, + calendarController: widget.calendarController, + onSuccess: () { + // TODO: بروزرسانی لیست انتقالات + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('انتقال با موفقیت ثبت شد'), + backgroundColor: Colors.green, + ), + ); + }, + ), + ); + + if (result == true) { + // بروزرسانی صفحه در صورت نیاز + setState(() {}); + } + } + + @override + Widget build(BuildContext context) { + final t = AppLocalizations.of(context); + + return Scaffold( + appBar: AppBar( + title: Text(t.transfers), + backgroundColor: Theme.of(context).colorScheme.surface, + foregroundColor: Theme.of(context).colorScheme.onSurface, + elevation: 0, + actions: [ + IconButton( + icon: const Icon(Icons.add), + onPressed: () => _showAddTransferDialog(), + tooltip: 'اضافه کردن انتقال جدید', + ), + ], + ), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.swap_horiz, + size: 80, + color: Theme.of(context).colorScheme.primary.withOpacity(0.5), + ), + const SizedBox(height: 24), + Text( + 'صفحه لیست انتقال', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + color: Theme.of(context).colorScheme.onSurface, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + Text( + 'این صفحه به زودی آماده خواهد شد', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7), + ), + ), + const SizedBox(height: 32), + ElevatedButton.icon( + onPressed: () => _showAddTransferDialog(), + icon: const Icon(Icons.add), + label: const Text('اضافه کردن انتقال جدید'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + ), + ), + ], + ), + ), + ); + } +} diff --git a/hesabixUI/hesabix_ui/lib/services/receipt_payment_service.dart b/hesabixUI/hesabix_ui/lib/services/receipt_payment_service.dart index 96836fb..c7678bd 100644 --- a/hesabixUI/hesabix_ui/lib/services/receipt_payment_service.dart +++ b/hesabixUI/hesabix_ui/lib/services/receipt_payment_service.dart @@ -1,4 +1,5 @@ import '../core/api_client.dart'; +import '../models/receipt_payment_document.dart'; /// سرویس دریافت و پرداخت class ReceiptPaymentService { @@ -14,6 +15,7 @@ class ReceiptPaymentService { /// [currencyId] شناسه ارز /// [personLines] لیست تراکنش‌های اشخاص /// [accountLines] لیست تراکنش‌های حساب‌ها + /// [description] توضیحات کلی سند (اختیاری) /// [extraInfo] اطلاعات اضافی (اختیاری) Future> createReceiptPayment({ required int businessId, @@ -22,6 +24,7 @@ class ReceiptPaymentService { required int currencyId, required List> personLines, required List> accountLines, + String? description, Map? extraInfo, }) async { final response = await _apiClient.post( @@ -30,6 +33,7 @@ class ReceiptPaymentService { 'document_type': documentType, 'document_date': documentDate.toIso8601String(), 'currency_id': currencyId, + if (description != null && description.isNotEmpty) 'description': description, 'person_lines': personLines, 'account_lines': accountLines, if (extraInfo != null) 'extra_info': extraInfo, @@ -66,8 +70,9 @@ class ReceiptPaymentService { if (sortBy != null) 'sort_by': sortBy, if (search != null && search.isNotEmpty) 'search': search, if (documentType != null) 'document_type': documentType, - if (fromDate != null) 'from_date': fromDate.toIso8601String(), - if (toDate != null) 'to_date': toDate.toIso8601String(), + // ارسال تاریخ به صورت ISO8601 با تنظیم timezone + if (fromDate != null) 'from_date': fromDate.toUtc().toIso8601String(), + if (toDate != null) 'to_date': toDate.toUtc().toIso8601String(), }; final response = await _apiClient.post( @@ -89,6 +94,18 @@ class ReceiptPaymentService { return response.data['data'] as Map; } + /// دریافت جزئیات یک سند دریافت/پرداخت (wrapper برای getReceiptPayment) + /// + /// [documentId] شناسه سند + Future getById(int documentId) async { + try { + final data = await getReceiptPayment(documentId); + return ReceiptPaymentDocument.fromJson(data); + } catch (e) { + return null; + } + } + /// حذف سند دریافت/پرداخت /// /// [documentId] شناسه سند @@ -105,6 +122,7 @@ class ReceiptPaymentService { required int currencyId, required List> personLines, required List> accountLines, + String? description, Map? extraInfo, }) async { final response = await _apiClient.put( @@ -112,6 +130,7 @@ class ReceiptPaymentService { data: { 'document_date': documentDate.toIso8601String(), 'currency_id': currencyId, + if (description != null && description.isNotEmpty) 'description': description, 'person_lines': personLines, 'account_lines': accountLines, if (extraInfo != null) 'extra_info': extraInfo, @@ -129,6 +148,7 @@ class ReceiptPaymentService { required int currencyId, required List> personLines, required List> accountLines, + String? description, Map? extraInfo, }) { return createReceiptPayment( @@ -138,6 +158,7 @@ class ReceiptPaymentService { currencyId: currencyId, personLines: personLines, accountLines: accountLines, + description: description, extraInfo: extraInfo, ); } @@ -151,6 +172,7 @@ class ReceiptPaymentService { required int currencyId, required List> personLines, required List> accountLines, + String? description, Map? extraInfo, }) { return createReceiptPayment( @@ -160,6 +182,7 @@ class ReceiptPaymentService { currencyId: currencyId, personLines: personLines, accountLines: accountLines, + description: description, extraInfo: extraInfo, ); } 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 8c2dd35..9984c1b 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 @@ -188,7 +188,9 @@ class _DataTableWidgetState extends State> { } Future _fetchData() async { - setState(() => _loadingList = true); + if (mounted) { + setState(() => _loadingList = true); + } _error = null; try { @@ -217,14 +219,16 @@ class _DataTableWidgetState extends State> { if (body is Map) { final response = DataTableResponse.fromJson(body, widget.fromJson); - setState(() { - _items = response.items; - _page = response.page; - _limit = response.limit; - _total = response.total; - _totalPages = response.totalPages; - _selectedRows.clear(); // Clear selection when data changes - }); + if (mounted) { + setState(() { + _items = response.items; + _page = response.page; + _limit = response.limit; + _total = response.total; + _totalPages = response.totalPages; + _selectedRows.clear(); // Clear selection when data changes + }); + } // Call the refresh callback if provided if (widget.onRefresh != null) { @@ -234,11 +238,15 @@ class _DataTableWidgetState extends State> { } } } catch (e) { - setState(() { - _error = e.toString(); - }); + if (mounted) { + setState(() { + _error = e.toString(); + }); + } } finally { - setState(() => _loadingList = false); + if (mounted) { + setState(() => _loadingList = false); + } } } diff --git a/hesabixUI/hesabix_ui/lib/widgets/transfer/transfer_form_dialog.dart b/hesabixUI/hesabix_ui/lib/widgets/transfer/transfer_form_dialog.dart new file mode 100644 index 0000000..f693935 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/transfer/transfer_form_dialog.dart @@ -0,0 +1,910 @@ +import 'package:flutter/material.dart'; +import '../../core/calendar_controller.dart'; +import '../../services/bank_account_service.dart'; +import '../../services/cash_register_service.dart'; +import '../../services/petty_cash_service.dart'; +import '../date_input_field.dart'; + +class TransferFormDialog extends StatefulWidget { + final int businessId; + final CalendarController calendarController; + final VoidCallback? onSuccess; + + const TransferFormDialog({ + super.key, + required this.businessId, + required this.calendarController, + this.onSuccess, + }); + + @override + State createState() => _TransferFormDialogState(); +} + +class _TransferFormDialogState extends State { + final _formKey = GlobalKey(); + final _amountController = TextEditingController(); + final _commissionController = TextEditingController(); + final _descriptionController = TextEditingController(); + + bool _isLoading = false; + DateTime _transferDate = DateTime.now(); + + // سرویس‌ها + final BankAccountService _bankService = BankAccountService(); + final CashRegisterService _cashRegisterService = CashRegisterService(); + final PettyCashService _pettyCashService = PettyCashService(); + + // انتخاب مبدا و مقصد + String? _fromType = 'bank'; // پیش‌فرض بانک + String? _toType = 'bank'; // پیش‌فرض بانک + int? _fromId; + int? _toId; + + // لیست‌های داده + List> _banks = []; + List> _cashRegisters = []; + List> _pettyCashList = []; + + bool _isDataLoaded = false; + + @override + void initState() { + super.initState(); + _loadData(); + } + + @override + void dispose() { + _amountController.dispose(); + _commissionController.dispose(); + _descriptionController.dispose(); + super.dispose(); + } + + Future _loadData() async { + if (_isDataLoaded) return; + + setState(() { + _isLoading = true; + }); + + try { + // بارگذاری لیست بانک‌ها + final bankResponse = await _bankService.list( + businessId: widget.businessId, + queryInfo: {'take': 100, 'skip': 0}, + ); + _banks = (bankResponse['items'] as List?) + ?.map((item) => item as Map) + .toList() ?? []; + + // بارگذاری لیست صندوق‌ها + final cashRegisterResponse = await _cashRegisterService.list( + businessId: widget.businessId, + queryInfo: {'take': 100, 'skip': 0}, + ); + _cashRegisters = (cashRegisterResponse['items'] as List?) + ?.map((item) => item as Map) + .toList() ?? []; + + // بارگذاری لیست تنخواه گردان‌ها + final pettyCashResponse = await _pettyCashService.list( + businessId: widget.businessId, + queryInfo: {'take': 100, 'skip': 0}, + ); + _pettyCashList = (pettyCashResponse['items'] as List?) + ?.map((item) => item as Map) + .toList() ?? []; + + setState(() { + _isDataLoaded = true; + }); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('خطا در بارگذاری داده‌ها: $e'), + backgroundColor: Colors.red, + ), + ); + } + } finally { + setState(() { + _isLoading = false; + }); + } + } + + Future _save() async { + if (!_formKey.currentState!.validate()) return; + + if (_fromType == null || _toType == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('لطفاً مبدا و مقصد انتقال را انتخاب کنید'), + backgroundColor: Colors.red, + ), + ); + return; + } + + if (_fromId == null || _toId == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('لطفاً مبدا و مقصد انتقال را انتخاب کنید'), + backgroundColor: Colors.red, + ), + ); + return; + } + + if (_fromType == _toType && _fromId == _toId) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('مبدا و مقصد نمی‌توانند یکسان باشند'), + backgroundColor: Colors.red, + ), + ); + return; + } + + setState(() { + _isLoading = true; + }); + + try { + // TODO: ایجاد سرویس انتقال و ارسال درخواست به API + // فعلاً فقط پیام موفقیت نمایش می‌دهیم + + await Future.delayed(const Duration(seconds: 1)); // شبیه‌سازی درخواست API + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('انتقال با موفقیت ثبت شد'), + backgroundColor: Colors.green, + ), + ); + + Navigator.of(context).pop(); + widget.onSuccess?.call(); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('خطا در ثبت انتقال: $e'), + backgroundColor: Colors.red, + ), + ); + } + } finally { + setState(() { + _isLoading = false; + }); + } + } + + Widget _buildAccountSelector({ + required String label, + required String? selectedType, + required int? selectedId, + required ValueChanged onTypeChanged, + required ValueChanged onIdChanged, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + + // انتخاب نوع حساب با SegmentedButton + Card( + elevation: 4, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.3), + Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.1), + ], + ), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + // SegmentedButton برای انتخاب نوع + SegmentedButton( + segments: const [ + ButtonSegment( + value: 'bank', + label: Text('بانک'), + icon: Icon(Icons.account_balance, size: 16), + ), + ButtonSegment( + value: 'cash_register', + label: Text('صندوق'), + icon: Icon(Icons.point_of_sale, size: 16), + ), + ButtonSegment( + value: 'petty_cash', + label: Text('تنخواه'), + icon: Icon(Icons.money, size: 16), + ), + ], + selected: selectedType != null ? {selectedType} : {}, + onSelectionChanged: (Set selection) { + if (selection.isNotEmpty) { + onTypeChanged(selection.first); + onIdChanged(null); // ریست کردن انتخاب قبلی + } + }, + style: ButtonStyle( + backgroundColor: MaterialStateProperty.resolveWith((states) { + if (states.contains(MaterialState.selected)) { + return Theme.of(context).primaryColor; + } + return Theme.of(context).colorScheme.surface; + }), + foregroundColor: MaterialStateProperty.resolveWith((states) { + if (states.contains(MaterialState.selected)) { + return Colors.white; + } + return Theme.of(context).colorScheme.onSurface; + }), + minimumSize: MaterialStateProperty.all(const Size(0, 40)), + padding: MaterialStateProperty.all(const EdgeInsets.symmetric(horizontal: 8)), + ), + ), + ], + ), + ), + ), + ), + + const SizedBox(height: 12), + + // انتخاب حساب خاص + if (selectedType != null) + Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: DropdownButtonFormField( + value: selectedId, + decoration: InputDecoration( + labelText: _getAccountTypeLabel(selectedType), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outline.withOpacity(0.5), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: Theme.of(context).primaryColor, + width: 2, + ), + ), + prefixIcon: Icon( + _getAccountTypeIcon(selectedType), + color: Theme.of(context).primaryColor, + ), + filled: true, + fillColor: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.3), + ), + items: _getAccountItems(selectedType), + onChanged: onIdChanged, + validator: (value) { + if (value == null) return 'لطفاً حساب را انتخاب کنید'; + return null; + }, + ), + ), + ), + ], + ); + } + + String _getAccountTypeLabel(String type) { + switch (type) { + case 'bank': + return 'انتخاب بانک'; + case 'cash_register': + return 'انتخاب صندوق'; + case 'petty_cash': + return 'انتخاب تنخواه گردان'; + default: + return 'انتخاب حساب'; + } + } + + IconData _getAccountTypeIcon(String type) { + switch (type) { + case 'bank': + return Icons.account_balance; + case 'cash_register': + return Icons.point_of_sale; + case 'petty_cash': + return Icons.money; + default: + return Icons.account_balance_wallet; + } + } + + + List> _getAccountItems(String type) { + List> items = []; + + switch (type) { + case 'bank': + items = _banks; + break; + case 'cash_register': + items = _cashRegisters; + break; + case 'petty_cash': + items = _pettyCashList; + break; + } + + return items.map((item) { + return DropdownMenuItem( + value: item['id'] as int, + child: Text(item['name'] as String? ?? 'نامشخص'), + ); + }).toList(); + } + + @override + Widget build(BuildContext context) { + return Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + elevation: 8, + child: Container( + width: MediaQuery.of(context).size.width * 0.9, + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.9, + maxWidth: 1000, // حداکثر عرض برای دسکتاپ + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Theme.of(context).colorScheme.surface, + Theme.of(context).colorScheme.surface.withOpacity(0.95), + ], + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // هدر دیالوگ با طراحی بهبود یافته + Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + Theme.of(context).primaryColor, + Theme.of(context).primaryColor.withOpacity(0.8), + ], + ), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon( + Icons.swap_horiz, + color: Colors.white, + size: 24, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'ثبت انتقال', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + Text( + 'انتقال بین حساب‌های مختلف', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Colors.white.withOpacity(0.9), + ), + ), + ], + ), + ), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close, color: Colors.white), + style: IconButton.styleFrom( + backgroundColor: Colors.white.withOpacity(0.2), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ], + ), + ), + + // فرم + Expanded( + child: Form( + key: _formKey, + child: LayoutBuilder( + builder: (context, constraints) { + final isDesktop = constraints.maxWidth > 800; + + if (isDesktop) { + // طراحی دو ستونه برای دسکتاپ + return SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + // ردیف اول: انتخاب مبدا و مقصد + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: _buildAccountSelector( + label: 'از (مبدا)', + selectedType: _fromType, + selectedId: _fromId, + onTypeChanged: (value) { + setState(() { + _fromType = value; + _fromId = null; + }); + }, + onIdChanged: (value) { + setState(() { + _fromId = value; + }); + }, + ), + ), + const SizedBox(width: 24), + Expanded( + child: _buildAccountSelector( + label: 'به (مقصد)', + selectedType: _toType, + selectedId: _toId, + onTypeChanged: (value) { + setState(() { + _toType = value; + _toId = null; + }); + }, + onIdChanged: (value) { + setState(() { + _toId = value; + }); + }, + ), + ), + ], + ), + + const SizedBox(height: 24), + + // ردیف دوم: مبلغ و کارمزد + Row( + children: [ + Expanded( + child: TextFormField( + controller: _amountController, + decoration: InputDecoration( + labelText: 'مبلغ انتقال', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outline.withOpacity(0.5), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: Theme.of(context).primaryColor, + width: 2, + ), + ), + suffixText: 'ریال', + prefixIcon: Icon( + Icons.attach_money, + color: Theme.of(context).primaryColor, + ), + filled: true, + fillColor: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.3), + ), + keyboardType: TextInputType.number, + validator: (value) { + if (value == null || value.isEmpty) { + return 'لطفاً مبلغ را وارد کنید'; + } + if (double.tryParse(value) == null) { + return 'لطفاً مبلغ معتبر وارد کنید'; + } + if (double.parse(value) <= 0) { + return 'مبلغ باید بزرگتر از صفر باشد'; + } + return null; + }, + ), + ), + const SizedBox(width: 24), + Expanded( + child: TextFormField( + controller: _commissionController, + decoration: InputDecoration( + labelText: 'کارمزد', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outline.withOpacity(0.5), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: Theme.of(context).primaryColor, + width: 2, + ), + ), + suffixText: 'ریال', + helperText: 'اختیاری', + prefixIcon: Icon( + Icons.percent, + color: Theme.of(context).primaryColor, + ), + filled: true, + fillColor: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.3), + ), + keyboardType: TextInputType.number, + validator: (value) { + if (value != null && value.isNotEmpty) { + if (double.tryParse(value) == null) { + return 'لطفاً مبلغ معتبر وارد کنید'; + } + if (double.parse(value) < 0) { + return 'کارمزد نمی‌تواند منفی باشد'; + } + } + return null; + }, + ), + ), + ], + ), + + const SizedBox(height: 24), + + // ردیف سوم: تاریخ + Row( + children: [ + Expanded( + child: DateInputField( + value: _transferDate, + onChanged: (date) { + if (date != null) { + setState(() { + _transferDate = date; + }); + } + }, + labelText: 'تاریخ انتقال', + calendarController: widget.calendarController, + firstDate: DateTime(2020), + lastDate: DateTime.now().add(const Duration(days: 365)), + ), + ), + const SizedBox(width: 24), + const Expanded(child: SizedBox()), // فضای خالی برای تراز کردن + ], + ), + + const SizedBox(height: 24), + + // ردیف چهارم: توضیحات (تمام عرض) + TextFormField( + controller: _descriptionController, + decoration: InputDecoration( + labelText: 'توضیحات', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outline.withOpacity(0.5), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: Theme.of(context).primaryColor, + width: 2, + ), + ), + prefixIcon: Icon( + Icons.description, + color: Theme.of(context).primaryColor, + ), + filled: true, + fillColor: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.3), + ), + maxLines: 3, + validator: (value) { + if (value == null || value.isEmpty) { + return 'لطفاً توضیحات را وارد کنید'; + } + return null; + }, + ), + ], + ), + ), + ); + } else { + // طراحی تک ستونه برای موبایل + return SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + // انتخاب مبدا + _buildAccountSelector( + label: 'از (مبدا)', + selectedType: _fromType, + selectedId: _fromId, + onTypeChanged: (value) { + setState(() { + _fromType = value; + _fromId = null; + }); + }, + onIdChanged: (value) { + setState(() { + _fromId = value; + }); + }, + ), + + const SizedBox(height: 24), + + // انتخاب مقصد + _buildAccountSelector( + label: 'به (مقصد)', + selectedType: _toType, + selectedId: _toId, + onTypeChanged: (value) { + setState(() { + _toType = value; + _toId = null; + }); + }, + onIdChanged: (value) { + setState(() { + _toId = value; + }); + }, + ), + + const SizedBox(height: 24), + + // مبلغ + TextFormField( + controller: _amountController, + decoration: const InputDecoration( + labelText: 'مبلغ انتقال', + border: OutlineInputBorder(), + suffixText: 'ریال', + ), + keyboardType: TextInputType.number, + validator: (value) { + if (value == null || value.isEmpty) { + return 'لطفاً مبلغ را وارد کنید'; + } + if (double.tryParse(value) == null) { + return 'لطفاً مبلغ معتبر وارد کنید'; + } + if (double.parse(value) <= 0) { + return 'مبلغ باید بزرگتر از صفر باشد'; + } + return null; + }, + ), + + const SizedBox(height: 24), + + // کارمزد + TextFormField( + controller: _commissionController, + decoration: const InputDecoration( + labelText: 'کارمزد', + border: OutlineInputBorder(), + suffixText: 'ریال', + helperText: 'اختیاری', + ), + keyboardType: TextInputType.number, + validator: (value) { + if (value != null && value.isNotEmpty) { + if (double.tryParse(value) == null) { + return 'لطفاً مبلغ معتبر وارد کنید'; + } + if (double.parse(value) < 0) { + return 'کارمزد نمی‌تواند منفی باشد'; + } + } + return null; + }, + ), + + const SizedBox(height: 24), + + // تاریخ + DateInputField( + value: _transferDate, + onChanged: (date) { + if (date != null) { + setState(() { + _transferDate = date; + }); + } + }, + labelText: 'تاریخ انتقال', + calendarController: widget.calendarController, + firstDate: DateTime(2020), + lastDate: DateTime.now().add(const Duration(days: 365)), + ), + + const SizedBox(height: 24), + + // توضیحات + TextFormField( + controller: _descriptionController, + decoration: InputDecoration( + labelText: 'توضیحات', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outline.withOpacity(0.5), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: Theme.of(context).primaryColor, + width: 2, + ), + ), + prefixIcon: Icon( + Icons.description, + color: Theme.of(context).primaryColor, + ), + filled: true, + fillColor: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.3), + ), + maxLines: 3, + validator: (value) { + if (value == null || value.isEmpty) { + return 'لطفاً توضیحات را وارد کنید'; + } + return null; + }, + ), + ], + ), + ), + ); + } + }, + ), + ), + ), + + // دکمه‌های عملیات با طراحی بهبود یافته + Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.3), + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(16), + bottomRight: Radius.circular(16), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + OutlinedButton.icon( + onPressed: _isLoading ? null : () => Navigator.of(context).pop(), + icon: const Icon(Icons.close), + label: const Text('انصراف'), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + const SizedBox(width: 16), + ElevatedButton.icon( + onPressed: _isLoading ? null : _save, + icon: _isLoading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Icons.save), + label: Text(_isLoading ? 'در حال ثبت...' : 'ثبت انتقال'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + backgroundColor: Theme.of(context).primaryColor, + foregroundColor: Colors.white, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/hesabixUI/hesabix_ui/test.pdf b/hesabixUI/hesabix_ui/test.pdf new file mode 100644 index 0000000..4e0b64b --- /dev/null +++ b/hesabixUI/hesabix_ui/test.pdf @@ -0,0 +1 @@ +{"success":false,"error":{"code":"UNAUTHORIZED","message":"Invalid API key"}} \ No newline at end of file