feat: add description to invoices

This commit is contained in:
Vasily Zubarev
2025-05-09 15:34:35 +02:00
parent 11555a4dc0
commit 92dd66fd00
4 changed files with 152 additions and 156 deletions

View File

@@ -25,7 +25,13 @@ function invoiceFormReducer(state: InvoiceFormData, action: any): InvoiceFormDat
case "UPDATE_FIELD":
return { ...state, [action.field]: action.value }
case "ADD_ITEM":
return { ...state, items: [...state.items, { description: "", quantity: 1, unitPrice: 0, subtotal: 0 }] }
return {
...state,
items: [
...state.items,
{ name: "", subtitle: "", showSubtitle: false, quantity: 1, unitPrice: 0, subtotal: 0 },
],
}
case "UPDATE_ITEM": {
const items = [...state.items]
items[action.index] = { ...items[action.index], [action.field]: action.value }
@@ -146,7 +152,6 @@ export function InvoiceGenerator({
}
try {
// Get existing templates
const result = await addNewTemplateAction(user, {
id: `tmpl_${Math.random().toString(36).substring(2, 15)}`,
name: newTemplateName,

View File

@@ -7,7 +7,9 @@ import { X } from "lucide-react"
import { InputHTMLAttributes, memo, useCallback, useMemo } from "react"
export interface InvoiceItem {
description: string
name: string
subtitle: string
showSubtitle: boolean
quantity: number
unitPrice: number
subtotal: number
@@ -67,49 +69,92 @@ const ItemRow = memo(function ItemRow({
}: {
item: InvoiceItem
index: number
onChange: (index: number, field: keyof InvoiceItem, value: string | number) => void
onChange: (index: number, field: keyof InvoiceItem, value: string | number | boolean) => void
onRemove: (index: number) => void
currency: string
}) {
return (
<tr>
<td className="px-4 py-2">
<div className="flex flex-col sm:flex-row items-start py-3 px-4 bg-white hover:bg-gray-50">
{/* Mobile view label (visible only on small screens) */}
<div className="flex justify-between sm:hidden mb-2">
<span className="text-xs font-medium text-gray-500 uppercase">Item</span>
<Button variant="destructive" className="rounded-full p-1 h-5 w-5" onClick={() => onRemove(index)}>
<X />
</Button>
</div>
{/* Item name and subtitle */}
<div className="flex-1 sm:px-0">
<div className="flex flex-col">
<FormInput
type="text"
value={item.description}
onChange={(e) => onChange(index, "description", e.target.value)}
className="w-full min-w-64"
placeholder="Item description"
value={item.name}
onChange={(e) => onChange(index, "name", e.target.value)}
className="w-full min-w-0 font-semibold"
placeholder="Item name"
required
/>
</td>
<td className="px-4 py-2">
<div>
{!item.showSubtitle ? (
<button
type="button"
className="text-xs text-gray-400 hover:text-gray-800 mt-1 ml-1"
onClick={() => onChange(index, "showSubtitle", true)}
>
+ Add Description
</button>
) : (
<FormInput
type="text"
value={item.subtitle}
onChange={(e) => onChange(index, "subtitle", e.target.value)}
className="w-full mt-1 text-xs text-muted-foreground"
placeholder="Detailed description (optional)"
/>
)}
</div>
</div>
</div>
{/* Mobile labels for small screens */}
<div className="grid grid-cols-3 gap-2 mt-2 sm:hidden">
<div className="text-xs font-medium text-gray-500 uppercase">Quantity</div>
<div className="text-xs font-medium text-gray-500 uppercase">Unit Price</div>
<div className="text-xs font-medium text-gray-500 uppercase">Subtotal</div>
</div>
{/* Quantity, Unit Price, Subtotal, and Remove button */}
<div className="grid grid-cols-3 sm:flex gap-2 mt-1 sm:mt-0">
<div className="sm:w-20 sm:px-4">
<FormInput
type="number"
min="1"
value={item.quantity}
onChange={(e) => onChange(index, "quantity", Number(e.target.value))}
className="w-20 text-right"
className="w-full text-right"
required
/>
</td>
<td className="px-4 py-2">
</div>
<div className="sm:w-28 sm:px-4">
<FormInput
type="number"
step="0.01"
value={item.unitPrice}
onChange={(e) => onChange(index, "unitPrice", Number(e.target.value))}
className="w-24 text-right"
className="w-full text-right"
required
/>
</td>
<td className="px-4 py-2 text-right">{formatCurrency(item.subtotal * 100, currency)}</td>
<td className="px-4 py-2">
</div>
<div className="sm:w-28 sm:px-4 flex items-center justify-end">
<span className="text-sm text-right">{formatCurrency(item.subtotal * 100, currency)}</span>
</div>
<div className="hidden sm:flex sm:w-10 sm:px-2 items-center justify-center">
<Button variant="destructive" className="rounded-full p-1 h-5 w-5" onClick={() => onRemove(index)}>
<X />
</Button>
</td>
</tr>
</div>
</div>
</div>
)
})
@@ -196,7 +241,7 @@ export function InvoicePage({ invoiceData, dispatch, currencies }: InvoicePagePr
const addItem = useCallback(() => dispatch({ type: "ADD_ITEM" }), [dispatch])
const removeItem = useCallback((index: number) => dispatch({ type: "REMOVE_ITEM", index }), [dispatch])
const updateItem = useCallback(
(index: number, field: keyof InvoiceItem, value: string | number) =>
(index: number, field: keyof InvoiceItem, value: string | number | boolean) =>
dispatch({ type: "UPDATE_ITEM", index, field, value }),
[dispatch]
)
@@ -350,56 +395,48 @@ export function InvoicePage({ invoiceData, dispatch, currencies }: InvoicePagePr
</div>
</div>
{/* Items Table */}
{/* Items Section - Refactored to use only flex divs */}
<div className="mb-8">
<div className="border rounded-lg overflow-hidden">
{/* Table for desktop/tablet */}
<div className="overflow-x-auto sm:block hidden">
<table className="min-w-[600px] w-full divide-y divide-gray-200 text-xs sm:text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-2 sm:px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{/* Header row for column titles */}
<div className="hidden sm:flex bg-gray-50 text-xs font-medium text-gray-500 uppercase tracking-wider border-b">
<div className="flex-1 px-4 py-3">
<ShadyFormInput
type="text"
value={invoiceData.itemLabel}
onChange={(e) => dispatch({ type: "UPDATE_FIELD", field: "itemLabel", value: e.target.value })}
className="text-xs font-medium text-gray-500 uppercase tracking-wider"
/>
</th>
<th className="px-2 sm:px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
</div>
<div className="w-20 px-4 py-3 text-right">
<ShadyFormInput
type="text"
value={invoiceData.quantityLabel}
onChange={(e) =>
dispatch({ type: "UPDATE_FIELD", field: "quantityLabel", value: e.target.value })
}
className="text-xs font-medium text-gray-500 uppercase tracking-wider"
onChange={(e) => dispatch({ type: "UPDATE_FIELD", field: "quantityLabel", value: e.target.value })}
className="text-xs font-medium text-gray-500 uppercase tracking-wider text-right w-full"
/>
</th>
<th className="px-2 sm:px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
</div>
<div className="w-28 px-4 py-3 text-right">
<ShadyFormInput
type="text"
value={invoiceData.unitPriceLabel}
onChange={(e) =>
dispatch({ type: "UPDATE_FIELD", field: "unitPriceLabel", value: e.target.value })
}
className="text-xs font-medium text-gray-500 uppercase tracking-wider"
onChange={(e) => dispatch({ type: "UPDATE_FIELD", field: "unitPriceLabel", value: e.target.value })}
className="text-xs font-medium text-gray-500 uppercase tracking-wider text-right w-full"
/>
</th>
<th className="px-2 sm:px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
</div>
<div className="w-28 px-4 py-3 text-right">
<ShadyFormInput
type="text"
value={invoiceData.subtotalLabel}
onChange={(e) =>
dispatch({ type: "UPDATE_FIELD", field: "subtotalLabel", value: e.target.value })
}
className="text-xs font-medium text-gray-500 uppercase tracking-wider"
onChange={(e) => dispatch({ type: "UPDATE_FIELD", field: "subtotalLabel", value: e.target.value })}
className="text-xs font-medium text-gray-500 uppercase tracking-wider text-right w-full"
/>
</th>
<th className="px-2 sm:px-4 py-2"></th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
</div>
<div className="w-10 px-2 py-3"></div>
</div>
{/* Invoice items */}
<div className="flex flex-col divide-y divide-gray-200">
{invoiceData.items.map((item, index) => (
<ItemRow
key={index}
@@ -410,65 +447,8 @@ export function InvoicePage({ invoiceData, dispatch, currencies }: InvoicePagePr
currency={invoiceData.currency}
/>
))}
</tbody>
</table>
</div>
{/* Flex list for mobile */}
<div className="sm:hidden flex flex-col gap-2 p-2">
{invoiceData.items.map((item, index) => (
<div key={index} className="flex flex-col gap-1 border rounded-lg p-3 bg-gray-50 relative">
<button
type="button"
className="absolute top-2 right-2 text-gray-400 hover:text-red-500"
onClick={() => removeItem(index)}
aria-label="Remove item"
>
<X size={18} />
</button>
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-500 font-medium">{invoiceData.itemLabel}</label>
<FormInput
type="text"
value={item.description}
onChange={(e) => updateItem(index, "description", e.target.value)}
className="w-full min-w-0"
placeholder="Item description"
required
/>
</div>
<div className="flex gap-2 mt-2">
<div className="flex-1 flex flex-col">
<label className="text-xs text-gray-500 font-medium">{invoiceData.quantityLabel}</label>
<FormInput
type="number"
min="1"
value={item.quantity}
onChange={(e) => updateItem(index, "quantity", Number(e.target.value))}
className="w-full text-right"
required
/>
</div>
<div className="flex-1 flex flex-col">
<label className="text-xs text-gray-500 font-medium">{invoiceData.unitPriceLabel}</label>
<FormInput
type="number"
step="0.01"
value={item.unitPrice}
onChange={(e) => updateItem(index, "unitPrice", Number(e.target.value))}
className="w-full text-right"
required
/>
</div>
<div className="flex-1 flex flex-col items-end">
<label className="text-xs text-gray-500 font-medium">{invoiceData.subtotalLabel}</label>
<span className="text-sm font-semibold mt-2">
{formatCurrency(item.subtotal * 100, invoiceData.currency)}
</span>
</div>
</div>
</div>
))}
</div>
<Button onClick={addItem} className="m-2 sm:m-3 w-full sm:w-auto">
+ Add Item
</Button>

View File

@@ -210,6 +210,14 @@ const styles = StyleSheet.create({
fontSize: 12,
color: "#000000",
},
colName: {
fontWeight: "semibold",
},
itemSubtitle: {
fontSize: 10,
color: "#6B7280",
marginTop: 2,
},
notes: {
marginBottom: 30,
fontSize: 12,
@@ -342,7 +350,10 @@ export function InvoicePDF({ data }: { data: InvoiceFormData }): ReactElement {
{data.items.map((item: InvoiceItem, index: number) => (
<View key={index} style={styles.tableRow}>
<Text style={[styles.colValue, styles.colDescription]}>{item.description}</Text>
<View style={styles.colDescription}>
<Text style={[styles.colValue, styles.colName]}>{item.name}</Text>
{item.showSubtitle && item.subtitle && <Text style={styles.itemSubtitle}>{item.subtitle}</Text>}
</View>
<Text style={[styles.colValue, styles.colQuantity]}>{item.quantity}</Text>
<Text style={[styles.colValue, styles.colPrice]}>
{formatCurrency(item.unitPrice * 100, data.currency)}

View File

@@ -21,7 +21,7 @@ export default function defaultTemplates(user: User, settings: SettingsMap): Inv
companyDetailsLabel: "Bill From",
billTo: "",
billToLabel: "Bill To",
items: [{ description: "", quantity: 1, unitPrice: 0, subtotal: 0 }],
items: [{ name: "", subtitle: "", showSubtitle: false, quantity: 1, unitPrice: 0, subtotal: 0 }],
taxIncluded: true,
additionalTaxes: [{ name: "VAT", rate: 0, amount: 0 }],
additionalFees: [],
@@ -48,7 +48,7 @@ export default function defaultTemplates(user: User, settings: SettingsMap): Inv
companyDetailsLabel: "Rechnungssteller",
billTo: "",
billToLabel: "Rechnungsempfänger",
items: [{ description: "", quantity: 1, unitPrice: 0, subtotal: 0 }],
items: [{ name: "", subtitle: "", showSubtitle: false, quantity: 1, unitPrice: 0, subtotal: 0 }],
taxIncluded: true,
additionalTaxes: [{ name: "MwSt", rate: 19, amount: 0 }],
additionalFees: [],