Initial commit
This commit is contained in:
@@ -0,0 +1,584 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { client, errorMessage } from '../../api/client'
|
||||
import type { Course, StudentDetail } from '../../api/types'
|
||||
import { Icon } from '../../components/Icon'
|
||||
import { Modal } from '../../components/Modal'
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
Field,
|
||||
Select,
|
||||
Spinner,
|
||||
Textarea,
|
||||
} from '../../components/ui'
|
||||
import { useToasts } from '../../components/Toasts'
|
||||
import { formatLocal } from '../../lib/dates'
|
||||
import { ENROLLMENT_STATUS_LABEL, ENROLLMENT_STATUS_TONE } from './courseLabels'
|
||||
import { panelPath } from '../../lib/routes'
|
||||
|
||||
/*
|
||||
* Etykiety zgod. CONSENT_LABEL nie zna CONTACT_SALES, bo tamten enum opisuje
|
||||
* zgody zapisu na webinar; tutaj przychodza wszystkie typy, wiec brakujaca
|
||||
* nazwe pokazujemy wprost zamiast pustego miejsca.
|
||||
*/
|
||||
const CONSENT_LABELS: Record<string, string> = {
|
||||
TERMS: 'Regulamin',
|
||||
MARKETING_EMAIL: 'Marketing e-mail',
|
||||
MARKETING_SMS: 'Marketing SMS',
|
||||
CONTACT_SALES: 'Kontakt handlowy',
|
||||
}
|
||||
|
||||
type Tab = 'kursy' | 'zgody' | 'webinary' | 'certyfikaty' | 'zamowienia'
|
||||
|
||||
/**
|
||||
* Karta kursanta.
|
||||
*
|
||||
* CZEGO TU CELOWO NIE MA: podgladu hasla ani wejscia na konto kursanta.
|
||||
* Haslo jest skrotem BCrypt i nie da sie go odczytac, a logowanie sie jako
|
||||
* ktos inny sprawiloby, ze slad w dzienniku nie odroznia dzialania kursanta
|
||||
* od dzialania administratora. Wsparcie ma tu reset hasla i ponowny link
|
||||
* potwierdzajacy, czyli tyle, ile potrzeba, zeby odblokowac kogos bez
|
||||
* przejmowania jego tozsamosci.
|
||||
*/
|
||||
export function StudentDetailPage() {
|
||||
const { id } = useParams()
|
||||
const contactId = Number(id)
|
||||
const queryClient = useQueryClient()
|
||||
const toasts = useToasts()
|
||||
|
||||
const [tab, setTab] = useState<Tab>('kursy')
|
||||
const [enrolling, setEnrolling] = useState(false)
|
||||
|
||||
const key = ['student', contactId]
|
||||
|
||||
const { data, isPending, isError, error } = useQuery({
|
||||
queryKey: key,
|
||||
queryFn: async () =>
|
||||
(await client.get<StudentDetail>(`/admin/academy/students/${contactId}`)).data,
|
||||
enabled: Number.isFinite(contactId),
|
||||
})
|
||||
|
||||
const setEnabled = useMutation({
|
||||
mutationFn: async (value: boolean) => {
|
||||
await client.post(`/admin/academy/students/${contactId}/enabled`, null, {
|
||||
params: { value },
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
toasts.success('Zapisano')
|
||||
queryClient.invalidateQueries({ queryKey: key })
|
||||
queryClient.invalidateQueries({ queryKey: ['students'] })
|
||||
},
|
||||
onError: (e) => toasts.error(errorMessage(e, 'Nie udało się zmienić stanu konta')),
|
||||
})
|
||||
|
||||
const resendVerification = useMutation({
|
||||
mutationFn: async () => {
|
||||
await client.post(`/admin/academy/students/${contactId}/resend-verification`)
|
||||
},
|
||||
onSuccess: () => toasts.success('Link potwierdzający wysłany'),
|
||||
onError: (e) => toasts.error(errorMessage(e, 'Nie udało się wysłać linku')),
|
||||
})
|
||||
|
||||
const resetPassword = useMutation({
|
||||
mutationFn: async () => {
|
||||
await client.post(`/admin/academy/students/${contactId}/password-reset`)
|
||||
},
|
||||
onSuccess: () => toasts.success('Link do ustawienia nowego hasła wysłany'),
|
||||
onError: (e) => toasts.error(errorMessage(e, 'Nie udało się wysłać linku')),
|
||||
})
|
||||
|
||||
if (isPending) return <Spinner />
|
||||
if (isError || !data) {
|
||||
return <ErrorState message={errorMessage(error, 'Nie udało się wczytać kursanta')} />
|
||||
}
|
||||
|
||||
const student = data.student
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Link
|
||||
to={panelPath('/kursy/kursanci')}
|
||||
className="inline-flex items-center gap-1.5 text-sm text-slate-500 hover:text-blue-600"
|
||||
>
|
||||
<Icon name="arrow-left" size={16} />
|
||||
Kursanci
|
||||
</Link>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-3">
|
||||
<h1 className="text-xl font-semibold text-slate-900">
|
||||
{student.firstName} {student.lastName}
|
||||
</h1>
|
||||
{!student.accountEnabled && <Badge tone="alarm">Konto wyłączone</Badge>}
|
||||
{!student.emailVerified && <Badge tone="warn">Adres niepotwierdzony</Badge>}
|
||||
{data.unsubscribedAt && <Badge tone="neutral">Wypisany z marketingu</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* --- dane i dzialania --- */}
|
||||
<Card title="Dane konta">
|
||||
<dl className="grid gap-x-6 gap-y-3 sm:grid-cols-2">
|
||||
<Row label="Adres e-mail" value={student.email} mono />
|
||||
<Row label="Telefon" value={student.phone || '-'} mono />
|
||||
<Row label="Firma" value={student.company || '-'} />
|
||||
<Row label="Rejestracja" value={formatLocal(student.registeredAt)} mono />
|
||||
<Row
|
||||
label="Ostatnie logowanie"
|
||||
value={student.lastLoginAt ? formatLocal(student.lastLoginAt) : 'nigdy'}
|
||||
mono
|
||||
/>
|
||||
<Row
|
||||
label="Adres potwierdzony"
|
||||
value={student.emailVerified ? 'tak' : 'nie'}
|
||||
/>
|
||||
</dl>
|
||||
|
||||
{data.notes && (
|
||||
<div className="mt-4 rounded-md border border-slate-100 bg-slate-25 p-3">
|
||||
<p className="hx-eyebrow mb-1">Notatki</p>
|
||||
<p className="text-sm text-slate-700">{data.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-5 flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon="user-plus"
|
||||
onClick={() => setEnrolling(true)}
|
||||
>
|
||||
Zapisz na kurs
|
||||
</Button>
|
||||
|
||||
{!student.emailVerified && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon="mail"
|
||||
onClick={() => resendVerification.mutate()}
|
||||
disabled={resendVerification.isPending}
|
||||
>
|
||||
Wyślij link potwierdzający
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<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()
|
||||
}
|
||||
}}
|
||||
disabled={resetPassword.isPending}
|
||||
>
|
||||
Reset hasła
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={student.accountEnabled ? 'danger' : 'primary'}
|
||||
icon={student.accountEnabled ? 'user-off' : 'check'}
|
||||
onClick={() => {
|
||||
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)
|
||||
}
|
||||
}}
|
||||
disabled={setEnabled.isPending}
|
||||
>
|
||||
{student.accountEnabled ? 'Wyłącz konto' : 'Włącz konto'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="mt-3 text-xs leading-relaxed text-slate-500">
|
||||
Hasło jest przechowywane jako skrót i nie da się go odczytać. Nie ma też możliwości
|
||||
zalogowania się jako kursant: dziennik musi odróżniać jego działania od Twoich.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
{/* --- zakladki --- */}
|
||||
<nav
|
||||
role="tablist"
|
||||
aria-label="Zakładki kursanta"
|
||||
className="-mx-1 flex gap-1 overflow-x-auto px-1 pb-1"
|
||||
>
|
||||
{(
|
||||
[
|
||||
['kursy', 'Kursy', 'graduation-cap', data.enrollments.length],
|
||||
['zgody', 'Zgody', 'file-text', data.consents.length],
|
||||
['webinary', 'Webinary', 'presentation', data.webinars.length],
|
||||
['certyfikaty', 'Certyfikaty', 'award', data.certificates.length],
|
||||
['zamowienia', 'Zamówienia', 'credit-card', data.orders.length],
|
||||
] as const
|
||||
).map(([value, label, icon, count]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === value}
|
||||
onClick={() => setTab(value)}
|
||||
className={`flex min-h-[44px] shrink-0 items-center gap-2 whitespace-nowrap rounded-md px-3 text-sm transition-colors duration-fast ease-standard ${
|
||||
tab === value
|
||||
? 'bg-blue-50 font-medium text-blue-700'
|
||||
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
<Icon name={icon} size={16} />
|
||||
{label}
|
||||
{count > 0 && <span className="hx-data text-xs text-slate-400">{count}</span>}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{tab === 'kursy' && <EnrollmentsTab data={data} />}
|
||||
{tab === 'zgody' && <ConsentsTab data={data} />}
|
||||
{tab === 'webinary' && <WebinarsTab data={data} />}
|
||||
{tab === 'certyfikaty' && <CertificatesTab data={data} />}
|
||||
{tab === 'zamowienia' && <OrdersTab data={data} />}
|
||||
|
||||
{enrolling && (
|
||||
<ManualEnrollModal
|
||||
contactId={contactId}
|
||||
studentName={`${student.firstName} ${student.lastName}`}
|
||||
onClose={() => setEnrolling(false)}
|
||||
onSaved={() => {
|
||||
setEnrolling(false)
|
||||
queryClient.invalidateQueries({ queryKey: key })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) {
|
||||
return (
|
||||
<div>
|
||||
<dt className="hx-eyebrow">{label}</dt>
|
||||
<dd className={`mt-0.5 text-sm text-slate-800 ${mono ? 'hx-data' : ''}`}>{value}</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EnrollmentsTab({ data }: { data: StudentDetail }) {
|
||||
if (data.enrollments.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon="graduation-cap"
|
||||
title="Brak zapisów na kursy"
|
||||
description="Możesz zapisać kursanta ręcznie, podając uzasadnienie."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card title="Kursy">
|
||||
<ul className="space-y-3">
|
||||
{data.enrollments.map((e) => (
|
||||
<li key={e.enrollmentId} className="rounded-md border border-slate-100 p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<Link
|
||||
to={panelPath(`/kursy/${e.courseId}`)}
|
||||
className="text-sm font-medium text-slate-900 hover:text-blue-600"
|
||||
>
|
||||
{e.courseTitle}
|
||||
</Link>
|
||||
<Badge tone={ENROLLMENT_STATUS_TONE[e.status]}>
|
||||
{ENROLLMENT_STATUS_LABEL[e.status]}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 max-w-md">
|
||||
<div className="mb-1 flex items-center justify-between text-xs text-slate-600">
|
||||
<span>
|
||||
Ukończono {e.completedRequired} z {e.totalRequired}
|
||||
</span>
|
||||
<span className="hx-data font-medium text-slate-900">{e.progressPercent}%</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-slate-100">
|
||||
<div
|
||||
className="h-full rounded-full bg-blue-500"
|
||||
style={{ width: `${e.progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="hx-data mt-2 text-xs text-slate-500">
|
||||
Zapisany {formatLocal(e.enrolledAt)}
|
||||
{e.lastActivityAt && ` · ostatnia aktywność ${formatLocal(e.lastActivityAt)}`}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Zgody z historia.
|
||||
*
|
||||
* Zgody sa dopisywane, nigdy nadpisywane, wiec kursant ma ich kilka na ten sam
|
||||
* typ. Pokazujemy wszystkie, bo to slad rozliczalny, a obowiazujaca oznaczamy:
|
||||
* bez tego nie da sie odczytac stanu biezacego z samej listy.
|
||||
*/
|
||||
function ConsentsTab({ data }: { data: StudentDetail }) {
|
||||
if (data.consents.length === 0) {
|
||||
return <EmptyState icon="file-text" title="Brak zapisanych zgód" />
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="Zgody"
|
||||
description="Pełna historia. Zgody są dopisywane, nigdy nadpisywane, więc obowiązuje najnowsza każdego typu."
|
||||
>
|
||||
<ul className="space-y-2">
|
||||
{data.consents.map((consent) => (
|
||||
<li
|
||||
key={consent.id}
|
||||
className={`flex flex-wrap items-center justify-between gap-2 rounded-md border p-3 ${
|
||||
consent.current ? 'border-slate-200 bg-white' : 'border-slate-100 bg-slate-25'
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-sm font-medium text-slate-900">
|
||||
{CONSENT_LABELS[consent.type] ?? consent.type}
|
||||
</p>
|
||||
{consent.current && <Badge tone="info">obowiązuje</Badge>}
|
||||
</div>
|
||||
<p className="hx-data mt-0.5 text-xs text-slate-500">
|
||||
wersja {consent.version} · {formatLocal(consent.grantedAt)}
|
||||
</p>
|
||||
</div>
|
||||
<Badge tone={consent.granted ? 'ok' : 'neutral'}>
|
||||
{consent.granted ? 'udzielona' : 'odmowa'}
|
||||
</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function WebinarsTab({ data }: { data: StudentDetail }) {
|
||||
if (data.webinars.length === 0) {
|
||||
return <EmptyState icon="presentation" title="Brak zapisów na webinary" />
|
||||
}
|
||||
|
||||
return (
|
||||
<Card title="Webinary">
|
||||
<ul className="space-y-2">
|
||||
{data.webinars.map((w) => (
|
||||
<li
|
||||
key={w.webinarId}
|
||||
className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-slate-100 p-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
to={panelPath(`/webinary/${w.webinarId}`)}
|
||||
className="text-sm font-medium text-slate-900 hover:text-blue-600"
|
||||
>
|
||||
{w.title}
|
||||
</Link>
|
||||
<p className="hx-data mt-0.5 text-xs text-slate-500">{formatLocal(w.startsAt)}</p>
|
||||
</div>
|
||||
{w.attended ? (
|
||||
<Badge tone="ok">obecny</Badge>
|
||||
) : (
|
||||
<Badge tone="neutral">nieobecny</Badge>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function CertificatesTab({ data }: { data: StudentDetail }) {
|
||||
if (data.certificates.length === 0) {
|
||||
return <EmptyState icon="award" title="Brak certyfikatów" />
|
||||
}
|
||||
|
||||
return (
|
||||
<Card title="Certyfikaty">
|
||||
<ul className="space-y-2">
|
||||
{data.certificates.map((c) => (
|
||||
<li
|
||||
key={c.id}
|
||||
className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-slate-100 p-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-sm font-medium text-slate-900">{c.subjectTitle}</p>
|
||||
<Badge tone={c.forCourse ? 'brand' : 'info'}>
|
||||
{c.forCourse ? 'kurs' : 'webinar'}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="hx-data mt-0.5 text-xs text-slate-500">
|
||||
nr {c.number} · {formatLocal(c.issuedAt)}
|
||||
</p>
|
||||
</div>
|
||||
{c.revoked ? (
|
||||
<Badge tone="alarm">unieważniony</Badge>
|
||||
) : (
|
||||
<a
|
||||
href={`/api/public/certificates/${c.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
Pobierz
|
||||
</a>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function OrdersTab({ data }: { data: StudentDetail }) {
|
||||
if (data.orders.length === 0) {
|
||||
return <EmptyState icon="credit-card" title="Brak zamówień" />
|
||||
}
|
||||
|
||||
return (
|
||||
<Card title="Zamówienia">
|
||||
<ul className="space-y-2">
|
||||
{data.orders.map((o) => (
|
||||
<li
|
||||
key={o.id}
|
||||
className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-slate-100 p-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-slate-900">{o.courseTitle ?? 'Kurs usunięty'}</p>
|
||||
<p className="hx-data mt-0.5 text-xs text-slate-500">
|
||||
{o.orderNumber} · {formatLocal(o.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{o.amount && (
|
||||
<span className="hx-data text-sm text-slate-700">
|
||||
{o.amount} {o.currency}
|
||||
</span>
|
||||
)}
|
||||
<Badge tone={o.status === 'PAID' ? 'ok' : 'neutral'}>{o.status}</Badge>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reczne zapisanie na kurs.
|
||||
*
|
||||
* NOTATKA JEST WYMAGANA. Zapis z pominieciem platnosci to decyzja, ktora po
|
||||
* miesiacu musi dac sie wyjasnic: bez uzasadnienia nie wiadomo, czy kursant
|
||||
* zaplacil przelewem, czy dostal dostep przez pomylke.
|
||||
*/
|
||||
function ManualEnrollModal({
|
||||
contactId,
|
||||
studentName,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
contactId: number
|
||||
studentName: string
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const toasts = useToasts()
|
||||
const [courseId, setCourseId] = useState('')
|
||||
const [note, setNote] = useState('')
|
||||
|
||||
const courses = useQuery({
|
||||
queryKey: ['courses', 'for-enroll'],
|
||||
queryFn: async () =>
|
||||
(
|
||||
await client.get<{ content: Course[] }>('/admin/courses', {
|
||||
params: { size: 100 },
|
||||
})
|
||||
).data.content,
|
||||
})
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
await client.post(`/admin/academy/students/${contactId}/enroll`, {
|
||||
courseId: Number(courseId),
|
||||
note,
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
toasts.success('Kursant zapisany na kurs')
|
||||
onSaved()
|
||||
},
|
||||
onError: (e) => toasts.error(errorMessage(e, 'Nie udało się zapisać na kurs')),
|
||||
})
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={`Zapisz na kurs: ${studentName}`}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Anuluj
|
||||
</Button>
|
||||
<Button
|
||||
icon="check"
|
||||
onClick={() => save.mutate()}
|
||||
disabled={!courseId || !note.trim() || save.isPending}
|
||||
>
|
||||
{save.isPending ? 'Zapisywanie...' : 'Zapisz na kurs'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Alert tone="warn" icon="alert-triangle">
|
||||
Ręczny zapis omija płatność i akceptację regulaminu. Kursant dostanie dostęp od razu.
|
||||
</Alert>
|
||||
|
||||
<Field label="Kurs">
|
||||
<Select value={courseId} onChange={(e) => setCourseId(e.target.value)}>
|
||||
<option value="">Wybierz kurs</option>
|
||||
{(courses.data ?? [])
|
||||
.filter((c) => c.status !== 'DRAFT')
|
||||
.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.title}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Uzasadnienie"
|
||||
hint="Trafi do dziennika. Bez niego po miesiącu nie da się ustalić, dlaczego kursant dostał dostęp."
|
||||
>
|
||||
<Textarea
|
||||
rows={3}
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder="np. opłacone przelewem tradycyjnym, faktura FV/2026/14"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user