Opgave 11b (del 3): Mail-skabeloner + annoncér
TemplatesPage (/skabeloner) — én sektion pr. event_type
(round_announced/order_confirmed/payment_confirmed), hver med
opret/gem/slet og et hint om hvilke {{variabler}} der er tilgængelige
for netop den mailtype. Ingen backend-ændringer nødvendige, al CRUD
fandtes allerede (opgave 7a).
"Annoncér runde"-knap på RoundDetailPage (kun ved status "open"), med
et eksplicit confirm() da handlingen sender rigtig mail til alle
aktive deltagere på ruten og ikke er idempotent. Kalder det
eksisterende /announce-endpoint (opgave 7b) og viser resultatet.
Verificeret i en fuldstændig isoleret sandbox (midlertidig separat
rute) for at undgå en utilsigtet rigtig annoncering til de 308
rigtige deltagere under udvikling. Brugeren oprettede under
browser-test rigtige mail-skabeloner for alle tre event-typer på
rute 4 — det tidligere udskudte punkt er dermed løst.
Dette afslutter opgave 11b (alle tre dele).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
588888cd9e
commit
cae8657ec8
5 changed files with 260 additions and 15 deletions
53
CLAUDE.md
53
CLAUDE.md
|
|
@ -203,12 +203,11 @@ offentlige side, egen `Jinja2Templates`-instans i
|
||||||
Opgave 11 (React admin-UI) er delt op ligesom opgave 8: **11a**
|
Opgave 11 (React admin-UI) er delt op ligesom opgave 8: **11a**
|
||||||
(scaffold + hosting-mekanisme, ✅). **11b** viste sig under afklaring
|
(scaffold + hosting-mekanisme, ✅). **11b** viste sig under afklaring
|
||||||
at være for stort til én omgang og er selv delt i tre dele, alle
|
at være for stort til én omgang og er selv delt i tre dele, alle
|
||||||
stadig under "11b" (ingen ny bogstav-opdeling): **del 1** (delt
|
stadig under "11b" (ingen ny bogstav-opdeling), **alle tre nu ✅**:
|
||||||
frontend-infrastruktur + Deltagere-CRUD, ✅, se nedenfor), **del 2**
|
**del 1** (delt frontend-infrastruktur + Deltagere-CRUD), **del 2**
|
||||||
(runder + vinliste, ikke bygget) og **del 3** (mail-skabelon-editor +
|
(runder + vinliste), **del 3** (mail-skabelon-editor +
|
||||||
annoncér-knap, ikke bygget — "annoncér" er reelt ubrugelig uden en
|
annoncér-knap). **11c** (ordreoversigt/markér betalt) er ikke bygget
|
||||||
måde at oprette en `round_announced`-skabelon på). **11c**
|
endnu.
|
||||||
(ordreoversigt/markér betalt) er heller ikke bygget endnu.
|
|
||||||
|
|
||||||
**Hosting-beslutning:** Admin-UI'et (`admin-ui/`, React + TypeScript,
|
**Hosting-beslutning:** Admin-UI'et (`admin-ui/`, React + TypeScript,
|
||||||
almindelig CSS — ingen framework) serveres af **samme FastAPI-app**
|
almindelig CSS — ingen framework) serveres af **samme FastAPI-app**
|
||||||
|
|
@ -313,9 +312,34 @@ fandtes allerede (opgave 6):
|
||||||
token). Åbne runder kan aldrig slettes (håndhævet server-side,
|
token). Åbne runder kan aldrig slettes (håndhævet server-side,
|
||||||
ingen UI-vej udenom).
|
ingen UI-vej udenom).
|
||||||
- **Bevidst udeladt** (matcher 8/11-opdelingen): annoncér-knap og
|
- **Bevidst udeladt** (matcher 8/11-opdelingen): annoncér-knap og
|
||||||
mail-skabeloner (del 3); link til opgave 10's afhentningsliste
|
mail-skabeloner (del 3, se nedenfor); link til opgave 10's
|
||||||
(autentificeret HTML-endpoint — kræver en særskilt løsning, da et
|
afhentningsliste (autentificeret HTML-endpoint — kræver en særskilt
|
||||||
almindeligt `<a href>` ikke sender `Authorization`-headeren).
|
løsning, da et almindeligt `<a href>` ikke sender
|
||||||
|
`Authorization`-headeren).
|
||||||
|
|
||||||
|
**11b del 3** (mail-skabeloner + annoncér) afslutter 11b. Ingen
|
||||||
|
backend-ændringer nødvendige — opgave 7a's `MailTemplate`-CRUD og
|
||||||
|
7b's `/announce`-endpoint fandtes allerede, blot uden noget UI:
|
||||||
|
- **`TemplatesPage`** (`/skabeloner`) — én sektion pr. `event_type`
|
||||||
|
(Runde åbnet/Ordre bekræftet/Betaling bekræftet), hver med Emne +
|
||||||
|
Indhold (rå HTML i en `<textarea>`, bevidst ingen rich-text-editor-
|
||||||
|
afhængighed) og et hint om hvilke `{{variabler}}` der er
|
||||||
|
tilgængelige for netop den event-type (genlæst fra
|
||||||
|
`send_order_event_mail`/`announce_purchase_round`). Opret/gem/slet
|
||||||
|
pr. sektion — ingen elevation nødvendig (backend kræver kun
|
||||||
|
`CurrentUser`).
|
||||||
|
- **"Annoncér runde"-knap** på `RoundDetailPage`, vist når
|
||||||
|
`status === 'open'`. Sender rigtig mail til **alle aktive
|
||||||
|
deltagere på ruten** og er ikke idempotent — derfor et eksplicit
|
||||||
|
`window.confirm(...)` først. Viser resultatet
|
||||||
|
(sendt/fejlet/deltagere i alt) direkte på siden.
|
||||||
|
- **Rute 4 har nu rigtige mail-skabeloner for alle tre event-typer**
|
||||||
|
(oprettet af brugeren under browser-test af editoren — det tidligere
|
||||||
|
udskudte punkt er dermed løst som en sideeffekt). "Annoncér" er
|
||||||
|
fortsat kun afprøvet i en fuldstændig isoleret sandbox (en
|
||||||
|
midlertidig separat rute, ikke rute 4) for at undgå at sende en
|
||||||
|
utilsigtet rigtig annoncering til de 308 rigtige deltagere under
|
||||||
|
udvikling.
|
||||||
|
|
||||||
## Mail-events
|
## Mail-events
|
||||||
1. Runde åbnes → mail til alle aktive deltagere på ruten. **Implementeret**
|
1. Runde åbnes → mail til alle aktive deltagere på ruten. **Implementeret**
|
||||||
|
|
@ -349,7 +373,7 @@ logges/advares om i stedet.
|
||||||
|
|
||||||
## Fase 1 — nuværende scope
|
## Fase 1 — nuværende scope
|
||||||
|
|
||||||
**Færdige opgaver (1-10, 11a, 11b del 1-2):**
|
**Færdige opgaver (1-10, 11a, 11b):**
|
||||||
1. ✅ FastAPI + SQLModel + PostgreSQL + Alembic scaffolding
|
1. ✅ FastAPI + SQLModel + PostgreSQL + Alembic scaffolding
|
||||||
2. ✅ Datamodellerne (Organization/Route-hierarki)
|
2. ✅ Datamodellerne (Organization/Route-hierarki)
|
||||||
3. ✅ Deltager-migrering fra MongoDB (308 deltagere importeret,
|
3. ✅ Deltager-migrering fra MongoDB (308 deltagere importeret,
|
||||||
|
|
@ -400,11 +424,10 @@ logges/advares om i stedet.
|
||||||
11. Simpelt React admin-UI til pkt. 5, 6, 7, 9 — delt i tre:
|
11. Simpelt React admin-UI til pkt. 5, 6, 7, 9 — delt i tre:
|
||||||
- **11a**: ✅ Scaffold + domæne-baseret hosting-mekanisme — se
|
- **11a**: ✅ Scaffold + domæne-baseret hosting-mekanisme — se
|
||||||
"Admin-UI-arkitektur" ovenfor.
|
"Admin-UI-arkitektur" ovenfor.
|
||||||
- **11b**: Deltagere, runder/vinliste, annoncering — delt i tre
|
- **11b**: ✅ Deltagere, runder/vinliste, annoncering — delt i tre
|
||||||
(se "Admin-UI-arkitektur" ovenfor): **del 1** ✅
|
(se "Admin-UI-arkitektur" ovenfor), alle færdige: **del 1**
|
||||||
(frontend-infrastruktur + Deltagere-CRUD), **del 2** ✅
|
(frontend-infrastruktur + Deltagere-CRUD), **del 2** (runder +
|
||||||
(runder + vinliste), **del 3** (mail-skabelon-editor +
|
vinliste), **del 3** (mail-skabelon-editor + annoncér-knap).
|
||||||
annoncér-knap, ikke bygget).
|
|
||||||
- **11c**: Ordreoversigt/markér betalt — ikke bygget.
|
- **11c**: Ordreoversigt/markér betalt — ikke bygget.
|
||||||
|
|
||||||
**Resterende opgaver:**
|
**Resterende opgaver:**
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { LoginPage } from './pages/LoginPage'
|
||||||
import { ParticipantsPage } from './pages/ParticipantsPage'
|
import { ParticipantsPage } from './pages/ParticipantsPage'
|
||||||
import { RoundDetailPage } from './pages/RoundDetailPage'
|
import { RoundDetailPage } from './pages/RoundDetailPage'
|
||||||
import { RoundsPage } from './pages/RoundsPage'
|
import { RoundsPage } from './pages/RoundsPage'
|
||||||
|
import { TemplatesPage } from './pages/TemplatesPage'
|
||||||
import './App.css'
|
import './App.css'
|
||||||
|
|
||||||
function AppRoutes() {
|
function AppRoutes() {
|
||||||
|
|
@ -19,6 +20,7 @@ function AppRoutes() {
|
||||||
<Route path="/deltagere" element={<ParticipantsPage />} />
|
<Route path="/deltagere" element={<ParticipantsPage />} />
|
||||||
<Route path="/runder" element={<RoundsPage />} />
|
<Route path="/runder" element={<RoundsPage />} />
|
||||||
<Route path="/runder/:roundId" element={<RoundDetailPage />} />
|
<Route path="/runder/:roundId" element={<RoundDetailPage />} />
|
||||||
|
<Route path="/skabeloner" element={<TemplatesPage />} />
|
||||||
<Route path="*" element={<Navigate to="/deltagere" replace />} />
|
<Route path="*" element={<Navigate to="/deltagere" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ export function Layout() {
|
||||||
<div className="navbar-links">
|
<div className="navbar-links">
|
||||||
<NavLink to="/deltagere">Deltagere</NavLink>
|
<NavLink to="/deltagere">Deltagere</NavLink>
|
||||||
<NavLink to="/runder">Runder</NavLink>
|
<NavLink to="/runder">Runder</NavLink>
|
||||||
|
<NavLink to="/skabeloner">Skabeloner</NavLink>
|
||||||
{currentUser?.is_superadmin &&
|
{currentUser?.is_superadmin &&
|
||||||
(isElevated ? (
|
(isElevated ? (
|
||||||
<span className="elevated-badge" title="Udløber automatisk efter 5 minutter">
|
<span className="elevated-badge" title="Udløber automatisk efter 5 minutter">
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,12 @@ interface NewWineForm {
|
||||||
|
|
||||||
const emptyWineForm: NewWineForm = { name: '', price: '', is_organic: false, category_id: '' }
|
const emptyWineForm: NewWineForm = { name: '', price: '', is_organic: false, category_id: '' }
|
||||||
|
|
||||||
|
interface AnnounceResult {
|
||||||
|
sent: number
|
||||||
|
failed: number
|
||||||
|
total_participants: number
|
||||||
|
}
|
||||||
|
|
||||||
export function RoundDetailPage() {
|
export function RoundDetailPage() {
|
||||||
const { roundId } = useParams<{ roundId: string }>()
|
const { roundId } = useParams<{ roundId: string }>()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
@ -75,6 +81,7 @@ export function RoundDetailPage() {
|
||||||
const [wines, setWines] = useState<WineOffering[]>([])
|
const [wines, setWines] = useState<WineOffering[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [announceResult, setAnnounceResult] = useState<AnnounceResult | null>(null)
|
||||||
const [newWine, setNewWine] = useState<NewWineForm>(emptyWineForm)
|
const [newWine, setNewWine] = useState<NewWineForm>(emptyWineForm)
|
||||||
const [editingWineId, setEditingWineId] = useState<number | null>(null)
|
const [editingWineId, setEditingWineId] = useState<number | null>(null)
|
||||||
const [editWineForm, setEditWineForm] = useState<NewWineForm>(emptyWineForm)
|
const [editWineForm, setEditWineForm] = useState<NewWineForm>(emptyWineForm)
|
||||||
|
|
@ -159,6 +166,24 @@ export function RoundDetailPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleAnnounce() {
|
||||||
|
if (!roundId) return
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
'Send "runde åbnet"-mail til alle aktive deltagere på ruten? Dette kan ikke fortrydes, og sender igen til alle hvis du klikker mere end én gang.',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
setError(null)
|
||||||
|
setAnnounceResult(null)
|
||||||
|
try {
|
||||||
|
const result = await apiFetch<AnnounceResult>(`/purchase-rounds/${roundId}/announce`, { method: 'POST' })
|
||||||
|
setAnnounceResult(result)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : 'Kunne ikke annoncere runden')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleCreateWine(event: FormEvent) {
|
async function handleCreateWine(event: FormEvent) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
if (!roundId) return
|
if (!roundId) return
|
||||||
|
|
@ -296,6 +321,11 @@ export function RoundDetailPage() {
|
||||||
<button type="button" onClick={handleCopy}>
|
<button type="button" onClick={handleCopy}>
|
||||||
Kopiér denne runde
|
Kopiér denne runde
|
||||||
</button>
|
</button>
|
||||||
|
{round.status === 'open' && (
|
||||||
|
<button type="button" onClick={handleAnnounce}>
|
||||||
|
Annoncér runde
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{(round.status === 'draft' || (round.status === 'closed' && currentUser?.is_superadmin && isElevated)) && (
|
{(round.status === 'draft' || (round.status === 'closed' && currentUser?.is_superadmin && isElevated)) && (
|
||||||
<button type="button" onClick={handleDelete}>
|
<button type="button" onClick={handleDelete}>
|
||||||
Slet
|
Slet
|
||||||
|
|
@ -305,6 +335,12 @@ export function RoundDetailPage() {
|
||||||
<p className="hint">Forhøj dine rettigheder (knap øverst) for at kunne slette en lukket runde.</p>
|
<p className="hint">Forhøj dine rettigheder (knap øverst) for at kunne slette en lukket runde.</p>
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
|
{announceResult && (
|
||||||
|
<p className="hint">
|
||||||
|
Sendt: {announceResult.sent}, fejlet: {announceResult.failed}, deltagere i alt:{' '}
|
||||||
|
{announceResult.total_participants}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<h2>Vinliste</h2>
|
<h2>Vinliste</h2>
|
||||||
<form onSubmit={handleCreateWine} className="inline-form">
|
<form onSubmit={handleCreateWine} className="inline-form">
|
||||||
|
|
|
||||||
183
admin-ui/src/pages/TemplatesPage.tsx
Normal file
183
admin-ui/src/pages/TemplatesPage.tsx
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
import { useEffect, useState, type FormEvent } from 'react'
|
||||||
|
import { apiFetch, ApiError } from '../api'
|
||||||
|
|
||||||
|
interface Route {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type EventType = 'round_announced' | 'order_confirmed' | 'payment_confirmed'
|
||||||
|
|
||||||
|
interface MailTemplate {
|
||||||
|
id: number
|
||||||
|
event_type: EventType
|
||||||
|
subject: string
|
||||||
|
body_html: string
|
||||||
|
route_id: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const ORDER_MAIL_VARIABLES = [
|
||||||
|
'participant_name',
|
||||||
|
'route_name',
|
||||||
|
'round_name',
|
||||||
|
'order_number',
|
||||||
|
'order_deadline_at',
|
||||||
|
'pickup_at',
|
||||||
|
'pickup_info_text',
|
||||||
|
'order_lines_html',
|
||||||
|
'order_total_eur',
|
||||||
|
'order_total_dkk',
|
||||||
|
]
|
||||||
|
|
||||||
|
const EVENT_TYPES: { value: EventType; label: string; variables: string[] }[] = [
|
||||||
|
{
|
||||||
|
value: 'round_announced',
|
||||||
|
label: 'Runde åbnet',
|
||||||
|
variables: [
|
||||||
|
'participant_name',
|
||||||
|
'route_name',
|
||||||
|
'round_name',
|
||||||
|
'opens_at',
|
||||||
|
'order_deadline_at',
|
||||||
|
'pickup_at',
|
||||||
|
'intro_text',
|
||||||
|
'pickup_info_text',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ value: 'order_confirmed', label: 'Ordre bekræftet', variables: ORDER_MAIL_VARIABLES },
|
||||||
|
{ value: 'payment_confirmed', label: 'Betaling bekræftet', variables: ORDER_MAIL_VARIABLES },
|
||||||
|
]
|
||||||
|
|
||||||
|
function TemplateSection({
|
||||||
|
eventType,
|
||||||
|
label,
|
||||||
|
variables,
|
||||||
|
template,
|
||||||
|
routeId,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
eventType: EventType
|
||||||
|
label: string
|
||||||
|
variables: string[]
|
||||||
|
template: MailTemplate | undefined
|
||||||
|
routeId: number
|
||||||
|
onChange: () => Promise<void>
|
||||||
|
}) {
|
||||||
|
const [subject, setSubject] = useState(template?.subject ?? '')
|
||||||
|
const [bodyHtml, setBodyHtml] = useState(template?.body_html ?? '')
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSubject(template?.subject ?? '')
|
||||||
|
setBodyHtml(template?.body_html ?? '')
|
||||||
|
}, [template])
|
||||||
|
|
||||||
|
async function handleSave(event: FormEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
if (template) {
|
||||||
|
await apiFetch(`/mail-templates/${template.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ subject, body_html: bodyHtml }),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await apiFetch('/mail-templates', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ event_type: eventType, subject, body_html: bodyHtml, route_id: routeId }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
await onChange()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : 'Kunne ikke gemme skabelonen')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!template) return
|
||||||
|
if (!window.confirm(`Slet skabelonen for "${label}"?`)) return
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await apiFetch(`/mail-templates/${template.id}`, { method: 'DELETE' })
|
||||||
|
await onChange()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : 'Kunne ikke slette skabelonen')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<h2>{label}</h2>
|
||||||
|
<p className="hint">Tilgængelige variabler: {variables.map((v) => `{{${v}}}`).join(', ')}</p>
|
||||||
|
<form onSubmit={handleSave} className="stacked-form">
|
||||||
|
<label>
|
||||||
|
Emne
|
||||||
|
<input value={subject} onChange={(e) => setSubject(e.target.value)} required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Indhold (HTML)
|
||||||
|
<textarea value={bodyHtml} onChange={(e) => setBodyHtml(e.target.value)} rows={8} required />
|
||||||
|
</label>
|
||||||
|
<button type="submit">{template ? 'Gem' : 'Opret'}</button>
|
||||||
|
{template && (
|
||||||
|
<button type="button" onClick={handleDelete}>
|
||||||
|
Slet
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TemplatesPage() {
|
||||||
|
const [routeId, setRouteId] = useState<number | null>(null)
|
||||||
|
const [templates, setTemplates] = useState<MailTemplate[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
async function loadTemplates(forRouteId: number) {
|
||||||
|
const data = await apiFetch<MailTemplate[]>(`/mail-templates?route_id=${forRouteId}`)
|
||||||
|
setTemplates(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
apiFetch<Route[]>('/routes')
|
||||||
|
.then(async (routes) => {
|
||||||
|
if (routes.length === 0) {
|
||||||
|
setError('Ingen ruter fundet for din organisation')
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setRouteId(routes[0].id)
|
||||||
|
await loadTemplates(routes[0].id)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
setError(err instanceof ApiError ? err.message : 'Kunne ikke hente skabeloner')
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (loading) return <p>Indlæser...</p>
|
||||||
|
if (routeId === null) return <p className="error">{error}</p>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Mail-skabeloner</h1>
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
{EVENT_TYPES.map(({ value, label, variables }) => (
|
||||||
|
<TemplateSection
|
||||||
|
key={value}
|
||||||
|
eventType={value}
|
||||||
|
label={label}
|
||||||
|
variables={variables}
|
||||||
|
template={templates.find((t) => t.event_type === value)}
|
||||||
|
routeId={routeId}
|
||||||
|
onChange={() => loadTemplates(routeId)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue