ADD: changed to rect router
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string().min(1, "Name ist erforderlich"),
|
||||
legalForm: z.string().optional(),
|
||||
taxId: z.string().optional(),
|
||||
vatId: z.string().optional(),
|
||||
address: z.string().min(1, "Adresse ist erforderlich"),
|
||||
zip: z.string().min(1, "PLZ ist erforderlich"),
|
||||
city: z.string().min(1, "Ort ist erforderlich"),
|
||||
country: z.string().optional(),
|
||||
email: z.string().email("Ungültige E-Mail").optional().or(z.literal("")),
|
||||
phone: z.string().optional(),
|
||||
website: z.string().optional(),
|
||||
bankIban: z.string().optional(),
|
||||
bankBic: z.string().optional(),
|
||||
bankName: z.string().optional(),
|
||||
invoicePrefix: z.string().optional(),
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof schema>;
|
||||
|
||||
interface CompanyFormProps {
|
||||
defaultValues?: Partial<FormData>;
|
||||
onSubmit: (data: FormData) => Promise<void>;
|
||||
submitLabel?: string;
|
||||
}
|
||||
|
||||
function Field({ label, error, children }: { label: string; error?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label>{label}</Label>
|
||||
{children}
|
||||
{error && <p className="text-xs text-red-600">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CompanyForm({ defaultValues, onSubmit, submitLabel = "Speichern" }: CompanyFormProps) {
|
||||
const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<FormData>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { country: "DE", invoicePrefix: "RE", ...defaultValues },
|
||||
});
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">Stammdaten</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Field label="Firmenname *" error={errors.name?.message}>
|
||||
<Input {...register("name")} placeholder="Muster GmbH" />
|
||||
</Field>
|
||||
<Field label="Rechtsform" error={errors.legalForm?.message}>
|
||||
<Input {...register("legalForm")} placeholder="GmbH, AG, UG..." />
|
||||
</Field>
|
||||
<Field label="Steuernummer" error={errors.taxId?.message}>
|
||||
<Input {...register("taxId")} placeholder="123/456/78901" />
|
||||
</Field>
|
||||
<Field label="USt-IdNr." error={errors.vatId?.message}>
|
||||
<Input {...register("vatId")} placeholder="DE123456789" />
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">Anschrift</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="md:col-span-2">
|
||||
<Field label="Straße & Hausnummer *" error={errors.address?.message}>
|
||||
<Input {...register("address")} placeholder="Musterstraße 1" />
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="PLZ *" error={errors.zip?.message}>
|
||||
<Input {...register("zip")} placeholder="10115" />
|
||||
</Field>
|
||||
<Field label="Ort *" error={errors.city?.message}>
|
||||
<Input {...register("city")} placeholder="Berlin" />
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">Kontakt</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Field label="E-Mail" error={errors.email?.message}>
|
||||
<Input {...register("email")} type="email" placeholder="info@firma.de" />
|
||||
</Field>
|
||||
<Field label="Telefon" error={errors.phone?.message}>
|
||||
<Input {...register("phone")} placeholder="+49 30 12345678" />
|
||||
</Field>
|
||||
<Field label="Website" error={errors.website?.message}>
|
||||
<Input {...register("website")} placeholder="https://firma.de" />
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">Bankverbindung</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="md:col-span-2">
|
||||
<Field label="IBAN" error={errors.bankIban?.message}>
|
||||
<Input {...register("bankIban")} placeholder="DE89 3704 0044 0532 0130 00" />
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="BIC" error={errors.bankBic?.message}>
|
||||
<Input {...register("bankBic")} placeholder="COBADEFFXXX" />
|
||||
</Field>
|
||||
<Field label="Kreditinstitut" error={errors.bankName?.message}>
|
||||
<Input {...register("bankName")} placeholder="Commerzbank" />
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">Rechnungseinstellungen</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Field label="Rechnungsnummern-Präfix" error={errors.invoicePrefix?.message}>
|
||||
<Input {...register("invoicePrefix")} placeholder="RE" />
|
||||
</Field>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">Format: {"{Präfix}"}-{"{Jahr}"}-{"{Nummer}"} z.B. RE-2024-001</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Speichern..." : submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useForm, useFieldArray } from "react-hook-form";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { calcItemAmounts, calcInvoiceTotals, formatCurrency, TAX_RATES } from "@/lib/tax";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
|
||||
interface Customer {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface ItemFormData {
|
||||
position: number;
|
||||
description: string;
|
||||
quantity: string;
|
||||
unit: string;
|
||||
unitPrice: string;
|
||||
taxRate: string;
|
||||
netAmount: number;
|
||||
taxAmount: number;
|
||||
grossAmount: number;
|
||||
}
|
||||
|
||||
interface InvoiceFormValues {
|
||||
customerId: string;
|
||||
issueDate: string;
|
||||
deliveryDate: string;
|
||||
dueDate: string;
|
||||
notes: string;
|
||||
items: ItemFormData[];
|
||||
}
|
||||
|
||||
interface InvoiceFormProps {
|
||||
customers: Customer[];
|
||||
companyId: string;
|
||||
onSubmit: (data: Record<string, unknown>) => Promise<void>;
|
||||
defaultValues?: Partial<InvoiceFormValues>;
|
||||
}
|
||||
|
||||
const defaultItem = (): ItemFormData => ({
|
||||
position: 1,
|
||||
description: "",
|
||||
quantity: "1",
|
||||
unit: "Stück",
|
||||
unitPrice: "0.00",
|
||||
taxRate: "19",
|
||||
netAmount: 0,
|
||||
taxAmount: 0,
|
||||
grossAmount: 0,
|
||||
});
|
||||
|
||||
export function InvoiceForm({ customers, companyId, onSubmit }: InvoiceFormProps) {
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
const dueDefault = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split("T")[0];
|
||||
|
||||
const { register, handleSubmit, watch, setValue, control, formState: { errors, isSubmitting } } = useForm<InvoiceFormValues>({
|
||||
defaultValues: {
|
||||
customerId: "",
|
||||
issueDate: today,
|
||||
deliveryDate: today,
|
||||
dueDate: dueDefault,
|
||||
notes: "",
|
||||
items: [defaultItem()],
|
||||
},
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({ control, name: "items" });
|
||||
const watchedItems = watch("items");
|
||||
|
||||
const recalcItem = useCallback((index: number) => {
|
||||
const item = watchedItems[index];
|
||||
if (!item) return;
|
||||
const qty = parseFloat(item.quantity) || 0;
|
||||
const price = parseFloat(item.unitPrice) || 0;
|
||||
const rate = parseFloat(item.taxRate) || 0;
|
||||
const { netAmount, taxAmount, grossAmount } = calcItemAmounts(qty, price, rate);
|
||||
setValue(`items.${index}.netAmount`, netAmount);
|
||||
setValue(`items.${index}.taxAmount`, taxAmount);
|
||||
setValue(`items.${index}.grossAmount`, grossAmount);
|
||||
}, [watchedItems, setValue]);
|
||||
|
||||
const totals = calcInvoiceTotals(
|
||||
watchedItems.map((item) => ({
|
||||
netAmount: item.netAmount ?? 0,
|
||||
taxAmount: item.taxAmount ?? 0,
|
||||
grossAmount: item.grossAmount ?? 0,
|
||||
}))
|
||||
);
|
||||
|
||||
async function handleFormSubmit(data: InvoiceFormValues) {
|
||||
const items = data.items.map((item, i) => {
|
||||
const qty = parseFloat(item.quantity) || 0;
|
||||
const price = parseFloat(item.unitPrice) || 0;
|
||||
const rate = parseFloat(item.taxRate) || 0;
|
||||
const { netAmount, taxAmount, grossAmount } = calcItemAmounts(qty, price, rate);
|
||||
return {
|
||||
position: i + 1,
|
||||
description: item.description,
|
||||
quantity: qty,
|
||||
unit: item.unit || undefined,
|
||||
unitPrice: price,
|
||||
taxRate: rate,
|
||||
netAmount,
|
||||
taxAmount,
|
||||
grossAmount,
|
||||
};
|
||||
});
|
||||
|
||||
const totals = calcInvoiceTotals(items);
|
||||
|
||||
await onSubmit({
|
||||
companyId,
|
||||
customerId: data.customerId,
|
||||
issueDate: data.issueDate,
|
||||
deliveryDate: data.deliveryDate || undefined,
|
||||
dueDate: data.dueDate,
|
||||
notes: data.notes || undefined,
|
||||
items,
|
||||
...totals,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Kunde *</Label>
|
||||
<Select onValueChange={(v) => setValue("customerId", v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Kunde auswählen..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{customers.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.customerId && <p className="text-xs text-red-600">Pflichtfeld</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Rechnungsdatum *</Label>
|
||||
<Input type="date" {...register("issueDate", { required: true })} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Leistungsdatum</Label>
|
||||
<Input type="date" {...register("deliveryDate")} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Fällig am *</Label>
|
||||
<Input type="date" {...register("dueDate", { required: true })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">Rechnungspositionen</h3>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => append(defaultItem())}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" /> Position hinzufügen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="border border-gray-200 rounded-xl overflow-hidden">
|
||||
<div className="grid grid-cols-12 gap-2 px-3 py-2 bg-gray-50 border-b border-gray-200 text-xs font-medium text-gray-600">
|
||||
<div className="col-span-4">Beschreibung</div>
|
||||
<div className="col-span-1">Menge</div>
|
||||
<div className="col-span-1">Einh.</div>
|
||||
<div className="col-span-2">Einzelpreis</div>
|
||||
<div className="col-span-1">MwSt.</div>
|
||||
<div className="col-span-2 text-right">Gesamt (brutto)</div>
|
||||
<div className="col-span-1"></div>
|
||||
</div>
|
||||
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="grid grid-cols-12 gap-2 px-3 py-2.5 border-b border-gray-100 last:border-0 items-center">
|
||||
<div className="col-span-4">
|
||||
<Input
|
||||
{...register(`items.${index}.description`, { required: true })}
|
||||
placeholder="Leistungsbeschreibung"
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<Input
|
||||
{...register(`items.${index}.quantity`)}
|
||||
className="text-sm text-right"
|
||||
onBlur={() => recalcItem(index)}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<Input
|
||||
{...register(`items.${index}.unit`)}
|
||||
placeholder="Stück"
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<Input
|
||||
{...register(`items.${index}.unitPrice`)}
|
||||
className="text-sm text-right"
|
||||
onBlur={() => recalcItem(index)}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<Select
|
||||
defaultValue="19"
|
||||
onValueChange={(v) => {
|
||||
setValue(`items.${index}.taxRate`, v);
|
||||
recalcItem(index);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TAX_RATES.map((r) => (
|
||||
<SelectItem key={r.value} value={String(r.value)}>
|
||||
{r.value}%
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="col-span-2 text-right text-sm font-medium text-gray-900">
|
||||
{formatCurrency(watchedItems[index]?.grossAmount ?? 0)}
|
||||
</div>
|
||||
<div className="col-span-1 flex justify-end">
|
||||
{fields.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-red-400 hover:text-red-600 hover:bg-red-50"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end">
|
||||
<div className="w-64 space-y-1.5">
|
||||
<div className="flex justify-between text-sm text-gray-600">
|
||||
<span>Netto</span>
|
||||
<span>{formatCurrency(totals.netTotal)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm text-gray-600">
|
||||
<span>MwSt.</span>
|
||||
<span>{formatCurrency(totals.taxTotal)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-base font-semibold text-gray-900 border-t border-gray-200 pt-1.5">
|
||||
<span>Gesamt (brutto)</span>
|
||||
<span>{formatCurrency(totals.grossTotal)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Anmerkungen</Label>
|
||||
<Textarea {...register("notes")} placeholder="Zahlungsbedingungen, Hinweise..." rows={3} />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button type="submit" disabled={isSubmitting} size="lg">
|
||||
{isSubmitting ? "Erstelle Rechnung..." : "Rechnung erstellen"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Document,
|
||||
Page,
|
||||
Text,
|
||||
View,
|
||||
StyleSheet,
|
||||
} from "@react-pdf/renderer";
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
page: {
|
||||
fontFamily: "Helvetica",
|
||||
fontSize: 9,
|
||||
color: "#111827",
|
||||
paddingTop: 50,
|
||||
paddingBottom: 60,
|
||||
paddingLeft: 55,
|
||||
paddingRight: 55,
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 30,
|
||||
},
|
||||
companyName: {
|
||||
fontSize: 16,
|
||||
fontFamily: "Helvetica-Bold",
|
||||
color: "#1e1b4b",
|
||||
marginBottom: 3,
|
||||
},
|
||||
companyInfo: {
|
||||
fontSize: 8,
|
||||
color: "#6b7280",
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
invoiceTitle: {
|
||||
fontSize: 22,
|
||||
fontFamily: "Helvetica-Bold",
|
||||
color: "#1e1b4b",
|
||||
marginBottom: 20,
|
||||
},
|
||||
metaGrid: {
|
||||
flexDirection: "row",
|
||||
gap: 0,
|
||||
marginBottom: 20,
|
||||
backgroundColor: "#f9fafb",
|
||||
padding: 10,
|
||||
borderRadius: 4,
|
||||
},
|
||||
metaItem: {
|
||||
flex: 1,
|
||||
},
|
||||
metaLabel: {
|
||||
fontSize: 7,
|
||||
color: "#9ca3af",
|
||||
textTransform: "uppercase",
|
||||
marginBottom: 2,
|
||||
},
|
||||
metaValue: {
|
||||
fontSize: 9,
|
||||
fontFamily: "Helvetica-Bold",
|
||||
color: "#111827",
|
||||
},
|
||||
addressSection: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 25,
|
||||
},
|
||||
addressBlock: {
|
||||
flex: 1,
|
||||
},
|
||||
addressLabel: {
|
||||
fontSize: 7,
|
||||
color: "#9ca3af",
|
||||
textTransform: "uppercase",
|
||||
marginBottom: 4,
|
||||
},
|
||||
addressName: {
|
||||
fontSize: 10,
|
||||
fontFamily: "Helvetica-Bold",
|
||||
color: "#111827",
|
||||
marginBottom: 2,
|
||||
},
|
||||
addressLine: {
|
||||
fontSize: 8.5,
|
||||
color: "#374151",
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
tableHeader: {
|
||||
flexDirection: "row",
|
||||
backgroundColor: "#1e1b4b",
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: 8,
|
||||
borderRadius: 3,
|
||||
marginBottom: 0,
|
||||
},
|
||||
tableHeaderText: {
|
||||
color: "#ffffff",
|
||||
fontSize: 8,
|
||||
fontFamily: "Helvetica-Bold",
|
||||
},
|
||||
tableRow: {
|
||||
flexDirection: "row",
|
||||
paddingVertical: 5,
|
||||
paddingHorizontal: 8,
|
||||
borderBottomColor: "#e5e7eb",
|
||||
borderBottomWidth: 0.5,
|
||||
},
|
||||
tableRowAlt: {
|
||||
backgroundColor: "#f9fafb",
|
||||
},
|
||||
col_pos: { width: "5%" },
|
||||
col_desc: { width: "40%" },
|
||||
col_qty: { width: "10%", textAlign: "right" },
|
||||
col_unit: { width: "8%", textAlign: "center" },
|
||||
col_price: { width: "14%", textAlign: "right" },
|
||||
col_tax: { width: "8%", textAlign: "center" },
|
||||
col_total: { width: "15%", textAlign: "right" },
|
||||
totalsSection: {
|
||||
marginTop: 15,
|
||||
flexDirection: "row",
|
||||
justifyContent: "flex-end",
|
||||
},
|
||||
totalsTable: {
|
||||
width: 220,
|
||||
},
|
||||
totalsRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
paddingVertical: 3,
|
||||
},
|
||||
totalsLabel: {
|
||||
fontSize: 9,
|
||||
color: "#6b7280",
|
||||
},
|
||||
totalsValue: {
|
||||
fontSize: 9,
|
||||
color: "#374151",
|
||||
},
|
||||
totalsFinalRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
paddingVertical: 4,
|
||||
borderTopColor: "#1e1b4b",
|
||||
borderTopWidth: 1.5,
|
||||
marginTop: 3,
|
||||
},
|
||||
totalsFinalLabel: {
|
||||
fontSize: 10,
|
||||
fontFamily: "Helvetica-Bold",
|
||||
color: "#1e1b4b",
|
||||
},
|
||||
totalsFinalValue: {
|
||||
fontSize: 10,
|
||||
fontFamily: "Helvetica-Bold",
|
||||
color: "#1e1b4b",
|
||||
},
|
||||
notes: {
|
||||
marginTop: 20,
|
||||
padding: 10,
|
||||
backgroundColor: "#f9fafb",
|
||||
borderRadius: 4,
|
||||
},
|
||||
notesLabel: {
|
||||
fontSize: 7,
|
||||
color: "#9ca3af",
|
||||
textTransform: "uppercase",
|
||||
marginBottom: 3,
|
||||
},
|
||||
notesText: {
|
||||
fontSize: 8.5,
|
||||
color: "#374151",
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
footer: {
|
||||
position: "absolute",
|
||||
bottom: 30,
|
||||
left: 55,
|
||||
right: 55,
|
||||
borderTopColor: "#e5e7eb",
|
||||
borderTopWidth: 0.5,
|
||||
paddingTop: 8,
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
},
|
||||
footerText: {
|
||||
fontSize: 7,
|
||||
color: "#9ca3af",
|
||||
},
|
||||
});
|
||||
|
||||
function formatMoney(amount: number) {
|
||||
return new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }).format(amount);
|
||||
}
|
||||
|
||||
function formatDate(date: Date | string) {
|
||||
return new Intl.DateTimeFormat("de-DE").format(new Date(date));
|
||||
}
|
||||
|
||||
interface InvoicePDFProps {
|
||||
invoice: {
|
||||
number: string;
|
||||
issueDate: Date | string;
|
||||
deliveryDate?: Date | string | null;
|
||||
dueDate: Date | string;
|
||||
notes?: string | null;
|
||||
netTotal: number | string | { toString(): string };
|
||||
taxTotal: number | string | { toString(): string };
|
||||
grossTotal: number | string | { toString(): string };
|
||||
company: {
|
||||
name: string;
|
||||
legalForm?: string | null;
|
||||
taxId?: string | null;
|
||||
vatId?: string | null;
|
||||
address: string;
|
||||
zip: string;
|
||||
city: string;
|
||||
email?: string | null;
|
||||
phone?: string | null;
|
||||
bankIban?: string | null;
|
||||
bankBic?: string | null;
|
||||
bankName?: string | null;
|
||||
};
|
||||
customer: {
|
||||
name: string;
|
||||
vatId?: string | null;
|
||||
address: string;
|
||||
zip: string;
|
||||
city: string;
|
||||
country: string;
|
||||
};
|
||||
items: Array<{
|
||||
position: number;
|
||||
description: string;
|
||||
quantity: number | string | { toString(): string };
|
||||
unit?: string | null;
|
||||
unitPrice: number | string | { toString(): string };
|
||||
taxRate: number | string | { toString(): string };
|
||||
netAmount: number | string | { toString(): string };
|
||||
taxAmount: number | string | { toString(): string };
|
||||
grossAmount: number | string | { toString(): string };
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
export function InvoicePDFDocument({ invoice }: InvoicePDFProps) {
|
||||
const n = (v: unknown) => Number(v);
|
||||
|
||||
const taxGroups = invoice.items.reduce(
|
||||
(acc, item) => {
|
||||
const rate = n(item.taxRate);
|
||||
if (!acc[rate]) acc[rate] = { net: 0, tax: 0 };
|
||||
acc[rate].net += n(item.netAmount);
|
||||
acc[rate].tax += n(item.taxAmount);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<number, { net: number; tax: number }>
|
||||
);
|
||||
|
||||
return (
|
||||
<Document>
|
||||
<Page size="A4" style={styles.page}>
|
||||
<View style={styles.header}>
|
||||
<View>
|
||||
<Text style={styles.companyName}>{invoice.company.name}</Text>
|
||||
{invoice.company.legalForm && (
|
||||
<Text style={styles.companyInfo}>{invoice.company.legalForm}</Text>
|
||||
)}
|
||||
<Text style={styles.companyInfo}>{invoice.company.address}</Text>
|
||||
<Text style={styles.companyInfo}>{invoice.company.zip} {invoice.company.city}</Text>
|
||||
{invoice.company.email && (
|
||||
<Text style={styles.companyInfo}>{invoice.company.email}</Text>
|
||||
)}
|
||||
{invoice.company.phone && (
|
||||
<Text style={styles.companyInfo}>{invoice.company.phone}</Text>
|
||||
)}
|
||||
</View>
|
||||
<View style={{ alignItems: "flex-end" }}>
|
||||
{invoice.company.taxId && (
|
||||
<Text style={styles.companyInfo}>St.-Nr.: {invoice.company.taxId}</Text>
|
||||
)}
|
||||
{invoice.company.vatId && (
|
||||
<Text style={styles.companyInfo}>USt-IdNr.: {invoice.company.vatId}</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text style={styles.invoiceTitle}>Rechnung</Text>
|
||||
|
||||
<View style={styles.addressSection}>
|
||||
<View style={styles.addressBlock}>
|
||||
<Text style={styles.addressLabel}>Rechnungsempfänger</Text>
|
||||
<Text style={styles.addressName}>{invoice.customer.name}</Text>
|
||||
<Text style={styles.addressLine}>{invoice.customer.address}</Text>
|
||||
<Text style={styles.addressLine}>{invoice.customer.zip} {invoice.customer.city}</Text>
|
||||
{invoice.customer.country !== "DE" && (
|
||||
<Text style={styles.addressLine}>{invoice.customer.country}</Text>
|
||||
)}
|
||||
{invoice.customer.vatId && (
|
||||
<Text style={{ ...styles.addressLine, marginTop: 3 }}>
|
||||
USt-IdNr.: {invoice.customer.vatId}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.metaGrid}>
|
||||
<View style={styles.metaItem}>
|
||||
<Text style={styles.metaLabel}>Rechnungsnummer</Text>
|
||||
<Text style={styles.metaValue}>{invoice.number}</Text>
|
||||
</View>
|
||||
<View style={styles.metaItem}>
|
||||
<Text style={styles.metaLabel}>Rechnungsdatum</Text>
|
||||
<Text style={styles.metaValue}>{formatDate(invoice.issueDate)}</Text>
|
||||
</View>
|
||||
{invoice.deliveryDate && (
|
||||
<View style={styles.metaItem}>
|
||||
<Text style={styles.metaLabel}>Leistungsdatum</Text>
|
||||
<Text style={styles.metaValue}>{formatDate(invoice.deliveryDate)}</Text>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.metaItem}>
|
||||
<Text style={styles.metaLabel}>Fällig am</Text>
|
||||
<Text style={styles.metaValue}>{formatDate(invoice.dueDate)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.tableHeader}>
|
||||
<Text style={{ ...styles.tableHeaderText, ...styles.col_pos }}>#</Text>
|
||||
<Text style={{ ...styles.tableHeaderText, ...styles.col_desc }}>Beschreibung</Text>
|
||||
<Text style={{ ...styles.tableHeaderText, ...styles.col_qty }}>Menge</Text>
|
||||
<Text style={{ ...styles.tableHeaderText, ...styles.col_unit }}>Einh.</Text>
|
||||
<Text style={{ ...styles.tableHeaderText, ...styles.col_price }}>EP (netto)</Text>
|
||||
<Text style={{ ...styles.tableHeaderText, ...styles.col_tax }}>MwSt.</Text>
|
||||
<Text style={{ ...styles.tableHeaderText, ...styles.col_total }}>Gesamt (brutto)</Text>
|
||||
</View>
|
||||
|
||||
{invoice.items.map((item, idx) => (
|
||||
<View key={idx} style={[styles.tableRow, idx % 2 === 1 ? styles.tableRowAlt : {}]}>
|
||||
<Text style={{ ...styles.col_pos, fontSize: 9 }}>{item.position}</Text>
|
||||
<Text style={{ ...styles.col_desc, fontSize: 9 }}>{item.description}</Text>
|
||||
<Text style={{ ...styles.col_qty, fontSize: 9 }}>{n(item.quantity)}</Text>
|
||||
<Text style={{ ...styles.col_unit, fontSize: 9 }}>{item.unit ?? ""}</Text>
|
||||
<Text style={{ ...styles.col_price, fontSize: 9 }}>{formatMoney(n(item.unitPrice))}</Text>
|
||||
<Text style={{ ...styles.col_tax, fontSize: 9 }}>{n(item.taxRate)}%</Text>
|
||||
<Text style={{ ...styles.col_total, fontSize: 9, fontFamily: "Helvetica-Bold" }}>
|
||||
{formatMoney(n(item.grossAmount))}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
|
||||
<View style={styles.totalsSection}>
|
||||
<View style={styles.totalsTable}>
|
||||
<View style={styles.totalsRow}>
|
||||
<Text style={styles.totalsLabel}>Nettobetrag</Text>
|
||||
<Text style={styles.totalsValue}>{formatMoney(n(invoice.netTotal))}</Text>
|
||||
</View>
|
||||
{Object.entries(taxGroups).map(([rate, { net, tax }]) => (
|
||||
<View key={rate} style={styles.totalsRow}>
|
||||
<Text style={styles.totalsLabel}>MwSt. {rate}% auf {formatMoney(net)}</Text>
|
||||
<Text style={styles.totalsValue}>{formatMoney(tax)}</Text>
|
||||
</View>
|
||||
))}
|
||||
<View style={styles.totalsFinalRow}>
|
||||
<Text style={styles.totalsFinalLabel}>Gesamtbetrag (inkl. MwSt.)</Text>
|
||||
<Text style={styles.totalsFinalValue}>{formatMoney(n(invoice.grossTotal))}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{invoice.notes && (
|
||||
<View style={styles.notes}>
|
||||
<Text style={styles.notesLabel}>Hinweise</Text>
|
||||
<Text style={styles.notesText}>{invoice.notes}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.footer} fixed>
|
||||
<Text style={styles.footerText}>
|
||||
{invoice.company.name}
|
||||
{invoice.company.taxId ? ` · St.-Nr.: ${invoice.company.taxId}` : ""}
|
||||
{invoice.company.vatId ? ` · USt-IdNr.: ${invoice.company.vatId}` : ""}
|
||||
</Text>
|
||||
{invoice.company.bankIban && (
|
||||
<Text style={styles.footerText}>
|
||||
{invoice.company.bankName ? `${invoice.company.bankName} · ` : ""}
|
||||
IBAN: {invoice.company.bankIban}
|
||||
{invoice.company.bankBic ? ` · BIC: ${invoice.company.bankBic}` : ""}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</Page>
|
||||
</Document>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { InvoiceStatus } from "@prisma/client";
|
||||
|
||||
const statusConfig: Record<InvoiceStatus, { label: string; variant: "secondary" | "default" | "success" | "destructive" | "warning" }> = {
|
||||
DRAFT: { label: "Entwurf", variant: "secondary" },
|
||||
SENT: { label: "Versendet", variant: "warning" },
|
||||
PAID: { label: "Bezahlt", variant: "success" },
|
||||
CANCELLED: { label: "Storniert", variant: "destructive" },
|
||||
};
|
||||
|
||||
export function InvoiceStatusBadge({ status }: { status: InvoiceStatus }) {
|
||||
const config = statusConfig[status];
|
||||
return <Badge variant={config.variant}>{config.label}</Badge>;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Link, Form, useLocation } from "react-router";
|
||||
import {
|
||||
Calculator,
|
||||
Building2,
|
||||
LayoutDashboard,
|
||||
LogOut,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface NavItem {
|
||||
label: string;
|
||||
href: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ label: "Dashboard", href: "/", icon: <LayoutDashboard className="h-4 w-4" /> },
|
||||
{ label: "Mandanten", href: "/companies", icon: <Building2 className="h-4 w-4" /> },
|
||||
];
|
||||
|
||||
export function Sidebar({ userName }: { userName?: string | null }) {
|
||||
const location = useLocation();
|
||||
const pathname = location.pathname;
|
||||
|
||||
return (
|
||||
<aside className="w-60 shrink-0 flex flex-col bg-white border-r border-gray-200 min-h-screen">
|
||||
<div className="flex items-center gap-3 px-4 py-5 border-b border-gray-200">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-indigo-600">
|
||||
<Calculator className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<span className="font-semibold text-gray-900 text-sm leading-tight">
|
||||
Rechnungs-<br />manager
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 px-3 py-4 space-y-0.5">
|
||||
{navItems.map((item) => {
|
||||
const active = pathname === item.href || (item.href !== "/" && pathname.startsWith(item.href));
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
to={item.href}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
|
||||
active
|
||||
? "bg-indigo-50 text-indigo-700"
|
||||
: "text-gray-600 hover:bg-gray-100 hover:text-gray-900"
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
{active && <ChevronRight className="ml-auto h-3 w-3 text-indigo-400" />}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="px-3 py-4 border-t border-gray-200">
|
||||
{userName && (
|
||||
<p className="text-xs text-gray-500 px-3 mb-2 truncate">{userName}</p>
|
||||
)}
|
||||
<Form method="post" action="/logout">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start text-gray-600 hover:text-red-600 hover:bg-red-50"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Abmelden
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-transparent bg-indigo-100 text-indigo-800",
|
||||
secondary: "border-transparent bg-gray-100 text-gray-800",
|
||||
destructive: "border-transparent bg-red-100 text-red-800",
|
||||
success: "border-transparent bg-green-100 text-green-800",
|
||||
warning: "border-transparent bg-yellow-100 text-yellow-800",
|
||||
outline: "text-gray-700 border-gray-300",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
@@ -0,0 +1,52 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-indigo-600 text-white shadow hover:bg-indigo-700",
|
||||
destructive: "bg-red-500 text-white shadow-sm hover:bg-red-600",
|
||||
outline: "border border-gray-300 bg-white shadow-sm hover:bg-gray-50 text-gray-700",
|
||||
secondary: "bg-gray-100 text-gray-900 shadow-sm hover:bg-gray-200",
|
||||
ghost: "hover:bg-gray-100 text-gray-700",
|
||||
link: "text-indigo-600 underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-lg px-6",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -0,0 +1,50 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("rounded-xl border border-gray-200 bg-white shadow-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
Card.displayName = "Card";
|
||||
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h3 ref={ref} className={cn("font-semibold leading-none tracking-tight text-gray-900", className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<p ref={ref} className={cn("text-sm text-gray-500", className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardDescription.displayName = "CardDescription";
|
||||
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardFooter.displayName = "CardFooter";
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||
@@ -0,0 +1,79 @@
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn("fixed inset-0 z-50 bg-black/50 backdrop-blur-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 rounded-xl border border-gray-200 bg-white p-6 shadow-xl",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogClose className="absolute right-4 top-4 rounded-md opacity-70 ring-offset-white transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Schließen</span>
|
||||
</DialogClose>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
|
||||
);
|
||||
DialogHeader.displayName = "DialogHeader";
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold leading-none tracking-tight text-gray-900", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-gray-500", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog, DialogPortal, DialogOverlay, DialogTrigger, DialogClose,
|
||||
DialogContent, DialogHeader, DialogTitle, DialogDescription,
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-lg border border-gray-300 bg-white px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-gray-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
@@ -0,0 +1,17 @@
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("text-sm font-medium text-gray-700 leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
@@ -0,0 +1,84 @@
|
||||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { Check, ChevronDown } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 text-gray-400" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 min-w-[8rem] overflow-hidden rounded-lg border border-gray-200 bg-white text-gray-900 shadow-lg animate-in fade-in-0 zoom-in-95",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.Viewport className="p-1">{children}</SelectPrimitive.Viewport>
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-md py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-indigo-50 focus:text-indigo-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("px-8 py-1.5 text-xs font-semibold text-gray-500", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
|
||||
export { Select, SelectGroup, SelectValue, SelectTrigger, SelectContent, SelectItem, SelectLabel };
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm placeholder:text-gray-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Textarea.displayName = "Textarea";
|
||||
|
||||
export { Textarea };
|
||||
Reference in New Issue
Block a user