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":
|
case "UPDATE_FIELD":
|
||||||
return { ...state, [action.field]: action.value }
|
return { ...state, [action.field]: action.value }
|
||||||
case "ADD_ITEM":
|
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": {
|
case "UPDATE_ITEM": {
|
||||||
const items = [...state.items]
|
const items = [...state.items]
|
||||||
items[action.index] = { ...items[action.index], [action.field]: action.value }
|
items[action.index] = { ...items[action.index], [action.field]: action.value }
|
||||||
@@ -146,7 +152,6 @@ export function InvoiceGenerator({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get existing templates
|
|
||||||
const result = await addNewTemplateAction(user, {
|
const result = await addNewTemplateAction(user, {
|
||||||
id: `tmpl_${Math.random().toString(36).substring(2, 15)}`,
|
id: `tmpl_${Math.random().toString(36).substring(2, 15)}`,
|
||||||
name: newTemplateName,
|
name: newTemplateName,
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import { X } from "lucide-react"
|
|||||||
import { InputHTMLAttributes, memo, useCallback, useMemo } from "react"
|
import { InputHTMLAttributes, memo, useCallback, useMemo } from "react"
|
||||||
|
|
||||||
export interface InvoiceItem {
|
export interface InvoiceItem {
|
||||||
description: string
|
name: string
|
||||||
|
subtitle: string
|
||||||
|
showSubtitle: boolean
|
||||||
quantity: number
|
quantity: number
|
||||||
unitPrice: number
|
unitPrice: number
|
||||||
subtotal: number
|
subtotal: number
|
||||||
@@ -67,49 +69,92 @@ const ItemRow = memo(function ItemRow({
|
|||||||
}: {
|
}: {
|
||||||
item: InvoiceItem
|
item: InvoiceItem
|
||||||
index: number
|
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
|
onRemove: (index: number) => void
|
||||||
currency: string
|
currency: string
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<tr>
|
<div className="flex flex-col sm:flex-row items-start py-3 px-4 bg-white hover:bg-gray-50">
|
||||||
<td className="px-4 py-2">
|
{/* 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
|
<FormInput
|
||||||
type="text"
|
type="text"
|
||||||
value={item.description}
|
value={item.name}
|
||||||
onChange={(e) => onChange(index, "description", e.target.value)}
|
onChange={(e) => onChange(index, "name", e.target.value)}
|
||||||
className="w-full min-w-64"
|
className="w-full min-w-0 font-semibold"
|
||||||
placeholder="Item description"
|
placeholder="Item name"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</td>
|
<div>
|
||||||
<td className="px-4 py-2">
|
{!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
|
<FormInput
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
value={item.quantity}
|
value={item.quantity}
|
||||||
onChange={(e) => onChange(index, "quantity", Number(e.target.value))}
|
onChange={(e) => onChange(index, "quantity", Number(e.target.value))}
|
||||||
className="w-20 text-right"
|
className="w-full text-right"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</td>
|
</div>
|
||||||
<td className="px-4 py-2">
|
<div className="sm:w-28 sm:px-4">
|
||||||
<FormInput
|
<FormInput
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
value={item.unitPrice}
|
value={item.unitPrice}
|
||||||
onChange={(e) => onChange(index, "unitPrice", Number(e.target.value))}
|
onChange={(e) => onChange(index, "unitPrice", Number(e.target.value))}
|
||||||
className="w-24 text-right"
|
className="w-full text-right"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</td>
|
</div>
|
||||||
<td className="px-4 py-2 text-right">{formatCurrency(item.subtotal * 100, currency)}</td>
|
<div className="sm:w-28 sm:px-4 flex items-center justify-end">
|
||||||
<td className="px-4 py-2">
|
<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)}>
|
<Button variant="destructive" className="rounded-full p-1 h-5 w-5" onClick={() => onRemove(index)}>
|
||||||
<X />
|
<X />
|
||||||
</Button>
|
</Button>
|
||||||
</td>
|
</div>
|
||||||
</tr>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -196,7 +241,7 @@ export function InvoicePage({ invoiceData, dispatch, currencies }: InvoicePagePr
|
|||||||
const addItem = useCallback(() => dispatch({ type: "ADD_ITEM" }), [dispatch])
|
const addItem = useCallback(() => dispatch({ type: "ADD_ITEM" }), [dispatch])
|
||||||
const removeItem = useCallback((index: number) => dispatch({ type: "REMOVE_ITEM", index }), [dispatch])
|
const removeItem = useCallback((index: number) => dispatch({ type: "REMOVE_ITEM", index }), [dispatch])
|
||||||
const updateItem = useCallback(
|
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({ type: "UPDATE_ITEM", index, field, value }),
|
||||||
[dispatch]
|
[dispatch]
|
||||||
)
|
)
|
||||||
@@ -350,56 +395,48 @@ export function InvoicePage({ invoiceData, dispatch, currencies }: InvoicePagePr
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Items Table */}
|
{/* Items Section - Refactored to use only flex divs */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="border rounded-lg overflow-hidden">
|
<div className="border rounded-lg overflow-hidden">
|
||||||
{/* Table for desktop/tablet */}
|
{/* Header row for column titles */}
|
||||||
<div className="overflow-x-auto sm:block hidden">
|
<div className="hidden sm:flex bg-gray-50 text-xs font-medium text-gray-500 uppercase tracking-wider border-b">
|
||||||
<table className="min-w-[600px] w-full divide-y divide-gray-200 text-xs sm:text-sm">
|
<div className="flex-1 px-4 py-3">
|
||||||
<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">
|
|
||||||
<ShadyFormInput
|
<ShadyFormInput
|
||||||
type="text"
|
type="text"
|
||||||
value={invoiceData.itemLabel}
|
value={invoiceData.itemLabel}
|
||||||
onChange={(e) => dispatch({ type: "UPDATE_FIELD", field: "itemLabel", value: e.target.value })}
|
onChange={(e) => dispatch({ type: "UPDATE_FIELD", field: "itemLabel", value: e.target.value })}
|
||||||
className="text-xs font-medium text-gray-500 uppercase tracking-wider"
|
className="text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||||
/>
|
/>
|
||||||
</th>
|
</div>
|
||||||
<th className="px-2 sm:px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<div className="w-20 px-4 py-3 text-right">
|
||||||
<ShadyFormInput
|
<ShadyFormInput
|
||||||
type="text"
|
type="text"
|
||||||
value={invoiceData.quantityLabel}
|
value={invoiceData.quantityLabel}
|
||||||
onChange={(e) =>
|
onChange={(e) => dispatch({ type: "UPDATE_FIELD", field: "quantityLabel", value: e.target.value })}
|
||||||
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"
|
||||||
}
|
|
||||||
className="text-xs font-medium text-gray-500 uppercase tracking-wider"
|
|
||||||
/>
|
/>
|
||||||
</th>
|
</div>
|
||||||
<th className="px-2 sm:px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<div className="w-28 px-4 py-3 text-right">
|
||||||
<ShadyFormInput
|
<ShadyFormInput
|
||||||
type="text"
|
type="text"
|
||||||
value={invoiceData.unitPriceLabel}
|
value={invoiceData.unitPriceLabel}
|
||||||
onChange={(e) =>
|
onChange={(e) => dispatch({ type: "UPDATE_FIELD", field: "unitPriceLabel", value: e.target.value })}
|
||||||
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"
|
||||||
}
|
|
||||||
className="text-xs font-medium text-gray-500 uppercase tracking-wider"
|
|
||||||
/>
|
/>
|
||||||
</th>
|
</div>
|
||||||
<th className="px-2 sm:px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<div className="w-28 px-4 py-3 text-right">
|
||||||
<ShadyFormInput
|
<ShadyFormInput
|
||||||
type="text"
|
type="text"
|
||||||
value={invoiceData.subtotalLabel}
|
value={invoiceData.subtotalLabel}
|
||||||
onChange={(e) =>
|
onChange={(e) => dispatch({ type: "UPDATE_FIELD", field: "subtotalLabel", value: e.target.value })}
|
||||||
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"
|
||||||
}
|
|
||||||
className="text-xs font-medium text-gray-500 uppercase tracking-wider"
|
|
||||||
/>
|
/>
|
||||||
</th>
|
</div>
|
||||||
<th className="px-2 sm:px-4 py-2"></th>
|
<div className="w-10 px-2 py-3"></div>
|
||||||
</tr>
|
</div>
|
||||||
</thead>
|
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
{/* Invoice items */}
|
||||||
|
<div className="flex flex-col divide-y divide-gray-200">
|
||||||
{invoiceData.items.map((item, index) => (
|
{invoiceData.items.map((item, index) => (
|
||||||
<ItemRow
|
<ItemRow
|
||||||
key={index}
|
key={index}
|
||||||
@@ -410,65 +447,8 @@ export function InvoicePage({ invoiceData, dispatch, currencies }: InvoicePagePr
|
|||||||
currency={invoiceData.currency}
|
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>
|
</div>
|
||||||
|
|
||||||
<Button onClick={addItem} className="m-2 sm:m-3 w-full sm:w-auto">
|
<Button onClick={addItem} className="m-2 sm:m-3 w-full sm:w-auto">
|
||||||
+ Add Item
|
+ Add Item
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -210,6 +210,14 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: "#000000",
|
color: "#000000",
|
||||||
},
|
},
|
||||||
|
colName: {
|
||||||
|
fontWeight: "semibold",
|
||||||
|
},
|
||||||
|
itemSubtitle: {
|
||||||
|
fontSize: 10,
|
||||||
|
color: "#6B7280",
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
notes: {
|
notes: {
|
||||||
marginBottom: 30,
|
marginBottom: 30,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
@@ -342,7 +350,10 @@ export function InvoicePDF({ data }: { data: InvoiceFormData }): ReactElement {
|
|||||||
|
|
||||||
{data.items.map((item: InvoiceItem, index: number) => (
|
{data.items.map((item: InvoiceItem, index: number) => (
|
||||||
<View key={index} style={styles.tableRow}>
|
<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.colQuantity]}>{item.quantity}</Text>
|
||||||
<Text style={[styles.colValue, styles.colPrice]}>
|
<Text style={[styles.colValue, styles.colPrice]}>
|
||||||
{formatCurrency(item.unitPrice * 100, data.currency)}
|
{formatCurrency(item.unitPrice * 100, data.currency)}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export default function defaultTemplates(user: User, settings: SettingsMap): Inv
|
|||||||
companyDetailsLabel: "Bill From",
|
companyDetailsLabel: "Bill From",
|
||||||
billTo: "",
|
billTo: "",
|
||||||
billToLabel: "Bill To",
|
billToLabel: "Bill To",
|
||||||
items: [{ description: "", quantity: 1, unitPrice: 0, subtotal: 0 }],
|
items: [{ name: "", subtitle: "", showSubtitle: false, quantity: 1, unitPrice: 0, subtotal: 0 }],
|
||||||
taxIncluded: true,
|
taxIncluded: true,
|
||||||
additionalTaxes: [{ name: "VAT", rate: 0, amount: 0 }],
|
additionalTaxes: [{ name: "VAT", rate: 0, amount: 0 }],
|
||||||
additionalFees: [],
|
additionalFees: [],
|
||||||
@@ -48,7 +48,7 @@ export default function defaultTemplates(user: User, settings: SettingsMap): Inv
|
|||||||
companyDetailsLabel: "Rechnungssteller",
|
companyDetailsLabel: "Rechnungssteller",
|
||||||
billTo: "",
|
billTo: "",
|
||||||
billToLabel: "Rechnungsempfänger",
|
billToLabel: "Rechnungsempfänger",
|
||||||
items: [{ description: "", quantity: 1, unitPrice: 0, subtotal: 0 }],
|
items: [{ name: "", subtitle: "", showSubtitle: false, quantity: 1, unitPrice: 0, subtotal: 0 }],
|
||||||
taxIncluded: true,
|
taxIncluded: true,
|
||||||
additionalTaxes: [{ name: "MwSt", rate: 19, amount: 0 }],
|
additionalTaxes: [{ name: "MwSt", rate: 19, amount: 0 }],
|
||||||
additionalFees: [],
|
additionalFees: [],
|
||||||
|
|||||||
Reference in New Issue
Block a user