feat(tags): match add-tag modal to tauri design (preset swatches + active toggle)
- TagsSettingsPage modal: replace native color input with 4 preset swatches, add 'وضعیت برچسب' active toggle, update labels and submit button to mirror tauri AddPurchaseSubTabModal - TenantTagController::create now honors an optional 'active' flag (defaults true, non-breaking for existing callers) - tests: backend active-flag case, frontend preset-color + inactive case - docs/api/tag.md: document the new create 'active' field Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -36,9 +36,26 @@ describe('TagsSettingsPage', () => {
|
||||
await screen.findByText('فوری');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /برچسب جدید/ }));
|
||||
fireEvent.change(screen.getByPlaceholderText('مثلاً: فوری'), { target: { value: 'اورژانس' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'ذخیره' }));
|
||||
fireEvent.change(screen.getByPlaceholderText('نام برچسب را وارد کنید'), { target: { value: 'اورژانس' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'ثبت برچسب' }));
|
||||
|
||||
await waitFor(() => expect(post).toHaveBeenCalledWith('/api/v1/tenant-tag', expect.objectContaining({ name: 'اورژانس' })));
|
||||
await waitFor(() => expect(post).toHaveBeenCalledWith('/api/v1/tenant-tag', expect.objectContaining({
|
||||
name: 'اورژانس', color: '#d61600', active: true,
|
||||
})));
|
||||
});
|
||||
|
||||
it('sends the picked preset color and inactive status on create', async () => {
|
||||
renderWithProviders(<TagsSettingsPage />, { route: '/admin/tags-settings' });
|
||||
await screen.findByText('فوری');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /برچسب جدید/ }));
|
||||
fireEvent.change(screen.getByPlaceholderText('نام برچسب را وارد کنید'), { target: { value: 'بدهکار' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'رنگ #0088ff' }));
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'وضعیت برچسب' })); // toggle off (default active)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'ثبت برچسب' }));
|
||||
|
||||
await waitFor(() => expect(post).toHaveBeenCalledWith('/api/v1/tenant-tag', expect.objectContaining({
|
||||
name: 'بدهکار', color: '#0088ff', active: false,
|
||||
})));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,11 +14,16 @@ import SettingsLayout from '../components/layout/SettingsLayout';
|
||||
interface TenantTag { uuid: string; name: string; color: string; active: boolean }
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string().min(1, 'نام برچسب الزامی است'),
|
||||
color: z.string().regex(/^#([0-9a-fA-F]{6})$/, 'رنگ نامعتبر است'),
|
||||
name: z.string().min(1, 'نام برچسب الزامی است'),
|
||||
color: z.string().regex(/^#([0-9a-fA-F]{6})$/, 'رنگ نامعتبر است'),
|
||||
active: z.boolean(),
|
||||
});
|
||||
type Form = z.infer<typeof schema>;
|
||||
|
||||
/** Preset swatches — mirrors the tauri AddPurchaseSubTabModal palette. */
|
||||
const TAG_COLORS = ['#d61600', '#0088ff', '#ffa200', '#7cc985'];
|
||||
const DEFAULTS: Form = { name: '', color: TAG_COLORS[0], active: true };
|
||||
|
||||
const EMPTY: TenantTag[] = [];
|
||||
|
||||
/** برچسبها — per-tenant tag management inside the settings shell. */
|
||||
@@ -33,13 +38,13 @@ export default function TagsSettingsPage() {
|
||||
});
|
||||
const tags = data?.data ?? EMPTY;
|
||||
|
||||
const form = useForm<Form>({ resolver: zodResolver(schema), defaultValues: { name: '', color: '#5559CE' } });
|
||||
const form = useForm<Form>({ resolver: zodResolver(schema), defaultValues: DEFAULTS });
|
||||
|
||||
const invalidate = () => qc.invalidateQueries({ queryKey: ['tenant-tags'] });
|
||||
|
||||
const createTag = useMutation({
|
||||
mutationFn: (d: Form) => api.post('/api/v1/tenant-tag', d),
|
||||
onSuccess: () => { invalidate(); setModal(null); form.reset({ name: '', color: '#5559CE' }); toast.success('برچسب ایجاد شد'); },
|
||||
onSuccess: () => { invalidate(); setModal(null); form.reset(DEFAULTS); toast.success('برچسب ایجاد شد'); },
|
||||
onError: (e: any) => toast.error(e.message),
|
||||
});
|
||||
const editTag = useMutation({
|
||||
@@ -53,8 +58,8 @@ export default function TagsSettingsPage() {
|
||||
onError: (e: any) => { toast.error(e.message); setDeleteTarget(null); },
|
||||
});
|
||||
|
||||
const openCreate = () => { form.reset({ name: '', color: '#5559CE' }); setModal('create'); };
|
||||
const openEdit = (t: TenantTag) => { form.reset({ name: t.name, color: t.color }); setModal(t); };
|
||||
const openCreate = () => { form.reset(DEFAULTS); setModal('create'); };
|
||||
const openEdit = (t: TenantTag) => { form.reset({ name: t.name, color: t.color, active: t.active }); setModal(t); };
|
||||
|
||||
return (
|
||||
<SettingsLayout active="tags">
|
||||
@@ -89,27 +94,53 @@ export default function TagsSettingsPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Modal open={modal !== null} onClose={() => setModal(null)} title={modal === 'create' ? 'برچسب جدید' : 'ویرایش برچسب'}>
|
||||
<Modal open={modal !== null} onClose={() => setModal(null)} title={modal === 'create' ? 'افزودن برچسب جدید' : 'ویرایش برچسب'}>
|
||||
<form onSubmit={form.handleSubmit((d) => {
|
||||
if (modal === 'create') createTag.mutate(d);
|
||||
else if (modal && typeof modal === 'object') editTag.mutate({ uuid: modal.uuid, d });
|
||||
})} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
})} style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
<div>
|
||||
<label className="field-label">نام برچسب *</label>
|
||||
<div className="field"><input {...form.register('name')} placeholder="مثلاً: فوری" autoFocus /></div>
|
||||
<label className="field-label">نام برچسب</label>
|
||||
<div className="field"><input {...form.register('name')} placeholder="نام برچسب را وارد کنید" autoFocus /></div>
|
||||
{form.formState.errors.name && <span className="field-error">{form.formState.errors.name.message}</span>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="field-label">رنگ</label>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<input type="color" aria-label="رنگ برچسب" value={form.watch('color')} onChange={(e) => form.setValue('color', e.target.value)} style={{ width: 44, height: 38, border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', background: 'none', cursor: 'pointer' }} />
|
||||
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>{form.watch('color')}</span>
|
||||
<label className="field-label" style={{ fontWeight: 700 }}>رنگ برچسب</label>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
{TAG_COLORS.map((c) => {
|
||||
const selected = form.watch('color') === c;
|
||||
return (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
aria-label={`رنگ ${c}`}
|
||||
aria-pressed={selected}
|
||||
onClick={() => form.setValue('color', c)}
|
||||
style={{
|
||||
width: 28, height: 28, borderRadius: '50%', background: c, cursor: 'pointer', padding: 0,
|
||||
border: selected ? '2px solid var(--text)' : '1px solid var(--border)',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="submit" className="btn primary" disabled={createTag.isPending || editTag.isPending}>ذخیره</button>
|
||||
<button type="button" className="btn" onClick={() => setModal(null)}>انصراف</button>
|
||||
|
||||
<div>
|
||||
<label className="field-label" style={{ fontWeight: 700 }}>وضعیت برچسب</label>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<label className="switch" title="وضعیت برچسب">
|
||||
<input type="checkbox" checked={form.watch('active')} onChange={(e) => form.setValue('active', e.target.checked)} aria-label="وضعیت برچسب" />
|
||||
<span className="switch-track"><span className="switch-thumb" /></span>
|
||||
</label>
|
||||
<span style={{ fontSize: 14, color: 'var(--text-2)' }}>{form.watch('active') ? 'فعال' : 'غیرفعال'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn primary" style={{ width: '100%', justifyContent: 'center', height: 46 }} disabled={createTag.isPending || editTag.isPending}>
|
||||
{modal === 'create' ? 'ثبت برچسب' : 'ذخیره تغییرات'}
|
||||
</button>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
|
||||
+2
-1
@@ -158,12 +158,13 @@ Without `sort`, the default ordering (weight/name) is unchanged.
|
||||
|
||||
### POST `/api/v1/tenant-tag`
|
||||
```json
|
||||
{ "name": "فوری", "color": "#FF0000" }
|
||||
{ "name": "فوری", "color": "#FF0000", "active": true }
|
||||
```
|
||||
| Field | Type | Required | Validation |
|
||||
|-------|------|----------|------------|
|
||||
| `name` | string | ✅ | غیرخالی، حداکثر ۶۰ |
|
||||
| `color` | string | ❌ | هگز `#RRGGBB` یا `#RRGGBBAA` (پیشفرض `#5559CE`) |
|
||||
| `active` | bool | ❌ | وضعیت اولیه (پیشفرض `true`) |
|
||||
|
||||
Response `201`: TenantTag object.
|
||||
|
||||
|
||||
@@ -68,6 +68,9 @@ class TenantTagController extends BaseController
|
||||
}
|
||||
|
||||
$tag = new TenantTag($type, $id, $name, $color);
|
||||
if (array_key_exists('active', $data)) {
|
||||
$tag->setActive((bool) $data['active']);
|
||||
}
|
||||
$this->tagRepo->save($tag);
|
||||
|
||||
return $this->success($tag->toArray(), 201);
|
||||
|
||||
@@ -51,6 +51,24 @@ class TenantTagTest extends ApiTestCase
|
||||
self::assertCount(0, $after['data']);
|
||||
}
|
||||
|
||||
public function testCreateHonorsActiveFlag(): void
|
||||
{
|
||||
[$user] = $this->doctorUser();
|
||||
|
||||
// explicit active:false is persisted, not forced to the default true
|
||||
$created = $this->authJson('POST', '/api/v1/tenant-tag', $user, [
|
||||
'name' => 'بایگانی', 'color' => '#123456', 'active' => false,
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
self::assertFalse($created['data']['active']);
|
||||
|
||||
// omitting active still defaults to true
|
||||
$default = $this->authJson('POST', '/api/v1/tenant-tag', $user, [
|
||||
'name' => 'پیشفرض', 'color' => '#654321',
|
||||
]);
|
||||
self::assertTrue($default['data']['active']);
|
||||
}
|
||||
|
||||
public function testRejectsInvalidNameAndColor(): void
|
||||
{
|
||||
[$user] = $this->doctorUser();
|
||||
|
||||
Reference in New Issue
Block a user