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"]:
|
for key in ["document_type", "from_date", "to_date"]:
|
||||||
if key in body_json:
|
if key in body_json:
|
||||||
query_dict[key] = body_json[key]
|
query_dict[key] = body_json[key]
|
||||||
|
print(f"API - پارامتر {key}: {body_json[key]}")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
@ -108,6 +109,7 @@ async def create_receipt_payment_endpoint(
|
||||||
"document_type": "receipt" | "payment",
|
"document_type": "receipt" | "payment",
|
||||||
"document_date": "2025-01-15T10:30:00",
|
"document_date": "2025-01-15T10:30:00",
|
||||||
"currency_id": 1,
|
"currency_id": 1,
|
||||||
|
"description": "توضیحات کلی سند (اختیاری)",
|
||||||
"person_lines": [
|
"person_lines": [
|
||||||
{
|
{
|
||||||
"person_id": 123,
|
"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(
|
@router.post(
|
||||||
"/businesses/{business_id}/receipts-payments/export/pdf",
|
"/businesses/{business_id}/receipts-payments/export/pdf",
|
||||||
summary="خروجی PDF لیست اسناد دریافت و پرداخت",
|
summary="خروجی PDF لیست اسناد دریافت و پرداخت",
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
from datetime import date, datetime
|
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 sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from adapters.db.session import Base
|
from adapters.db.session import Base
|
||||||
|
|
@ -24,6 +24,7 @@ class Document(Base):
|
||||||
document_date: Mapped[date] = mapped_column(Date, nullable=False)
|
document_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
document_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
document_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
is_proforma: Mapped[bool] = mapped_column(Boolean, nullable=False, default=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)
|
extra_info: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
developer_settings: 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)
|
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.user import User
|
||||||
from adapters.db.models.fiscal_year import FiscalYear
|
from adapters.db.models.fiscal_year import FiscalYear
|
||||||
from app.core.responses import ApiError
|
from app.core.responses import ApiError
|
||||||
|
import jdatetime
|
||||||
|
|
||||||
# تنظیم لاگر
|
# تنظیم لاگر
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -43,16 +44,57 @@ ACCOUNT_TYPE_CHECK_PAYABLE = "check" # اسناد پرداختنی (چک پرد
|
||||||
|
|
||||||
|
|
||||||
def _parse_iso_date(dt: str | datetime | date) -> date:
|
def _parse_iso_date(dt: str | datetime | date) -> date:
|
||||||
"""تبدیل تاریخ به فرمت date"""
|
"""تبدیل تاریخ به فرمت date - پشتیبانی از تاریخهای شمسی و میلادی"""
|
||||||
if isinstance(dt, date):
|
if isinstance(dt, date):
|
||||||
return dt
|
return dt
|
||||||
if isinstance(dt, datetime):
|
if isinstance(dt, datetime):
|
||||||
return dt.date()
|
return dt.date()
|
||||||
|
|
||||||
|
dt_str = str(dt).strip()
|
||||||
|
|
||||||
try:
|
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()
|
return parsed.date()
|
||||||
except Exception:
|
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:
|
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,
|
created_by_user_id=user_id,
|
||||||
registered_at=datetime.utcnow(),
|
registered_at=datetime.utcnow(),
|
||||||
is_proforma=False,
|
is_proforma=False,
|
||||||
|
description=data.get("description"),
|
||||||
extra_info=data.get("extra_info"),
|
extra_info=data.get("extra_info"),
|
||||||
)
|
)
|
||||||
db.add(document)
|
db.add(document)
|
||||||
|
|
@ -602,8 +645,12 @@ def list_receipts_payments(
|
||||||
|
|
||||||
# فیلتر بر اساس نوع
|
# فیلتر بر اساس نوع
|
||||||
doc_type = query.get("document_type")
|
doc_type = query.get("document_type")
|
||||||
|
logger.info(f"فیلتر نوع سند: {doc_type}")
|
||||||
if doc_type:
|
if doc_type:
|
||||||
q = q.filter(Document.document_type == doc_type)
|
q = q.filter(Document.document_type == doc_type)
|
||||||
|
logger.info(f"فیلتر نوع سند اعمال شد: {doc_type}")
|
||||||
|
else:
|
||||||
|
logger.info("فیلتر نوع سند اعمال نشد - نمایش همه انواع")
|
||||||
|
|
||||||
# فیلتر بر اساس تاریخ
|
# فیلتر بر اساس تاریخ
|
||||||
from_date = query.get("from_date")
|
from_date = query.get("from_date")
|
||||||
|
|
@ -613,14 +660,18 @@ def list_receipts_payments(
|
||||||
try:
|
try:
|
||||||
from_dt = _parse_iso_date(from_date)
|
from_dt = _parse_iso_date(from_date)
|
||||||
q = q.filter(Document.document_date >= from_dt)
|
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
|
pass
|
||||||
|
|
||||||
if to_date:
|
if to_date:
|
||||||
try:
|
try:
|
||||||
to_dt = _parse_iso_date(to_date)
|
to_dt = _parse_iso_date(to_date)
|
||||||
q = q.filter(Document.document_date <= to_dt)
|
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
|
pass
|
||||||
|
|
||||||
# جستجو
|
# جستجو
|
||||||
|
|
@ -822,6 +873,8 @@ def update_receipt_payment(
|
||||||
document.currency_id = int(currency_id)
|
document.currency_id = int(currency_id)
|
||||||
if isinstance(data.get("extra_info"), dict) or data.get("extra_info") is None:
|
if isinstance(data.get("extra_info"), dict) or data.get("extra_info") is None:
|
||||||
document.extra_info = data.get("extra_info")
|
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)
|
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_user_id": document.created_by_user_id,
|
||||||
"created_by_name": created_by_name,
|
"created_by_name": created_by_name,
|
||||||
"is_proforma": document.is_proforma,
|
"is_proforma": document.is_proforma,
|
||||||
|
"description": document.description,
|
||||||
"extra_info": document.extra_info,
|
"extra_info": document.extra_info,
|
||||||
"person_lines": person_lines,
|
"person_lines": person_lines,
|
||||||
"account_lines": account_lines,
|
"account_lines": account_lines,
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ adapters/api/v1/categories.py
|
||||||
adapters/api/v1/checks.py
|
adapters/api/v1/checks.py
|
||||||
adapters/api/v1/currencies.py
|
adapters/api/v1/currencies.py
|
||||||
adapters/api/v1/customers.py
|
adapters/api/v1/customers.py
|
||||||
|
adapters/api/v1/fiscal_years.py
|
||||||
adapters/api/v1/health.py
|
adapters/api/v1/health.py
|
||||||
adapters/api/v1/invoices.py
|
adapters/api/v1/invoices.py
|
||||||
adapters/api/v1/persons.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/5553f8745c6e_add_support_tables.py
|
||||||
migrations/versions/7891282548e9_merge_tax_types_and_unit_fields_heads.py
|
migrations/versions/7891282548e9_merge_tax_types_and_unit_fields_heads.py
|
||||||
migrations/versions/7ecb63029764_merge_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/9f9786ae7191_create_tax_units_table.py
|
||||||
migrations/versions/a1443c153b47_merge_heads.py
|
migrations/versions/a1443c153b47_merge_heads.py
|
||||||
migrations/versions/ac9e4b3dcffc_add_fiscal_year_to_documents.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
|
// Utilities
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ import 'pages/business/petty_cash_page.dart';
|
||||||
import 'pages/business/checks_page.dart';
|
import 'pages/business/checks_page.dart';
|
||||||
import 'pages/business/check_form_page.dart';
|
import 'pages/business/check_form_page.dart';
|
||||||
import 'pages/business/receipts_payments_list_page.dart';
|
import 'pages/business/receipts_payments_list_page.dart';
|
||||||
|
import 'pages/business/transfers_page.dart';
|
||||||
import 'pages/error_404_page.dart';
|
import 'pages/error_404_page.dart';
|
||||||
import 'core/locale_controller.dart';
|
import 'core/locale_controller.dart';
|
||||||
import 'core/calendar_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(
|
GoRoute(
|
||||||
path: 'checks',
|
path: 'checks',
|
||||||
name: 'business_checks',
|
name: 'business_checks',
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,7 @@ class ReceiptPaymentDocument {
|
||||||
final int createdByUserId;
|
final int createdByUserId;
|
||||||
final String? createdByName;
|
final String? createdByName;
|
||||||
final bool isProforma;
|
final bool isProforma;
|
||||||
|
final String? description;
|
||||||
final Map<String, dynamic>? extraInfo;
|
final Map<String, dynamic>? extraInfo;
|
||||||
final List<PersonLine> personLines;
|
final List<PersonLine> personLines;
|
||||||
final List<AccountLine> accountLines;
|
final List<AccountLine> accountLines;
|
||||||
|
|
@ -135,6 +136,7 @@ class ReceiptPaymentDocument {
|
||||||
required this.createdByUserId,
|
required this.createdByUserId,
|
||||||
this.createdByName,
|
this.createdByName,
|
||||||
required this.isProforma,
|
required this.isProforma,
|
||||||
|
this.description,
|
||||||
this.extraInfo,
|
this.extraInfo,
|
||||||
required this.personLines,
|
required this.personLines,
|
||||||
required this.accountLines,
|
required this.accountLines,
|
||||||
|
|
@ -156,6 +158,7 @@ class ReceiptPaymentDocument {
|
||||||
createdByUserId: json['created_by_user_id'] ?? 0,
|
createdByUserId: json['created_by_user_id'] ?? 0,
|
||||||
createdByName: json['created_by_name'],
|
createdByName: json['created_by_name'],
|
||||||
isProforma: json['is_proforma'] ?? false,
|
isProforma: json['is_proforma'] ?? false,
|
||||||
|
description: json['description'],
|
||||||
extraInfo: json['extra_info'],
|
extraInfo: json['extra_info'],
|
||||||
personLines: (json['person_lines'] as List<dynamic>?)
|
personLines: (json['person_lines'] as List<dynamic>?)
|
||||||
?.map((item) => PersonLine.fromJson(item))
|
?.map((item) => PersonLine.fromJson(item))
|
||||||
|
|
@ -182,6 +185,7 @@ class ReceiptPaymentDocument {
|
||||||
'created_by_user_id': createdByUserId,
|
'created_by_user_id': createdByUserId,
|
||||||
'created_by_name': createdByName,
|
'created_by_name': createdByName,
|
||||||
'is_proforma': isProforma,
|
'is_proforma': isProforma,
|
||||||
|
'description': description,
|
||||||
'extra_info': extraInfo,
|
'extra_info': extraInfo,
|
||||||
'person_lines': personLines.map((item) => item.toJson()).toList(),
|
'person_lines': personLines.map((item) => item.toJson()).toList(),
|
||||||
'account_lines': accountLines.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/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:hesabix_ui/l10n/app_localizations.dart';
|
import 'package:hesabix_ui/l10n/app_localizations.dart';
|
||||||
import 'package:hesabix_ui/core/calendar_controller.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/invoice_type_model.dart';
|
||||||
import 'package:hesabix_ui/models/person_model.dart';
|
import 'package:hesabix_ui/models/person_model.dart';
|
||||||
import 'package:hesabix_ui/models/business_dashboard_models.dart';
|
import 'package:hesabix_ui/models/business_dashboard_models.dart';
|
||||||
|
import 'dart:html' as html;
|
||||||
|
|
||||||
/// صفحه لیست اسناد دریافت و پرداخت با ویجت جدول
|
/// صفحه لیست اسناد دریافت و پرداخت با ویجت جدول
|
||||||
class ReceiptsPaymentsListPage extends StatefulWidget {
|
class ReceiptsPaymentsListPage extends StatefulWidget {
|
||||||
|
|
@ -164,7 +166,7 @@ class _ReceiptsPaymentsListPageState extends State<ReceiptsPaymentsListPage> {
|
||||||
icon: const Icon(Icons.upload_outlined),
|
icon: const Icon(Icons.upload_outlined),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
selected: {_selectedDocumentType},
|
selected: _selectedDocumentType != null ? {_selectedDocumentType} : <String?>{},
|
||||||
onSelectionChanged: (set) {
|
onSelectionChanged: (set) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectedDocumentType = set.first;
|
_selectedDocumentType = set.first;
|
||||||
|
|
@ -251,9 +253,10 @@ class _ReceiptsPaymentsListPageState extends State<ReceiptsPaymentsListPage> {
|
||||||
],
|
],
|
||||||
getExportParams: () => {
|
getExportParams: () => {
|
||||||
'business_id': widget.businessId,
|
'business_id': widget.businessId,
|
||||||
if (_selectedDocumentType != null) 'document_type': _selectedDocumentType,
|
// همیشه document_type را ارسال کن، حتی اگر null باشد
|
||||||
if (_fromDate != null) 'from_date': _fromDate!.toIso8601String(),
|
'document_type': _selectedDocumentType,
|
||||||
if (_toDate != null) 'to_date': _toDate!.toIso8601String(),
|
if (_fromDate != null) 'from_date': _fromDate!.toUtc().toIso8601String(),
|
||||||
|
if (_toDate != null) 'to_date': _toDate!.toUtc().toIso8601String(),
|
||||||
},
|
},
|
||||||
columns: [
|
columns: [
|
||||||
// کد سند
|
// کد سند
|
||||||
|
|
@ -297,6 +300,14 @@ class _ReceiptsPaymentsListPageState extends State<ReceiptsPaymentsListPage> {
|
||||||
formatter: (item) => item.personNames ?? 'نامشخص',
|
formatter: (item) => item.personNames ?? 'نامشخص',
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// توضیحات
|
||||||
|
TextColumn(
|
||||||
|
'description',
|
||||||
|
'توضیحات',
|
||||||
|
width: ColumnWidth.large,
|
||||||
|
formatter: (item) => item.description ?? '',
|
||||||
|
),
|
||||||
|
|
||||||
// تعداد حسابها
|
// تعداد حسابها
|
||||||
NumberColumn(
|
NumberColumn(
|
||||||
'account_lines_count',
|
'account_lines_count',
|
||||||
|
|
@ -368,9 +379,10 @@ class _ReceiptsPaymentsListPageState extends State<ReceiptsPaymentsListPage> {
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
additionalParams: {
|
additionalParams: {
|
||||||
if (_selectedDocumentType != null) 'document_type': _selectedDocumentType,
|
// همیشه document_type را ارسال کن، حتی اگر null باشد
|
||||||
if (_fromDate != null) 'from_date': _fromDate!.toIso8601String(),
|
'document_type': _selectedDocumentType,
|
||||||
if (_toDate != null) 'to_date': _toDate!.toIso8601String(),
|
if (_fromDate != null) 'from_date': _fromDate!.toUtc().toIso8601String(),
|
||||||
|
if (_toDate != null) 'to_date': _toDate!.toUtc().toIso8601String(),
|
||||||
},
|
},
|
||||||
onRowTap: (item) => _onView(item),
|
onRowTap: (item) => _onView(item),
|
||||||
onRowDoubleTap: (item) => _onEdit(item),
|
onRowDoubleTap: (item) => _onEdit(item),
|
||||||
|
|
@ -400,13 +412,34 @@ class _ReceiptsPaymentsListPageState extends State<ReceiptsPaymentsListPage> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// مشاهده جزئیات سند
|
/// مشاهده جزئیات سند
|
||||||
void _onView(ReceiptPaymentDocument document) {
|
void _onView(ReceiptPaymentDocument document) async {
|
||||||
// TODO: باز کردن صفحه جزئیات سند
|
try {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
// دریافت جزئیات کامل سند
|
||||||
SnackBar(
|
final fullDoc = await _service.getById(document.id);
|
||||||
content: Text('مشاهده سند ${document.code}'),
|
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 DateTime _docDate;
|
||||||
late bool _isReceipt;
|
late bool _isReceipt;
|
||||||
int? _selectedCurrencyId;
|
int? _selectedCurrencyId;
|
||||||
|
final TextEditingController _descriptionController = TextEditingController();
|
||||||
final List<_PersonLine> _personLines = <_PersonLine>[];
|
final List<_PersonLine> _personLines = <_PersonLine>[];
|
||||||
final List<InvoiceTransaction> _centerTransactions = <InvoiceTransaction>[];
|
final List<InvoiceTransaction> _centerTransactions = <InvoiceTransaction>[];
|
||||||
|
|
||||||
|
|
@ -675,6 +709,7 @@ class _BulkSettlementDialogState extends State<BulkSettlementDialog> {
|
||||||
_isReceipt = initial.isReceipt;
|
_isReceipt = initial.isReceipt;
|
||||||
_docDate = initial.documentDate;
|
_docDate = initial.documentDate;
|
||||||
_selectedCurrencyId = initial.currencyId;
|
_selectedCurrencyId = initial.currencyId;
|
||||||
|
_descriptionController.text = initial.description ?? '';
|
||||||
// تبدیل خطوط اشخاص
|
// تبدیل خطوط اشخاص
|
||||||
_personLines.clear();
|
_personLines.clear();
|
||||||
for (final pl in initial.personLines) {
|
for (final pl in initial.personLines) {
|
||||||
|
|
@ -724,6 +759,12 @@ class _BulkSettlementDialogState extends State<BulkSettlementDialog> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_descriptionController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final t = AppLocalizations.of(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),
|
const Divider(height: 1),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Row(
|
child: Row(
|
||||||
|
|
@ -917,6 +970,7 @@ class _BulkSettlementDialogState extends State<BulkSettlementDialog> {
|
||||||
documentId: widget.initialDocument!.id,
|
documentId: widget.initialDocument!.id,
|
||||||
documentDate: _docDate,
|
documentDate: _docDate,
|
||||||
currencyId: _selectedCurrencyId!,
|
currencyId: _selectedCurrencyId!,
|
||||||
|
description: _descriptionController.text.trim().isNotEmpty ? _descriptionController.text.trim() : null,
|
||||||
personLines: personLinesData,
|
personLines: personLinesData,
|
||||||
accountLines: accountLinesData,
|
accountLines: accountLinesData,
|
||||||
);
|
);
|
||||||
|
|
@ -927,6 +981,7 @@ class _BulkSettlementDialogState extends State<BulkSettlementDialog> {
|
||||||
documentType: _isReceipt ? 'receipt' : 'payment',
|
documentType: _isReceipt ? 'receipt' : 'payment',
|
||||||
documentDate: _docDate,
|
documentDate: _docDate,
|
||||||
currencyId: _selectedCurrencyId!,
|
currencyId: _selectedCurrencyId!,
|
||||||
|
description: _descriptionController.text.trim().isNotEmpty ? _descriptionController.text.trim() : null,
|
||||||
personLines: personLinesData,
|
personLines: personLinesData,
|
||||||
accountLines: accountLinesData,
|
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 DateTime _docDate;
|
||||||
late bool _isReceipt;
|
late bool _isReceipt;
|
||||||
int? _selectedCurrencyId;
|
int? _selectedCurrencyId;
|
||||||
|
final TextEditingController _descriptionController = TextEditingController();
|
||||||
final List<_PersonLine> _personLines = <_PersonLine>[];
|
final List<_PersonLine> _personLines = <_PersonLine>[];
|
||||||
final List<InvoiceTransaction> _centerTransactions = <InvoiceTransaction>[];
|
final List<InvoiceTransaction> _centerTransactions = <InvoiceTransaction>[];
|
||||||
|
|
||||||
|
|
@ -231,6 +232,12 @@ class _BulkSettlementDialogState extends State<_BulkSettlementDialog> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_descriptionController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final t = AppLocalizations.of(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),
|
const Divider(height: 1),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Row(
|
child: Row(
|
||||||
|
|
@ -423,6 +442,7 @@ class _BulkSettlementDialogState extends State<_BulkSettlementDialog> {
|
||||||
documentType: _isReceipt ? 'receipt' : 'payment',
|
documentType: _isReceipt ? 'receipt' : 'payment',
|
||||||
documentDate: _docDate,
|
documentDate: _docDate,
|
||||||
currencyId: _selectedCurrencyId!,
|
currencyId: _selectedCurrencyId!,
|
||||||
|
description: _descriptionController.text.trim().isNotEmpty ? _descriptionController.text.trim() : null,
|
||||||
personLines: personLinesData,
|
personLines: personLinesData,
|
||||||
accountLines: accountLinesData,
|
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 '../core/api_client.dart';
|
||||||
|
import '../models/receipt_payment_document.dart';
|
||||||
|
|
||||||
/// سرویس دریافت و پرداخت
|
/// سرویس دریافت و پرداخت
|
||||||
class ReceiptPaymentService {
|
class ReceiptPaymentService {
|
||||||
|
|
@ -14,6 +15,7 @@ class ReceiptPaymentService {
|
||||||
/// [currencyId] شناسه ارز
|
/// [currencyId] شناسه ارز
|
||||||
/// [personLines] لیست تراکنشهای اشخاص
|
/// [personLines] لیست تراکنشهای اشخاص
|
||||||
/// [accountLines] لیست تراکنشهای حسابها
|
/// [accountLines] لیست تراکنشهای حسابها
|
||||||
|
/// [description] توضیحات کلی سند (اختیاری)
|
||||||
/// [extraInfo] اطلاعات اضافی (اختیاری)
|
/// [extraInfo] اطلاعات اضافی (اختیاری)
|
||||||
Future<Map<String, dynamic>> createReceiptPayment({
|
Future<Map<String, dynamic>> createReceiptPayment({
|
||||||
required int businessId,
|
required int businessId,
|
||||||
|
|
@ -22,6 +24,7 @@ class ReceiptPaymentService {
|
||||||
required int currencyId,
|
required int currencyId,
|
||||||
required List<Map<String, dynamic>> personLines,
|
required List<Map<String, dynamic>> personLines,
|
||||||
required List<Map<String, dynamic>> accountLines,
|
required List<Map<String, dynamic>> accountLines,
|
||||||
|
String? description,
|
||||||
Map<String, dynamic>? extraInfo,
|
Map<String, dynamic>? extraInfo,
|
||||||
}) async {
|
}) async {
|
||||||
final response = await _apiClient.post(
|
final response = await _apiClient.post(
|
||||||
|
|
@ -30,6 +33,7 @@ class ReceiptPaymentService {
|
||||||
'document_type': documentType,
|
'document_type': documentType,
|
||||||
'document_date': documentDate.toIso8601String(),
|
'document_date': documentDate.toIso8601String(),
|
||||||
'currency_id': currencyId,
|
'currency_id': currencyId,
|
||||||
|
if (description != null && description.isNotEmpty) 'description': description,
|
||||||
'person_lines': personLines,
|
'person_lines': personLines,
|
||||||
'account_lines': accountLines,
|
'account_lines': accountLines,
|
||||||
if (extraInfo != null) 'extra_info': extraInfo,
|
if (extraInfo != null) 'extra_info': extraInfo,
|
||||||
|
|
@ -66,8 +70,9 @@ class ReceiptPaymentService {
|
||||||
if (sortBy != null) 'sort_by': sortBy,
|
if (sortBy != null) 'sort_by': sortBy,
|
||||||
if (search != null && search.isNotEmpty) 'search': search,
|
if (search != null && search.isNotEmpty) 'search': search,
|
||||||
if (documentType != null) 'document_type': documentType,
|
if (documentType != null) 'document_type': documentType,
|
||||||
if (fromDate != null) 'from_date': fromDate.toIso8601String(),
|
// ارسال تاریخ به صورت ISO8601 با تنظیم timezone
|
||||||
if (toDate != null) 'to_date': toDate.toIso8601String(),
|
if (fromDate != null) 'from_date': fromDate.toUtc().toIso8601String(),
|
||||||
|
if (toDate != null) 'to_date': toDate.toUtc().toIso8601String(),
|
||||||
};
|
};
|
||||||
|
|
||||||
final response = await _apiClient.post(
|
final response = await _apiClient.post(
|
||||||
|
|
@ -89,6 +94,18 @@ class ReceiptPaymentService {
|
||||||
return response.data['data'] as Map<String, dynamic>;
|
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] شناسه سند
|
/// [documentId] شناسه سند
|
||||||
|
|
@ -105,6 +122,7 @@ class ReceiptPaymentService {
|
||||||
required int currencyId,
|
required int currencyId,
|
||||||
required List<Map<String, dynamic>> personLines,
|
required List<Map<String, dynamic>> personLines,
|
||||||
required List<Map<String, dynamic>> accountLines,
|
required List<Map<String, dynamic>> accountLines,
|
||||||
|
String? description,
|
||||||
Map<String, dynamic>? extraInfo,
|
Map<String, dynamic>? extraInfo,
|
||||||
}) async {
|
}) async {
|
||||||
final response = await _apiClient.put(
|
final response = await _apiClient.put(
|
||||||
|
|
@ -112,6 +130,7 @@ class ReceiptPaymentService {
|
||||||
data: {
|
data: {
|
||||||
'document_date': documentDate.toIso8601String(),
|
'document_date': documentDate.toIso8601String(),
|
||||||
'currency_id': currencyId,
|
'currency_id': currencyId,
|
||||||
|
if (description != null && description.isNotEmpty) 'description': description,
|
||||||
'person_lines': personLines,
|
'person_lines': personLines,
|
||||||
'account_lines': accountLines,
|
'account_lines': accountLines,
|
||||||
if (extraInfo != null) 'extra_info': extraInfo,
|
if (extraInfo != null) 'extra_info': extraInfo,
|
||||||
|
|
@ -129,6 +148,7 @@ class ReceiptPaymentService {
|
||||||
required int currencyId,
|
required int currencyId,
|
||||||
required List<Map<String, dynamic>> personLines,
|
required List<Map<String, dynamic>> personLines,
|
||||||
required List<Map<String, dynamic>> accountLines,
|
required List<Map<String, dynamic>> accountLines,
|
||||||
|
String? description,
|
||||||
Map<String, dynamic>? extraInfo,
|
Map<String, dynamic>? extraInfo,
|
||||||
}) {
|
}) {
|
||||||
return createReceiptPayment(
|
return createReceiptPayment(
|
||||||
|
|
@ -138,6 +158,7 @@ class ReceiptPaymentService {
|
||||||
currencyId: currencyId,
|
currencyId: currencyId,
|
||||||
personLines: personLines,
|
personLines: personLines,
|
||||||
accountLines: accountLines,
|
accountLines: accountLines,
|
||||||
|
description: description,
|
||||||
extraInfo: extraInfo,
|
extraInfo: extraInfo,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -151,6 +172,7 @@ class ReceiptPaymentService {
|
||||||
required int currencyId,
|
required int currencyId,
|
||||||
required List<Map<String, dynamic>> personLines,
|
required List<Map<String, dynamic>> personLines,
|
||||||
required List<Map<String, dynamic>> accountLines,
|
required List<Map<String, dynamic>> accountLines,
|
||||||
|
String? description,
|
||||||
Map<String, dynamic>? extraInfo,
|
Map<String, dynamic>? extraInfo,
|
||||||
}) {
|
}) {
|
||||||
return createReceiptPayment(
|
return createReceiptPayment(
|
||||||
|
|
@ -160,6 +182,7 @@ class ReceiptPaymentService {
|
||||||
currencyId: currencyId,
|
currencyId: currencyId,
|
||||||
personLines: personLines,
|
personLines: personLines,
|
||||||
accountLines: accountLines,
|
accountLines: accountLines,
|
||||||
|
description: description,
|
||||||
extraInfo: extraInfo,
|
extraInfo: extraInfo,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -188,7 +188,9 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _fetchData() async {
|
Future<void> _fetchData() async {
|
||||||
setState(() => _loadingList = true);
|
if (mounted) {
|
||||||
|
setState(() => _loadingList = true);
|
||||||
|
}
|
||||||
_error = null;
|
_error = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -217,14 +219,16 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
|
||||||
if (body is Map<String, dynamic>) {
|
if (body is Map<String, dynamic>) {
|
||||||
final response = DataTableResponse<T>.fromJson(body, widget.fromJson);
|
final response = DataTableResponse<T>.fromJson(body, widget.fromJson);
|
||||||
|
|
||||||
setState(() {
|
if (mounted) {
|
||||||
_items = response.items;
|
setState(() {
|
||||||
_page = response.page;
|
_items = response.items;
|
||||||
_limit = response.limit;
|
_page = response.page;
|
||||||
_total = response.total;
|
_limit = response.limit;
|
||||||
_totalPages = response.totalPages;
|
_total = response.total;
|
||||||
_selectedRows.clear(); // Clear selection when data changes
|
_totalPages = response.totalPages;
|
||||||
});
|
_selectedRows.clear(); // Clear selection when data changes
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Call the refresh callback if provided
|
// Call the refresh callback if provided
|
||||||
if (widget.onRefresh != null) {
|
if (widget.onRefresh != null) {
|
||||||
|
|
@ -234,11 +238,15 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setState(() {
|
if (mounted) {
|
||||||
_error = e.toString();
|
setState(() {
|
||||||
});
|
_error = e.toString();
|
||||||
|
});
|
||||||
|
}
|
||||||
} finally {
|
} 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