Initial commit

This commit is contained in:
2026-09-02 08:21:27 +02:00
commit 84eec0039e
609 changed files with 94880 additions and 0 deletions
@@ -0,0 +1,193 @@
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 { 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 [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={() => {
const extra = rule.alreadySent
? '\n\nZ tej reguły wysłano już wiadomości. Historia wysyłek zostanie usunięta.'
: ''
if (window.confirm(`Usunąć regułę "${describeOffset(rule.offsetMinutes)}"?${extra}`)) {
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>
)
}