# Pattern — `{Entity}FormPage.tsx`

## When to use

Create OR edit view for an entity. Single `FormPage` component handles both
modes via `id === 'new'` convention. The form is a vertical list of labeled
inputs wrapped in a `.card` section. Errors surface as a token-based banner
at the top.

## Anatomy

```
PermissionGuard (create or update depending on mode)
  PageTemplate (title = createTitle | editTitle, icon = FilePen)
    error banner       — bg-[var(--error-bg)] + AlertTriangle (R7)
    form (onSubmit)
      card section     — rounded-[var(--radius-card)] border bg-[var(--bg-card)]
        Slot           — {entity}.form.fields.before (context={formData, onChange})
        field × N      — <label> + <input className="input">
        Slot           — {entity}.form.fields.after
      actions row      — Cancel + Submit (Submit = accent CTA)
```

## Canonical example (compliant)

```tsx
import { useState, useEffect } from 'react';
import type { FormEvent } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Loader2, AlertTriangle, FilePen } from 'lucide-react';
import { PermissionGuard, Slot } from '@atlashub/smartstack';
import { PageTemplate } from '@/components/ui/PageTemplate';
import { useContact, useCreateContact, useUpdateContact } from '../hooks/useContact';

type ContactFormData = {
  lastName: string;
  firstName?: string;
  email?: string;
};

const initialContactFormData: ContactFormData = {
  lastName: '',
  firstName: '',
  email: '',
};

export function ContactFormPage() {
  const { id } = useParams<{ id: string }>();
  const navigate = useNavigate();
  const { t } = useTranslation('contact');
  const isEdit = !!id && id !== 'new';  // R9 — combined null-check + mode
  const { data: existing } = useContact(isEdit ? id! : '');
  const createMutation = useCreateContact();
  const updateMutation = useUpdateContact();

  const [formData, setFormData] = useState<ContactFormData>(initialContactFormData);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    if (existing) setFormData(existing as ContactFormData);
  }, [existing]);

  const onChange = <K extends keyof ContactFormData>(field: K, value: ContactFormData[K]) => {
    setFormData((prev) => ({ ...prev, [field]: value }));
  };

  const handleSubmit = async (event: FormEvent) => {
    event.preventDefault();
    setError(null);
    try {
      if (isEdit && id) {
        await updateMutation.mutateAsync({ id, data: formData });
      } else {
        await createMutation.mutateAsync(formData);
      }
      navigate(-1);
    } catch (err) {
      setError(err instanceof Error ? err.message : String(err));
    }
  };

  const permission = isEdit ? 'crm.contacts.update' : 'crm.contacts.create';
  const isPending = createMutation.isPending || updateMutation.isPending;

  return (
    <PermissionGuard permission={permission}>
      <PageTemplate
        title={isEdit ? t('form.editTitle') : t('form.createTitle')}
        icon={<FilePen className="w-6 h-6" />}
      >
        {error && (
          <div className="p-4 rounded-[var(--radius-card)] bg-[var(--error-bg)] border border-[var(--error-border)]">
            <div className="flex items-center gap-2 text-[var(--error-text)]">
              <AlertTriangle className="w-5 h-5" />
              <span>{error}</span>
            </div>
          </div>
        )}

        <form onSubmit={handleSubmit} className="space-y-6">
          <div className="p-6 rounded-[var(--radius-card)] border border-[var(--border-color)] bg-[var(--bg-card)] space-y-4">
            <Slot name="contact.form.fields.before" context={{ formData, onChange }} />

            <div>
              <label htmlFor="lastName" className="block text-sm font-medium text-[var(--text-primary)] mb-1">
                {t('form.fields.lastName')} <span className="text-[var(--error-text)]">*</span>
              </label>
              <input
                id="lastName"
                type="text"
                value={formData.lastName}
                onChange={(e) => onChange('lastName', e.target.value)}
                required
                disabled={isPending}
                className="input w-full disabled:opacity-50"
              />
            </div>
            {/* repeat <div> + <label> + <input className="input"> per field */}

            <Slot name="contact.form.fields.after" context={{ formData, onChange }} />
          </div>

          <div className="flex justify-end gap-2">
            <button
              type="button"
              onClick={() => navigate(-1)}
              disabled={isPending}
              className="px-6 py-2 rounded-[var(--radius-button)] bg-[var(--bg-secondary)] text-[var(--text-primary)] hover:bg-[var(--bg-hover)] disabled:opacity-50 transition-colors"
            >
              {t('form.cancel')}
            </button>
            <button
              type="submit"
              disabled={isPending}
              className="flex items-center gap-2 px-6 py-2 rounded-[var(--radius-button)] bg-[var(--color-accent-500)] text-white hover:bg-[var(--color-accent-600)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
            >
              {isPending && <Loader2 className="w-4 h-4 animate-spin" />}
              {isEdit ? t('form.submitUpdate') : t('form.submitCreate')}
            </button>
          </div>
        </form>
      </PageTemplate>
    </PermissionGuard>
  );
}
```

## Rules this pattern enforces

- **R1** — `<PageTemplate>`
- **R3** — submit button uses accent token; cancel uses `bg-[var(--bg-secondary)]`
- **R4** — field labels, button text, errors all flow through `t('form.…')`
- **R5** — lucide-react icons only
- **R6** — Loader2 animate-spin during submit
- **R7** — error banner uses `bg-[var(--error-bg)] border-[var(--error-border)] text-[var(--error-text)]`
- **R9** — `!!id && id !== 'new'` combined null-check + mode detection
- **R10** — permission key `{module}.{section}.{action}` per mode
- **R11** — Slot extension points: `{entity}.form.fields.before/after`
- **R13** — NOT directly applicable (no badges in form)
- **R16** — radius tokens
- **R19** — no palette / absolute colors
- **R20** — every border paired with color token

## The `.input` helper class

Inputs use the global `.input` class (defined in baseline `base.css`) which
bundles: radius-input token, focus ring accent-500, border-color token,
disabled opacity, padding. DO NOT redefine these via Tailwind arbitrary
values — `.input` is the single source of truth.

## Import contract

| Name | From |
|---|---|
| `PermissionGuard`, `Slot` | `@atlashub/smartstack` |
| `PageTemplate` | `@/components/ui/PageTemplate` |
| Icons (AlertTriangle, Loader2, FilePen) | `lucide-react` |
| `useState`, `useEffect`, `FormEvent` | `react` |
| `useParams`, `useNavigate` | `react-router-dom` |
| `useTranslation` | `react-i18next` |
| mutations | `../hooks/use{Entity}` (scaffolded by api-client) |

## Do NOT

- Use `<input />` without the `.input` class — loses focus ring + theme.
- Render error banners with `bg-red-50 text-red-700` — use the R7 anatomy.
- Use `disabled:bg-gray-200` — pair `disabled:opacity-50` instead (transparent, theme-safe).
- Submit without the `isPending` disabled guard — users double-submit.
- Skip the `Slot fields.before` / `fields.after` — they're the official
  extension point for client customizations adding optional fields.
