Replace opaque company settings form with a real company profile page.

Users now fill legal name, national ID, economic code, address and contacts instead of generic setting key/value ops documents.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Mortezakoohjani 2026-07-26 18:37:50 +03:30
parent cdb69b014b
commit 320d9cd547
6 changed files with 397 additions and 28 deletions

View File

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

View File

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

View File

@ -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<CompanyForm>({ 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 <LoadingState />;
if (profileQ.error) {
return <ErrorState message={profileQ.error.message} onRetry={() => profileQ.refetch()} />;
}
return (
<SpecializedOpsPage
title="اطلاعات شرکت"
description="اطلاعات شرکت — پارامترهای مستأجر در لایه عملیاتی حسابداری."
module="settings"
docType="company"
createLabel="ثبت اطلاعات شرکت"
fields={[
{ key: "setting_key", label: "کلید تنظیم", kind: "text" },
{ key: "setting_value", label: "مقدار", kind: "text" },
{ key: "amount", label: "مقدار عددی (اختیاری)", kind: "money", required: false },
]}
/>
<div>
<PageHeader
title="اطلاعات شرکت"
description="مشخصات حقوقی و تماس شرکت برای چاپ اسناد، گزارش‌ها و اظهارنامه‌ها. فقط فیلدهایی که دارید را پر کنید."
/>
<form className="space-y-6" onSubmit={form.handleSubmit((d) => saveM.mutate(d))}>
<Card>
<CardHeader>
<CardTitle>هویت حقوقی</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<FormField label="نام قانونی شرکت" hint="همان نام ثبت‌شده در روزنامه رسمی">
<Input placeholder="مثال: شرکت بازرگانی آفتاب سپاهان" {...form.register("legal_name")} />
</FormField>
<FormField label="نام تجاری / برند" hint="اگر با نام قانونی فرق دارد">
<Input placeholder="مثال: آفتاب‌مارکت" {...form.register("trade_name")} />
</FormField>
<FormField label="شناسه ملی" hint="۱۱ رقم برای اشخاص حقوقی">
<Input dir="ltr" placeholder="1400xxxxxxx" {...form.register("national_id")} />
</FormField>
<FormField label="کد اقتصادی" hint="برای فاکتور رسمی و مالیات">
<Input dir="ltr" placeholder="کد اقتصادی" {...form.register("economic_code")} />
</FormField>
<FormField label="شماره ثبت" hint="شماره ثبت در اداره ثبت شرکت‌ها">
<Input dir="ltr" placeholder="مثال: ۱۲۳۴۵۶" {...form.register("registration_number")} />
</FormField>
<FormField label="کد پستی" hint="۱۰ رقم">
<Input dir="ltr" placeholder="xxxxxxxxxx" {...form.register("postal_code")} />
</FormField>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>آدرس و تماس</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<FormField label="استان">
<Input placeholder="مثال: خراسان رضوی" {...form.register("province")} />
</FormField>
<FormField label="شهر">
<Input placeholder="مثال: تربت حیدریه" {...form.register("city")} />
</FormField>
<div className="sm:col-span-2">
<FormField label="آدرس کامل">
<Input placeholder="خیابان، پلاک، واحد…" {...form.register("address")} />
</FormField>
</div>
<FormField label="تلفن ثابت">
<Input dir="ltr" placeholder="051-xxxxx" {...form.register("phone")} />
</FormField>
<FormField label="موبایل">
<Input dir="ltr" placeholder="09xxxxxxxxx" {...form.register("mobile")} />
</FormField>
<FormField label="ایمیل">
<Input dir="ltr" type="email" placeholder="info@example.com" {...form.register("email")} />
</FormField>
<FormField label="وب‌سایت">
<Input dir="ltr" placeholder="https://…" {...form.register("website")} />
</FormField>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>اشخاص مرتبط (اختیاری)</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<FormField label="نام مدیرعامل / مدیر مسئول">
<Input {...form.register("ceo_name")} />
</FormField>
<FormField label="نام حسابدار مسئول">
<Input {...form.register("accountant_name")} />
</FormField>
</CardContent>
</Card>
<div className="flex flex-wrap justify-end gap-2">
<Button type="button" variant="outline" disabled={saveM.isPending} onClick={() => profileQ.refetch()}>
بازخوانی
</Button>
<Button type="submit" disabled={saveM.isPending}>
ذخیره اطلاعات شرکت
</Button>
</div>
</form>
</div>
);
}

View File

@ -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 (
<SpecializedOpsPage
title="تنظیمات عمومی"
description="تنظیمات عمومی — پارامترهای مستأجر در لایه عملیاتی حسابداری."
module="settings"
docType="general"
createLabel="ثبت تنظیمات عمومی"
fields={[
{ key: "setting_key", label: "کلید تنظیم", kind: "text" },
{ key: "setting_value", label: "مقدار", kind: "text" },
{ key: "amount", label: "مقدار عددی (اختیاری)", kind: "money", required: false },
]}
/>
<div>
<PageHeader
title="تنظیمات عمومی"
description="میانبر به تنظیمات پرکاربرد حسابداری — اطلاعات شرکت را از منوی «اطلاعات شرکت» وارد کنید."
/>
<div className="grid gap-4 sm:grid-cols-2">
{LINKS.map((item) => (
<Card key={item.href}>
<CardHeader>
<CardTitle>{item.title}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-[var(--muted)]">{item.description}</p>
<Link href={item.href}>
<Button type="button" variant="outline" size="sm">
مشاهده
</Button>
</Link>
</CardContent>
</Card>
))}
</div>
</div>
);
}

View File

@ -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({
<div className={cn("space-y-1.5", className)}>
{label ? <label className="block text-sm font-medium text-secondary">{label}</label> : null}
{children}
{hint && !error ? <p className="text-xs text-[var(--muted)]">{hint}</p> : null}
{error ? <p className="text-xs text-red-600">{error}</p> : null}
</div>
);

View File

@ -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<Record<string, string>>("/api/v1/setup/company-profile", {
tenantId,
method: "PUT",
body: JSON.stringify(body),
}),
},
charts: {