This commit is contained in:
2026-04-23 14:38:41 +03:00
parent f17dd1486c
commit c0f78c6e18
90 changed files with 10819 additions and 1583 deletions
@@ -0,0 +1,110 @@
"use client"
import { Separator } from "@/shared/components/ui/separator"
import { formatCurrency } from "@/shared/utils/formatters"
import { cn } from "@/shared/lib/utils"
import type { DocumentTotals } from "@/shared/hooks/use-document-totals"
export type DocumentTotalsSummaryProps = {
totals: DocumentTotals
discountType?: string | null
taxLabel?: string | null
/** Override labels for line groups, keyed by group name */
groupLabels?: Record<string, number>
}
function SummaryRow({
label,
value,
muted = false,
className,
}: {
label: string
value: string
muted?: boolean
className?: string
}) {
return (
<div className={cn("flex items-center justify-between text-sm", className)}>
<span className={muted ? "text-muted-foreground" : ""}>{label}</span>
<span className={muted ? "text-muted-foreground" : ""}>{value}</span>
</div>
)
}
export function DocumentTotalsSummary({
totals,
discountType,
taxLabel,
groupLabels,
}: DocumentTotalsSummaryProps) {
const {
subTotal,
lineItemDiscount,
transactionDiscount,
totalDiscount,
taxAmount,
total,
hasLineItems,
} = totals
if (!hasLineItems) return null
return (
<div className="space-y-2">
{/* Group breakdowns */}
{groupLabels &&
Object.entries(groupLabels).map(([label, amount]) => (
<SummaryRow
key={label}
label={label}
value={formatCurrency(amount)}
muted
/>
))}
{groupLabels && Object.keys(groupLabels).length > 0 && <Separator />}
{/* Subtotal */}
<SummaryRow label="Subtotal" value={formatCurrency(subTotal)} />
{/* Line-item discount */}
{discountType === "line_item_level" && lineItemDiscount > 0 && (
<SummaryRow
label="Line Discounts"
value={`– ${formatCurrency(lineItemDiscount)}`}
muted
/>
)}
{/* Transaction-level discount */}
{discountType === "transaction_level" && transactionDiscount > 0 && (
<SummaryRow
label="Discount"
value={`– ${formatCurrency(transactionDiscount)}`}
muted
/>
)}
{/* Tax */}
{taxAmount > 0 && (
<SummaryRow
label={taxLabel ?? "Tax"}
value={`+ ${formatCurrency(taxAmount)}`}
muted
/>
)}
<Separator />
{/* Total */}
<div className={cn(
"flex items-center justify-between rounded-md px-3 py-2 text-sm font-semibold",
"bg-primary/10 text-primary",
)}>
<span>Total</span>
<span>{formatCurrency(total)}</span>
</div>
</div>
)
}
@@ -109,15 +109,14 @@ export function RhfResourceField<
{triggerLabel ?? label}
</Button>
</CardHeader>
<CardContent>
{(items as any[]).length > 0
? renderItems(items, helpers)
: (
<p className="text-sm text-muted-foreground text-center py-4">
No items added yet.
</p>
)}
</CardContent>
{
items.length > 0 &&
<CardContent>
{(items as any[]).length > 0
&& renderItems(items, helpers)
}
</CardContent>
}
</Card>
{error && <FieldError>{error.message}</FieldError>}
<ResourceSelectorDialog<TClient>
@@ -44,7 +44,11 @@ function ActionsCell<TData extends { id: string | number }>({
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{options.onEdit && (
<DropdownMenuItem onClick={() => options.onEdit!(row.original)}>
<DropdownMenuItem onClick={(e) => {
e.stopPropagation()
options.onEdit!(row.original)
}}>
<Pencil className="size-3.5 text-muted-foreground" />
Edit
</DropdownMenuItem>
@@ -52,7 +56,10 @@ function ActionsCell<TData extends { id: string | number }>({
{options.onDelete && (
<DropdownMenuItem
variant="destructive"
onClick={() => options.onDelete!(row.original)}
onClick={(e) => {
e.stopPropagation()
options.onDelete!(row.original)
}}
>
<Trash2 className="size-3.5" />
Delete
@@ -0,0 +1,84 @@
/**
* useDocumentTotals
*
* Pure calculation hook for invoice-style documents (invoices, bills, estimates,
* credit notes, vendor credits, etc.).
*
* Inputs
* ────────────────────────────────────────────────────────────────────────────
* lineItems – flat array of all items (parts + services + expenses)
* discountType – 'no' | 'line_item_level' | 'transaction_level'
* discountAmount – used when discountType === 'transaction_level'
* taxRate – percentage (e.g. 15 for 15 %). Undefined → no tax.
*
* Calculation order
* ────────────────────────────────────────────────────────────────────────────
* 1. subTotal = Σ (qty × rate)
* 2. lineItemDiscount = Σ discount_amount (only when discountType === 'line_item_level')
* 3. transactionDiscount = discountAmount (only when discountType === 'transaction_level')
* 4. totalDiscount = lineItemDiscount + transactionDiscount
* 5. afterDiscount = subTotal - totalDiscount
* 6. taxAmount = afterDiscount × (taxRate / 100)
* 7. total = afterDiscount + taxAmount
*/
export type DocumentLineItem = {
quantity: number
rate: number
discount_amount?: number
}
export type UseDocumentTotalsInput = {
lineItems: DocumentLineItem[]
discountType?: string | null
discountAmount?: number
taxRate?: number
}
export type DocumentTotals = {
subTotal: number
lineItemDiscount: number
transactionDiscount: number
totalDiscount: number
afterDiscount: number
taxAmount: number
total: number
hasLineItems: boolean
}
export function useDocumentTotals({
lineItems,
discountType,
discountAmount,
taxRate,
}: UseDocumentTotalsInput): DocumentTotals {
const subTotal = lineItems.reduce((sum, item) => {
const qty = Number(item.quantity) || 0
const rate = Number(item.rate) || 0
return sum + qty * rate
}, 0)
const lineItemDiscount =
discountType === "line_item_level"
? lineItems.reduce((sum, item) => sum + (Number(item.discount_amount) || 0), 0)
: 0
const transactionDiscount =
discountType === "transaction_level" ? Number(discountAmount) || 0 : 0
const totalDiscount = lineItemDiscount + transactionDiscount
const afterDiscount = Math.max(0, subTotal - totalDiscount)
const taxAmount = taxRate ? afterDiscount * (taxRate / 100) : 0
const total = afterDiscount + taxAmount
return {
subTotal,
lineItemDiscount,
transactionDiscount,
totalDiscount,
afterDiscount,
taxAmount,
total,
hasLineItems: lineItems.length > 0,
}
}
+5
View File
@@ -24,3 +24,8 @@ export function toId(relation: RelationFieldValue | undefined): number | undefin
return relation ? Number(relation.value) : undefined
}
export function getTodayDate() {
const today = new Date().toISOString().split("T")[0]
return today
}