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:
parent
cdb69b014b
commit
320d9cd547
@ -145,3 +145,48 @@ async def update_posting_policy(
|
|||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return policy
|
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
|
||||||
|
|||||||
@ -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)
|
||||||
@ -1,18 +1,179 @@
|
|||||||
"use client";
|
"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 (
|
return (
|
||||||
<SpecializedOpsPage
|
<div>
|
||||||
|
<PageHeader
|
||||||
title="اطلاعات شرکت"
|
title="اطلاعات شرکت"
|
||||||
description="اطلاعات شرکت — پارامترهای مستأجر در لایه عملیاتی حسابداری."
|
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 },
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,18 +1,67 @@
|
|||||||
"use client";
|
"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 (
|
return (
|
||||||
<SpecializedOpsPage
|
<div>
|
||||||
|
<PageHeader
|
||||||
title="تنظیمات عمومی"
|
title="تنظیمات عمومی"
|
||||||
description="تنظیمات عمومی — پارامترهای مستأجر در لایه عملیاتی حسابداری."
|
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 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,11 +27,13 @@ export function Select({
|
|||||||
export function FormField({
|
export function FormField({
|
||||||
label,
|
label,
|
||||||
error,
|
error,
|
||||||
|
hint,
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
label?: string;
|
label?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
hint?: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
@ -39,6 +41,7 @@ export function FormField({
|
|||||||
<div className={cn("space-y-1.5", className)}>
|
<div className={cn("space-y-1.5", className)}>
|
||||||
{label ? <label className="block text-sm font-medium text-secondary">{label}</label> : null}
|
{label ? <label className="block text-sm font-medium text-secondary">{label}</label> : null}
|
||||||
{children}
|
{children}
|
||||||
|
{hint && !error ? <p className="text-xs text-[var(--muted)]">{hint}</p> : null}
|
||||||
{error ? <p className="text-xs text-red-600">{error}</p> : null}
|
{error ? <p className="text-xs text-red-600">{error}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -291,6 +291,49 @@ export const accountingApi = {
|
|||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify(body),
|
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: {
|
charts: {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user