diff --git a/backend/services/accounting/app/api/v1/setup.py b/backend/services/accounting/app/api/v1/setup.py index 327b363..78d016c 100644 --- a/backend/services/accounting/app/api/v1/setup.py +++ b/backend/services/accounting/app/api/v1/setup.py @@ -145,3 +145,48 @@ async def update_posting_policy( ) await db.commit() return policy + + +class CompanyProfileUpdate(BaseModel): + legal_name: str | None = None + trade_name: str | None = None + national_id: str | None = None + economic_code: str | None = None + registration_number: str | None = None + postal_code: str | None = None + province: str | None = None + city: str | None = None + address: str | None = None + phone: str | None = None + mobile: str | None = None + email: str | None = None + website: str | None = None + ceo_name: str | None = None + accountant_name: str | None = None + + +@router.get("/company-profile") +async def get_company_profile( + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + from app.services.company_profile_service import CompanyProfileService + + return await CompanyProfileService(db).get_profile(tenant_id) + + +@router.put("/company-profile") +async def update_company_profile( + body: CompanyProfileUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + from app.services.company_profile_service import CompanyProfileService + + profile = await CompanyProfileService(db).update_profile( + tenant_id, body.model_dump(exclude_none=False) + ) + await db.commit() + return profile diff --git a/backend/services/accounting/app/services/company_profile_service.py b/backend/services/accounting/app/services/company_profile_service.py new file mode 100644 index 0000000..51d1af2 --- /dev/null +++ b/backend/services/accounting/app/services/company_profile_service.py @@ -0,0 +1,68 @@ +"""Tenant company profile stored in accounting_settings (company.* keys).""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.foundation import AccountingSettings + +COMPANY_FIELDS: dict[str, str] = { + "legal_name": "", + "trade_name": "", + "national_id": "", + "economic_code": "", + "registration_number": "", + "postal_code": "", + "province": "", + "city": "", + "address": "", + "phone": "", + "mobile": "", + "email": "", + "website": "", + "ceo_name": "", + "accountant_name": "", +} + + +class CompanyProfileService: + PREFIX = "company." + + def __init__(self, session: AsyncSession) -> None: + self.session = session + + async def get_profile(self, tenant_id: UUID) -> dict[str, str]: + stmt = select(AccountingSettings).where( + AccountingSettings.tenant_id == tenant_id, + AccountingSettings.key.like(f"{self.PREFIX}%"), + ) + rows = list((await self.session.execute(stmt)).scalars().all()) + stored = {r.key[len(self.PREFIX) :]: (r.value or "") for r in rows} + return {**COMPANY_FIELDS, **{k: stored.get(k, v) for k, v in COMPANY_FIELDS.items()}} + + async def update_profile(self, tenant_id: UUID, body: dict) -> dict[str, str]: + for field in COMPANY_FIELDS: + if field not in body: + continue + value = "" if body[field] is None else str(body[field]).strip() + full_key = f"{self.PREFIX}{field}" + stmt = select(AccountingSettings).where( + AccountingSettings.tenant_id == tenant_id, + AccountingSettings.key == full_key, + ) + row = (await self.session.execute(stmt)).scalar_one_or_none() + if row is None: + self.session.add( + AccountingSettings( + tenant_id=tenant_id, + key=full_key, + value=value, + value_type="string", + ) + ) + else: + row.value = value + await self.session.flush() + return await self.get_profile(tenant_id) diff --git a/frontend/app/accounting/settings/company/page.tsx b/frontend/app/accounting/settings/company/page.tsx index a29dd75..4b78d25 100644 --- a/frontend/app/accounting/settings/company/page.tsx +++ b/frontend/app/accounting/settings/company/page.tsx @@ -1,18 +1,179 @@ "use client"; -import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage"; -export default function Page() { + +import { useEffect } from "react"; +import { useForm } from "react-hook-form"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { accountingApi } from "@/lib/accounting-api"; +import { useTenantId } from "@/hooks/useTenantId"; +import { + PageHeader, + LoadingState, + ErrorState, + Card, + CardContent, + CardHeader, + CardTitle, + Button, + Input, + FormField, +} from "@/components/ds"; + +type CompanyForm = { + legal_name: string; + trade_name: string; + national_id: string; + economic_code: string; + registration_number: string; + postal_code: string; + province: string; + city: string; + address: string; + phone: string; + mobile: string; + email: string; + website: string; + ceo_name: string; + accountant_name: string; +}; + +const EMPTY: CompanyForm = { + legal_name: "", + trade_name: "", + national_id: "", + economic_code: "", + registration_number: "", + postal_code: "", + province: "", + city: "", + address: "", + phone: "", + mobile: "", + email: "", + website: "", + ceo_name: "", + accountant_name: "", +}; + +export default function CompanySettingsPage() { + const { tenantId } = useTenantId(); + const qc = useQueryClient(); + const form = useForm({ defaultValues: EMPTY }); + + const profileQ = useQuery({ + queryKey: ["accounting", tenantId, "company-profile"], + queryFn: () => accountingApi.setup.getCompanyProfile(tenantId!), + enabled: !!tenantId, + }); + + useEffect(() => { + if (profileQ.data) { + form.reset({ ...EMPTY, ...profileQ.data }); + } + }, [profileQ.data, form]); + + const saveM = useMutation({ + mutationFn: (data: CompanyForm) => accountingApi.setup.updateCompanyProfile(tenantId!, data), + onSuccess: async () => { + toast.success("اطلاعات شرکت ذخیره شد"); + await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "company-profile"] }); + }, + onError: (e: Error) => toast.error(e.message), + }); + + if (!tenantId || profileQ.isLoading) return ; + if (profileQ.error) { + return profileQ.refetch()} />; + } + return ( - +
+ + +
saveM.mutate(d))}> + + + هویت حقوقی + + + + + + + + + + + + + + + + + + + + + + + + + + آدرس و تماس + + + + + + + + +
+ + + +
+ + + + + + + + + + + + +
+
+ + + + اشخاص مرتبط (اختیاری) + + + + + + + + + + + +
+ + +
+
+
); } diff --git a/frontend/app/accounting/settings/general/page.tsx b/frontend/app/accounting/settings/general/page.tsx index 883999e..6119951 100644 --- a/frontend/app/accounting/settings/general/page.tsx +++ b/frontend/app/accounting/settings/general/page.tsx @@ -1,18 +1,67 @@ "use client"; -import { SpecializedOpsPage } from "@/components/accounting/SpecializedOpsPage"; -export default function Page() { + +import Link from "next/link"; +import { + PageHeader, + Card, + CardContent, + CardHeader, + CardTitle, + Button, +} from "@/components/ds"; + +const LINKS = [ + { + href: "/accounting/settings/company", + title: "اطلاعات شرکت", + description: "نام حقوقی، شناسه ملی، کد اقتصادی، آدرس و تماس", + }, + { + href: "/accounting/settings/auto-posting", + title: "اسناد اتوماتیک", + description: "ثبت خودکار اسناد دفتر کل و قواعد استاندارد", + }, + { + href: "/accounting/fiscal", + title: "سال و دوره مالی", + description: "تعریف سال مالی و دوره جاری", + }, + { + href: "/accounting/currencies", + title: "ارزها", + description: "ارز پایه و نرخ‌ها", + }, + { + href: "/accounting/chart-of-accounts", + title: "دفتر حساب", + description: "چارت حساب‌ها و ساختار حسابداری", + }, +]; + +export default function GeneralSettingsPage() { return ( - +
+ +
+ {LINKS.map((item) => ( + + + {item.title} + + +

{item.description}

+ + + +
+
+ ))} +
+
); } diff --git a/frontend/components/ds/FormControls.tsx b/frontend/components/ds/FormControls.tsx index 000a11c..aad1d6b 100644 --- a/frontend/components/ds/FormControls.tsx +++ b/frontend/components/ds/FormControls.tsx @@ -27,11 +27,13 @@ export function Select({ export function FormField({ label, error, + hint, children, className, }: { label?: string; error?: string; + hint?: string; children: React.ReactNode; className?: string; }) { @@ -39,6 +41,7 @@ export function FormField({
{label ? : null} {children} + {hint && !error ?

{hint}

: null} {error ?

{error}

: null}
); diff --git a/frontend/lib/accounting-api.ts b/frontend/lib/accounting-api.ts index ec129e5..3052243 100644 --- a/frontend/lib/accounting-api.ts +++ b/frontend/lib/accounting-api.ts @@ -291,6 +291,49 @@ export const accountingApi = { method: "PUT", body: JSON.stringify(body), }), + getCompanyProfile: (tenantId: string) => + request<{ + legal_name: string; + trade_name: string; + national_id: string; + economic_code: string; + registration_number: string; + postal_code: string; + province: string; + city: string; + address: string; + phone: string; + mobile: string; + email: string; + website: string; + ceo_name: string; + accountant_name: string; + }>("/api/v1/setup/company-profile", { tenantId }), + updateCompanyProfile: ( + tenantId: string, + body: Partial<{ + legal_name: string; + trade_name: string; + national_id: string; + economic_code: string; + registration_number: string; + postal_code: string; + province: string; + city: string; + address: string; + phone: string; + mobile: string; + email: string; + website: string; + ceo_name: string; + accountant_name: string; + }> + ) => + request>("/api/v1/setup/company-profile", { + tenantId, + method: "PUT", + body: JSON.stringify(body), + }), }, charts: {