progress in transfer

This commit is contained in:
Hesabix 2025-10-16 20:52:59 +03:30
parent 030438c236
commit 1b6e2eb71c
15 changed files with 1967 additions and 35 deletions

View file

@ -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"""
<!DOCTYPE html>
<html dir="{'rtl' if is_fa else 'ltr'}">
<head>
<meta charset="utf-8">
<title>{title_text}</title>
<style>
@page {{
margin: 1cm;
size: A4;
}}
body {{
font-family: {'Tahoma, Arial' if is_fa else 'Arial, sans-serif'};
font-size: 12px;
line-height: 1.4;
color: #333;
direction: {'rtl' if is_fa else 'ltr'};
}}
.header {{
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 2px solid #366092;
}}
.title {{
font-size: 18px;
font-weight: bold;
color: #366092;
}}
.meta {{
font-size: 11px;
color: #666;
}}
.document-info {{
margin: 20px 0;
padding: 15px;
border: 1px solid #ddd;
border-radius: 5px;
background-color: #f9f9f9;
}}
.info-row {{
display: flex;
margin-bottom: 8px;
}}
.info-label {{
font-weight: bold;
width: 150px;
flex-shrink: 0;
}}
.info-value {{
flex: 1;
}}
.section {{
margin: 20px 0;
}}
.section-title {{
font-size: 14px;
font-weight: bold;
margin-bottom: 10px;
padding: 8px;
background-color: #366092;
color: white;
border-radius: 3px;
}}
.lines-table {{
width: 100%;
border-collapse: collapse;
margin: 10px 0;
font-size: 11px;
}}
.lines-table th {{
background-color: #f0f0f0;
border: 1px solid #ddd;
padding: 8px;
text-align: {'right' if is_fa else 'left'};
font-weight: bold;
}}
.lines-table td {{
border: 1px solid #ddd;
padding: 6px;
text-align: {'right' if is_fa else 'left'};
}}
.lines-table tr:nth-child(even) {{
background-color: #f9f9f9;
}}
.amount {{
text-align: {'left' if is_fa else 'right'};
font-weight: bold;
}}
.commission-row {{
background-color: #ffe6e6 !important;
font-style: italic;
}}
.footer {{
position: running(footer);
font-size: 10px;
color: #666;
margin-top: 8px;
text-align: {'left' if is_fa else 'right'};
}}
</style>
</head>
<body>
<div class="header">
<div>
<div class="title">{title_text}</div>
<div class="meta">{label_biz}: {escape(business_name)}</div>
</div>
<div class="meta">{label_date}: {escape(now)}</div>
</div>
<div class="document-info">
<div class="info-row">
<div class="info-label">کد سند:</div>
<div class="info-value">{escape(doc_code)}</div>
</div>
<div class="info-row">
<div class="info-label">نوع سند:</div>
<div class="info-value">{escape(doc_type_name)}</div>
</div>
<div class="info-row">
<div class="info-label">تاریخ سند:</div>
<div class="info-value">{escape(doc_date)}</div>
</div>
<div class="info-row">
<div class="info-label">مبلغ کل:</div>
<div class="info-value">{escape(str(total_amount))} ریال</div>
</div>
{f'<div class="info-row"><div class="info-label">توضیحات:</div><div class="info-value">{escape(description or "")}</div></div>' if description else ''}
</div>
<div class="section">
<div class="section-title">خطوط اشخاص</div>
<table class="lines-table">
<thead>
<tr>
<th>نام شخص</th>
<th>مبلغ</th>
<th>توضیحات</th>
</tr>
</thead>
<tbody>
{''.join([f'<tr><td>{escape(line.get("person_name") or "نامشخص")}</td><td class="amount">{escape(str(line.get("amount", 0)))} ریال</td><td>{escape(line.get("description") or "")}</td></tr>' for line in person_lines])}
</tbody>
</table>
</div>
<div class="section">
<div class="section-title">خطوط حسابها</div>
<table class="lines-table">
<thead>
<tr>
<th>نام حساب</th>
<th>کد حساب</th>
<th>نوع تراکنش</th>
<th>مبلغ</th>
<th>توضیحات</th>
</tr>
</thead>
<tbody>
{''.join([f'<tr class="{"commission-row" if line.get("extra_info", {}).get("is_commission_line") else ""}"><td>{escape(line.get("account_name") or "")}</td><td>{escape(line.get("account_code") or "")}</td><td>{escape(line.get("transaction_type") or "")}</td><td class="amount">{escape(str(line.get("amount", 0)))} ریال</td><td>{escape(line.get("description") or "")}</td></tr>' for line in account_lines])}
</tbody>
</table>
</div>
<div class="footer">{footer_text}</div>
</body>
</html>
"""
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 لیست اسناد دریافت و پرداخت",

View file

@ -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)

View file

@ -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,

View file

@ -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

View file

@ -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 ###

View file

@ -193,6 +193,20 @@ class ApiClient {
},
);
}
// Download PDF API
Future<List<int>> downloadPdf(String path) async {
final response = await get<List<int>>(
path,
responseType: ResponseType.bytes,
options: Options(
headers: {
'Accept': 'application/pdf',
},
),
);
return response.data ?? [];
}
}
// Utilities

View file

@ -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<MyApp> {
);
},
),
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',

View file

@ -116,6 +116,7 @@ class ReceiptPaymentDocument {
final int createdByUserId;
final String? createdByName;
final bool isProforma;
final String? description;
final Map<String, dynamic>? extraInfo;
final List<PersonLine> personLines;
final List<AccountLine> 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<dynamic>?)
?.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(),

View file

@ -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<ReceiptsPaymentsListPage> {
icon: const Icon(Icons.upload_outlined),
),
],
selected: {_selectedDocumentType},
selected: _selectedDocumentType != null ? {_selectedDocumentType} : <String?>{},
onSelectionChanged: (set) {
setState(() {
_selectedDocumentType = set.first;
@ -251,9 +253,10 @@ class _ReceiptsPaymentsListPageState extends State<ReceiptsPaymentsListPage> {
],
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<ReceiptsPaymentsListPage> {
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<ReceiptsPaymentsListPage> {
});
},
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<ReceiptsPaymentsListPage> {
}
/// مشاهده جزئیات سند
void _onView(ReceiptPaymentDocument document) {
// TODO: باز کردن صفحه جزئیات سند
void _onView(ReceiptPaymentDocument document) async {
try {
// دریافت جزئیات کامل سند
final fullDoc = await _service.getById(document.id);
if (fullDoc == null) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('مشاهده سند ${document.code}'),
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<BulkSettlementDialog> {
late DateTime _docDate;
late bool _isReceipt;
int? _selectedCurrencyId;
final TextEditingController _descriptionController = TextEditingController();
final List<_PersonLine> _personLines = <_PersonLine>[];
final List<InvoiceTransaction> _centerTransactions = <InvoiceTransaction>[];
@ -675,6 +709,7 @@ class _BulkSettlementDialogState extends State<BulkSettlementDialog> {
_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<BulkSettlementDialog> {
}
}
@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<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(
@ -917,6 +970,7 @@ class _BulkSettlementDialogState extends State<BulkSettlementDialog> {
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<BulkSettlementDialog> {
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<ReceiptPaymentViewDialog> createState() => _ReceiptPaymentViewDialogState();
}
class _ReceiptPaymentViewDialogState extends State<ReceiptPaymentViewDialog> {
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<void> _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<void> _savePdfFile(List<int> 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;
}
}
}

View file

@ -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<InvoiceTransaction> _centerTransactions = <InvoiceTransaction>[];
@ -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,
);

View file

@ -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<TransfersPage> createState() => _TransfersPageState();
}
class _TransfersPageState extends State<TransfersPage> {
Future<void> _showAddTransferDialog() async {
final result = await showDialog<bool>(
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),
),
),
],
),
),
);
}
}

View file

@ -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<Map<String, dynamic>> createReceiptPayment({
required int businessId,
@ -22,6 +24,7 @@ class ReceiptPaymentService {
required int currencyId,
required List<Map<String, dynamic>> personLines,
required List<Map<String, dynamic>> accountLines,
String? description,
Map<String, dynamic>? 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<String, dynamic>;
}
/// دریافت جزئیات یک سند دریافت/پرداخت (wrapper برای getReceiptPayment)
///
/// [documentId] شناسه سند
Future<ReceiptPaymentDocument?> 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<Map<String, dynamic>> personLines,
required List<Map<String, dynamic>> accountLines,
String? description,
Map<String, dynamic>? 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<Map<String, dynamic>> personLines,
required List<Map<String, dynamic>> accountLines,
String? description,
Map<String, dynamic>? 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<Map<String, dynamic>> personLines,
required List<Map<String, dynamic>> accountLines,
String? description,
Map<String, dynamic>? extraInfo,
}) {
return createReceiptPayment(
@ -160,6 +182,7 @@ class ReceiptPaymentService {
currencyId: currencyId,
personLines: personLines,
accountLines: accountLines,
description: description,
extraInfo: extraInfo,
);
}

View file

@ -188,7 +188,9 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
}
Future<void> _fetchData() async {
if (mounted) {
setState(() => _loadingList = true);
}
_error = null;
try {
@ -217,6 +219,7 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
if (body is Map<String, dynamic>) {
final response = DataTableResponse<T>.fromJson(body, widget.fromJson);
if (mounted) {
setState(() {
_items = response.items;
_page = response.page;
@ -225,6 +228,7 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
_totalPages = response.totalPages;
_selectedRows.clear(); // Clear selection when data changes
});
}
// Call the refresh callback if provided
if (widget.onRefresh != null) {
@ -234,13 +238,17 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
}
}
} catch (e) {
if (mounted) {
setState(() {
_error = e.toString();
});
}
} finally {
if (mounted) {
setState(() => _loadingList = false);
}
}
}
List<FilterItem> _buildFilters() {
final filters = <FilterItem>[];

View file

@ -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<TransferFormDialog> createState() => _TransferFormDialogState();
}
class _TransferFormDialogState extends State<TransferFormDialog> {
final _formKey = GlobalKey<FormState>();
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<Map<String, dynamic>> _banks = [];
List<Map<String, dynamic>> _cashRegisters = [];
List<Map<String, dynamic>> _pettyCashList = [];
bool _isDataLoaded = false;
@override
void initState() {
super.initState();
_loadData();
}
@override
void dispose() {
_amountController.dispose();
_commissionController.dispose();
_descriptionController.dispose();
super.dispose();
}
Future<void> _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<dynamic>?)
?.map((item) => item as Map<String, dynamic>)
.toList() ?? [];
// بارگذاری لیست صندوقها
final cashRegisterResponse = await _cashRegisterService.list(
businessId: widget.businessId,
queryInfo: {'take': 100, 'skip': 0},
);
_cashRegisters = (cashRegisterResponse['items'] as List<dynamic>?)
?.map((item) => item as Map<String, dynamic>)
.toList() ?? [];
// بارگذاری لیست تنخواه گردانها
final pettyCashResponse = await _pettyCashService.list(
businessId: widget.businessId,
queryInfo: {'take': 100, 'skip': 0},
);
_pettyCashList = (pettyCashResponse['items'] as List<dynamic>?)
?.map((item) => item as Map<String, dynamic>)
.toList() ?? [];
setState(() {
_isDataLoaded = true;
});
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('خطا در بارگذاری داده‌ها: $e'),
backgroundColor: Colors.red,
),
);
}
} finally {
setState(() {
_isLoading = false;
});
}
}
Future<void> _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<String?> onTypeChanged,
required ValueChanged<int?> 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<String>(
segments: const [
ButtonSegment<String>(
value: 'bank',
label: Text('بانک'),
icon: Icon(Icons.account_balance, size: 16),
),
ButtonSegment<String>(
value: 'cash_register',
label: Text('صندوق'),
icon: Icon(Icons.point_of_sale, size: 16),
),
ButtonSegment<String>(
value: 'petty_cash',
label: Text('تنخواه'),
icon: Icon(Icons.money, size: 16),
),
],
selected: selectedType != null ? {selectedType} : <String>{},
onSelectionChanged: (Set<String> 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<int>(
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<DropdownMenuItem<int>> _getAccountItems(String type) {
List<Map<String, dynamic>> 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<int>(
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,
),
),
],
),
),
],
),
),
);
}
}

View file

@ -0,0 +1 @@
{"success":false,"error":{"code":"UNAUTHORIZED","message":"Invalid API key"}}