progress in transfer
This commit is contained in:
parent
030438c236
commit
1b6e2eb71c
|
|
@ -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 لیست اسناد دریافت و پرداخت",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ###
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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: باز کردن صفحه جزئیات سند
|
||||
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<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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
107
hesabixUI/hesabix_ui/lib/pages/business/transfers_page.dart
Normal file
107
hesabixUI/hesabix_ui/lib/pages/business/transfers_page.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -188,7 +188,9 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
|
|||
}
|
||||
|
||||
Future<void> _fetchData() async {
|
||||
setState(() => _loadingList = true);
|
||||
if (mounted) {
|
||||
setState(() => _loadingList = true);
|
||||
}
|
||||
_error = null;
|
||||
|
||||
try {
|
||||
|
|
@ -217,14 +219,16 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
|
|||
if (body is Map<String, dynamic>) {
|
||||
final response = DataTableResponse<T>.fromJson(body, widget.fromJson);
|
||||
|
||||
setState(() {
|
||||
_items = response.items;
|
||||
_page = response.page;
|
||||
_limit = response.limit;
|
||||
_total = response.total;
|
||||
_totalPages = response.totalPages;
|
||||
_selectedRows.clear(); // Clear selection when data changes
|
||||
});
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_items = response.items;
|
||||
_page = response.page;
|
||||
_limit = response.limit;
|
||||
_total = response.total;
|
||||
_totalPages = response.totalPages;
|
||||
_selectedRows.clear(); // Clear selection when data changes
|
||||
});
|
||||
}
|
||||
|
||||
// Call the refresh callback if provided
|
||||
if (widget.onRefresh != null) {
|
||||
|
|
@ -234,11 +238,15 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
|
|||
}
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_error = e.toString();
|
||||
});
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_error = e.toString();
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setState(() => _loadingList = false);
|
||||
if (mounted) {
|
||||
setState(() => _loadingList = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
1
hesabixUI/hesabix_ui/test.pdf
Normal file
1
hesabixUI/hesabix_ui/test.pdf
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"success":false,"error":{"code":"UNAUTHORIZED","message":"Invalid API key"}}
|
||||
Loading…
Reference in a new issue