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}
+
+
+
+
+
+
+
+
کد سند:
+
{escape(doc_code)}
+
+
+
نوع سند:
+
{escape(doc_type_name)}
+
+
+
تاریخ سند:
+
{escape(doc_date)}
+
+
+
مبلغ کل:
+
{escape(str(total_amount))} ریال
+
+ {f'
توضیحات:
{escape(description or "")}
' if description else ''}
+
+
+
+
خطوط اشخاص
+
+
+
+ | نام شخص |
+ مبلغ |
+ توضیحات |
+
+
+
+ {''.join([f'| {escape(line.get("person_name") or "نامشخص")} | {escape(str(line.get("amount", 0)))} ریال | {escape(line.get("description") or "")} |
' for line in person_lines])}
+
+
+
+
+
+
خطوط حسابها
+
+
+
+ | نام حساب |
+ کد حساب |
+ نوع تراکنش |
+ مبلغ |
+ توضیحات |
+
+
+
+ {''.join([f'| {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 "")} |
' for line in account_lines])}
+
+
+
+
+
+
+
+ """
+
+ 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