feat: add customer number generation and invoice creation
Backend: - Add customerNumber field to AppUser with unique index - Add customer number generation starting at K1000 - Add InvoiceService for creating invoices with PDF data - Add InvoiceController with endpoints for listing customers and generating invoice data - Update CatalogService to assign customer numbers to new users - Update SampleService to preserve customerNumber on updates - Fix SecurityConfig to enable method security and admin role checks Frontend: - Update UserOption/UserRow types to include customerNumber - Add customer selection dialog in InvoiceManagementPage - Add PDF generation and preview for invoices - Fix encodePdfText function for proper PDF encoding - Fix LocalStorage key for auth token
This commit is contained in:
@@ -67,6 +67,7 @@ export interface UserOption {
|
||||
iban: string | null;
|
||||
bic: string | null;
|
||||
role: UserRole;
|
||||
customerNumber: string | null;
|
||||
}
|
||||
|
||||
export interface SessionResponse {
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { apiGet } from "../lib/api";
|
||||
import { useSession } from "../lib/session";
|
||||
import type { UserOption } from "../lib/types";
|
||||
import {
|
||||
createDefaultInvoiceStarterLayout,
|
||||
createMuhInvoiceContent,
|
||||
createPdfBlob as createTemplatePdfBlob,
|
||||
normalizeTemplateElements,
|
||||
type TemplateElement,
|
||||
} from "./InvoiceTemplatePage";
|
||||
|
||||
interface InvoiceSummary {
|
||||
id: string;
|
||||
@@ -15,6 +24,35 @@ interface InvoiceOverview {
|
||||
invoices: InvoiceSummary[];
|
||||
}
|
||||
|
||||
interface CustomerDto {
|
||||
id: string;
|
||||
displayName: string;
|
||||
companyName: string | null;
|
||||
street: string | null;
|
||||
houseNumber: string | null;
|
||||
postalCode: string | null;
|
||||
city: string | null;
|
||||
email: string | null;
|
||||
phoneNumber: string | null;
|
||||
accountHolder: string | null;
|
||||
bankName: string | null;
|
||||
iban: string | null;
|
||||
bic: string | null;
|
||||
customerNumber: string | null;
|
||||
}
|
||||
|
||||
interface InvoiceData {
|
||||
invoiceNumber: string;
|
||||
invoiceDate: string;
|
||||
dueDate: string;
|
||||
customer: CustomerDto;
|
||||
templateElements: unknown[];
|
||||
netAmount: number;
|
||||
vatAmount: number;
|
||||
grossAmount: number;
|
||||
monthlyPrice: number;
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<InvoiceSummary["status"], string> = {
|
||||
DRAFT: "Entwurf",
|
||||
SENT: "Versendet",
|
||||
@@ -31,10 +69,126 @@ const STATUS_CLASSES: Record<InvoiceSummary["status"], string> = {
|
||||
CANCELLED: "status-badge--neutral",
|
||||
};
|
||||
|
||||
function buildIssuerContact(user: UserOption | null) {
|
||||
const lines: string[] = [];
|
||||
if (user?.phoneNumber) {
|
||||
lines.push(`Tel.: ${user.phoneNumber}`);
|
||||
}
|
||||
if (user?.email) {
|
||||
lines.push(`E-Mail: ${user.email}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function buildBankDetails(user: UserOption | null) {
|
||||
const lines = ["Bankverbindung:"];
|
||||
if (user?.accountHolder) {
|
||||
lines.push(`Kontoinhaber: ${user.accountHolder}`);
|
||||
}
|
||||
if (user?.iban) {
|
||||
lines.push(`IBAN: ${user.iban}`);
|
||||
}
|
||||
if (user?.bic) {
|
||||
lines.push(`BIC: ${user.bic}`);
|
||||
}
|
||||
if (user?.bankName) {
|
||||
lines.push(`Bank: ${user.bankName}`);
|
||||
}
|
||||
return lines.join("\n") || "Bankverbindung:";
|
||||
}
|
||||
|
||||
function resolveTemplateContent(
|
||||
element: TemplateElement,
|
||||
invoiceData: InvoiceData,
|
||||
issuer: UserOption | null,
|
||||
) {
|
||||
const customer = invoiceData.customer;
|
||||
|
||||
switch (element.paletteId) {
|
||||
case "issuer-name":
|
||||
return issuer?.companyName ?? issuer?.displayName ?? element.content;
|
||||
case "issuer-street":
|
||||
return issuer?.street ?? "";
|
||||
case "issuer-house-number":
|
||||
return issuer?.houseNumber ?? "";
|
||||
case "issuer-postal-code":
|
||||
return issuer?.postalCode ?? "";
|
||||
case "issuer-city":
|
||||
return issuer?.city ?? "";
|
||||
case "issuer-contact":
|
||||
return buildIssuerContact(issuer);
|
||||
case "invoice-title":
|
||||
return "Rechnung";
|
||||
case "invoice-number":
|
||||
return `Rechnungsnr.: ${invoiceData.invoiceNumber}`;
|
||||
case "invoice-date":
|
||||
return `Datum: ${invoiceData.invoiceDate}`;
|
||||
case "invoice-due-date":
|
||||
return `Fällig bis: ${invoiceData.dueDate}`;
|
||||
case "customer-name":
|
||||
return customer.companyName || customer.displayName;
|
||||
case "customer-street":
|
||||
return customer.street || "";
|
||||
case "customer-house-number":
|
||||
return customer.houseNumber || "";
|
||||
case "customer-postal-code":
|
||||
return customer.postalCode || "";
|
||||
case "customer-city":
|
||||
return customer.city || "";
|
||||
case "customer-email":
|
||||
return customer.email ? `E-Mail: ${customer.email}` : "";
|
||||
case "customer-phone":
|
||||
return customer.phoneNumber ? `Tel.: ${customer.phoneNumber}` : "";
|
||||
case "customer-number":
|
||||
return `Kunden-Nr.: ${customer.customerNumber || "-"}`;
|
||||
case "invoice-items-muh":
|
||||
return createMuhInvoiceContent(invoiceData.monthlyPrice, element.width);
|
||||
case "payment-terms":
|
||||
return "Zahlungsbedingungen: Zahlung innerhalb von 14 Tagen ohne Abzug.";
|
||||
case "bank-details":
|
||||
return buildBankDetails(issuer);
|
||||
default:
|
||||
return element.content;
|
||||
}
|
||||
}
|
||||
|
||||
function buildInvoiceElements(invoiceData: InvoiceData, issuer: UserOption | null) {
|
||||
const storedElements = normalizeTemplateElements(invoiceData.templateElements);
|
||||
const templateElements =
|
||||
storedElements && storedElements.length > 0
|
||||
? storedElements
|
||||
: createDefaultInvoiceStarterLayout(issuer, invoiceData.monthlyPrice);
|
||||
|
||||
return templateElements.map((element) => {
|
||||
if (element.kind !== "text") {
|
||||
return element;
|
||||
}
|
||||
|
||||
return {
|
||||
...element,
|
||||
content: resolveTemplateContent(element, invoiceData, issuer),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function createInvoicePdfBlob(invoiceData: InvoiceData, issuer: UserOption | null) {
|
||||
return createTemplatePdfBlob(buildInvoiceElements(invoiceData, issuer), invoiceData.monthlyPrice);
|
||||
}
|
||||
|
||||
export default function InvoiceManagementPage() {
|
||||
const { user } = useSession();
|
||||
const [invoices, setInvoices] = useState<InvoiceSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Dialog states
|
||||
const [showCustomerDialog, setShowCustomerDialog] = useState(false);
|
||||
const [showPdfPreview, setShowPdfPreview] = useState(false);
|
||||
const [customers, setCustomers] = useState<CustomerDto[]>([]);
|
||||
const [selectedCustomerId, setSelectedCustomerId] = useState<string>("");
|
||||
const [invoiceData, setInvoiceData] = useState<InvoiceData | null>(null);
|
||||
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
|
||||
const [isLoadingInvoice, setIsLoadingInvoice] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -47,10 +201,8 @@ export default function InvoiceManagementPage() {
|
||||
setInvoices(response.invoices);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
// Für den Moment zeigen wir einfach eine leere Liste an
|
||||
// bis das Backend implementiert ist
|
||||
setInvoices([]);
|
||||
setError(null);
|
||||
}
|
||||
@@ -66,6 +218,28 @@ export default function InvoiceManagementPage() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Cleanup PDF URL
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (pdfUrl) {
|
||||
URL.revokeObjectURL(pdfUrl);
|
||||
}
|
||||
};
|
||||
}, [pdfUrl]);
|
||||
|
||||
// Handle Escape key for PDF preview
|
||||
useEffect(() => {
|
||||
function handleEscape(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
setShowPdfPreview(false);
|
||||
}
|
||||
}
|
||||
if (showPdfPreview) {
|
||||
window.addEventListener("keydown", handleEscape);
|
||||
return () => window.removeEventListener("keydown", handleEscape);
|
||||
}
|
||||
}, [showPdfPreview]);
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat("de-DE", {
|
||||
style: "currency",
|
||||
@@ -79,6 +253,59 @@ export default function InvoiceManagementPage() {
|
||||
}).format(new Date(dateString));
|
||||
};
|
||||
|
||||
const handleNewInvoice = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
const customerList = await apiGet<CustomerDto[]>("/admin/customers/primary");
|
||||
setCustomers(customerList);
|
||||
setSelectedCustomerId(customerList[0]?.id || "");
|
||||
setShowCustomerDialog(true);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "Unbekannter Fehler";
|
||||
setError(`Kunden konnten nicht geladen werden: ${errorMessage}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateInvoice = async () => {
|
||||
if (!selectedCustomerId) return;
|
||||
|
||||
setIsLoadingInvoice(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await apiGet<InvoiceData>(`/admin/customers/${selectedCustomerId}/invoice-data`);
|
||||
|
||||
if (!data.customer) {
|
||||
throw new Error("Ungültige Rechnungsdaten: Kunde fehlt");
|
||||
}
|
||||
|
||||
setInvoiceData(data);
|
||||
|
||||
if (pdfUrl) {
|
||||
URL.revokeObjectURL(pdfUrl);
|
||||
}
|
||||
|
||||
const pdfBlob = createInvoicePdfBlob(data, user);
|
||||
const url = URL.createObjectURL(pdfBlob);
|
||||
setPdfUrl(url);
|
||||
|
||||
setShowCustomerDialog(false);
|
||||
setShowPdfPreview(true);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "Unbekannter Fehler";
|
||||
setError(`Rechnung konnte nicht erstellt werden: ${errorMessage}`);
|
||||
} finally {
|
||||
setIsLoadingInvoice(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClosePdfPreview = () => {
|
||||
setShowPdfPreview(false);
|
||||
if (pdfUrl) {
|
||||
URL.revokeObjectURL(pdfUrl);
|
||||
setPdfUrl(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<section className="section-card section-card--hero">
|
||||
@@ -101,7 +328,11 @@ export default function InvoiceManagementPage() {
|
||||
<h3>Rechnungsliste</h3>
|
||||
</div>
|
||||
<div className="page-actions">
|
||||
<button type="button" className="accent-button" disabled>
|
||||
<button
|
||||
type="button"
|
||||
className="accent-button"
|
||||
onClick={handleNewInvoice}
|
||||
>
|
||||
Neue Rechnung
|
||||
</button>
|
||||
</div>
|
||||
@@ -113,7 +344,7 @@ export default function InvoiceManagementPage() {
|
||||
<div className="empty-state">
|
||||
<p>Noch keine Rechnungen vorhanden.</p>
|
||||
<p className="muted-text">
|
||||
Die Rechnungsverwaltung wird in Kürze verfügbar sein.
|
||||
Erstellen Sie Ihre erste Rechnung mit dem Button "Neue Rechnung".
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -191,6 +422,219 @@ export default function InvoiceManagementPage() {
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Customer Selection Dialog */}
|
||||
{showCustomerDialog && (
|
||||
<div
|
||||
className="modal-overlay"
|
||||
onClick={() => setShowCustomerDialog(false)}
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="modal-content"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
backgroundColor: "white",
|
||||
borderRadius: "8px",
|
||||
padding: "24px",
|
||||
width: "100%",
|
||||
maxWidth: "480px",
|
||||
maxHeight: "90vh",
|
||||
overflow: "auto",
|
||||
}}
|
||||
>
|
||||
<h3 style={{ marginTop: 0, marginBottom: "16px" }}>
|
||||
Rechnung erstellen
|
||||
</h3>
|
||||
<p style={{ marginBottom: "20px", color: "#666" }}>
|
||||
Wählen Sie einen Hauptbenutzer aus, für den die Rechnung erstellt werden soll:
|
||||
</p>
|
||||
|
||||
<div style={{ marginBottom: "24px" }}>
|
||||
<label
|
||||
htmlFor="customer-select"
|
||||
style={{
|
||||
display: "block",
|
||||
marginBottom: "8px",
|
||||
fontWeight: 500,
|
||||
fontSize: "14px",
|
||||
}}
|
||||
>
|
||||
Kunde
|
||||
</label>
|
||||
<select
|
||||
id="customer-select"
|
||||
value={selectedCustomerId}
|
||||
onChange={(e) => setSelectedCustomerId(e.target.value)}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "10px 12px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid #d1d5db",
|
||||
fontSize: "14px",
|
||||
backgroundColor: "white",
|
||||
}}
|
||||
>
|
||||
{customers.length === 0 && (
|
||||
<option value="">Keine Kunden verfügbar</option>
|
||||
)}
|
||||
{customers.map((customer) => (
|
||||
<option key={customer.id} value={customer.id}>
|
||||
{customer.companyName || customer.displayName}
|
||||
{customer.customerNumber ? ` (${customer.customerNumber})` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: "12px", justifyContent: "flex-end" }}>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
onClick={() => setShowCustomerDialog(false)}
|
||||
style={{
|
||||
padding: "10px 20px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid #d1d5db",
|
||||
backgroundColor: "white",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="accent-button"
|
||||
onClick={handleCreateInvoice}
|
||||
disabled={!selectedCustomerId || isLoadingInvoice}
|
||||
style={{
|
||||
padding: "10px 20px",
|
||||
borderRadius: "6px",
|
||||
border: "none",
|
||||
backgroundColor: "var(--primary-600, #2563eb)",
|
||||
color: "white",
|
||||
cursor: !selectedCustomerId || isLoadingInvoice ? "not-allowed" : "pointer",
|
||||
opacity: !selectedCustomerId || isLoadingInvoice ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{isLoadingInvoice ? "Wird erstellt..." : "Rechnung erstellen"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* PDF Preview Dialog */}
|
||||
{showPdfPreview && pdfUrl && (
|
||||
<div
|
||||
className="modal-overlay"
|
||||
onClick={handleClosePdfPreview}
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.7)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="modal-content"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
backgroundColor: "white",
|
||||
borderRadius: "8px",
|
||||
width: "95%",
|
||||
maxWidth: "900px",
|
||||
height: "90vh",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: "16px 24px",
|
||||
borderBottom: "1px solid #e5e7eb",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h3 style={{ margin: 0, fontSize: "18px" }}>
|
||||
Rechnungsvorschau
|
||||
</h3>
|
||||
{invoiceData && (
|
||||
<p style={{ margin: "4px 0 0 0", fontSize: "14px", color: "#666" }}>
|
||||
{invoiceData.invoiceNumber} - {invoiceData.customer.companyName || invoiceData.customer.displayName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: "12px" }}>
|
||||
<a
|
||||
href={pdfUrl}
|
||||
download={invoiceData ? `${invoiceData.invoiceNumber}.pdf` : "rechnung.pdf"}
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid #d1d5db",
|
||||
backgroundColor: "white",
|
||||
color: "#374151",
|
||||
textDecoration: "none",
|
||||
fontSize: "14px",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Download
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClosePdfPreview}
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
borderRadius: "6px",
|
||||
border: "none",
|
||||
backgroundColor: "var(--primary-600, #2563eb)",
|
||||
color: "white",
|
||||
fontSize: "14px",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: "auto", backgroundColor: "#f3f4f6" }}>
|
||||
<iframe
|
||||
src={pdfUrl}
|
||||
title="Rechnungsvorschau"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
border: "none",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -58,10 +58,10 @@ const INVOICE_LOCKED_TEXT_PALETTE_IDS = new Set([
|
||||
"invoice-items-muh",
|
||||
]);
|
||||
|
||||
type ElementKind = "text" | "line" | "image";
|
||||
type LineOrientation = "horizontal" | "vertical";
|
||||
type TextAlign = "left" | "center" | "right";
|
||||
type FontWeight = 400 | 500 | 600 | 700;
|
||||
export type ElementKind = "text" | "line" | "image";
|
||||
export type LineOrientation = "horizontal" | "vertical";
|
||||
export type TextAlign = "left" | "center" | "right";
|
||||
export type FontWeight = 400 | 500 | 600 | 700;
|
||||
type PaletteCategory = string;
|
||||
|
||||
function isMuhInvoicePaletteId(paletteId?: string) {
|
||||
@@ -87,7 +87,7 @@ interface PaletteItem {
|
||||
defaultContent: (user: UserOption | null) => string;
|
||||
}
|
||||
|
||||
interface TemplateElement {
|
||||
export interface TemplateElement {
|
||||
id: string;
|
||||
paletteId: string;
|
||||
kind: ElementKind;
|
||||
@@ -1006,7 +1006,7 @@ function createPdfContentStream(
|
||||
return commands.join("\n");
|
||||
}
|
||||
|
||||
function createPdfBlob(elements: TemplateElement[], monthlyPrice: number | null) {
|
||||
export function createPdfBlob(elements: TemplateElement[], monthlyPrice: number | null) {
|
||||
const imageResources = createPdfImageResources(elements);
|
||||
const imageResourceMap = new Map(imageResources.map((resource) => [resource.elementId, resource]));
|
||||
const contentStream = createPdfContentStream(elements, imageResourceMap, monthlyPrice);
|
||||
@@ -1227,7 +1227,7 @@ function getMuhInvoiceRows(monthlyPrice: number | null): MuhInvoiceRow[] {
|
||||
];
|
||||
}
|
||||
|
||||
function createMuhInvoiceContent(monthlyPrice: number | null, _elementWidth: number = 646): string {
|
||||
export function createMuhInvoiceContent(monthlyPrice: number | null, _elementWidth: number = 646): string {
|
||||
return getMuhInvoiceRows(monthlyPrice)
|
||||
.map((row) => `${row.label} | ${row.amount}`)
|
||||
.join("\n");
|
||||
@@ -1319,6 +1319,13 @@ function createInvoiceStarterLayout(user: UserOption | null, paletteItems: Palet
|
||||
];
|
||||
}
|
||||
|
||||
export function createDefaultInvoiceStarterLayout(
|
||||
user: UserOption | null,
|
||||
monthlyPrice: number | null = null,
|
||||
) {
|
||||
return createInvoiceStarterLayout(user, INVOICE_PALETTE_ITEMS, monthlyPrice);
|
||||
}
|
||||
|
||||
function readFileAsDataUrl(file: File) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
@@ -1381,7 +1388,7 @@ async function convertImageFileToJpeg(file: File) {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTemplateElements(raw: unknown) {
|
||||
export function normalizeTemplateElements(raw: unknown) {
|
||||
if (!Array.isArray(raw)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -978,7 +978,7 @@ a {
|
||||
position: absolute;
|
||||
display: grid;
|
||||
gap: 0;
|
||||
padding: 4px 5px;
|
||||
padding: 4px 11px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 16px;
|
||||
background: transparent;
|
||||
|
||||
Reference in New Issue
Block a user