mirror of
https://github.com/marcogll/TaxHacker_s23.git
synced 2026-01-13 13:25:18 +00:00
feat: add description to invoices
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
Reference in New Issue
Block a user