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:
hamed
2026-07-14 15:39:45 +03:30
co-authored by Claude Opus 4.8
parent c9a2db6e9d
commit 7e1d887b72
5 changed files with 91 additions and 21 deletions
+20 -3
View File
@@ -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,
})));
});
});
+48 -17
View File
@@ -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
View File
@@ -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);
+18
View File
@@ -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();