mirror of
https://github.com/marcogll/TaxHacker_s23.git
synced 2026-01-13 13:25:18 +00:00
315 lines
10 KiB
TypeScript
315 lines
10 KiB
TypeScript
"use client"
|
|
|
|
import { BulkActionsMenu } from "@/components/transactions/bulk-actions"
|
|
import { Badge } from "@/components/ui/badge"
|
|
import { Checkbox } from "@/components/ui/checkbox"
|
|
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
|
import { calcTotalPerCurrency } from "@/lib/stats"
|
|
import { cn, formatCurrency } from "@/lib/utils"
|
|
import { Category, Field, Project, Transaction } from "@/prisma/client"
|
|
import { formatDate } from "date-fns"
|
|
import { ArrowDownIcon, ArrowUpIcon, File } from "lucide-react"
|
|
import { useRouter, useSearchParams } from "next/navigation"
|
|
import { useEffect, useMemo, useState } from "react"
|
|
|
|
type FieldRenderer = {
|
|
name: string
|
|
code: string
|
|
classes?: string
|
|
sortable: boolean
|
|
formatValue?: (transaction: Transaction & any) => React.ReactNode
|
|
footerValue?: (transactions: Transaction[]) => React.ReactNode
|
|
}
|
|
|
|
type FieldWithRenderer = Field & {
|
|
renderer: FieldRenderer
|
|
}
|
|
|
|
export const standardFieldRenderers: Record<string, FieldRenderer> = {
|
|
name: {
|
|
name: "Name",
|
|
code: "name",
|
|
classes: "font-medium min-w-[120px] max-w-[300px] overflow-hidden",
|
|
sortable: true,
|
|
},
|
|
merchant: {
|
|
name: "Merchant",
|
|
code: "merchant",
|
|
classes: "min-w-[120px] max-w-[250px] overflow-hidden",
|
|
sortable: true,
|
|
},
|
|
issuedAt: {
|
|
name: "Date",
|
|
code: "issuedAt",
|
|
classes: "min-w-[100px]",
|
|
sortable: true,
|
|
formatValue: (transaction: Transaction) =>
|
|
transaction.issuedAt ? formatDate(transaction.issuedAt, "yyyy-MM-dd") : "",
|
|
},
|
|
projectCode: {
|
|
name: "Project",
|
|
code: "projectCode",
|
|
sortable: true,
|
|
formatValue: (transaction: Transaction & { project: Project }) =>
|
|
transaction.projectCode ? (
|
|
<Badge className="whitespace-nowrap" style={{ backgroundColor: transaction.project?.color }}>
|
|
{transaction.project?.name || ""}
|
|
</Badge>
|
|
) : (
|
|
"-"
|
|
),
|
|
},
|
|
categoryCode: {
|
|
name: "Category",
|
|
code: "categoryCode",
|
|
sortable: true,
|
|
formatValue: (transaction: Transaction & { category: Category }) =>
|
|
transaction.categoryCode ? (
|
|
<Badge className="whitespace-nowrap" style={{ backgroundColor: transaction.category?.color }}>
|
|
{transaction.category?.name || ""}
|
|
</Badge>
|
|
) : (
|
|
"-"
|
|
),
|
|
},
|
|
files: {
|
|
name: "Files",
|
|
code: "files",
|
|
sortable: false,
|
|
formatValue: (transaction: Transaction) => (
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<File className="w-4 h-4" />
|
|
{(transaction.files as string[]).length}
|
|
</div>
|
|
),
|
|
},
|
|
total: {
|
|
name: "Total",
|
|
code: "total",
|
|
classes: "text-right",
|
|
sortable: true,
|
|
formatValue: (transaction: Transaction) => (
|
|
<div className="text-right text-lg">
|
|
<div
|
|
className={cn(
|
|
{ income: "text-green-500", expense: "text-red-500", other: "text-black" }[transaction.type || "other"],
|
|
"flex flex-col justify-end"
|
|
)}
|
|
>
|
|
<span>
|
|
{transaction.total && transaction.currencyCode
|
|
? formatCurrency(transaction.total, transaction.currencyCode)
|
|
: transaction.total}
|
|
</span>
|
|
{transaction.convertedTotal &&
|
|
transaction.convertedCurrencyCode &&
|
|
transaction.convertedCurrencyCode !== transaction.currencyCode && (
|
|
<span className="text-sm -mt-1">
|
|
({formatCurrency(transaction.convertedTotal, transaction.convertedCurrencyCode)})
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
),
|
|
footerValue: (transactions: Transaction[]) => {
|
|
const totalPerCurrency = calcTotalPerCurrency(transactions)
|
|
return (
|
|
<div className="flex flex-col">
|
|
{Object.entries(totalPerCurrency).map(([currency, total]) => (
|
|
<div key={currency} className="text-sm first:text-base">
|
|
{formatCurrency(total, currency)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)
|
|
},
|
|
},
|
|
}
|
|
|
|
const getFieldRenderer = (field: Field): FieldRenderer => {
|
|
if (standardFieldRenderers[field.code as keyof typeof standardFieldRenderers]) {
|
|
return standardFieldRenderers[field.code as keyof typeof standardFieldRenderers]
|
|
} else {
|
|
return {
|
|
name: field.name,
|
|
code: field.code,
|
|
classes: "",
|
|
sortable: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
export function TransactionList({ transactions, fields = [] }: { transactions: Transaction[]; fields?: Field[] }) {
|
|
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
|
const router = useRouter()
|
|
const searchParams = useSearchParams()
|
|
|
|
const [sorting, setSorting] = useState<{ field: string | null; direction: "asc" | "desc" | null }>(() => {
|
|
const ordering = searchParams.get("ordering")
|
|
if (!ordering) return { field: null, direction: null }
|
|
const isDesc = ordering.startsWith("-")
|
|
return {
|
|
field: isDesc ? ordering.slice(1) : ordering,
|
|
direction: isDesc ? "desc" : "asc",
|
|
}
|
|
})
|
|
|
|
const visibleFields = useMemo(
|
|
(): FieldWithRenderer[] =>
|
|
fields
|
|
.filter((field) => field.isVisibleInList)
|
|
.map((field) => ({
|
|
...field,
|
|
renderer: getFieldRenderer(field),
|
|
})),
|
|
[fields]
|
|
)
|
|
|
|
const toggleAllRows = () => {
|
|
if (selectedIds.length === transactions.length) {
|
|
setSelectedIds([])
|
|
} else {
|
|
setSelectedIds(transactions.map((transaction) => transaction.id))
|
|
}
|
|
}
|
|
|
|
const toggleOneRow = (e: React.MouseEvent, id: string) => {
|
|
e.stopPropagation()
|
|
if (selectedIds.includes(id)) {
|
|
setSelectedIds(selectedIds.filter((item) => item !== id))
|
|
} else {
|
|
setSelectedIds([...selectedIds, id])
|
|
}
|
|
}
|
|
|
|
const handleRowClick = (id: string) => {
|
|
router.push(`/transactions/${id}`)
|
|
}
|
|
|
|
const handleSort = (field: string) => {
|
|
let newDirection: "asc" | "desc" | null = "asc"
|
|
|
|
if (sorting.field === field) {
|
|
if (sorting.direction === "asc") newDirection = "desc"
|
|
else if (sorting.direction === "desc") newDirection = null
|
|
}
|
|
|
|
setSorting({
|
|
field: newDirection ? field : null,
|
|
direction: newDirection,
|
|
})
|
|
}
|
|
|
|
const renderFieldInTable = (transaction: Transaction, field: FieldWithRenderer): string | React.ReactNode => {
|
|
if (field.isExtra) {
|
|
return transaction.extra?.[field.code as keyof typeof transaction.extra] ?? ""
|
|
} else if (field.renderer.formatValue) {
|
|
return field.renderer.formatValue(transaction)
|
|
} else {
|
|
return String(transaction[field.code as keyof Transaction])
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
const params = new URLSearchParams(searchParams.toString())
|
|
if (sorting.field && sorting.direction) {
|
|
const ordering = sorting.direction === "desc" ? `-${sorting.field}` : sorting.field
|
|
params.set("ordering", ordering)
|
|
} else {
|
|
params.delete("ordering")
|
|
}
|
|
router.push(`/transactions?${params.toString()}`)
|
|
}, [sorting])
|
|
|
|
const getSortIcon = (field: string) => {
|
|
if (sorting.field !== field) return null
|
|
return sorting.direction === "asc" ? (
|
|
<ArrowUpIcon className="w-4 h-4 ml-1 inline" />
|
|
) : sorting.direction === "desc" ? (
|
|
<ArrowDownIcon className="w-4 h-4 ml-1 inline" />
|
|
) : null
|
|
}
|
|
|
|
// Function to check if a transaction is incomplete
|
|
const isTransactionIncomplete = (transaction: Transaction): boolean => {
|
|
const requiredFields = fields.filter((field) => field.isRequired)
|
|
|
|
return requiredFields.some((field) => {
|
|
const value = field.isExtra
|
|
? (transaction.extra as Record<string, any>)?.[field.code]
|
|
: transaction[field.code as keyof Transaction]
|
|
|
|
return value === undefined || value === null || value === "" || value === 0
|
|
})
|
|
}
|
|
|
|
return (
|
|
<div className="rounded-md border">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="min-w-[30px] select-none">
|
|
<Checkbox checked={selectedIds.length === transactions.length} onCheckedChange={toggleAllRows} />
|
|
</TableHead>
|
|
{visibleFields.map((field) => (
|
|
<TableHead
|
|
key={field.code}
|
|
className={cn(
|
|
field.renderer.classes,
|
|
field.renderer.sortable && "hover:cursor-pointer hover:bg-accent select-none"
|
|
)}
|
|
onClick={() => field.renderer.sortable && handleSort(field.code)}
|
|
>
|
|
{field.name || field.renderer.name}
|
|
{field.renderer.sortable && getSortIcon(field.code)}
|
|
</TableHead>
|
|
))}
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{transactions.map((transaction) => (
|
|
<TableRow
|
|
key={transaction.id}
|
|
className={cn(
|
|
isTransactionIncomplete(transaction) && "bg-yellow-50",
|
|
selectedIds.includes(transaction.id) && "bg-muted",
|
|
"cursor-pointer hover:bg-muted/50"
|
|
)}
|
|
onClick={() => handleRowClick(transaction.id)}
|
|
>
|
|
<TableCell onClick={(e) => e.stopPropagation()}>
|
|
<Checkbox
|
|
checked={selectedIds.includes(transaction.id)}
|
|
onCheckedChange={(checked) => {
|
|
if (checked !== "indeterminate") {
|
|
toggleOneRow({ stopPropagation: () => {} } as React.MouseEvent, transaction.id)
|
|
}
|
|
}}
|
|
/>
|
|
</TableCell>
|
|
{visibleFields.map((field) => (
|
|
<TableCell key={field.code} className={field.renderer.classes}>
|
|
{renderFieldInTable(transaction, field)}
|
|
</TableCell>
|
|
))}
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
<TableFooter>
|
|
<TableRow>
|
|
<TableCell></TableCell>
|
|
{visibleFields.map((field) => (
|
|
<TableCell key={field.code} className={field.renderer.classes}>
|
|
{field.renderer.footerValue ? field.renderer.footerValue(transactions) : ""}
|
|
</TableCell>
|
|
))}
|
|
</TableRow>
|
|
</TableFooter>
|
|
</Table>
|
|
{selectedIds.length > 0 && (
|
|
<BulkActionsMenu selectedIds={selectedIds} onActionComplete={() => setSelectedIds([])} />
|
|
)}
|
|
</div>
|
|
)
|
|
}
|