feat: add multi-city representation support and domain context resolution

- Created migration to add representation_cities table and domain, is_global fields to representations.
- Implemented SiteContextController to resolve domain to site context (city | representation | unknown).
- Developed DomainContext and DomainContextResolver services for domain mapping.
- Added tests for DomainContextResolver and commission logic based on domain ownership.
This commit is contained in:
hamed
2026-07-09 07:26:58 +03:30
parent 59559e2e31
commit 0750bc9812
56 changed files with 4297 additions and 1600 deletions
+63 -19
View File
@@ -22,7 +22,9 @@ import SearchableSelect from '../components/ui/SearchableSelect';
const schema = z.object({
full_name: z.string().min(2, 'نام الزامی است'),
mobile_number: iranMobileSchema,
city_id: z.number().nullable().optional(),
city_ids: z.array(z.number()).optional(),
domain: z.string().optional(),
is_global: z.boolean().optional(),
commission_percent: z.coerce.number().min(0).max(100),
});
type FormData = z.infer<typeof schema>;
@@ -55,17 +57,20 @@ export default function RepresentationsPage() {
},
});
const { register, handleSubmit, reset, control, formState: { errors, isSubmitting } } = useForm<FormData>({
const { register, handleSubmit, reset, control, watch, formState: { errors, isSubmitting } } = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: { commission_percent: 10 },
defaultValues: { commission_percent: 10, city_ids: [], is_global: false },
});
const isGlobal = watch('is_global') ?? false;
const createMutation = useMutation({
mutationFn: (d: FormData) =>
api.post<ApiResponse<Representation>>('/api/v1/representation', {
full_name: d.full_name,
mobile_number: d.mobile_number,
city_id: d.city_id ?? null,
city_ids: d.city_ids ?? [],
is_global: d.is_global ?? false,
...(d.domain?.trim() && { domain: d.domain.trim() }),
commission_percent: d.commission_percent,
}),
onSuccess: () => {
@@ -89,9 +94,15 @@ export default function RepresentationsPage() {
});
const columns: Column<Representation>[] = [
{ key: 'full_name', header: 'نام', render: (r) => <b>{r.full_name}</b> },
{ key: 'full_name', header: 'نام', render: (r) => (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<b>{r.full_name}</b>
{r.is_global && <span className="badge green" title="نماینده سراسری">سراسری </span>}
</span>
) },
{ key: 'mobile_number', header: 'موبایل', render: (r) => <span dir="ltr">{r.mobile_number ?? '—'}</span> },
{ key: 'city', header: 'شهر', render: (r) => r.city ?? '—' },
{ key: 'domain', header: 'دامنه', render: (r) => r.domain ? <span dir="ltr">{r.domain}</span> : '—' },
{ key: 'city', header: 'شهرها', render: (r) => r.city ?? '—' },
{ key: 'commission_percent', header: 'کمیسیون', render: (r) => `${formatNumber(r.commission_percent)}٪` },
{ key: 'wallet_balance', header: 'موجودی کیف‌پول', render: (r) => formatRial(r.wallet_balance ?? 0) },
{ key: 'is_active', header: 'وضعیت', render: (r) => <ActiveBadge active={r.is_active ?? r.active ?? false} /> },
@@ -183,21 +194,54 @@ export default function RepresentationsPage() {
{errors.mobile_number && <p className="err-text">{errors.mobile_number.message}</p>}
</div>
<div className="form-row" style={{ marginTop: 12 }}>
<label>شهر</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input type="checkbox" {...register('is_global')} style={{ width: 16, height: 16 }} />
نماینده سراسری (دامنه اختصاصی فقط پزشکان/کلینیکهای خودش نمایش داده میشوند)
</label>
</div>
<div className="form-row" style={{ marginTop: 12 }}>
<label>دامنه</label>
<input {...register('domain')} placeholder="example-nobat.ir" dir="ltr" className="input" />
{errors.domain && <p className="err-text">{errors.domain.message}</p>}
</div>
<div className="form-row" style={{ marginTop: 12 }}>
<label>شهرها {isGlobal && <span className="muted">(برای نماینده سراسری اختیاری)</span>}</label>
<Controller
name="city_id"
name="city_ids"
control={control}
render={({ field }) => (
<SearchableSelect
options={cityOptions}
value={field.value ?? null}
onChange={(val) => field.onChange(val as number | null)}
placeholder="انتخاب شهر..."
isClearable
isLoading={citiesQuery.isLoading}
noOptionsMessage="هیچ شهری یافت نشد"
/>
)}
render={({ field }) => {
const selected: number[] = field.value ?? [];
return (
<div>
<SearchableSelect
options={cityOptions.filter((o) => !selected.includes(o.value))}
value={null}
onChange={(val) => {
if (val !== null && !selected.includes(val as number)) {
field.onChange([...selected, val as number]);
}
}}
placeholder={isGlobal ? 'اختیاری — افزودن شهر...' : 'افزودن شهر...'}
isClearable
isLoading={citiesQuery.isLoading}
noOptionsMessage="هیچ شهری یافت نشد"
/>
{selected.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 8 }}>
{selected.map((id) => (
<span key={id} className="badge" style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
{cityOptions.find((o) => o.value === id)?.label ?? id}
<button type="button" className="mini-btn" style={{ padding: 0, width: 16, height: 16 }}
onClick={() => field.onChange(selected.filter((v) => v !== id))}>
×
</button>
</span>
))}
</div>
)}
</div>
);
}}
/>
</div>
<div className="form-row" style={{ marginTop: 12 }}>
+4 -1
View File
@@ -147,9 +147,12 @@ export interface Representation {
id: number;
uuid: string;
full_name: string;
domain?: string;
domain?: string | null;
is_global?: boolean;
mobile_number: string | null;
city_id: number | null;
city_ids?: number[];
cities?: { id: number; name: string }[];
city: string | null;
commission_percent: number;
bank_account: { card?: string; bank_name?: string; iban?: string } | null;