Rechnungen: Überblick über fällige/überfällige Rechnungen und Verträge
This commit is contained in:
parent
5efe37ecc0
commit
3a2864ee76
5 changed files with 125 additions and 4 deletions
|
|
@ -1,6 +1,6 @@
|
|||
'use client';
|
||||
import { useCallback, useEffect, useState, type FormEvent } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react';
|
||||
import { useParams, useSearchParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { useSession } from '@/lib/session';
|
||||
|
|
@ -17,7 +17,7 @@ interface Contract { id: string; number: string; productName: string | null }
|
|||
interface Dom { id: string; domain: string; sellNetCents: number | null }
|
||||
|
||||
export default function RechnungDetail() {
|
||||
const { id } = useParams<{ id: string }>(); const { can } = useSession(); const staff = can('invoices.read');
|
||||
const { id } = useParams<{ id: string }>(); const sp = useSearchParams(); const { can } = useSession(); const staff = can('invoices.read');
|
||||
const [inv, setInv] = useState<Inv | null>(null); const [err, setErr] = useState(''); const [ok, setOk] = useState('');
|
||||
const [rows, setRows] = useState<Row[]>([emptyRow()]); const [busy, setBusy] = useState(false);
|
||||
const [products, setProducts] = useState<Prod[]>([]); const [contracts, setContracts] = useState<Contract[]>([]); const [domains, setDomains] = useState<Dom[]>([]);
|
||||
|
|
@ -36,6 +36,9 @@ export default function RechnungDetail() {
|
|||
if (!pickId) return; setErr('');
|
||||
try { const r = await api<{ items: Suggested[] }>('GET', `/invoices/suggest-from-${kind}/${pickId}${termMonths ? `?termMonths=${termMonths}` : ''}`); appendSuggested(r.items); } catch (x) { setErr(errMsg(x)); }
|
||||
}
|
||||
// Von der "Fällig"-Seite kommend: Position des Vertrags einmalig automatisch übernehmen.
|
||||
const fromContract = sp.get('fromContract'); const appliedRef = useRef(false);
|
||||
useEffect(() => { if (fromContract && inv?.status === 'draft' && inv.items.length === 0 && !appliedRef.current) { appliedRef.current = true; void takeFrom('contract', fromContract); } }, [fromContract, inv]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
async function saveItems(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
const items = rows.filter((r) => r.description.trim() && r.unitPrice.trim()).map((r) => ({ description: r.description.trim(), quantity: Number(r.quantity.replace(',', '.')), unitPriceNetCents: Math.round(Number(r.unitPrice.replace(',', '.')) * 100), taxBp: Math.round(Number(r.taxBp.replace(',', '.')) * 100) }));
|
||||
|
|
|
|||
46
apps/web/src/app/(app)/rechnungen/faellig/page.tsx
Normal file
46
apps/web/src/app/(app)/rechnungen/faellig/page.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
'use client';
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { Alert, Empty, eur, fmt } from '@/components/ui';
|
||||
|
||||
interface OverdueInv { id: string; number: string | null; orgName: string; customerNumber: string; dueDate: string | null; totalGrossCents: number }
|
||||
interface DueContract { id: string; number: string; orgId: string; orgName: string; customerNumber: string; productName: string | null; lastInvoicedAt: string | null; dueSince: string; lastGrossCents: number | null }
|
||||
interface Data { overdueInvoices: OverdueInv[]; dueContracts: DueContract[] }
|
||||
|
||||
export default function Faellig() {
|
||||
const r = useRouter();
|
||||
const [d, setD] = useState<Data | null>(null); const [err, setErr] = useState(''); const [busy, setBusy] = useState('');
|
||||
useEffect(() => { api<Data>('GET', '/invoices/reminders').then(setD).catch((e) => setErr(errMsg(e))); }, []);
|
||||
async function createFor(c: DueContract) {
|
||||
setErr(''); setBusy(c.id);
|
||||
try { const inv = await api<{ id: string }>('POST', '/invoices', { orgId: c.orgId }); r.push(`/rechnungen/${inv.id}?fromContract=${c.id}`); }
|
||||
catch (x) { setErr(errMsg(x)); setBusy(''); }
|
||||
}
|
||||
return (<>
|
||||
<div className="row between"><h1>Fällig</h1><Link className="btn" href="/rechnungen">← Rechnungen</Link></div>
|
||||
<p className="muted">Überfällige, bereits ausgestellte Rechnungen und aktive Verträge, bei denen die letzte Rechnung länger zurückliegt als ihre Verlängerungslaufzeit.</p>
|
||||
{err && <Alert kind="err">{err}</Alert>}
|
||||
{d === null ? <p className="muted" role="status">Wird geladen …</p> : (<>
|
||||
<div className="card">
|
||||
<h2>Überfällige Rechnungen ({d.overdueInvoices.length})</h2>
|
||||
{d.overdueInvoices.length === 0 ? <Empty title="Keine überfälligen Rechnungen" /> :
|
||||
<div className="tablewrap"><table><thead><tr><th>Nr.</th><th>Kunde</th><th>Fällig seit</th><th>Betrag</th></tr></thead><tbody>
|
||||
{d.overdueInvoices.map((i) => (<tr key={i.id}><td className="mono"><Link href={`/rechnungen/${i.id}`}>{i.number}</Link></td><td>{i.orgName} <span className="muted small">({i.customerNumber})</span></td>
|
||||
<td><span className="badge err"><span aria-hidden="true">▲</span>{i.dueDate ? fmt(i.dueDate) : '–'}</span></td><td>{eur(i.totalGrossCents)}</td></tr>))}
|
||||
</tbody></table></div>}
|
||||
</div>
|
||||
<div className="card">
|
||||
<h2>Verträge ohne aktuelle Rechnung ({d.dueContracts.length})</h2>
|
||||
{d.dueContracts.length === 0 ? <Empty title="Kein Vertrag fällig" /> :
|
||||
<div className="tablewrap"><table><thead><tr><th>Vertrag</th><th>Kunde</th><th>Produkt</th><th>Zuletzt berechnet</th><th>Fällig seit</th><th>Letzter Betrag</th><th></th></tr></thead><tbody>
|
||||
{d.dueContracts.map((c) => (<tr key={c.id}><td className="mono">{c.number}</td><td>{c.orgName} <span className="muted small">({c.customerNumber})</span></td><td>{c.productName ?? '–'}</td>
|
||||
<td className="small">{c.lastInvoicedAt ? fmt(c.lastInvoicedAt) : <span className="muted">nie</span>}</td><td><span className="badge warn"><span aria-hidden="true">▲</span>{fmt(c.dueSince)}</span></td>
|
||||
<td>{c.lastGrossCents === null ? '–' : eur(c.lastGrossCents)}</td><td><button className="btn small" disabled={busy === c.id} onClick={() => createFor(c)}>{busy === c.id ? '…' : 'Rechnung erstellen'}</button></td></tr>))}
|
||||
</tbody></table></div>}
|
||||
<p className="muted small">Grundlage ist die Verlängerungslaufzeit des Vertrags, nicht das Abrechnungsintervall des Produkts. Bei manuell abweichender Abrechnung bitte selbst prüfen.</p>
|
||||
</div>
|
||||
</>)}
|
||||
</>);
|
||||
}
|
||||
|
|
@ -1,3 +1,8 @@
|
|||
'use client';
|
||||
import Link from 'next/link';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { InvoiceList } from '@/components/InvoiceList';
|
||||
export default function Rechnungen() { return (<><h1>Rechnungen</h1><InvoiceList /></>); }
|
||||
export default function Rechnungen() {
|
||||
const { can } = useSession();
|
||||
return (<><div className="row between"><h1>Rechnungen</h1>{can('invoices.read') && <Link className="btn" href="/rechnungen/faellig">Fällig</Link>}</div><InvoiceList /></>);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue