Files
akademiawodociagowca/frontend/src/pages/webinars/ReminderSection.tsx
T
debian 1599c7b0c1
Deploy PROD / deploy (push) Successful in 25s
Potwierdzenia operacji jako okno panelu, nie okno przegladarki
Panel pytal o potwierdzenie natywnym `window.confirm`. Takie okno wyglada
inaczej w kazdej przegladarce i w kazdym systemie, wyskakuje przy gornej
krawedzi zamiast na srodku i pokazuje nad trescia adres domeny ("Komunikat
ze strony ..."). W panelu, ktory poza tym jest spojnym interfejsem, czyta sie
to jak blad strony, a nie jak pytanie aplikacji.

CLAUDE.md wymagalo juz, zeby kazde okno modalne uzywalo komponentu `Modal`,
i tlumaczylo przy otwartym mikrofonie, dlaczego nie `window.confirm`. Regula
obowiazywala w jednym miejscu, a w 42 innych nie.

HOOK, A NIE OKNO W KAZDYM MIEJSCU Z OSOBNA
Potwierdzen jest ponad czterdziesci. Wlasny stan i wlasny `Modal` do kazdego
oznaczalyby kilkanascie linii na jedno pytanie i te sama pewnosc, ze predzej
czy pozniej ktores bedzie wygladalo inaczej. `useConfirm` oddaje funkcje
o ksztalcie zblizonym do `confirm`, wiec zamiana jest jednolinijkowa. Wynik
przychodzi obietnica, bo okno musi sie najpierw wyrenderowac.

TRESC MOWI, CO SIE STANIE
Przy okazji poprawione komunikaty: zamiast "Czy na pewno?" pytania nazywaja
obiekt i skutek. Plik zniknie ze skladu instancji, odbiorca straci dostep do
API, razem z banerem przepadnie jego statystyka wraz z liczbami. Etykieta
przycisku nazywa czynnosc ("Usun nagranie"), a nie mowi "OK".

NIEODWRACALNE WYROZNIONE, ODWRACALNE NIE
`destructive` daje czerwony przycisk i ostrzezenie "Tej operacji nie da sie
cofnac". Odwolanie webinaru i dezaktywacja konta go NIE dostaja: sa
odwracalne, a straszenie tam, gdzie nic nie przepada, rozmydla sygnal tam,
gdzie strata jest prawdziwa. Zgodnie z reguła, ze usuniecie webinaru ma
podpowiadac odwolanie jako lagodniejsza alternatywe.

Sprawdzone `npx tsc -b --force` (pelne typowanie bez cache) i `npm run build`.
Przy okazji poprawione dwa em dash sprzed tej zmiany, w `ApiKeyListPage`
i `ProviderPicker`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 14:13:16 +02:00

209 lines
8.4 KiB
TypeScript

import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { client, errorMessage } from '../../api/client'
import type { NotificationChannel, ReminderRule } from '../../api/types'
import { useConfirm } from '../../components/Confirm'
import { useToasts } from '../../components/Toasts'
import { Badge, Button, Card, ErrorState, Input, Select, Spinner } from '../../components/ui'
import { formatWithZone } from '../../lib/dates'
import { describeOffset, REMINDER_PRESETS as PRESETS } from '../../lib/reminders'
export function ReminderSection({ webinarId, timezone }: { webinarId: number; timezone: string }) {
const queryClient = useQueryClient()
const toasts = useToasts()
const confirm = useConfirm()
const [offset, setOffset] = useState('1440')
const [channel, setChannel] = useState<NotificationChannel>('EMAIL')
const rules = useQuery({
queryKey: ['reminders', webinarId],
queryFn: async () => {
const response = await client.get<ReminderRule[]>(`/admin/webinars/${webinarId}/reminders`)
return response.data
},
})
function invalidate() {
queryClient.invalidateQueries({ queryKey: ['reminders', webinarId] })
}
const add = useMutation({
mutationFn: () =>
client.post(`/admin/webinars/${webinarId}/reminders`, {
offsetMinutes: Number(offset),
channel,
template: 'reminder',
}),
onSuccess: () => {
toasts.success('Reguła została dodana')
invalidate()
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się dodać reguły')),
})
const toggle = useMutation({
mutationFn: ({ id, value }: { id: number; value: boolean }) =>
client.post(`/admin/webinars/${webinarId}/reminders/${id}/enabled?value=${value}`),
onSuccess: () => invalidate(),
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się zmienić reguły')),
})
const remove = useMutation({
mutationFn: (id: number) => client.delete(`/admin/webinars/${webinarId}/reminders/${id}`),
onSuccess: () => {
toasts.success('Reguła została usunięta')
invalidate()
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się usunąć reguły')),
})
return (
<Card
title="Przypomnienia"
description="Wiadomości wysyłane automatycznie przed startem webinaru"
>
{rules.isPending && <Spinner />}
{rules.isError && (
<ErrorState message={errorMessage(rules.error, 'Nie udało się wczytać reguł')} />
)}
{rules.data && (
<div className="space-y-4">
{rules.data.length === 0 ? (
<p className="rounded-md border border-slate-200 bg-slate-50 p-3 text-sm text-slate-500">
Brak reguł. Ten webinar nie wyśle żadnych przypomnień.
</p>
) : (
<ul className="divide-y divide-slate-100 rounded-md border border-slate-200">
{rules.data.map((rule) => (
<li key={rule.id} className="flex flex-wrap items-center gap-3 p-3">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={rule.enabled}
onChange={(e) => toggle.mutate({ id: rule.id, value: e.target.checked })}
className="h-4 w-4 rounded border-slate-200 text-blue-700 focus:ring-blue-600"
aria-label={rule.enabled ? 'Wyłącz regułę' : 'Włącz regułę'}
/>
</label>
<div className="min-w-0 flex-1">
<p className={`text-sm font-medium ${rule.enabled ? 'text-slate-900' : 'text-slate-400'}`}>
{describeOffset(rule.offsetMinutes)}
<span className="ml-2 text-xs font-normal text-slate-500">
{rule.channel === 'SMS' ? 'SMS' : 'e-mail'}
</span>
</p>
<p className="text-xs text-slate-500">
{formatWithZone(rule.scheduledAt, timezone)}
</p>
</div>
{/* liczniki pokazuja, co juz poszlo z tej reguly */}
<div className="flex shrink-0 gap-1.5">
{rule.sentCount > 0 && (
<Badge className="bg-ok-50 text-ok-600 ring-ok-500/20">
wysłano {rule.sentCount}
</Badge>
)}
{rule.queuedCount > 0 && (
<Badge className="bg-warn-50 text-warn-600 ring-warn-500/25">
w kolejce {rule.queuedCount}
</Badge>
)}
{rule.failedCount > 0 && (
<Badge className="bg-alarm-50 text-alarm-600 ring-alarm-500/20">
błędy {rule.failedCount}
</Badge>
)}
</div>
<Button
type="button"
variant="ghost"
icon="trash"
onClick={async () => {
const ok = await confirm({
title: 'Usunąć regułę przypomnienia?',
message: (
<>
Przypomnienie <strong>{describeOffset(rule.offsetMinutes)}</strong>{' '}
przestanie być wysyłane uczestnikom tego webinaru.
{rule.alreadySent && (
<>
{' '}
Z tej reguły wysłano już wiadomości, więc historia wysyłek
zostanie usunięta razem z nią.
</>
)}
</>
),
confirmLabel: 'Usuń regułę',
destructive: true,
})
if (ok) remove.mutate(rule.id)
}}
className="shrink-0 text-alarm-600 hover:bg-alarm-50"
title="Usuń regułę"
>
<span className="sr-only">Usuń</span>
</Button>
</li>
))}
</ul>
)}
<div className="flex flex-wrap items-end gap-2 border-t border-slate-100 pt-4">
<div className="w-44">
<label className="mb-1 block text-xs font-semibold text-slate-700">Kiedy wysłać</label>
<Select value={offset} onChange={(e) => setOffset(e.target.value)}>
{PRESETS.map((preset) => (
<option key={preset.minutes} value={preset.minutes}>
{preset.label}
</option>
))}
</Select>
</div>
<div className="w-28">
<label className="mb-1 block text-xs font-semibold text-slate-700">Kanał</label>
<Select
value={channel}
onChange={(e) => setChannel(e.target.value as NotificationChannel)}
>
<option value="EMAIL">E-mail</option>
<option value="SMS">SMS</option>
</Select>
</div>
<div className="w-32">
<label className="mb-1 block text-xs font-semibold text-slate-700">Własny odstęp</label>
<Input
type="number"
min={1}
value={offset}
onChange={(e) => setOffset(e.target.value)}
placeholder="minuty"
/>
</div>
<Button
type="button"
icon="plus"
variant="secondary"
onClick={() => add.mutate()}
disabled={add.isPending || !offset || Number(offset) < 1}
>
Dodaj
</Button>
</div>
{rules.data.some((rule) => rule.channel === 'SMS') && (
<p className="rounded-md border border-blue-200 bg-blue-50 p-3 text-xs leading-relaxed text-blue-900">
SMS trafi wyłącznie do uczestników, którzy podali numer telefonu
i wyrazili zgodę marketingową na SMS.
</p>
)}
</div>
)}
</Card>
)
}