Add payments with TDS, invoice detail view and filterable History (Phase E2)
- Migration M8 adds payments (cash, TDS, mode, reference). Payment status is derived, never stored: unpaid, partially paid, paid, or overdue. Overpayment is rejected, TDS counts toward settling, and only issued invoices accept payments. list_invoices and get_invoice carry the paid, TDS and balance summary. - New invoice detail view: archived-PDF preview, payments list, Record payment modal, Export, Duplicate (as a draft), Re-render and Cancel. An invoice with payments cannot be cancelled until they are deleted. - History gets search, status, financial-year, month and client filters, sortable columns, pagination, footer totals and an FY summary. Cancelled invoices are excluded from totals. The per-row archive-status calls are gone; archiving is on the detail page. - Rust tests for payments and the list summary; vitest for fiscal-year, money, filter and duplicate helpers. PDF output and fingerprint goldens are unchanged. The new UI has not been run in a webview yet.
This commit is contained in:
@@ -0,0 +1,529 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
InlineLoading,
|
||||
InlineNotification,
|
||||
Modal,
|
||||
OverflowMenu,
|
||||
OverflowMenuItem,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Tag,
|
||||
TextInput,
|
||||
Tile,
|
||||
} from "@carbon/react";
|
||||
import { ArrowLeft, TrashCan } from "@carbon/icons-react";
|
||||
import { api, type ArchiveStatus } from "../lib/api";
|
||||
import { formatAmount, formatDate } from "../lib/format";
|
||||
import { formatPaise } from "../lib/money";
|
||||
import { paymentModeLabel, statusBadge, type Payment } from "../lib/payments";
|
||||
import { createExportDeps } from "../lib/exportDeps";
|
||||
import { renderAndArchive, type ExportSource } from "../lib/exportFlow";
|
||||
import { toDraftPayload } from "../lib/draft";
|
||||
import { invoiceToFormState } from "../lib/duplicate";
|
||||
import type { ExportMode } from "../lib/filename";
|
||||
import { useInvoiceExport } from "../hooks/useInvoiceExport";
|
||||
import { useReturnFocus } from "../hooks/useReturnFocus";
|
||||
import ExportFeedback from "../components/ExportFeedback";
|
||||
import PdfPreview from "../components/PdfPreview";
|
||||
import RecordPaymentModal from "../components/RecordPaymentModal";
|
||||
import { useToast } from "../components/ToastProvider";
|
||||
import { INDIAN_STATES, type Invoice, type Settings } from "../lib/types";
|
||||
|
||||
interface Props {
|
||||
invoiceId: number;
|
||||
settings: Settings;
|
||||
onBack: () => void;
|
||||
/** A draft was created from this invoice; the shell opens it in the New invoice form. */
|
||||
onDuplicated: (draftId: number) => void;
|
||||
}
|
||||
|
||||
interface Frozen {
|
||||
vendorName?: string;
|
||||
vendorAddress?: string;
|
||||
vendorGstin?: string;
|
||||
vendorPan?: string;
|
||||
vendorEmail?: string;
|
||||
vendorPhone?: string;
|
||||
}
|
||||
|
||||
function parseSnapshot(text: string): Frozen {
|
||||
try {
|
||||
return text ? (JSON.parse(text) as Frozen) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
const stateName = (code: string) => {
|
||||
const s = INDIAN_STATES.find((x) => x.code === code);
|
||||
return s ? `${code} - ${s.name}` : code;
|
||||
};
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="voiced-detail__field">
|
||||
<dt className="voiced-muted">{label}</dt>
|
||||
<dd>{children || "—"}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function InvoiceDetail({ invoiceId, settings, onBack, onDuplicated }: Props) {
|
||||
const toast = useToast();
|
||||
const [invoice, setInvoice] = useState<Invoice | null>(null);
|
||||
const [payments, setPayments] = useState<Payment[]>([]);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [archive, setArchive] = useState<ArchiveStatus | null>(null);
|
||||
const [pdf, setPdf] = useState<Uint8Array | null>(null);
|
||||
const [pdfError, setPdfError] = useState<string | null>(null);
|
||||
const [archiving, setArchiving] = useState(false);
|
||||
const [recordOpen, setRecordOpen] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Payment | null>(null);
|
||||
const [cancelOpen, setCancelOpen] = useState(false);
|
||||
const [reason, setReason] = useState("");
|
||||
const [duplicating, setDuplicating] = useState(false);
|
||||
useReturnFocus(deleteTarget !== null);
|
||||
useReturnFocus(cancelOpen);
|
||||
|
||||
// Guards against a slow load for a previous id landing after the id changed.
|
||||
const generation = useRef(0);
|
||||
|
||||
const loadPdf = useCallback(async (id: number, gen: number) => {
|
||||
setPdfError(null);
|
||||
try {
|
||||
const status = await api.archiveStatus(id);
|
||||
if (gen !== generation.current) return;
|
||||
setArchive(status);
|
||||
if (status.archived) {
|
||||
const bytes = await api.readArchive(id);
|
||||
if (gen === generation.current) setPdf(bytes);
|
||||
} else {
|
||||
setPdf(null);
|
||||
}
|
||||
} catch (e) {
|
||||
if (gen === generation.current) setPdfError(String(e));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadData = useCallback(async (id: number, gen: number) => {
|
||||
try {
|
||||
const [inv, pays] = await Promise.all([api.getInvoice(id), api.listPayments(id)]);
|
||||
if (gen !== generation.current) return;
|
||||
setInvoice(inv);
|
||||
setPayments(pays);
|
||||
setLoadError(null);
|
||||
} catch (e) {
|
||||
if (gen === generation.current) setLoadError(String(e));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const gen = ++generation.current;
|
||||
setInvoice(null);
|
||||
setPdf(null);
|
||||
setArchive(null);
|
||||
void loadData(invoiceId, gen);
|
||||
void loadPdf(invoiceId, gen);
|
||||
}, [invoiceId, loadData, loadPdf]);
|
||||
|
||||
const refresh = () => loadData(invoiceId, generation.current);
|
||||
|
||||
const exp = useInvoiceExport({
|
||||
settings,
|
||||
bank: null,
|
||||
// An export that had to archive the original first: show it in the preview.
|
||||
onInvoiceUpdated: () => void loadPdf(invoiceId, generation.current),
|
||||
});
|
||||
|
||||
const busy = exp.busy || archiving || duplicating;
|
||||
|
||||
const onExport = (mode: ExportMode, source: ExportSource) => {
|
||||
if (!invoice) return;
|
||||
void exp.start(invoice, mode, source).catch((e) => toast.error("Export failed", String(e)));
|
||||
};
|
||||
|
||||
const onArchive = async () => {
|
||||
if (!invoice) return;
|
||||
setArchiving(true);
|
||||
try {
|
||||
const outcome = await renderAndArchive(invoice, createExportDeps({ settings, bank: null }));
|
||||
if (outcome.archived) {
|
||||
toast.success(`Invoice ${invoice.number} is archived`);
|
||||
await Promise.all([loadPdf(invoiceId, generation.current), refresh()]);
|
||||
} else {
|
||||
toast.error("Could not archive the PDF", outcome.error ?? "The PDF could not be archived.");
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error("Could not archive the PDF", String(e));
|
||||
} finally {
|
||||
setArchiving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onDuplicate = async () => {
|
||||
if (!invoice) return;
|
||||
setDuplicating(true);
|
||||
try {
|
||||
const id = await api.saveDraft(null, toDraftPayload(invoiceToFormState(invoice, settings)));
|
||||
toast.success("Draft created from this invoice", "Review it, then issue it as a new invoice.");
|
||||
onDuplicated(id);
|
||||
} catch (e) {
|
||||
toast.error("Could not duplicate the invoice", String(e));
|
||||
} finally {
|
||||
setDuplicating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onCancelInvoice = async () => {
|
||||
if (!invoice) return;
|
||||
try {
|
||||
await api.cancelInvoice(invoice.id, reason);
|
||||
toast.success(`Invoice ${invoice.number} cancelled`);
|
||||
setCancelOpen(false);
|
||||
setReason("");
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setCancelOpen(false);
|
||||
toast.error("Could not cancel the invoice", String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const onDeletePayment = async () => {
|
||||
if (!deleteTarget) return;
|
||||
const target = deleteTarget;
|
||||
setDeleteTarget(null);
|
||||
try {
|
||||
await api.deletePayment(target.id);
|
||||
toast.success("Payment deleted", `${formatPaise(target.amountPaise + target.tdsPaise)} on ${formatDate(target.paidOn)}`);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
toast.error("Could not delete the payment", String(e));
|
||||
}
|
||||
};
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<div className="voiced-page">
|
||||
<Button kind="ghost" size="sm" renderIcon={ArrowLeft} onClick={onBack}>
|
||||
Back to invoices
|
||||
</Button>
|
||||
<InlineNotification kind="error" lowContrast hideCloseButton title="Could not load the invoice" subtitle={loadError} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!invoice) {
|
||||
return (
|
||||
<div className="voiced-page">
|
||||
<InlineLoading description="Loading invoice" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const badge = statusBadge(invoice.status, invoice.paymentStatus);
|
||||
const vendor = parseSnapshot(invoice.vendorSnapshot);
|
||||
const total = invoice.totalPaise ?? Math.round(invoice.total * 100);
|
||||
const balance = invoice.balancePaise ?? 0;
|
||||
const canPay = invoice.status === "issued" && balance > 0;
|
||||
const half = invoice.taxRate / 2;
|
||||
|
||||
return (
|
||||
<div className="voiced-page voiced-detail">
|
||||
<Button kind="ghost" size="sm" renderIcon={ArrowLeft} onClick={onBack}>
|
||||
Back to invoices
|
||||
</Button>
|
||||
|
||||
<div className="voiced-detail__head">
|
||||
<h1 className="voiced-page__title">{invoice.number}</h1>
|
||||
<Tag type={badge.type}>{badge.label}</Tag>
|
||||
{invoice.status === "issued" && invoice.paymentStatus === "overdue" && (invoice.paidPaise ?? 0) + (invoice.tdsPaise ?? 0) > 0 ? (
|
||||
<Tag type="blue" size="sm">
|
||||
Partly paid
|
||||
</Tag>
|
||||
) : null}
|
||||
{archive && !archive.archived ? (
|
||||
<Tag type="warm-gray" size="sm">
|
||||
Not archived
|
||||
</Tag>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="voiced-page__subtitle">
|
||||
{invoice.clientName || "No client name"} · issued {formatDate(invoice.invoiceDate)}
|
||||
{invoice.dueDate ? ` · due ${formatDate(invoice.dueDate)}` : ""}
|
||||
</p>
|
||||
|
||||
{invoice.status === "cancelled" ? (
|
||||
<InlineNotification
|
||||
kind="warning"
|
||||
lowContrast
|
||||
hideCloseButton
|
||||
title="This invoice is cancelled"
|
||||
subtitle={invoice.cancelReason ? `Reason: ${invoice.cancelReason}` : "Its number is not reused."}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="voiced-detail__actions" role="toolbar" aria-label="Invoice actions">
|
||||
{canPay ? (
|
||||
<Button size="md" onClick={() => setRecordOpen(true)} disabled={busy}>
|
||||
Record payment
|
||||
</Button>
|
||||
) : null}
|
||||
<Button kind="tertiary" size="md" onClick={() => onExport("searchable", "original")} disabled={busy}>
|
||||
Export PDF
|
||||
</Button>
|
||||
<Button kind="tertiary" size="md" onClick={() => onExport("flattened", "original")} disabled={busy}>
|
||||
Export flattened
|
||||
</Button>
|
||||
<Button kind="tertiary" size="md" onClick={() => void onDuplicate()} disabled={busy}>
|
||||
{duplicating ? "Duplicating…" : "Duplicate"}
|
||||
</Button>
|
||||
<OverflowMenu aria-label="More actions" iconDescription="More actions" flipped size="md" disabled={busy}>
|
||||
<OverflowMenuItem itemText="Re-render with current template" onClick={() => onExport("searchable", "rerender")} />
|
||||
{invoice.status === "issued" ? (
|
||||
<OverflowMenuItem
|
||||
itemText={payments.length > 0 ? "Cancel invoice (delete payments first)" : "Cancel invoice"}
|
||||
hasDivider
|
||||
isDelete
|
||||
disabled={payments.length > 0}
|
||||
onClick={() => {
|
||||
setReason("");
|
||||
setCancelOpen(true);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</OverflowMenu>
|
||||
</div>
|
||||
|
||||
<ExportFeedback exp={exp} />
|
||||
|
||||
<div className="voiced-detail__grid">
|
||||
<div className="voiced-detail__main voiced-stack">
|
||||
<div className="voiced-detail__tiles">
|
||||
<Tile>
|
||||
<p className="voiced-muted">Total</p>
|
||||
<p className="voiced-detail__amount">{formatPaise(total)}</p>
|
||||
</Tile>
|
||||
<Tile>
|
||||
<p className="voiced-muted">Received</p>
|
||||
<p className="voiced-detail__amount">{formatPaise(invoice.paidPaise ?? 0)}</p>
|
||||
</Tile>
|
||||
<Tile>
|
||||
<p className="voiced-muted">TDS deducted</p>
|
||||
<p className="voiced-detail__amount">{formatPaise(invoice.tdsPaise ?? 0)}</p>
|
||||
</Tile>
|
||||
<Tile>
|
||||
<p className="voiced-muted">Balance</p>
|
||||
<p className="voiced-detail__amount">{formatPaise(balance)}</p>
|
||||
</Tile>
|
||||
</div>
|
||||
|
||||
<section aria-labelledby="detail-parties">
|
||||
<h2 id="detail-parties" className="voiced-detail__h2">
|
||||
Parties
|
||||
</h2>
|
||||
<div className="voiced-detail__parties">
|
||||
<dl>
|
||||
<Field label="Billed to">{invoice.clientName}</Field>
|
||||
<Field label="Address">
|
||||
<span className="voiced-detail__pre">{invoice.clientAddress}</span>
|
||||
</Field>
|
||||
<Field label="GSTIN">{invoice.clientGstin}</Field>
|
||||
<Field label="PO number">{invoice.poNumber}</Field>
|
||||
<Field label="Place of supply">{invoice.placeOfSupplyStateCode ? stateName(invoice.placeOfSupplyStateCode) : ""}</Field>
|
||||
</dl>
|
||||
<dl>
|
||||
<Field label="Issued by">{vendor.vendorName}</Field>
|
||||
<Field label="Address">
|
||||
<span className="voiced-detail__pre">{vendor.vendorAddress}</span>
|
||||
</Field>
|
||||
<Field label="GSTIN">{vendor.vendorGstin}</Field>
|
||||
<Field label="PAN">{vendor.vendorPan}</Field>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="detail-items">
|
||||
<h2 id="detail-items" className="voiced-detail__h2">
|
||||
Items
|
||||
</h2>
|
||||
<TableContainer>
|
||||
<Table aria-label="Invoice items" size="sm">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeader>Description</TableHeader>
|
||||
<TableHeader>HSN/SAC</TableHeader>
|
||||
<TableHeader className="voiced-num">Amount</TableHeader>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{invoice.items.map((item, i) => (
|
||||
<TableRow key={item.id ?? i}>
|
||||
<TableCell>
|
||||
<span className="voiced-detail__pre">{item.description}</span>
|
||||
{item.mode === "rate" ? (
|
||||
<div className="voiced-muted">
|
||||
{item.quantity} {item.unit} × ₹{formatAmount(item.rate)}
|
||||
</div>
|
||||
) : null}
|
||||
</TableCell>
|
||||
<TableCell>{item.hsnSac || "—"}</TableCell>
|
||||
<TableCell className="voiced-num">₹{formatAmount(item.amount)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<dl className="voiced-detail__totals">
|
||||
<div>
|
||||
<dt>Subtotal</dt>
|
||||
<dd>₹{formatAmount(invoice.subtotal)}</dd>
|
||||
</div>
|
||||
{invoice.discount > 0 ? (
|
||||
<div>
|
||||
<dt>Discount</dt>
|
||||
<dd>−₹{formatAmount(invoice.discount)}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{invoice.taxType === "cgst_sgst" ? (
|
||||
<>
|
||||
<div>
|
||||
<dt>CGST @ {half}%</dt>
|
||||
<dd>₹{formatAmount(invoice.cgstAmount)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>SGST @ {half}%</dt>
|
||||
<dd>₹{formatAmount(invoice.sgstAmount)}</dd>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
{invoice.taxType === "igst" ? (
|
||||
<div>
|
||||
<dt>IGST @ {invoice.taxRate}%</dt>
|
||||
<dd>₹{formatAmount(invoice.igstAmount)}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="voiced-detail__grand">
|
||||
<dt>Total</dt>
|
||||
<dd>₹{formatAmount(invoice.total)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{invoice.amountInWords ? <p className="voiced-muted">{invoice.amountInWords}</p> : null}
|
||||
{invoice.notes ? <p className="voiced-detail__pre voiced-muted">{invoice.notes}</p> : null}
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="detail-payments">
|
||||
<h2 id="detail-payments" className="voiced-detail__h2">
|
||||
Payments
|
||||
</h2>
|
||||
{payments.length === 0 ? (
|
||||
<p className="voiced-muted">No payments recorded.</p>
|
||||
) : (
|
||||
<TableContainer>
|
||||
<Table aria-label="Payments" size="sm">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeader>Date</TableHeader>
|
||||
<TableHeader>Mode</TableHeader>
|
||||
<TableHeader>Reference</TableHeader>
|
||||
<TableHeader className="voiced-num">Received</TableHeader>
|
||||
<TableHeader className="voiced-num">TDS</TableHeader>
|
||||
<TableHeader>
|
||||
<span className="cds--assistive-text">Actions</span>
|
||||
</TableHeader>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{payments.map((p) => (
|
||||
<TableRow key={p.id}>
|
||||
<TableCell>{formatDate(p.paidOn)}</TableCell>
|
||||
<TableCell>{paymentModeLabel(p.mode)}</TableCell>
|
||||
<TableCell>
|
||||
{p.reference || "—"}
|
||||
{p.notes ? <div className="voiced-muted">{p.notes}</div> : null}
|
||||
</TableCell>
|
||||
<TableCell className="voiced-num">{formatPaise(p.amountPaise)}</TableCell>
|
||||
<TableCell className="voiced-num">{formatPaise(p.tdsPaise)}</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
kind="ghost"
|
||||
size="sm"
|
||||
hasIconOnly
|
||||
renderIcon={TrashCan}
|
||||
iconDescription={`Delete payment of ${formatPaise(p.amountPaise + p.tdsPaise)} on ${formatDate(p.paidOn)}`}
|
||||
tooltipPosition="left"
|
||||
onClick={() => setDeleteTarget(p)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<aside className="voiced-detail__preview" aria-label="Archived PDF">
|
||||
<h2 className="voiced-detail__h2">PDF as issued</h2>
|
||||
{archive && !archive.archived ? (
|
||||
<div className="voiced-detail__unarchived">
|
||||
<p>Issued – not archived. The original PDF was never saved, so there is nothing to preview yet.</p>
|
||||
<Button kind="primary" size="sm" onClick={() => void onArchive()} disabled={busy}>
|
||||
{archiving ? "Working…" : "Render & archive"}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<PdfPreview bytes={pdf} pages={0} issues={[]} rendering={archive === null && !pdfError} mode="worker" error={pdfError} />
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<RecordPaymentModal
|
||||
open={recordOpen}
|
||||
invoiceId={invoice.id}
|
||||
invoiceNumber={invoice.number}
|
||||
balancePaise={balance}
|
||||
onClose={() => setRecordOpen(false)}
|
||||
onRecorded={(p) => {
|
||||
setRecordOpen(false);
|
||||
toast.success("Payment recorded", `${formatPaise(p.amountPaise + p.tdsPaise)} on ${formatDate(p.paidOn)}`);
|
||||
void refresh();
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
open={deleteTarget !== null}
|
||||
danger
|
||||
modalHeading="Delete this payment?"
|
||||
primaryButtonText="Delete payment"
|
||||
secondaryButtonText="Keep payment"
|
||||
onRequestClose={() => setDeleteTarget(null)}
|
||||
onRequestSubmit={() => void onDeletePayment()}
|
||||
>
|
||||
<p>
|
||||
{deleteTarget
|
||||
? `${formatPaise(deleteTarget.amountPaise + deleteTarget.tdsPaise)} (cash ${formatPaise(deleteTarget.amountPaise)}, TDS ${formatPaise(deleteTarget.tdsPaise)}) received on ${formatDate(deleteTarget.paidOn)}. The invoice balance goes back up by that amount.`
|
||||
: ""}
|
||||
</p>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={cancelOpen}
|
||||
danger
|
||||
modalHeading={`Cancel invoice ${invoice.number}?`}
|
||||
primaryButtonText="Cancel invoice"
|
||||
secondaryButtonText="Keep invoice"
|
||||
onRequestClose={() => setCancelOpen(false)}
|
||||
onRequestSubmit={() => void onCancelInvoice()}
|
||||
>
|
||||
<p style={{ marginBottom: "1rem" }}>The invoice stays in the list as cancelled and its number is not reused.</p>
|
||||
<TextInput id="detailCancelReason" labelText="Reason (optional)" value={reason} onChange={(e) => setReason(e.target.value)} />
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user