progress in money and persons

This commit is contained in:
Hesabix 2025-09-28 23:06:53 +03:30
parent af7aac7657
commit a409202f6f
17 changed files with 617 additions and 121 deletions

View file

@ -0,0 +1,30 @@
from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session
from adapters.db.session import get_db
from adapters.db.models.currency import Currency
from app.core.responses import success_response
router = APIRouter(prefix="/currencies", tags=["currencies"])
@router.get(
"",
summary="فهرست ارزها",
description="دریافت فهرست ارزهای قابل استفاده",
)
def list_currencies(request: Request, db: Session = Depends(get_db)) -> dict:
items = [
{
"id": c.id,
"name": c.name,
"title": c.title,
"symbol": c.symbol,
"code": c.code,
}
for c in db.query(Currency).order_by(Currency.title.asc()).all()
]
return success_response(items, request)

View file

@ -178,6 +178,8 @@ class BusinessCreateRequest(BaseModel):
city: Optional[str] = Field(default=None, max_length=100, description="شهر")
postal_code: Optional[str] = Field(default=None, max_length=20, description="کد پستی")
fiscal_years: Optional[List["FiscalYearCreate"]] = Field(default=None, description="آرایه سال‌های مالی برای ایجاد اولیه")
default_currency_id: Optional[int] = Field(default=None, description="شناسه ارز پیشفرض")
currency_ids: Optional[List[int]] = Field(default=None, description="لیست شناسه ارزهای قابل استفاده")
class BusinessUpdateRequest(BaseModel):
@ -214,6 +216,8 @@ class BusinessResponse(BaseModel):
postal_code: Optional[str] = Field(default=None, description="کد پستی")
created_at: str = Field(..., description="تاریخ ایجاد")
updated_at: str = Field(..., description="تاریخ آخرین بروزرسانی")
default_currency: Optional[dict] = Field(default=None, description="ارز پیشفرض")
currencies: Optional[List[dict]] = Field(default=None, description="ارزهای فعال کسب‌وکار")
class BusinessListResponse(BaseModel):

View file

@ -36,6 +36,7 @@ class Business(Base):
business_type: Mapped[BusinessType] = mapped_column(SQLEnum(BusinessType), nullable=False)
business_field: Mapped[BusinessField] = mapped_column(SQLEnum(BusinessField), nullable=False)
owner_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
default_currency_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("currencies.id", ondelete="RESTRICT"), nullable=True, index=True)
# فیلدهای جدید
address: Mapped[str | None] = mapped_column(Text, nullable=True)
@ -58,5 +59,6 @@ class Business(Base):
persons: Mapped[list["Person"]] = relationship("Person", back_populates="business", cascade="all, delete-orphan")
fiscal_years = relationship("FiscalYear", back_populates="business", cascade="all, delete-orphan")
currencies = relationship("Currency", secondary="business_currencies", back_populates="businesses")
default_currency = relationship("Currency", foreign_keys="[Business.default_currency_id]", uselist=False)
documents = relationship("Document", back_populates="business", cascade="all, delete-orphan")
accounts = relationship("Account", back_populates="business", cascade="all, delete-orphan")

View file

@ -50,6 +50,7 @@ class BusinessRepository(BaseRepository[Business]):
business_type: BusinessType,
business_field: BusinessField,
owner_id: int,
default_currency_id: int | None = None,
address: str | None = None,
phone: str | None = None,
mobile: str | None = None,
@ -67,6 +68,7 @@ class BusinessRepository(BaseRepository[Business]):
business_type=business_type,
business_field=business_field,
owner_id=owner_id,
default_currency_id=default_currency_id,
address=address,
phone=phone,
mobile=mobile,

View file

@ -7,6 +7,7 @@ from adapters.api.v1.health import router as health_router
from adapters.api.v1.auth import router as auth_router
from adapters.api.v1.users import router as users_router
from adapters.api.v1.businesses import router as businesses_router
from adapters.api.v1.currencies import router as currencies_router
from adapters.api.v1.business_dashboard import router as business_dashboard_router
from adapters.api.v1.business_users import router as business_users_router
from adapters.api.v1.accounts import router as accounts_router
@ -273,6 +274,7 @@ def create_app() -> FastAPI:
application.include_router(auth_router, prefix=settings.api_v1_prefix)
application.include_router(users_router, prefix=settings.api_v1_prefix)
application.include_router(businesses_router, prefix=settings.api_v1_prefix)
application.include_router(currencies_router, prefix=settings.api_v1_prefix)
application.include_router(business_dashboard_router, prefix=settings.api_v1_prefix)
application.include_router(business_users_router, prefix=settings.api_v1_prefix)
application.include_router(accounts_router, prefix=settings.api_v1_prefix)

View file

@ -6,6 +6,7 @@ from sqlalchemy import select, and_, func
from adapters.db.repositories.business_repo import BusinessRepository
from adapters.db.repositories.fiscal_year_repo import FiscalYearRepository
from adapters.db.models.currency import Currency, BusinessCurrency
from adapters.db.repositories.business_permission_repo import BusinessPermissionRepository
from adapters.db.models.business import Business, BusinessType, BusinessField
from adapters.api.v1.schemas import (
@ -31,6 +32,7 @@ def create_business(db: Session, business_data: BusinessCreateRequest, owner_id:
business_type=business_type_enum,
business_field=business_field_enum,
owner_id=owner_id,
default_currency_id=getattr(business_data, "default_currency_id", None),
address=business_data.address,
phone=business_data.phone,
mobile=business_data.mobile,
@ -59,6 +61,30 @@ def create_business(db: Session, business_data: BusinessCreateRequest, owner_id:
is_last=(idx == last_true_index) if last_true_index is not None else (idx == len(business_data.fiscal_years) - 1)
)
# مدیریت ارزها
currency_ids: list[int] = []
if getattr(business_data, "currency_ids", None):
currency_ids = list(dict.fromkeys(business_data.currency_ids)) # unique
default_currency_id = getattr(business_data, "default_currency_id", None)
if default_currency_id:
if default_currency_id not in currency_ids:
currency_ids.insert(0, default_currency_id)
# اعتبارسنجی وجود ارزها
if currency_ids:
existing_ids = [cid for (cid,) in db.query(Currency.id).filter(Currency.id.in_(currency_ids)).all()]
if set(existing_ids) != set(currency_ids):
missing = set(currency_ids) - set(existing_ids)
raise ValueError(f"Invalid currency ids: {sorted(list(missing))}")
# درج ارتباطات در business_currencies
for cid in currency_ids:
bc = BusinessCurrency(business_id=created_business.id, currency_id=cid)
db.add(bc)
db.commit()
db.refresh(created_business)
# تبدیل به response format
return _business_to_dict(created_business)
@ -269,7 +295,7 @@ def get_business_summary(db: Session, owner_id: int) -> Dict[str, Any]:
def _business_to_dict(business: Business) -> Dict[str, Any]:
"""تبدیل مدل کسب و کار به dictionary"""
return {
data = {
"id": business.id,
"name": business.name,
"business_type": business.business_type.value,
@ -288,3 +314,26 @@ def _business_to_dict(business: Business) -> Dict[str, Any]:
"created_at": business.created_at, # datetime object بماند
"updated_at": business.updated_at # datetime object بماند
}
# ارز پیشفرض
if getattr(business, "default_currency", None):
c = business.default_currency
data["default_currency"] = {
"id": c.id,
"code": c.code,
"title": c.title,
"symbol": c.symbol,
}
else:
data["default_currency"] = None
# ارزهای فعال کسب‌وکار
if getattr(business, "currencies", None):
data["currencies"] = [
{"id": c.id, "code": c.code, "title": c.title, "symbol": c.symbol}
for c in business.currencies
]
else:
data["currencies"] = []
return data

View file

@ -8,6 +8,7 @@ adapters/api/v1/auth.py
adapters/api/v1/business_dashboard.py
adapters/api/v1/business_users.py
adapters/api/v1/businesses.py
adapters/api/v1/currencies.py
adapters/api/v1/health.py
adapters/api/v1/persons.py
adapters/api/v1/schemas.py
@ -123,6 +124,7 @@ migrations/versions/20250927_000019_seed_accounts_chart.py
migrations/versions/20250927_000020_add_share_count_and_shareholder_type.py
migrations/versions/20250927_000021_update_person_type_enum_to_persian.py
migrations/versions/20250927_000022_add_person_commission_fields.py
migrations/versions/4b2ea782bcb3_merge_heads.py
migrations/versions/5553f8745c6e_add_support_tables.py
migrations/versions/d3e84892c1c2_sync_person_type_enum_values_callable_.py
migrations/versions/f876bfa36805_merge_multiple_heads.py

View file

@ -51,6 +51,22 @@ def run_migrations_online() -> None:
)
with connectable.connect() as connection:
# Ensure alembic_version.version_num can hold long revision strings
try:
res = connection.exec_driver_sql(
"SELECT CHARACTER_MAXIMUM_LENGTH FROM information_schema.columns "
"WHERE table_name='alembic_version' AND column_name='version_num';"
)
row = res.fetchone()
if row is not None:
length = row[0] or 0
if length < 255:
connection.exec_driver_sql(
"ALTER TABLE alembic_version MODIFY COLUMN version_num VARCHAR(255) NOT NULL;"
)
except Exception:
# Best-effort; ignore if table doesn't exist yet
pass
context.configure(
connection=connection,
target_metadata=target_metadata,

View file

@ -12,7 +12,10 @@ depends_on = None
def upgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
cols = {c['name'] for c in inspector.get_columns('persons')} if 'persons' in inspector.get_table_names() else set()
# اگر جدول persons وجود ندارد، این مایگریشن را نادیده بگیر
if 'persons' not in inspector.get_table_names():
return
cols = {c['name'] for c in inspector.get_columns('persons')}
with op.batch_alter_table('persons') as batch_op:
if 'code' not in cols:
batch_op.add_column(sa.Column('code', sa.Integer(), nullable=True))
@ -25,7 +28,20 @@ def upgrade() -> None:
def downgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
if 'persons' not in inspector.get_table_names():
return
with op.batch_alter_table('persons') as batch_op:
try:
batch_op.drop_constraint('uq_persons_business_code', type_='unique')
except Exception:
pass
try:
batch_op.drop_column('person_types')
except Exception:
pass
try:
batch_op.drop_column('code')
except Exception:
pass

View file

@ -1,5 +1,6 @@
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision = '20250926_000011_drop_person_is_active'
@ -9,11 +10,16 @@ depends_on = None
def upgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
tables = set(inspector.get_table_names())
if 'persons' in tables:
with op.batch_alter_table('persons') as batch_op:
try:
batch_op.drop_column('is_active')
except Exception:
pass
if 'person_bank_accounts' in tables:
with op.batch_alter_table('person_bank_accounts') as batch_op:
try:
batch_op.drop_column('is_active')
@ -22,7 +28,12 @@ def upgrade() -> None:
def downgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
tables = set(inspector.get_table_names())
if 'persons' in tables:
with op.batch_alter_table('persons') as batch_op:
batch_op.add_column(sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('1')))
if 'person_bank_accounts' in tables:
with op.batch_alter_table('person_bank_accounts') as batch_op:
batch_op.add_column(sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('1')))

View file

@ -9,7 +9,7 @@ from sqlalchemy import inspect
revision = '20250927_000012_add_fiscal_years_table'
down_revision = '20250926_000011_drop_person_is_active'
branch_labels = None
depends_on = None
depends_on = ('20250117_000003',)
def upgrade() -> None:

View file

@ -43,6 +43,17 @@ def upgrade() -> None:
sa.PrimaryKeyConstraint('id'),
mysql_charset='utf8mb4'
)
# Add default_currency_id to businesses if not exists
bind = op.get_bind()
inspector = sa.inspect(bind)
if 'businesses' in inspector.get_table_names():
cols = {c['name'] for c in inspector.get_columns('businesses')}
if 'default_currency_id' not in cols:
with op.batch_alter_table('businesses') as batch_op:
batch_op.add_column(sa.Column('default_currency_id', sa.Integer(), nullable=True))
batch_op.create_foreign_key('fk_businesses_default_currency', 'currencies', ['default_currency_id'], ['id'], ondelete='RESTRICT')
batch_op.create_index('ix_businesses_default_currency_id', ['default_currency_id'])
# Unique and indexes for association
op.create_unique_constraint('uq_business_currencies_business_currency', 'business_currencies', ['business_id', 'currency_id'])
op.create_index('ix_business_currencies_business_id', 'business_currencies', ['business_id'])
@ -50,6 +61,20 @@ def upgrade() -> None:
def downgrade() -> None:
# Drop index/foreign key/column default_currency_id if exists
with op.batch_alter_table('businesses') as batch_op:
try:
batch_op.drop_index('ix_businesses_default_currency_id')
except Exception:
pass
try:
batch_op.drop_constraint('fk_businesses_default_currency', type_='foreignkey')
except Exception:
pass
try:
batch_op.drop_column('default_currency_id')
except Exception:
pass
op.drop_index('ix_business_currencies_currency_id', table_name='business_currencies')
op.drop_index('ix_business_currencies_business_id', table_name='business_currencies')
op.drop_constraint('uq_business_currencies_business_currency', 'business_currencies', type_='unique')

View file

@ -0,0 +1,29 @@
"""merge_heads
Revision ID: 4b2ea782bcb3
Revises: 20250120_000003, 20250927_000022_add_person_commission_fields
Create Date: 2025-09-28 20:59:14.557570
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision = '4b2ea782bcb3'
down_revision = ('20250120_000002', '20250927_000022_add_person_commission_fields')
branch_labels = None
depends_on = None
def upgrade() -> None:
# این migration صرفاً برای ادغام شاخه‌ها است و تغییری در اسکیما ایجاد نمی‌کند
pass
def downgrade() -> None:
# بدون تغییر
pass

View file

@ -8,6 +8,8 @@ Create Date: 2025-09-27 19:18:06.253391
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
from sqlalchemy import text
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision = 'd3e84892c1c2'
@ -16,8 +18,27 @@ branch_labels = None
depends_on = None
def _table_exists(conn, name: str) -> bool:
res = conn.execute(text(
"SELECT COUNT(*) FROM information_schema.tables WHERE table_name=:t"
), {"t": name})
return (res.scalar() or 0) > 0
def _column_exists(conn, table: str, col: str) -> bool:
res = conn.execute(text(
"SELECT COUNT(*) FROM information_schema.columns WHERE table_name=:t AND column_name=:c"
), {"t": table, "c": col})
return (res.scalar() or 0) > 0
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
# ### commands auto generated by Alembic - guarded for idempotency ###
bind = op.get_bind()
inspector = inspect(bind)
existing_tables = set(inspector.get_table_names())
if 'storage_configs' not in existing_tables:
op.create_table('storage_configs',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('name', sa.String(length=100), nullable=False),
@ -31,6 +52,8 @@ def upgrade() -> None:
sa.ForeignKeyConstraint(['created_by'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
if 'file_storage' not in existing_tables:
op.create_table('file_storage',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('original_name', sa.String(length=255), nullable=False),
@ -58,6 +81,8 @@ def upgrade() -> None:
sa.ForeignKeyConstraint(['uploaded_by'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
if 'file_verifications' not in existing_tables:
op.create_table('file_verifications',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('file_id', sa.String(length=36), nullable=False),
@ -71,25 +96,43 @@ def upgrade() -> None:
sa.ForeignKeyConstraint(['verified_by'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
# Drop index if exists
try:
bind = op.get_bind()
insp = inspect(bind)
if 'fiscal_years' in insp.get_table_names():
existing_indexes = {idx['name'] for idx in insp.get_indexes('fiscal_years')}
if 'ix_fiscal_years_title' in existing_indexes:
op.drop_index(op.f('ix_fiscal_years_title'), table_name='fiscal_years')
except Exception:
pass
conn = op.get_bind()
if _table_exists(conn, 'person_bank_accounts') and _column_exists(conn, 'person_bank_accounts', 'person_id'):
op.alter_column('person_bank_accounts', 'person_id',
existing_type=mysql.INTEGER(),
comment=None,
existing_comment='شناسه شخص',
existing_nullable=False)
if _table_exists(conn, 'persons'):
if _column_exists(conn, 'persons', 'business_id'):
op.alter_column('persons', 'business_id',
existing_type=mysql.INTEGER(),
comment=None,
existing_comment='شناسه کسب و کار',
existing_nullable=False)
if _column_exists(conn, 'persons', 'code'):
op.alter_column('persons', 'code',
existing_type=mysql.INTEGER(),
comment='کد یکتا در هر کسب و کار',
existing_nullable=True)
if _column_exists(conn, 'persons', 'person_types'):
op.alter_column('persons', 'person_types',
existing_type=mysql.TEXT(collation='utf8mb4_general_ci'),
comment='لیست انواع شخص به صورت JSON',
existing_nullable=True)
if _column_exists(conn, 'persons', 'share_count'):
op.alter_column('persons', 'share_count',
existing_type=mysql.INTEGER(),
comment='تعداد سهام (فقط برای سهامدار)',
@ -99,25 +142,31 @@ def upgrade() -> None:
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
conn = op.get_bind()
if _table_exists(conn, 'persons') and _column_exists(conn, 'persons', 'share_count'):
op.alter_column('persons', 'share_count',
existing_type=mysql.INTEGER(),
comment=None,
existing_comment='تعداد سهام (فقط برای سهامدار)',
existing_nullable=True)
if _table_exists(conn, 'persons') and _column_exists(conn, 'persons', 'person_types'):
op.alter_column('persons', 'person_types',
existing_type=mysql.TEXT(collation='utf8mb4_general_ci'),
comment=None,
existing_comment='لیست انواع شخص به صورت JSON',
existing_nullable=True)
if _table_exists(conn, 'persons') and _column_exists(conn, 'persons', 'code'):
op.alter_column('persons', 'code',
existing_type=mysql.INTEGER(),
comment=None,
existing_comment='کد یکتا در هر کسب و کار',
existing_nullable=True)
if _table_exists(conn, 'persons') and _column_exists(conn, 'persons', 'business_id'):
op.alter_column('persons', 'business_id',
existing_type=mysql.INTEGER(),
comment='شناسه کسب و کار',
existing_nullable=False)
if _table_exists(conn, 'person_bank_accounts') and _column_exists(conn, 'person_bank_accounts', 'person_id'):
op.alter_column('person_bank_accounts', 'person_id',
existing_type=mysql.INTEGER(),
comment='شناسه شخص',

View file

@ -23,6 +23,7 @@ class _NewBusinessPageState extends State<NewBusinessPage> {
bool _isLoading = false;
int _fiscalTabIndex = 0;
late TextEditingController _fiscalTitleController;
List<Map<String, dynamic>> _currencies = [];
@override
void initState() {
@ -32,6 +33,7 @@ class _NewBusinessPageState extends State<NewBusinessPage> {
// Set default selections for business type and field
_businessData.businessType ??= BusinessType.shop;
_businessData.businessField ??= BusinessField.commercial;
_loadCurrencies();
}
@override
@ -58,6 +60,27 @@ class _NewBusinessPageState extends State<NewBusinessPage> {
}
}
Future<void> _loadCurrencies() async {
try {
final list = await BusinessApiService.getCurrencies();
if (mounted) {
setState(() {
_currencies = list;
final irr = _currencies.firstWhere(
(e) => (e['code'] as String?) == 'IRR',
orElse: () => {} as Map<String, dynamic>,
);
if (irr.isNotEmpty) {
_businessData.defaultCurrencyId ??= irr['id'] as int?;
if (_businessData.defaultCurrencyId != null && !_businessData.currencyIds.contains(_businessData.defaultCurrencyId)) {
_businessData.currencyIds.add(_businessData.defaultCurrencyId!);
}
}
});
}
} catch (_) {}
}
Widget _buildFiscalStep() {
if (_businessData.fiscalYears.isEmpty) {
_businessData.fiscalYears.add(FiscalYearData(isLast: true));
@ -238,7 +261,7 @@ class _NewBusinessPageState extends State<NewBusinessPage> {
case 2:
return t.businessLegalInfo;
case 3:
return 'سال مالی';
return 'ارز و سال مالی';
case 4:
return t.businessConfirmation;
default:
@ -379,7 +402,7 @@ class _NewBusinessPageState extends State<NewBusinessPage> {
_buildStepIndicator(0, t.businessBasicInfo),
_buildStepIndicator(1, t.businessContactInfo),
_buildStepIndicator(2, t.businessLegalInfo),
_buildStepIndicator(3, 'سال مالی'),
_buildStepIndicator(3, 'ارز و سال مالی'),
_buildStepIndicator(4, t.businessConfirmation),
],
),
@ -435,9 +458,6 @@ class _NewBusinessPageState extends State<NewBusinessPage> {
// Form content
Expanded(
child: SingleChildScrollView(
child: SizedBox(
height: MediaQuery.of(context).size.height - 200, // ارتفاع مناسب برای اسکرول
child: PageView(
controller: _pageController,
physics: const NeverScrollableScrollPhysics(),
@ -447,16 +467,14 @@ class _NewBusinessPageState extends State<NewBusinessPage> {
});
},
children: [
_buildStep1(),
_buildStep2(),
_buildStep3(),
_buildFiscalStep(),
_buildStep4(),
SingleChildScrollView(child: _buildStep1()),
SingleChildScrollView(child: _buildStep2()),
SingleChildScrollView(child: _buildStep3()),
SingleChildScrollView(child: _buildCurrencyAndFiscalStep()),
SingleChildScrollView(child: _buildStep4()),
],
),
),
),
),
// Navigation buttons
Container(
@ -1479,6 +1497,79 @@ class _NewBusinessPageState extends State<NewBusinessPage> {
);
}
Widget _buildCurrencyAndFiscalStep() {
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 800),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'ارز و سال مالی',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w600),
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(context).dividerColor.withValues(alpha: 0.3),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DropdownButtonFormField<int>(
value: _businessData.defaultCurrencyId,
items: _currencies.map((c) {
return DropdownMenuItem<int>(
value: c['id'] as int,
child: Text('${c['title']} (${c['code']})'),
);
}).toList(),
decoration: const InputDecoration(
labelText: 'ارز پیشفرض *',
border: OutlineInputBorder(),
),
onChanged: (v) {
setState(() {
_businessData.defaultCurrencyId = v;
if (v != null && !_businessData.currencyIds.contains(v)) {
_businessData.currencyIds.add(v);
}
});
},
),
const SizedBox(height: 12),
_CurrencyMultiSelect(
currencies: _currencies,
selectedIds: _businessData.currencyIds,
defaultId: _businessData.defaultCurrencyId,
onChanged: (ids) {
setState(() {
_businessData.currencyIds = ids;
final d = _businessData.defaultCurrencyId;
if (d != null && !_businessData.currencyIds.contains(d)) {
_businessData.currencyIds.add(d);
}
});
},
),
],
),
),
const SizedBox(height: 24),
_buildFiscalStep(),
],
),
),
),
);
}
Widget _buildStep4() {
final t = Localizations.of<AppLocalizations>(context, AppLocalizations)!;
@ -1604,3 +1695,159 @@ class _NewBusinessPageState extends State<NewBusinessPage> {
);
}
}
class _CurrencyMultiSelect extends StatefulWidget {
final List<Map<String, dynamic>> currencies;
final List<int> selectedIds;
final int? defaultId;
final ValueChanged<List<int>> onChanged;
const _CurrencyMultiSelect({
required this.currencies,
required this.selectedIds,
required this.defaultId,
required this.onChanged,
});
@override
State<_CurrencyMultiSelect> createState() => _CurrencyMultiSelectState();
}
class _CurrencyMultiSelectState extends State<_CurrencyMultiSelect> {
late List<int> _selected;
final TextEditingController _searchCtrl = TextEditingController();
bool _panelOpen = false;
@override
void initState() {
super.initState();
_selected = List<int>.from(widget.selectedIds);
}
@override
void didUpdateWidget(covariant _CurrencyMultiSelect oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.selectedIds != widget.selectedIds) {
_selected = List<int>.from(widget.selectedIds);
}
}
@override
void dispose() {
_searchCtrl.dispose();
super.dispose();
}
void _toggle(int id) {
setState(() {
if (_selected.contains(id)) {
if (widget.defaultId != id) {
_selected.remove(id);
}
} else {
_selected.add(id);
}
widget.onChanged(List<int>.from(_selected));
});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final filtered = widget.currencies.where((c) {
final q = _searchCtrl.text.trim();
if (q.isEmpty) return true;
final title = (c['title'] ?? '').toString();
final code = (c['code'] ?? '').toString();
return title.contains(q) || code.toLowerCase().contains(q.toLowerCase());
}).toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('ارزهای جانبی', style: theme.textTheme.titleSmall),
const SizedBox(height: 8),
GestureDetector(
onTap: () => setState(() => _panelOpen = !_panelOpen),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
border: Border.all(color: theme.colorScheme.outline.withValues(alpha: 0.4)),
borderRadius: BorderRadius.circular(8),
color: theme.colorScheme.surface,
),
child: Row(
children: [
Expanded(
child: Wrap(
spacing: 6,
runSpacing: 6,
children: _selected.isEmpty
? [
Text(
'انتخاب کنید...',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.hintColor,
),
)
]
: _selected.map((id) {
final c = widget.currencies.firstWhere((e) => e['id'] == id, orElse: () => {});
final isDefault = widget.defaultId == id;
return Chip(
label: Text('${c['title']} (${c['code']})'),
avatar: isDefault ? const Icon(Icons.star, size: 16) : null,
onDeleted: isDefault ? null : () => _toggle(id),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
);
}).toList(),
),
),
const SizedBox(width: 8),
Icon(_panelOpen ? Icons.expand_less : Icons.expand_more),
],
),
),
),
if (_panelOpen) ...[
const SizedBox(height: 8),
TextField(
controller: _searchCtrl,
decoration: const InputDecoration(
prefixIcon: Icon(Icons.search),
hintText: 'جستجو بر اساس نام یا کد...',
border: OutlineInputBorder(),
isDense: true,
),
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 8),
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 240),
child: Scrollbar(
child: ListView.builder(
shrinkWrap: true,
itemCount: filtered.length,
itemBuilder: (context, index) {
final c = filtered[index];
final id = c['id'] as int;
final selected = _selected.contains(id);
final isDefault = widget.defaultId == id;
return CheckboxListTile(
value: selected,
onChanged: (val) => _toggle(id),
dense: true,
title: Text('${c['title']} (${c['code']})'),
secondary: isDefault ? const Icon(Icons.star, size: 18) : null,
controlAffinity: ListTileControlAffinity.leading,
);
},
),
),
),
],
],
);
}
}

View file

@ -4,6 +4,7 @@ import '../models/business_models.dart';
class BusinessApiService {
static const String _basePath = '/api/v1/businesses';
static final ApiClient _apiClient = ApiClient();
static const String _currencyPath = '/api/v1/currencies';
// ایجاد کسب و کار جدید
static Future<BusinessResponse> createBusiness(BusinessData businessData) async {
@ -19,6 +20,17 @@ class BusinessApiService {
}
}
// دریافت فهرست ارزها
static Future<List<Map<String, dynamic>>> getCurrencies() async {
final response = await _apiClient.get(_currencyPath);
if (response.data['success'] == true) {
final List<dynamic> items = response.data['data'];
return items.cast<Map<String, dynamic>>();
} else {
throw Exception(response.data['message'] ?? 'خطا در دریافت فهرست ارزها');
}
}
// دریافت لیست کسب و کارها
static Future<List<BusinessResponse>> getBusinesses({
int page = 1,