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('فوری'); await screen.findByText('فوری');
fireEvent.click(screen.getByRole('button', { name: /برچسب جدید/ })); fireEvent.click(screen.getByRole('button', { name: /برچسب جدید/ }));
fireEvent.change(screen.getByPlaceholderText('مثلاً: فوری'), { target: { value: 'اورژانس' } }); fireEvent.change(screen.getByPlaceholderText('نام برچسب را وارد کنید'), { target: { value: 'اورژانس' } });
fireEvent.click(screen.getByRole('button', { name: 'ذخیره' })); 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 } interface TenantTag { uuid: string; name: string; color: string; active: boolean }
const schema = z.object({ const schema = z.object({
name: z.string().min(1, 'نام برچسب الزامی است'), name: z.string().min(1, 'نام برچسب الزامی است'),
color: z.string().regex(/^#([0-9a-fA-F]{6})$/, 'رنگ نامعتبر است'), color: z.string().regex(/^#([0-9a-fA-F]{6})$/, 'رنگ نامعتبر است'),
active: z.boolean(),
}); });
type Form = z.infer<typeof schema>; 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[] = []; const EMPTY: TenantTag[] = [];
/** برچسب‌ها — per-tenant tag management inside the settings shell. */ /** برچسب‌ها — per-tenant tag management inside the settings shell. */
@@ -33,13 +38,13 @@ export default function TagsSettingsPage() {
}); });
const tags = data?.data ?? EMPTY; 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 invalidate = () => qc.invalidateQueries({ queryKey: ['tenant-tags'] });
const createTag = useMutation({ const createTag = useMutation({
mutationFn: (d: Form) => api.post('/api/v1/tenant-tag', d), 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), onError: (e: any) => toast.error(e.message),
}); });
const editTag = useMutation({ const editTag = useMutation({
@@ -53,8 +58,8 @@ export default function TagsSettingsPage() {
onError: (e: any) => { toast.error(e.message); setDeleteTarget(null); }, onError: (e: any) => { toast.error(e.message); setDeleteTarget(null); },
}); });
const openCreate = () => { form.reset({ name: '', color: '#5559CE' }); setModal('create'); }; const openCreate = () => { form.reset(DEFAULTS); setModal('create'); };
const openEdit = (t: TenantTag) => { form.reset({ name: t.name, color: t.color }); setModal(t); }; const openEdit = (t: TenantTag) => { form.reset({ name: t.name, color: t.color, active: t.active }); setModal(t); };
return ( return (
<SettingsLayout active="tags"> <SettingsLayout active="tags">
@@ -89,27 +94,53 @@ export default function TagsSettingsPage() {
)} )}
</div> </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) => { <form onSubmit={form.handleSubmit((d) => {
if (modal === 'create') createTag.mutate(d); if (modal === 'create') createTag.mutate(d);
else if (modal && typeof modal === 'object') editTag.mutate({ uuid: modal.uuid, 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> <div>
<label className="field-label">نام برچسب *</label> <label className="field-label">نام برچسب</label>
<div className="field"><input {...form.register('name')} placeholder="مثلاً: فوری" autoFocus /></div> <div className="field"><input {...form.register('name')} placeholder="نام برچسب را وارد کنید" autoFocus /></div>
{form.formState.errors.name && <span className="field-error">{form.formState.errors.name.message}</span>} {form.formState.errors.name && <span className="field-error">{form.formState.errors.name.message}</span>}
</div> </div>
<div> <div>
<label className="field-label">رنگ</label> <label className="field-label" style={{ fontWeight: 700 }}>رنگ برچسب</label>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<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' }} /> {TAG_COLORS.map((c) => {
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>{form.watch('color')}</span> 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> </div>
<div style={{ display: 'flex', gap: 8 }}>
<button type="submit" className="btn primary" disabled={createTag.isPending || editTag.isPending}>ذخیره</button> <div>
<button type="button" className="btn" onClick={() => setModal(null)}>انصراف</button> <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> </div>
<button type="submit" className="btn primary" style={{ width: '100%', justifyContent: 'center', height: 46 }} disabled={createTag.isPending || editTag.isPending}>
{modal === 'create' ? 'ثبت برچسب' : 'ذخیره تغییرات'}
</button>
</form> </form>
</Modal> </Modal>
+2 -1
View File
@@ -158,12 +158,13 @@ Without `sort`, the default ordering (weight/name) is unchanged.
### POST `/api/v1/tenant-tag` ### POST `/api/v1/tenant-tag`
```json ```json
{ "name": "فوری", "color": "#FF0000" } { "name": "فوری", "color": "#FF0000", "active": true }
``` ```
| Field | Type | Required | Validation | | Field | Type | Required | Validation |
|-------|------|----------|------------| |-------|------|----------|------------|
| `name` | string | ✅ | غیرخالی، حداکثر ۶۰ | | `name` | string | ✅ | غیرخالی، حداکثر ۶۰ |
| `color` | string | ❌ | هگز `#RRGGBB` یا `#RRGGBBAA` (پیش‌فرض `#5559CE`) | | `color` | string | ❌ | هگز `#RRGGBB` یا `#RRGGBBAA` (پیش‌فرض `#5559CE`) |
| `active` | bool | ❌ | وضعیت اولیه (پیش‌فرض `true`) |
Response `201`: TenantTag object. Response `201`: TenantTag object.
@@ -68,6 +68,9 @@ class TenantTagController extends BaseController
} }
$tag = new TenantTag($type, $id, $name, $color); $tag = new TenantTag($type, $id, $name, $color);
if (array_key_exists('active', $data)) {
$tag->setActive((bool) $data['active']);
}
$this->tagRepo->save($tag); $this->tagRepo->save($tag);
return $this->success($tag->toArray(), 201); return $this->success($tag->toArray(), 201);
+18
View File
@@ -51,6 +51,24 @@ class TenantTagTest extends ApiTestCase
self::assertCount(0, $after['data']); 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 public function testRejectsInvalidNameAndColor(): void
{ {
[$user] = $this->doctorUser(); [$user] = $this->doctorUser();