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()
|
||||
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";
|
||||
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
|
||||
<div>
|
||||
<PageHeader
|
||||
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 },
|
||||
]}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
|
||||
<div>
|
||||
<PageHeader
|
||||
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 },
|
||||
]}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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: {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user