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:
@@ -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 }}>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user