Potwierdzenia operacji jako okno panelu, nie okno przegladarki
Deploy PROD / deploy (push) Successful in 25s
Deploy PROD / deploy (push) Successful in 25s
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>
This commit is contained in:
@@ -14,6 +14,7 @@ import {
|
||||
Input,
|
||||
Spinner,
|
||||
} from '../components/ui'
|
||||
import { useConfirm } from '../components/Confirm'
|
||||
import { Icon } from '../components/Icon'
|
||||
import { BrandMark } from '../components/BrandMark'
|
||||
import { formatWithZone } from '../lib/dates'
|
||||
@@ -428,6 +429,7 @@ function CertificatesTab({ items }: { items: StudentDashboard['certificates'] })
|
||||
|
||||
|
||||
function ProfileTab({ data }: { data: StudentDashboard }) {
|
||||
const confirm = useConfirm()
|
||||
const queryClient = useQueryClient()
|
||||
const { refresh } = useStudentAuth()
|
||||
const [form, setForm] = useState({
|
||||
@@ -559,10 +561,14 @@ function ProfileTab({ data }: { data: StudentDashboard }) {
|
||||
variant="secondary"
|
||||
icon="user-off"
|
||||
disabled={unsubscribe.isPending}
|
||||
onClick={() => {
|
||||
if (window.confirm('Czy na pewno chcesz wypisać się z komunikacji marketingowej?')) {
|
||||
unsubscribe.mutate()
|
||||
}
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Wypisać się z komunikacji marketingowej?',
|
||||
message:
|
||||
'Przestaniesz dostawać zaproszenia na nowe szkolenia. Powiadomienia o kursach i webinarach, na które jesteś już zapisany, przychodzą dalej.',
|
||||
confirmLabel: 'Wypisz się',
|
||||
})
|
||||
if (ok) unsubscribe.mutate()
|
||||
}}
|
||||
>
|
||||
{unsubscribe.isPending ? 'Zapisywanie...' : 'Wypisz się z marketingu'}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { createContext, useCallback, useContext, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { Icon } from './Icon'
|
||||
import { Modal } from './Modal'
|
||||
import { Button } from './ui'
|
||||
|
||||
/**
|
||||
* Potwierdzenie operacji, jako okno panelu.
|
||||
*
|
||||
* DLACZEGO NIE `window.confirm`
|
||||
* ------------------------------------------------------------------------
|
||||
* Natywne okno przegladarki 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, wyglada to jak blad strony, a nie jak pytanie
|
||||
* aplikacji. Nie da sie tam takze wyroznic operacji nieodwracalnej ani
|
||||
* pokazac, co konkretnie przepadnie.
|
||||
*
|
||||
* DLACZEGO HOOK, A NIE OKNO W KAZDYM MIEJSCU Z OSOBNA
|
||||
* ------------------------------------------------------------------------
|
||||
* Potwierdzen jest w panelu ponad czterdziesci. Budowanie do kazdego wlasnego
|
||||
* stanu i wlasnego `Modal` oznaczaloby kilkanascie linii na jedno pytanie
|
||||
* i te sama pewnosc, ze predzej czy pozniej ktores bedzie wygladalo inaczej.
|
||||
* Hook oddaje funkcje o tym samym ksztalcie co `confirm`, wiec zamiana jest
|
||||
* jednolinijkowa:
|
||||
*
|
||||
* if (await confirm({ title: 'Usunac plik?' })) remove.mutate(id)
|
||||
*
|
||||
* OBIETNICA ZAMIAST WARTOSCI
|
||||
* ------------------------------------------------------------------------
|
||||
* `window.confirm` zatrzymuje watek i oddaje odpowiedz od razu; tutaj okno
|
||||
* musi sie wyrenderowac, wiec wynik przychodzi obietnica. Referencja na
|
||||
* `resolve` trzymana jest poza stanem Reacta: to nie jest dana do wyswietlenia,
|
||||
* a jej zmiana nie ma przerysowywac okna.
|
||||
*/
|
||||
|
||||
type ConfirmOptions = {
|
||||
title: string
|
||||
/** Zdanie pod tytulem. Warto powiedziec, co dokladnie przepadnie. */
|
||||
message?: ReactNode
|
||||
/** Etykieta przycisku potwierdzenia. Nazwij czynnosc, nie mow "OK". */
|
||||
confirmLabel?: string
|
||||
cancelLabel?: string
|
||||
/**
|
||||
* Operacja nieodwracalna: przycisk jest czerwony, a nad trescia pojawia sie
|
||||
* ostrzezenie. Uzywac tam, gdzie po kliknieciu nie da sie nic odzyskac.
|
||||
*/
|
||||
destructive?: boolean
|
||||
}
|
||||
|
||||
type ConfirmApi = (options: ConfirmOptions) => Promise<boolean>
|
||||
|
||||
const ConfirmContext = createContext<ConfirmApi | null>(null)
|
||||
|
||||
export function ConfirmProvider({ children }: { children: ReactNode }) {
|
||||
const [pending, setPending] = useState<ConfirmOptions | null>(null)
|
||||
const resolveRef = useRef<((value: boolean) => void) | null>(null)
|
||||
|
||||
const confirm = useCallback<ConfirmApi>((options) => {
|
||||
setPending(options)
|
||||
return new Promise<boolean>((resolve) => {
|
||||
resolveRef.current = resolve
|
||||
})
|
||||
}, [])
|
||||
|
||||
const settle = useCallback((value: boolean) => {
|
||||
// najpierw odpowiedz, potem zamkniecie: kolejnosc odwrotna zostawialaby
|
||||
// obietnice nierozstrzygnieta, gdyby renderowanie sie przerwalo
|
||||
resolveRef.current?.(value)
|
||||
resolveRef.current = null
|
||||
setPending(null)
|
||||
}, [])
|
||||
|
||||
const api = useMemo(() => confirm, [confirm])
|
||||
|
||||
return (
|
||||
<ConfirmContext.Provider value={api}>
|
||||
{children}
|
||||
|
||||
{pending && (
|
||||
<Modal
|
||||
title={pending.title}
|
||||
onClose={() => settle(false)}
|
||||
footer={
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="secondary" onClick={() => settle(false)}>
|
||||
{pending.cancelLabel ?? 'Anuluj'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={pending.destructive ? 'danger' : 'primary'}
|
||||
onClick={() => settle(true)}
|
||||
>
|
||||
{pending.confirmLabel ?? 'Potwierdź'}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{pending.destructive && (
|
||||
<div className="flex items-start gap-3 rounded-md border border-alarm-500/30 bg-alarm-50 p-3">
|
||||
<Icon name="alert-triangle" size={20} className="mt-0.5 shrink-0 text-alarm-600" />
|
||||
<p className="text-sm leading-relaxed text-alarm-600">
|
||||
Tej operacji nie da się cofnąć.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{pending.message && (
|
||||
<div className="text-sm leading-relaxed text-slate-600">{pending.message}</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</ConfirmContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useConfirm(): ConfirmApi {
|
||||
const api = useContext(ConfirmContext)
|
||||
if (!api) {
|
||||
throw new Error('useConfirm musi byc uzyte wewnatrz ConfirmProvider')
|
||||
}
|
||||
return api
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useRef, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { client, errorMessage } from '../api/client'
|
||||
import type { MediaFile, Page } from '../api/types'
|
||||
import { useConfirm } from './Confirm'
|
||||
import { Icon } from './Icon'
|
||||
import { useToasts } from './Toasts'
|
||||
import { Button, ErrorState, Spinner } from './ui'
|
||||
@@ -25,6 +26,7 @@ export function MediaPicker({
|
||||
onClose: () => void
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
const confirm = useConfirm()
|
||||
const toasts = useToasts()
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
@@ -137,10 +139,19 @@ export function MediaPicker({
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Usuń plik ${file.originalName}`}
|
||||
onClick={() => {
|
||||
if (window.confirm(`Usunąć plik "${file.originalName}"?`)) {
|
||||
remove.mutate(file.id)
|
||||
}
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Usunąć plik?',
|
||||
message: (
|
||||
<>
|
||||
Plik <strong>{file.originalName}</strong> zniknie z biblioteki
|
||||
mediów. Miejsca, w których jest już osadzony, przestaną go pokazywać.
|
||||
</>
|
||||
),
|
||||
confirmLabel: 'Usuń plik',
|
||||
destructive: true,
|
||||
})
|
||||
if (ok) remove.mutate(file.id)
|
||||
}}
|
||||
className="absolute right-1 top-1 rounded-md bg-white/90 p-1 text-alarm-600 opacity-0 ring-1 ring-slate-200 transition-opacity duration-fast group-hover:opacity-100 focus:opacity-100"
|
||||
>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { BrowserRouter } from 'react-router-dom'
|
||||
import { App } from './App'
|
||||
import { AuthProvider } from './auth/AuthContext'
|
||||
import { StudentAuthProvider } from './academy/StudentAuthContext'
|
||||
import { ConfirmProvider } from './components/Confirm'
|
||||
import { ToastProvider } from './components/Toasts'
|
||||
import './index.css'
|
||||
|
||||
@@ -23,6 +24,12 @@ createRoot(document.getElementById('root')!).render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<ToastProvider>
|
||||
{/*
|
||||
Potwierdzenia operacji jako okno panelu, nie `window.confirm`:
|
||||
natywne okno wyglada inaczej w kazdej przegladarce, wyskakuje przy
|
||||
gornej krawedzi i pokazuje nad trescia adres domeny.
|
||||
*/}
|
||||
<ConfirmProvider>
|
||||
{/*
|
||||
Dwie sesje obok siebie: panel i Akademia. Kazda z wlasnym tokenem
|
||||
i wlasnym kluczem w localStorage, wiec zalogowanie sie do jednej
|
||||
@@ -33,6 +40,7 @@ createRoot(document.getElementById('root')!).render(
|
||||
<App />
|
||||
</StudentAuthProvider>
|
||||
</AuthProvider>
|
||||
</ConfirmProvider>
|
||||
</ToastProvider>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { client, errorMessage, fieldErrors } from '../../api/client'
|
||||
import { useConfirm } from '../../components/Confirm'
|
||||
import { useToasts } from '../../components/Toasts'
|
||||
import {
|
||||
Alert,
|
||||
@@ -42,6 +43,7 @@ type ApiKeyCreated = {
|
||||
*/
|
||||
export function ApiKeyListPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const confirm = useConfirm()
|
||||
const toasts = useToasts()
|
||||
|
||||
const [creating, setCreating] = useState(false)
|
||||
@@ -159,7 +161,7 @@ export function ApiKeyListPage() {
|
||||
{' '}Klucz jest pokazywany tylko raz przy utworzeniu i nie da się go później
|
||||
odczytać. Gdy zostanie zgubiony, użyj{' '}
|
||||
<span className="whitespace-nowrap">„wygeneruj ponownie”</span>
|
||||
{' '}(ikona odświeżania) — powstaje nowy klucz, a stary przestaje działać.
|
||||
{' '}(ikona odświeżania) - powstaje nowy klucz, a stary przestaje działać.
|
||||
</Alert>
|
||||
|
||||
{/* Surowy klucz po utworzeniu: pokazany jeden raz. */}
|
||||
@@ -261,14 +263,19 @@ export function ApiKeyListPage() {
|
||||
<IconButton
|
||||
icon="refresh"
|
||||
label="Wygeneruj ponownie"
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
`Wygenerować nowy klucz dla „${key.name}”? Dotychczasowy przestanie działać.`,
|
||||
)
|
||||
) {
|
||||
regenerate.mutate(key.id)
|
||||
}
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Wygenerować nowy klucz?',
|
||||
message: (
|
||||
<>
|
||||
Dotychczasowy klucz <strong>{key.name}</strong> przestanie działać
|
||||
natychmiast. Odbiorca straci dostęp, dopóki nie dostanie nowego.
|
||||
</>
|
||||
),
|
||||
confirmLabel: 'Wygeneruj nowy',
|
||||
destructive: true,
|
||||
})
|
||||
if (ok) regenerate.mutate(key.id)
|
||||
}}
|
||||
disabled={regenerate.isPending}
|
||||
/>
|
||||
@@ -281,10 +288,19 @@ export function ApiKeyListPage() {
|
||||
<IconButton
|
||||
icon="trash"
|
||||
label="Usuń"
|
||||
onClick={() => {
|
||||
if (window.confirm(`Usunąć klucz „${key.name}”? Odbiorca straci dostęp.`)) {
|
||||
remove.mutate(key.id)
|
||||
}
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Usunąć klucz?',
|
||||
message: (
|
||||
<>
|
||||
Klucz <strong>{key.name}</strong> zniknie z panelu, a odbiorca
|
||||
straci dostęp do publicznego API.
|
||||
</>
|
||||
),
|
||||
confirmLabel: 'Usuń klucz',
|
||||
destructive: true,
|
||||
})
|
||||
if (ok) remove.mutate(key.id)
|
||||
}}
|
||||
disabled={remove.isPending}
|
||||
className="text-alarm-600 hover:bg-alarm-50"
|
||||
@@ -329,14 +345,19 @@ export function ApiKeyListPage() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="refresh"
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
`Wygenerować nowy klucz dla „${key.name}”? Dotychczasowy przestanie działać.`,
|
||||
)
|
||||
) {
|
||||
regenerate.mutate(key.id)
|
||||
}
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Wygenerować nowy klucz?',
|
||||
message: (
|
||||
<>
|
||||
Dotychczasowy klucz <strong>{key.name}</strong> przestanie działać
|
||||
natychmiast. Odbiorca straci dostęp, dopóki nie dostanie nowego.
|
||||
</>
|
||||
),
|
||||
confirmLabel: 'Wygeneruj nowy',
|
||||
destructive: true,
|
||||
})
|
||||
if (ok) regenerate.mutate(key.id)
|
||||
}}
|
||||
disabled={regenerate.isPending}
|
||||
title="Wygeneruj ponownie"
|
||||
@@ -355,10 +376,19 @@ export function ApiKeyListPage() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="trash"
|
||||
onClick={() => {
|
||||
if (window.confirm(`Usunąć klucz „${key.name}”? Odbiorca straci dostęp.`)) {
|
||||
remove.mutate(key.id)
|
||||
}
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Usunąć klucz?',
|
||||
message: (
|
||||
<>
|
||||
Klucz <strong>{key.name}</strong> zniknie z panelu, a odbiorca
|
||||
straci dostęp do publicznego API.
|
||||
</>
|
||||
),
|
||||
confirmLabel: 'Usuń klucz',
|
||||
destructive: true,
|
||||
})
|
||||
if (ok) remove.mutate(key.id)
|
||||
}}
|
||||
disabled={remove.isPending}
|
||||
title="Usuń"
|
||||
|
||||
@@ -7,6 +7,7 @@ import { sanitizeHtmlPreview } from '../../lib/sanitizeHtmlPreview'
|
||||
import { Icon } from '../../components/Icon'
|
||||
import { MediaField } from '../../components/MediaField'
|
||||
import { Modal } from '../../components/Modal'
|
||||
import { useConfirm } from '../../components/Confirm'
|
||||
import { useToasts } from '../../components/Toasts'
|
||||
import { Button, Field, Input, Select } from '../../components/ui'
|
||||
|
||||
@@ -50,6 +51,7 @@ export function BannerFormDialog({
|
||||
onClose: () => void
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
const confirm = useConfirm()
|
||||
const toasts = useToasts()
|
||||
const isEdit = Boolean(banner)
|
||||
|
||||
@@ -308,16 +310,19 @@ export function BannerFormDialog({
|
||||
variant="ghost"
|
||||
icon="trash"
|
||||
disabled={remove.isPending}
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
`Usunąć baner "${banner!.title}"?\n\n` +
|
||||
`Przepadnie także jego statystyka: ${banner!.impressions} wyświetleń ` +
|
||||
`i ${banner!.clicks} kliknięć. Tej operacji nie da się cofnąć.`,
|
||||
)
|
||||
) {
|
||||
remove.mutate()
|
||||
}
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Usunąć baner?',
|
||||
message: (
|
||||
<>
|
||||
Baner <strong>{banner!.title}</strong> zniknie z sali, a razem z nim jego
|
||||
statystyka: {banner!.impressions} wyświetleń i {banner!.clicks} kliknięć.
|
||||
</>
|
||||
),
|
||||
confirmLabel: 'Usuń baner',
|
||||
destructive: true,
|
||||
})
|
||||
if (ok) remove.mutate()
|
||||
}}
|
||||
>
|
||||
Usuń
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { client, errorMessage, fieldErrors } from '../../api/client'
|
||||
import type { Contact, ContactGroup, ContactRequest } from '../../api/types'
|
||||
import { useConfirm } from '../../components/Confirm'
|
||||
import { useToasts } from '../../components/Toasts'
|
||||
import { Button, Field, Input, Textarea } from '../../components/ui'
|
||||
import { Modal } from '../../components/Modal'
|
||||
@@ -17,6 +18,7 @@ export function ContactFormDialog({
|
||||
onClose: () => void
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
const confirm = useConfirm()
|
||||
const toasts = useToasts()
|
||||
const isEdit = contact !== null
|
||||
|
||||
@@ -206,15 +208,19 @@ export function ContactFormDialog({
|
||||
variant="ghost"
|
||||
icon="trash"
|
||||
disabled={remove.isPending}
|
||||
onClick={() => {
|
||||
if (
|
||||
confirm(
|
||||
`Usunąć kontakt ${contact!.email} wraz z historią zgód?
|
||||
|
||||
` +
|
||||
'Tej operacji nie da się cofnąć.',
|
||||
)
|
||||
) {
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Usunąć kontakt?',
|
||||
message: (
|
||||
<>
|
||||
Kontakt <strong>{contact!.email}</strong> zniknie z bazy razem z całą
|
||||
historią zgód, która dokumentuje podstawę wysyłki.
|
||||
</>
|
||||
),
|
||||
confirmLabel: 'Usuń kontakt',
|
||||
destructive: true,
|
||||
})
|
||||
if (ok) {
|
||||
remove.mutate()
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { client, errorMessage } from '../../api/client'
|
||||
import type { ContactGroup } from '../../api/types'
|
||||
import { useConfirm } from '../../components/Confirm'
|
||||
import { useToasts } from '../../components/Toasts'
|
||||
import { Button, Input } from '../../components/ui'
|
||||
import { Modal } from '../../components/Modal'
|
||||
@@ -15,6 +16,7 @@ export function GroupsDialog({
|
||||
onClose: () => void
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
const confirm = useConfirm()
|
||||
const toasts = useToasts()
|
||||
const [name, setName] = useState('')
|
||||
|
||||
@@ -82,11 +84,20 @@ export function GroupsDialog({
|
||||
variant="ghost"
|
||||
icon="trash"
|
||||
disabled={remove.isPending}
|
||||
onClick={() => {
|
||||
onClick={async () => {
|
||||
// grupa znika, kontakty zostaja: to tylko etykieta
|
||||
if (confirm(`Usunąć grupę "${group.name}"? Kontakty zostaną w bazie.`)) {
|
||||
remove.mutate(group.id)
|
||||
}
|
||||
const ok = await confirm({
|
||||
title: 'Usunąć grupę?',
|
||||
message: (
|
||||
<>
|
||||
Grupa <strong>{group.name}</strong> zniknie z listy, ale należące do
|
||||
niej kontakty zostaną w bazie.
|
||||
</>
|
||||
),
|
||||
confirmLabel: 'Usuń grupę',
|
||||
destructive: true,
|
||||
})
|
||||
if (ok) remove.mutate(group.id)
|
||||
}}
|
||||
>
|
||||
Usuń
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
Spinner,
|
||||
Textarea,
|
||||
} from '../../components/ui'
|
||||
import { useConfirm } from '../../components/Confirm'
|
||||
import { useToasts } from '../../components/Toasts'
|
||||
import { formatLocal } from '../../lib/dates'
|
||||
import { usePublicUrl } from '../../lib/usePublicConfig'
|
||||
@@ -458,6 +459,8 @@ function RowActions({
|
||||
onRevoke: () => void
|
||||
busy: boolean
|
||||
}) {
|
||||
const confirm = useConfirm()
|
||||
|
||||
return (
|
||||
<>
|
||||
<a
|
||||
@@ -473,15 +476,18 @@ function RowActions({
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
`Wysłać certyfikat ${row.number} ponownie na adres ${row.email}? ` +
|
||||
'Wyślemy dokładnie ten plik, który został wydany.',
|
||||
)
|
||||
) {
|
||||
onResend()
|
||||
}
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Wysłać certyfikat ponownie?',
|
||||
message: (
|
||||
<>
|
||||
Certyfikat <strong>{row.number}</strong> trafi na adres{' '}
|
||||
<strong>{row.email}</strong>. Wyślemy dokładnie ten plik, który został wydany.
|
||||
</>
|
||||
),
|
||||
confirmLabel: 'Wyślij ponownie',
|
||||
})
|
||||
if (ok) onResend()
|
||||
}}
|
||||
disabled={busy}
|
||||
className="text-sm text-slate-600 hover:text-slate-900 disabled:opacity-50"
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Spinner,
|
||||
Textarea,
|
||||
} from '../../components/ui'
|
||||
import { useConfirm } from '../../components/Confirm'
|
||||
import { useToasts } from '../../components/Toasts'
|
||||
import { formatLocal } from '../../lib/dates'
|
||||
import { usePublicUrl } from '../../lib/usePublicConfig'
|
||||
@@ -34,6 +35,7 @@ import { usePublicUrl } from '../../lib/usePublicConfig'
|
||||
export function CourseCertificateSection({ course }: { course: Course }) {
|
||||
const queryClient = useQueryClient()
|
||||
const toasts = useToasts()
|
||||
const confirm = useConfirm()
|
||||
const publicUrl = usePublicUrl()
|
||||
|
||||
const [enabled, setEnabled] = useState(course.certificateEnabled)
|
||||
@@ -187,15 +189,18 @@ export function CourseCertificateSection({ course }: { course: Course }) {
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
<Button
|
||||
icon="award"
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
`Wydać certyfikaty wszystkim, którzy ukończyli kurs "${course.title}"? ` +
|
||||
`Dotyczy ${completedPending.length} osób.`,
|
||||
)
|
||||
) {
|
||||
issueAll.mutate({ force: false })
|
||||
}
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Wydać certyfikaty tym, którzy ukończyli kurs?',
|
||||
message: (
|
||||
<>
|
||||
Dokument dostaną osoby, które ukończyły kurs{' '}
|
||||
<strong>{course.title}</strong>. Dotyczy {completedPending.length} osób.
|
||||
</>
|
||||
),
|
||||
confirmLabel: 'Wydaj certyfikaty',
|
||||
})
|
||||
if (ok) issueAll.mutate({ force: false })
|
||||
}}
|
||||
disabled={issueAll.isPending || completedPending.length === 0}
|
||||
>
|
||||
@@ -313,17 +318,20 @@ export function CourseCertificateSection({ course }: { course: Course }) {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
`Unieważnić certyfikat ${certificate.number} ` +
|
||||
`wydany dla ${certificate.participantName}? ` +
|
||||
'Dokument zostanie oznaczony jako nieważny przy weryfikacji publicznej, ' +
|
||||
'ale nie zniknie.',
|
||||
)
|
||||
) {
|
||||
revoke.mutate(certificate.id)
|
||||
}
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Unieważnić certyfikat?',
|
||||
message: (
|
||||
<>
|
||||
Certyfikat <strong>{certificate.number}</strong> wydany dla{' '}
|
||||
<strong>{certificate.participantName}</strong> zostanie oznaczony jako
|
||||
nieważny przy weryfikacji publicznej, ale nie zniknie.
|
||||
</>
|
||||
),
|
||||
confirmLabel: 'Unieważnij certyfikat',
|
||||
destructive: true,
|
||||
})
|
||||
if (ok) revoke.mutate(certificate.id)
|
||||
}}
|
||||
disabled={revoke.isPending}
|
||||
>
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Icon } from '../../components/Icon'
|
||||
import { PAGE_SIZE, Pagination } from '../../components/Pagination'
|
||||
import { SortableHeader } from '../../components/SortableHeader'
|
||||
import type { SortState } from '../../components/SortableHeader'
|
||||
import { useConfirm } from '../../components/Confirm'
|
||||
import { useToasts } from '../../components/Toasts'
|
||||
import { formatLocal } from '../../lib/dates'
|
||||
import { COURSE_STATUS_LABEL, COURSE_STATUS_TONE, formatPrice } from './courseLabels'
|
||||
@@ -315,6 +316,8 @@ function RowActions({
|
||||
onArchive: () => void
|
||||
onCopy: () => void
|
||||
}) {
|
||||
const confirm = useConfirm()
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconLink to={panelPath(`/kursy/${course.id}/edycja`)} icon="pencil" label="Edytuj kurs" />
|
||||
@@ -323,10 +326,17 @@ function RowActions({
|
||||
<ActionButton
|
||||
icon="upload"
|
||||
label="Publikuj kurs"
|
||||
onClick={() => {
|
||||
if (window.confirm(`Opublikować kurs „${course.title}”?`)) {
|
||||
onPublish()
|
||||
}
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Opublikować kurs?',
|
||||
message: (
|
||||
<>
|
||||
Kurs <strong>{course.title}</strong> pojawi się w ofercie Akademii.
|
||||
</>
|
||||
),
|
||||
confirmLabel: 'Opublikuj kurs',
|
||||
})
|
||||
if (ok) onPublish()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -335,15 +345,18 @@ function RowActions({
|
||||
<ActionButton
|
||||
icon="history"
|
||||
label="Archiwizuj kurs"
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
`Zarchiwizować kurs „${course.title}”?\n\n` +
|
||||
'Kurs zniknie z oferty, ale osoby już zapisane zachowają dostęp.',
|
||||
)
|
||||
) {
|
||||
onArchive()
|
||||
}
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Zarchiwizować kurs?',
|
||||
message: (
|
||||
<>
|
||||
Kurs <strong>{course.title}</strong> zniknie z oferty, ale osoby już zapisane
|
||||
zachowają dostęp.
|
||||
</>
|
||||
),
|
||||
confirmLabel: 'Zarchiwizuj kurs',
|
||||
})
|
||||
if (ok) onArchive()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -351,16 +364,18 @@ function RowActions({
|
||||
<ActionButton
|
||||
icon="copy"
|
||||
label="Kopiuj kurs"
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
`Skopiować kurs „${course.title}”?\n\n` +
|
||||
'Kopia przejmie program, opis i ustawienia certyfikatu.\n' +
|
||||
'Nie przejdą zapisy kursantów ani ich postęp.',
|
||||
)
|
||||
) {
|
||||
onCopy()
|
||||
}
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Skopiować kurs?',
|
||||
message: (
|
||||
<>
|
||||
Kopia kursu <strong>{course.title}</strong> przejmie program, opis i ustawienia
|
||||
certyfikatu. Nie przejdą zapisy kursantów ani ich postęp.
|
||||
</>
|
||||
),
|
||||
confirmLabel: 'Skopiuj kurs',
|
||||
})
|
||||
if (ok) onCopy()
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Spinner,
|
||||
} from '../../components/ui'
|
||||
import { Icon } from '../../components/Icon'
|
||||
import { useConfirm } from '../../components/Confirm'
|
||||
import { useToasts } from '../../components/Toasts'
|
||||
import { LESSON_TYPE_ICON, LESSON_TYPE_LABEL, formatDuration } from './courseLabels'
|
||||
import { LessonEditor } from './LessonEditor'
|
||||
@@ -36,6 +37,7 @@ import { LessonEditor } from './LessonEditor'
|
||||
export function CurriculumSection({ courseId }: { courseId: number }) {
|
||||
const queryClient = useQueryClient()
|
||||
const toasts = useToasts()
|
||||
const confirm = useConfirm()
|
||||
|
||||
const [newModuleTitle, setNewModuleTitle] = useState('')
|
||||
const [editingLesson, setEditingLesson] = useState<{ lesson: Lesson | null; moduleId: number } | null>(null)
|
||||
@@ -210,15 +212,19 @@ export function CurriculumSection({ courseId }: { courseId: number }) {
|
||||
<IconAction
|
||||
icon="trash"
|
||||
label={`Usuń moduł ${module.title}`}
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
`Usunąć moduł „${module.title}”?\n\n` +
|
||||
`Przepadnie razem z lekcjami: ${module.lessons.length}.`,
|
||||
)
|
||||
) {
|
||||
deleteModule.mutate(module.id)
|
||||
}
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Usunąć moduł?',
|
||||
message: (
|
||||
<>
|
||||
Moduł <strong>{module.title}</strong> przepadnie razem z lekcjami:{' '}
|
||||
{module.lessons.length}.
|
||||
</>
|
||||
),
|
||||
confirmLabel: 'Usuń moduł',
|
||||
destructive: true,
|
||||
})
|
||||
if (ok) deleteModule.mutate(module.id)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -272,10 +278,18 @@ export function CurriculumSection({ courseId }: { courseId: number }) {
|
||||
<IconAction
|
||||
icon="trash"
|
||||
label={`Usuń lekcję ${lesson.title}`}
|
||||
onClick={() => {
|
||||
if (window.confirm(`Usunąć lekcję „${lesson.title}”?`)) {
|
||||
deleteLesson.mutate(lesson.id)
|
||||
}
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Usunąć lekcję?',
|
||||
message: (
|
||||
<>
|
||||
Lekcja <strong>{lesson.title}</strong> zniknie z programu kursu.
|
||||
</>
|
||||
),
|
||||
confirmLabel: 'Usuń lekcję',
|
||||
destructive: true,
|
||||
})
|
||||
if (ok) deleteLesson.mutate(lesson.id)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from '../../components/ui'
|
||||
import { Icon } from '../../components/Icon'
|
||||
import { Modal } from '../../components/Modal'
|
||||
import { useConfirm } from '../../components/Confirm'
|
||||
import { useToasts } from '../../components/Toasts'
|
||||
|
||||
type Tab = 'pytania' | 'ustawienia' | 'statystyki' | 'podglad'
|
||||
@@ -132,6 +133,7 @@ function QuestionsTab({
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
const toasts = useToasts()
|
||||
const confirm = useConfirm()
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const [editing, setEditing] = useState<{ id: number | null; draft: QuestionDraft } | null>(null)
|
||||
@@ -335,8 +337,25 @@ function QuestionsTab({
|
||||
<IconAction
|
||||
icon="trash"
|
||||
label={`Usuń pytanie ${index + 1}`}
|
||||
onClick={() => {
|
||||
if (window.confirm('Usunąć to pytanie?')) remove.mutate(question.id)
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Usunąć pytanie?',
|
||||
message: (
|
||||
<>
|
||||
{/* tresc pytania bywa dluga: w oknie pokazujemy poczatek */}
|
||||
Pytanie {index + 1}{' '}
|
||||
<strong>
|
||||
{question.content.length > 120
|
||||
? `${question.content.slice(0, 120).trimEnd()}...`
|
||||
: question.content}
|
||||
</strong>{' '}
|
||||
zniknie z quizu razem z odpowiedziami.
|
||||
</>
|
||||
),
|
||||
confirmLabel: 'Usuń pytanie',
|
||||
destructive: true,
|
||||
})
|
||||
if (ok) remove.mutate(question.id)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
Spinner,
|
||||
Textarea,
|
||||
} from '../../components/ui'
|
||||
import { useConfirm } from '../../components/Confirm'
|
||||
import { useToasts } from '../../components/Toasts'
|
||||
import { formatLocal } from '../../lib/dates'
|
||||
import { ENROLLMENT_STATUS_LABEL, ENROLLMENT_STATUS_TONE } from './courseLabels'
|
||||
@@ -51,6 +52,7 @@ export function StudentDetailPage() {
|
||||
const contactId = Number(id)
|
||||
const queryClient = useQueryClient()
|
||||
const toasts = useToasts()
|
||||
const confirm = useConfirm()
|
||||
|
||||
const [tab, setTab] = useState<Tab>('kursy')
|
||||
const [enrolling, setEnrolling] = useState(false)
|
||||
@@ -169,15 +171,18 @@ export function StudentDetailPage() {
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon="key"
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
`Wysłać ${student.email} link do ustawienia nowego hasła? ` +
|
||||
'Nie poznasz jego hasła: kursant ustawi je sam.',
|
||||
)
|
||||
) {
|
||||
resetPassword.mutate()
|
||||
}
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Wysłać link do ustawienia nowego hasła?',
|
||||
message: (
|
||||
<>
|
||||
Link trafi na adres <strong>{student.email}</strong>. Nie poznasz jego hasła:
|
||||
kursant ustawi je sam.
|
||||
</>
|
||||
),
|
||||
confirmLabel: 'Wyślij link',
|
||||
})
|
||||
if (ok) resetPassword.mutate()
|
||||
}}
|
||||
disabled={resetPassword.isPending}
|
||||
>
|
||||
@@ -187,15 +192,24 @@ export function StudentDetailPage() {
|
||||
<Button
|
||||
variant={student.accountEnabled ? 'danger' : 'primary'}
|
||||
icon={student.accountEnabled ? 'user-off' : 'check'}
|
||||
onClick={() => {
|
||||
onClick={async () => {
|
||||
const next = !student.accountEnabled
|
||||
const question = next
|
||||
? `Włączyć konto ${student.email}?`
|
||||
: `Wyłączyć konto ${student.email}? Kursant straci możliwość zalogowania się, ` +
|
||||
'ale jego zapisy, postęp i certyfikaty zostaną zachowane.'
|
||||
if (window.confirm(question)) {
|
||||
setEnabled.mutate(next)
|
||||
}
|
||||
const question = next ? (
|
||||
<>
|
||||
Konto <strong>{student.email}</strong> odzyska możliwość zalogowania się.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Konto <strong>{student.email}</strong> straci możliwość zalogowania się, ale jego
|
||||
zapisy, postęp i certyfikaty zostaną zachowane.
|
||||
</>
|
||||
)
|
||||
const ok = await confirm({
|
||||
title: next ? 'Włączyć konto kursanta?' : 'Wyłączyć konto kursanta?',
|
||||
message: question,
|
||||
confirmLabel: next ? 'Włącz konto' : 'Wyłącz konto',
|
||||
})
|
||||
if (ok) setEnabled.mutate(next)
|
||||
}}
|
||||
disabled={setEnabled.isPending}
|
||||
>
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { Icon } from '../../components/Icon'
|
||||
import { Modal } from '../../components/Modal'
|
||||
import { PAGE_SIZE, Pagination } from '../../components/Pagination'
|
||||
import { useConfirm } from '../../components/Confirm'
|
||||
import { useToasts } from '../../components/Toasts'
|
||||
import { formatLocal } from '../../lib/dates'
|
||||
|
||||
@@ -207,6 +208,7 @@ export function OrderListPage() {
|
||||
*/
|
||||
function OrderDetailModal({ orderId, onClose }: { orderId: number; onClose: () => void }) {
|
||||
const queryClient = useQueryClient()
|
||||
const confirm = useConfirm()
|
||||
const toasts = useToasts()
|
||||
const [note, setNote] = useState('')
|
||||
const [granting, setGranting] = useState(false)
|
||||
@@ -312,8 +314,15 @@ function OrderDetailModal({ orderId, onClose }: { orderId: number; onClose: () =
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="x"
|
||||
onClick={() => {
|
||||
if (window.confirm('Anulować to zamówienie?')) cancel.mutate()
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Anulować zamówienie?',
|
||||
message:
|
||||
'Kursant nie dostanie dostępu do kursu. Zamówienie zostaje w dzienniku jako anulowane.',
|
||||
confirmLabel: 'Anuluj zamówienie',
|
||||
destructive: true,
|
||||
})
|
||||
if (ok) cancel.mutate()
|
||||
}}
|
||||
>
|
||||
Anuluj zamówienie
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { client, errorMessage } from '../../api/client'
|
||||
import { Icon } from '../../components/Icon'
|
||||
import { ImageEditor } from '../../components/ImageEditor'
|
||||
import { useConfirm } from '../../components/Confirm'
|
||||
import { useToasts } from '../../components/Toasts'
|
||||
import { Button, Card } from '../../components/ui'
|
||||
import { PUBLIC_CONFIG_KEY, usePublicConfig } from '../../lib/usePublicConfig'
|
||||
@@ -40,6 +41,7 @@ const KINDS: Record<
|
||||
export function BrandingSection() {
|
||||
const config = usePublicConfig()
|
||||
const queryClient = useQueryClient()
|
||||
const confirm = useConfirm()
|
||||
const toasts = useToasts()
|
||||
|
||||
const [editing, setEditing] = useState<{ kind: Kind; file: File } | null>(null)
|
||||
@@ -145,8 +147,14 @@ export function BrandingSection() {
|
||||
variant="ghost"
|
||||
icon="trash"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (window.confirm('Przywrócić grafikę domyślną?')) remove.mutate(kind)
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Przywrócić grafikę domyślną?',
|
||||
message: 'Wgrany plik zostanie usunięty, a instancja wróci do wyglądu sprzed jego dodania.',
|
||||
confirmLabel: 'Przywróć domyślną',
|
||||
destructive: true,
|
||||
})
|
||||
if (ok) remove.mutate(kind)
|
||||
}}
|
||||
className="text-alarm-600 hover:bg-alarm-50"
|
||||
>
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
Spinner,
|
||||
Textarea,
|
||||
} from '../../components/ui'
|
||||
import { useConfirm } from '../../components/Confirm'
|
||||
import { useToasts } from '../../components/Toasts'
|
||||
|
||||
/**
|
||||
@@ -106,6 +107,7 @@ export function HomePageSettingsPage() {
|
||||
|
||||
function SectionEditor({ section }: { section: (typeof SECTIONS)[number] }) {
|
||||
const queryClient = useQueryClient()
|
||||
const confirm = useConfirm()
|
||||
const toasts = useToasts()
|
||||
const [editing, setEditing] = useState<PageSection | 'new' | null>(null)
|
||||
|
||||
@@ -215,11 +217,19 @@ function SectionEditor({ section }: { section: (typeof SECTIONS)[number] }) {
|
||||
icon="trash"
|
||||
label={`Usuń "${item.title}"`}
|
||||
variant="danger"
|
||||
onClick={() => {
|
||||
onClick={async () => {
|
||||
// pytanie nazywa konkretny obiekt, a nie "czy na pewno?"
|
||||
if (window.confirm(`Usunąć pozycję "${item.title}"? Tej operacji nie można cofnąć.`)) {
|
||||
remove.mutate(item.id)
|
||||
}
|
||||
const ok = await confirm({
|
||||
title: 'Usunąć pozycję?',
|
||||
message: (
|
||||
<>
|
||||
Pozycja <strong>{item.title}</strong> zniknie ze strony głównej.
|
||||
</>
|
||||
),
|
||||
confirmLabel: 'Usuń pozycję',
|
||||
destructive: true,
|
||||
})
|
||||
if (ok) remove.mutate(item.id)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { client, errorMessage, fieldErrors } from '../../api/client'
|
||||
import type { Page, User, UserRole } from '../../api/types'
|
||||
import { useAuth } from '../../auth/AuthContext'
|
||||
import { useConfirm } from '../../components/Confirm'
|
||||
import { useToasts } from '../../components/Toasts'
|
||||
import { Badge, Button, Card, ErrorState, Field, IconButton, Input, PasswordInput, Select, Spinner } from '../../components/ui'
|
||||
import { formatLocal } from '../../lib/dates'
|
||||
@@ -22,6 +23,7 @@ const EMPTY: FormState = { email: '', fullName: '', role: 'MODERATOR', active: t
|
||||
|
||||
export function UserListPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const confirm = useConfirm()
|
||||
const toasts = useToasts()
|
||||
const { user: currentUser } = useAuth()
|
||||
|
||||
@@ -273,8 +275,18 @@ export function UserListPage() {
|
||||
<IconButton
|
||||
icon="key"
|
||||
label="Resetuj hasło"
|
||||
onClick={() => {
|
||||
if (window.confirm(`Zresetować hasło konta ${user.email}?`)) reset.mutate(user)
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Zresetować hasło?',
|
||||
message: (
|
||||
<>
|
||||
Konto <strong>{user.email}</strong> dostanie nowe hasło jednorazowe
|
||||
i będzie musiało je zmienić przy najbliższym logowaniu.
|
||||
</>
|
||||
),
|
||||
confirmLabel: 'Zresetuj hasło',
|
||||
})
|
||||
if (ok) reset.mutate(user)
|
||||
}}
|
||||
disabled={reset.isPending}
|
||||
/>
|
||||
@@ -282,8 +294,19 @@ export function UserListPage() {
|
||||
<IconButton
|
||||
icon="user-off"
|
||||
label="Dezaktywuj"
|
||||
onClick={() => {
|
||||
if (window.confirm(`Dezaktywować konto ${user.email}?`)) deactivate.mutate(user.id)
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Dezaktywować konto?',
|
||||
message: (
|
||||
<>
|
||||
Konto <strong>{user.email}</strong> straci dostęp do panelu
|
||||
natychmiast, także w otwartych już sesjach. Można je później
|
||||
włączyć z powrotem.
|
||||
</>
|
||||
),
|
||||
confirmLabel: 'Dezaktywuj konto',
|
||||
})
|
||||
if (ok) deactivate.mutate(user.id)
|
||||
}}
|
||||
disabled={deactivate.isPending}
|
||||
className="text-alarm-600 hover:bg-alarm-50"
|
||||
@@ -351,10 +374,19 @@ export function UserListPage() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="key"
|
||||
onClick={() => {
|
||||
if (window.confirm(`Zresetować hasło konta ${user.email}?`)) {
|
||||
reset.mutate(user)
|
||||
}
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Zresetować hasło?',
|
||||
message: (
|
||||
<>
|
||||
Konto <strong>{user.email}</strong> dostanie nowe hasło
|
||||
jednorazowe i będzie musiało je zmienić przy najbliższym
|
||||
logowaniu.
|
||||
</>
|
||||
),
|
||||
confirmLabel: 'Zresetuj hasło',
|
||||
})
|
||||
if (ok) reset.mutate(user)
|
||||
}}
|
||||
disabled={reset.isPending}
|
||||
title="Resetuj hasło"
|
||||
@@ -365,10 +397,19 @@ export function UserListPage() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="user-off"
|
||||
onClick={() => {
|
||||
if (window.confirm(`Dezaktywować konto ${user.email}?`)) {
|
||||
deactivate.mutate(user.id)
|
||||
}
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Dezaktywować konto?',
|
||||
message: (
|
||||
<>
|
||||
Konto <strong>{user.email}</strong> straci dostęp do panelu
|
||||
natychmiast, także w otwartych już sesjach. Można je później
|
||||
włączyć z powrotem.
|
||||
</>
|
||||
),
|
||||
confirmLabel: 'Dezaktywuj konto',
|
||||
})
|
||||
if (ok) deactivate.mutate(user.id)
|
||||
}}
|
||||
disabled={deactivate.isPending}
|
||||
title="Dezaktywuj"
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { client, errorMessage } from '../../api/client'
|
||||
import type { Certificate, CertificateBulkResult, Page, Registration, Webinar } from '../../api/types'
|
||||
import { useConfirm } from '../../components/Confirm'
|
||||
import { Icon } from '../../components/Icon'
|
||||
import { useToasts } from '../../components/Toasts'
|
||||
import { Alert, Badge, Button, Card, EmptyState, ErrorState, Field, Input, Spinner } from '../../components/ui'
|
||||
@@ -18,6 +19,7 @@ import { formatLocal } from '../../lib/dates'
|
||||
export function CertificateSection({ webinar }: { webinar: Webinar }) {
|
||||
const queryClient = useQueryClient()
|
||||
const toasts = useToasts()
|
||||
const confirm = useConfirm()
|
||||
const publicUrl = usePublicUrl()
|
||||
|
||||
const [enabled, setEnabled] = useState(webinar.certificatesEnabled)
|
||||
@@ -183,10 +185,20 @@ export function CertificateSection({ webinar }: { webinar: Webinar }) {
|
||||
<Button
|
||||
icon="check-circle"
|
||||
disabled={issueAll.isPending}
|
||||
onClick={() => {
|
||||
if (confirm('Wydać certyfikaty wszystkim uczestnikom z odnotowaną obecnością?')) {
|
||||
issueAll.mutate(false)
|
||||
}
|
||||
onClick={async () => {
|
||||
const attending = pending.filter((r) => r.attended).length
|
||||
const ok = await confirm({
|
||||
title: 'Wydać certyfikaty obecnym uczestnikom?',
|
||||
message: (
|
||||
<>
|
||||
Certyfikat dostanie <strong>{attending}</strong>{' '}
|
||||
{attending === 1 ? 'uczestnik' : 'uczestników'} z odnotowaną obecnością.
|
||||
Każdy dokument dostanie własny numer i trafi do składu instancji.
|
||||
</>
|
||||
),
|
||||
confirmLabel: 'Wydaj certyfikaty',
|
||||
})
|
||||
if (ok) issueAll.mutate(false)
|
||||
}}
|
||||
>
|
||||
Wydaj obecnym
|
||||
@@ -195,16 +207,22 @@ export function CertificateSection({ webinar }: { webinar: Webinar }) {
|
||||
variant="secondary"
|
||||
icon="alert-triangle"
|
||||
disabled={issueAll.isPending}
|
||||
onClick={() => {
|
||||
if (
|
||||
confirm(
|
||||
'Wydać certyfikaty WSZYSTKIM potwierdzonym uczestnikom, także tym bez ' +
|
||||
'odnotowanej obecności? Tej operacji nie da się cofnąć, można jedynie ' +
|
||||
'unieważnić pojedyncze certyfikaty.',
|
||||
)
|
||||
) {
|
||||
issueAll.mutate(true)
|
||||
}
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Wydać certyfikaty także nieobecnym?',
|
||||
message: (
|
||||
<>
|
||||
Certyfikat dostanie <strong>{pending.length}</strong>{' '}
|
||||
{pending.length === 1 ? 'uczestnik' : 'uczestników'}, w tym{' '}
|
||||
<strong>{pending.filter((r) => !r.attended).length}</strong> bez
|
||||
odnotowanej obecności. Wydanego dokumentu nie da się wycofać, można
|
||||
jedynie unieważnić pojedyncze certyfikaty.
|
||||
</>
|
||||
),
|
||||
confirmLabel: 'Wydaj wszystkim',
|
||||
destructive: true,
|
||||
})
|
||||
if (ok) issueAll.mutate(true)
|
||||
}}
|
||||
>
|
||||
Wydaj wszystkim, także nieobecnym
|
||||
@@ -234,14 +252,23 @@ export function CertificateSection({ webinar }: { webinar: Webinar }) {
|
||||
variant="secondary"
|
||||
icon="file-text"
|
||||
disabled={issueOne.isPending}
|
||||
onClick={() => {
|
||||
if (
|
||||
onClick={async () => {
|
||||
const ok =
|
||||
registration.attended ||
|
||||
confirm(
|
||||
`${registration.firstName} ${registration.lastName} nie ma odnotowanej ` +
|
||||
'obecności. Wydać certyfikat mimo to?',
|
||||
)
|
||||
) {
|
||||
(await confirm({
|
||||
title: 'Wydać certyfikat bez odnotowanej obecności?',
|
||||
message: (
|
||||
<>
|
||||
<strong>
|
||||
{registration.firstName} {registration.lastName}
|
||||
</strong>{' '}
|
||||
nie ma odnotowanej obecności na tym webinarze. Certyfikat
|
||||
potwierdzi udział, którego system nie zarejestrował.
|
||||
</>
|
||||
),
|
||||
confirmLabel: 'Wydaj mimo to',
|
||||
}))
|
||||
if (ok) {
|
||||
issueOne.mutate({
|
||||
registrationId: registration.id,
|
||||
force: !registration.attended,
|
||||
@@ -313,15 +340,21 @@ export function CertificateSection({ webinar }: { webinar: Webinar }) {
|
||||
variant="ghost"
|
||||
icon="x-circle"
|
||||
disabled={revoke.isPending}
|
||||
onClick={() => {
|
||||
if (
|
||||
confirm(
|
||||
`Unieważnić certyfikat ${certificate.number}? Plik zostanie, ale ` +
|
||||
'weryfikacja publiczna pokaże go jako nieważny.',
|
||||
)
|
||||
) {
|
||||
revoke.mutate(certificate.id)
|
||||
}
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Unieważnić certyfikat?',
|
||||
message: (
|
||||
<>
|
||||
Certyfikat <strong>{certificate.number}</strong> wydany dla{' '}
|
||||
<strong>{certificate.participantName}</strong> przestanie być ważny.
|
||||
Plik zostanie w składzie, ale weryfikacja publiczna pokaże go jako
|
||||
nieważny, a uczestnik straci do niego odnośnik.
|
||||
</>
|
||||
),
|
||||
confirmLabel: 'Unieważnij certyfikat',
|
||||
destructive: true,
|
||||
})
|
||||
if (ok) revoke.mutate(certificate.id)
|
||||
}}
|
||||
>
|
||||
Unieważnij
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useRef, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { client, errorMessage } from '../../api/client'
|
||||
import type { Material, Recording, Visibility } from '../../api/types'
|
||||
import { useConfirm } from '../../components/Confirm'
|
||||
import { useToasts } from '../../components/Toasts'
|
||||
import { Badge, Button, Card, EmptyState, ErrorState, Select, Spinner } from '../../components/ui'
|
||||
import { usePublicUrl } from '../../lib/usePublicConfig'
|
||||
@@ -154,6 +155,7 @@ function RecordingsCard({ webinarId }: { webinarId: number }) {
|
||||
|
||||
function RecordingRow({ recording, webinarId }: { recording: Recording; webinarId: number }) {
|
||||
const queryClient = useQueryClient()
|
||||
const confirm = useConfirm()
|
||||
const toasts = useToasts()
|
||||
const publicUrl = usePublicUrl()
|
||||
|
||||
@@ -236,13 +238,20 @@ function RecordingRow({ recording, webinarId }: { recording: Recording; webinarI
|
||||
variant="ghost"
|
||||
icon="trash"
|
||||
disabled={remove.isPending}
|
||||
onClick={() => {
|
||||
onClick={async () => {
|
||||
const name = recording.title ?? 'Nagranie webinaru'
|
||||
if (confirm(`Usunąć nagranie "${name}" wraz z plikiem?
|
||||
|
||||
Tej operacji nie da się cofnąć.`)) {
|
||||
remove.mutate()
|
||||
}
|
||||
const ok = await confirm({
|
||||
title: 'Usunąć nagranie?',
|
||||
message: (
|
||||
<>
|
||||
Nagranie <strong>{name}</strong> zniknie z panelu, a plik zostanie usunięty
|
||||
ze składu instancji. Uczestnicy stracą do niego dostęp.
|
||||
</>
|
||||
),
|
||||
confirmLabel: 'Usuń nagranie',
|
||||
destructive: true,
|
||||
})
|
||||
if (ok) remove.mutate()
|
||||
}}
|
||||
>
|
||||
Usuń
|
||||
@@ -351,6 +360,7 @@ function MaterialsCard({ webinarId }: { webinarId: number }) {
|
||||
|
||||
function MaterialRow({ material, webinarId }: { material: Material; webinarId: number }) {
|
||||
const queryClient = useQueryClient()
|
||||
const confirm = useConfirm()
|
||||
const toasts = useToasts()
|
||||
const publicUrl = usePublicUrl()
|
||||
|
||||
@@ -420,12 +430,19 @@ function MaterialRow({ material, webinarId }: { material: Material; webinarId: n
|
||||
variant="ghost"
|
||||
icon="trash"
|
||||
disabled={remove.isPending}
|
||||
onClick={() => {
|
||||
if (confirm(`Usunąć materiał "${material.title}" wraz z plikiem?
|
||||
|
||||
Tej operacji nie da się cofnąć.`)) {
|
||||
remove.mutate()
|
||||
}
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Usunąć materiał?',
|
||||
message: (
|
||||
<>
|
||||
Materiał <strong>{material.title}</strong> zniknie z panelu, a plik zostanie
|
||||
usunięty ze składu instancji. Uczestnicy stracą do niego dostęp.
|
||||
</>
|
||||
),
|
||||
confirmLabel: 'Usuń materiał',
|
||||
destructive: true,
|
||||
})
|
||||
if (ok) remove.mutate()
|
||||
}}
|
||||
>
|
||||
Usuń
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
RecipientPreview,
|
||||
Webinar,
|
||||
} from '../../api/types'
|
||||
import { useConfirm } from '../../components/Confirm'
|
||||
import { ContentEditor } from '../../components/ContentEditor'
|
||||
import { Icon } from '../../components/Icon'
|
||||
import { PAGE_SIZE, Pagination } from '../../components/Pagination'
|
||||
@@ -32,6 +33,7 @@ const STATUS_LABEL: Record<string, string> = {
|
||||
export function InvitationSection({ webinar }: { webinar: Webinar }) {
|
||||
const queryClient = useQueryClient()
|
||||
const toasts = useToasts()
|
||||
const confirm = useConfirm()
|
||||
|
||||
const [groupIds, setGroupIds] = useState<number[]>([])
|
||||
const [content, setContent] = useState(webinar.invitationContentHtml ?? '')
|
||||
@@ -186,11 +188,20 @@ export function InvitationSection({ webinar }: { webinar: Webinar }) {
|
||||
disabled={
|
||||
send.isPending || !published || !content.trim() || (recipients?.toSend ?? 0) === 0
|
||||
}
|
||||
onClick={() => {
|
||||
onClick={async () => {
|
||||
const count = recipients?.toSend ?? 0
|
||||
if (confirm(`Wysłać zaproszenie do ${count} odbiorców?`)) {
|
||||
send.mutate()
|
||||
}
|
||||
const ok = await confirm({
|
||||
title: 'Wysłać zaproszenia?',
|
||||
message: (
|
||||
<>
|
||||
Zaproszenie na webinar <strong>{webinar.title}</strong> trafi do{' '}
|
||||
<strong>{count}</strong> {count === 1 ? 'odbiorcy' : 'odbiorców'}. Wysłanej
|
||||
wiadomości nie da się cofnąć.
|
||||
</>
|
||||
),
|
||||
confirmLabel: 'Wyślij zaproszenia',
|
||||
})
|
||||
if (ok) send.mutate()
|
||||
}}
|
||||
>
|
||||
{send.isPending ? 'Wysyłanie...' : 'Wyślij zaproszenia'}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { client, errorMessage } from '../../api/client'
|
||||
import { useConfirm } from '../../components/Confirm'
|
||||
import { Icon } from '../../components/Icon'
|
||||
import { useToasts } from '../../components/Toasts'
|
||||
import { Button, Field, Select, Spinner } from '../../components/ui'
|
||||
@@ -82,6 +83,7 @@ export function ProviderPicker({
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
const toasts = useToasts()
|
||||
const confirm = useConfirm()
|
||||
|
||||
const state = useQuery({
|
||||
queryKey: ['video-provider', webinarId],
|
||||
@@ -173,15 +175,23 @@ export function ProviderPicker({
|
||||
variant="secondary"
|
||||
className="mt-2"
|
||||
disabled={migrate.isPending}
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
`Zamknąć pokój u dostawcy ${data.roomProvider}?\n\n` +
|
||||
'Tej operacji nie da się cofnąć. Nowy pokój założysz przyciskiem poniżej.',
|
||||
)
|
||||
) {
|
||||
migrate.mutate()
|
||||
}
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Zamknąć stary pokój?',
|
||||
message: (
|
||||
<>
|
||||
Pokój u dostawcy{' '}
|
||||
<strong>
|
||||
{PROVIDER_LABEL[data.roomProvider ?? ''] ?? data.roomProvider}
|
||||
</strong>{' '}
|
||||
zostanie zamknięty. Uczestnicy z linkiem sprzed zmiany trafią w puste
|
||||
miejsce. Nowy pokój założysz przyciskiem poniżej.
|
||||
</>
|
||||
),
|
||||
confirmLabel: 'Zamknij stary pokój',
|
||||
destructive: true,
|
||||
})
|
||||
if (ok) migrate.mutate()
|
||||
}}
|
||||
>
|
||||
Zamknij stary pokój
|
||||
@@ -232,7 +242,7 @@ function JitsiWarning() {
|
||||
</p>
|
||||
<p className="mt-1 text-xs leading-relaxed text-slate-700">
|
||||
Token JWT jest wyłączony, więc <strong>każdy, kto zna adres pokoju, wejdzie do
|
||||
środka</strong> — także osoba niezapisana. Ukrycie przycisku kamery uczestnikom to
|
||||
środka</strong> - także osoba niezapisana. Ukrycie przycisku kamery uczestnikom to
|
||||
jedyne zabezpieczenie, a wystarczy otworzyć ten sam pokój bezpośrednio na serwerze
|
||||
Jitsi, żeby je obejść.
|
||||
</p>
|
||||
|
||||
@@ -2,6 +2,7 @@ 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'
|
||||
@@ -10,6 +11,7 @@ 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')
|
||||
|
||||
@@ -120,13 +122,26 @@ export function ReminderSection({ webinarId, timezone }: { webinarId: number; ti
|
||||
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)
|
||||
}
|
||||
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łę"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { client, errorMessage } from '../../api/client'
|
||||
import { useConfirm } from '../../components/Confirm'
|
||||
import { Icon } from '../../components/Icon'
|
||||
import { Modal } from '../../components/Modal'
|
||||
import { useToasts } from '../../components/Toasts'
|
||||
@@ -51,6 +52,7 @@ export function SpeakQueue({
|
||||
onChanged: () => void
|
||||
}) {
|
||||
const toasts = useToasts()
|
||||
const confirm = useConfirm()
|
||||
const [busy, setBusy] = useState<number | null>(null)
|
||||
const [switching, setSwitching] = useState(false)
|
||||
// okno potwierdzenia wlaczenia otwartego mikrofonu (zamiast window.confirm)
|
||||
@@ -99,8 +101,20 @@ export function SpeakQueue({
|
||||
async function toggleRecording() {
|
||||
if (recordingBusy) return
|
||||
|
||||
if (egressId && !confirm('Zatrzymać nagrywanie? Tego fragmentu nie nagrasz ponownie.')) {
|
||||
return
|
||||
if (egressId) {
|
||||
const ok = await confirm({
|
||||
title: 'Zatrzymać nagrywanie?',
|
||||
message: (
|
||||
<>
|
||||
Nagrywanie tego spotkania zostanie zakończone, a plik trafi do zakładki Pliki.{' '}
|
||||
<strong>Tego fragmentu nie nagrasz ponownie</strong>: ponowne włączenie zacznie nowe
|
||||
nagranie od bieżącej chwili.
|
||||
</>
|
||||
),
|
||||
confirmLabel: 'Zatrzymaj nagrywanie',
|
||||
destructive: true,
|
||||
})
|
||||
if (!ok) return
|
||||
}
|
||||
|
||||
setRecordingBusy(true)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { client, errorMessage } from '../../api/client'
|
||||
import { useConfirm } from '../../components/Confirm'
|
||||
import { Icon } from '../../components/Icon'
|
||||
import { useToasts } from '../../components/Toasts'
|
||||
import { Badge, Button, Card, ErrorState, IconButton, Input, Spinner } from '../../components/ui'
|
||||
@@ -38,6 +39,7 @@ export function VideoSection({
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
const toasts = useToasts()
|
||||
const confirm = useConfirm()
|
||||
const publicUrl = usePublicUrl()
|
||||
|
||||
const [email, setEmail] = useState('')
|
||||
@@ -220,10 +222,19 @@ export function VideoSection({
|
||||
label={`Usuń prelegenta ${speaker.fullName}`}
|
||||
variant="ghost"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (window.confirm(`Usunąć prelegenta ${speaker.fullName}?`)) {
|
||||
removeSpeaker.mutate(speaker.id)
|
||||
}
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Usunąć prelegenta?',
|
||||
message: (
|
||||
<>
|
||||
<strong>{speaker.fullName}</strong> straci dostęp do sali, a jego
|
||||
dotychczasowy link do spotkania przestanie działać.
|
||||
</>
|
||||
),
|
||||
confirmLabel: 'Usuń prelegenta',
|
||||
destructive: true,
|
||||
})
|
||||
if (ok) removeSpeaker.mutate(speaker.id)
|
||||
}}
|
||||
className="text-alarm-600 hover:bg-alarm-50"
|
||||
/>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { client, errorMessage, fieldErrors } from '../../api/client'
|
||||
import type { Webinar, WebinarRequest } from '../../api/types'
|
||||
import { useAuth } from '../../auth/AuthContext'
|
||||
import { useConfirm } from '../../components/Confirm'
|
||||
import { useToasts } from '../../components/Toasts'
|
||||
import { Button, Card, ErrorState, Field, Input, Select, Spinner, Textarea } from '../../components/ui'
|
||||
import { COMMON_TIMEZONES, isoToZonedInput, zonedInputToIso } from '../../lib/dates'
|
||||
@@ -579,6 +580,7 @@ export function WebinarFormPage() {
|
||||
*/
|
||||
function RecordingNotice({ webinarId, savedUrl }: { webinarId: number; savedUrl: string | null }) {
|
||||
const toasts = useToasts()
|
||||
const confirm = useConfirm()
|
||||
|
||||
const notify = useMutation({
|
||||
mutationFn: async () => {
|
||||
@@ -623,10 +625,19 @@ function RecordingNotice({ webinarId, savedUrl }: { webinarId: number; savedUrl:
|
||||
variant="secondary"
|
||||
icon="mail"
|
||||
disabled={!hasSavedUrl || notify.isPending}
|
||||
onClick={() => {
|
||||
if (confirm('Wysłać powiadomienie o nagraniu do wszystkich potwierdzonych uczestników?')) {
|
||||
notify.mutate()
|
||||
}
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Wysłać powiadomienie o nagraniu?',
|
||||
message: (
|
||||
<>
|
||||
Wiadomość z odnośnikiem do nagrania trafi do{' '}
|
||||
<strong>wszystkich uczestników z potwierdzonym zapisem</strong>. Każdy dostanie
|
||||
ją tylko raz, nawet po kilku kliknięciach.
|
||||
</>
|
||||
),
|
||||
confirmLabel: 'Wyślij powiadomienie',
|
||||
})
|
||||
if (ok) notify.mutate()
|
||||
}}
|
||||
>
|
||||
{notify.isPending ? 'Wysyłanie...' : 'Wyślij powiadomienie o nagraniu'}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { client, errorMessage } from '../../api/client'
|
||||
import type { CopyResult, DeletionImpact, Page, Webinar, WebinarStatus } from '../../api/types'
|
||||
import { useConfirm } from '../../components/Confirm'
|
||||
import { Icon } from '../../components/Icon'
|
||||
import { PAGE_SIZE, Pagination } from '../../components/Pagination'
|
||||
import { SortableHeader, type SortState } from '../../components/SortableHeader'
|
||||
@@ -35,6 +36,7 @@ export function WebinarListPage() {
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const toasts = useToasts()
|
||||
const confirm = useConfirm()
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['webinars', scope, status, search, page, sort.field, sort.direction],
|
||||
@@ -151,21 +153,31 @@ export function WebinarListPage() {
|
||||
? `Bezpowrotnie przepadnie: ${losses.join(', ')}, a także obecność, wiadomości z czatu, pytania, nagrania i materiały.`
|
||||
: 'Ten webinar nie ma jeszcze zapisów ani certyfikatów.'
|
||||
|
||||
const confirmed = window.confirm(
|
||||
`Usunąć webinar "${webinar.title}"?
|
||||
|
||||
${detail}
|
||||
|
||||
` +
|
||||
'Tej operacji nie da się cofnąć. Jeśli chodzi Ci tylko o zamknięcie zapisów, użyj "Odwołaj".',
|
||||
)
|
||||
const confirmed = await confirm({
|
||||
title: 'Usunąć webinar?',
|
||||
message: (
|
||||
<>
|
||||
Webinar <strong>{webinar.title}</strong> zniknie z panelu i ze strony publicznej.{' '}
|
||||
{detail} Jeśli chodzi Ci tylko o zamknięcie zapisów, użyj przycisku „Odwołaj”.
|
||||
</>
|
||||
),
|
||||
confirmLabel: 'Usuń webinar',
|
||||
destructive: true,
|
||||
})
|
||||
if (confirmed) remove.mutate(webinar.id)
|
||||
}
|
||||
|
||||
function handleCancel(webinar: Webinar) {
|
||||
const confirmed = window.confirm(
|
||||
`Odwołać webinar "${webinar.title}"?\n\nZapisy zostaną zamknięte, a webinar zniknie ze strony publicznej.`,
|
||||
)
|
||||
async function handleCancel(webinar: Webinar) {
|
||||
const confirmed = await confirm({
|
||||
title: 'Odwołać webinar?',
|
||||
message: (
|
||||
<>
|
||||
Zapisy na webinar <strong>{webinar.title}</strong> zostaną zamknięte, a wydarzenie
|
||||
zniknie ze strony publicznej. Zapisani uczestnicy i ich dane zostają w panelu.
|
||||
</>
|
||||
),
|
||||
confirmLabel: 'Odwołaj webinar',
|
||||
})
|
||||
if (confirmed) cancel.mutate(webinar.id)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user