mirror of
https://github.com/marcogll/TaxHacker_s23.git
synced 2026-01-13 21:35:19 +00:00
(squash) init
feat: filters, settings, backups fix: ts compile errors feat: new dashboard, webp previews and settings feat: use webp for pdfs feat: use webp fix: analyze resets old data fix: switch to corsproxy fix: switch to free cors fix: max upload limit fix: currency conversion feat: transaction export fix: currency conversion feat: refactor settings actions feat: new loader feat: README + LICENSE doc: update readme doc: update readme doc: update readme doc: update screenshots ci: bump prisma
This commit is contained in:
129
components/transactions/create.tsx
Normal file
129
components/transactions/create.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
"use client"
|
||||
|
||||
import { createTransactionAction } from "@/app/transactions/actions"
|
||||
import { FormSelectCategory } from "@/components/forms/select-category"
|
||||
import { FormSelectCurrency } from "@/components/forms/select-currency"
|
||||
import { FormSelectProject } from "@/components/forms/select-project"
|
||||
import { FormSelectType } from "@/components/forms/select-type"
|
||||
import { FormInput, FormTextarea } from "@/components/forms/simple"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Category, Currency, Project } from "@prisma/client"
|
||||
import { format } from "date-fns"
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useActionState, useEffect, useState } from "react"
|
||||
|
||||
export default function TransactionCreateForm({
|
||||
categories,
|
||||
projects,
|
||||
currencies,
|
||||
settings,
|
||||
}: {
|
||||
categories: Category[]
|
||||
projects: Project[]
|
||||
currencies: Currency[]
|
||||
settings: Record<string, string>
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const [createState, createAction, isCreating] = useActionState(createTransactionAction, null)
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
merchant: "",
|
||||
description: "",
|
||||
total: 0.0,
|
||||
convertedTotal: 0.0,
|
||||
currencyCode: settings.default_currency,
|
||||
convertedCurrencyCode: settings.default_currency,
|
||||
type: settings.default_type,
|
||||
categoryCode: settings.default_category,
|
||||
projectCode: settings.default_project,
|
||||
issuedAt: format(new Date(), "yyyy-MM-dd"),
|
||||
note: "",
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (createState?.success) {
|
||||
router.push(`/transactions/${createState.transactionId}`)
|
||||
}
|
||||
}, [createState, router])
|
||||
|
||||
return (
|
||||
<form action={createAction} className="space-y-4">
|
||||
<FormInput title="Name" name="name" defaultValue={formData.name} />
|
||||
|
||||
<FormInput title="Merchant" name="merchant" defaultValue={formData.merchant} />
|
||||
|
||||
<FormInput title="Description" name="description" defaultValue={formData.description} />
|
||||
|
||||
<div className="flex flex-row gap-4">
|
||||
<FormInput title="Total" type="number" step="0.01" name="total" defaultValue={formData.total.toFixed(2)} />
|
||||
|
||||
<FormSelectCurrency
|
||||
title="Currency"
|
||||
name="currencyCode"
|
||||
currencies={currencies}
|
||||
placeholder="Select Currency"
|
||||
value={formData.currencyCode}
|
||||
onValueChange={(value) => {
|
||||
setFormData({ ...formData, currencyCode: value })
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormSelectType title="Type" name="type" defaultValue={formData.type} />
|
||||
</div>
|
||||
|
||||
{formData.currencyCode !== settings.default_currency ? (
|
||||
<div className="flex flex-row gap-4">
|
||||
<FormInput
|
||||
title={`Converted to ${settings.default_currency}`}
|
||||
type="number"
|
||||
step="0.01"
|
||||
name="convertedTotal"
|
||||
defaultValue={formData.convertedTotal.toFixed(2)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
<div className="flex flex-row flex-grow gap-4">
|
||||
<FormInput title="Issued At" type="date" name="issuedAt" defaultValue={formData.issuedAt} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-4">
|
||||
<FormSelectCategory
|
||||
title="Category"
|
||||
categories={categories}
|
||||
name="categoryCode"
|
||||
defaultValue={formData.categoryCode}
|
||||
placeholder="Select Category"
|
||||
/>
|
||||
|
||||
<FormSelectProject
|
||||
title="Project"
|
||||
projects={projects}
|
||||
name="projectCode"
|
||||
defaultValue={formData.projectCode}
|
||||
placeholder="Select Project"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormTextarea title="Note" name="note" defaultValue={formData.note} />
|
||||
|
||||
<div className="flex justify-end space-x-4 pt-6">
|
||||
<Button type="submit" disabled={isCreating}>
|
||||
{isCreating ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
"Create and Add Files"
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{createState?.error && <span className="text-red-500">⚠️ {createState.error}</span>}
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
175
components/transactions/edit.tsx
Normal file
175
components/transactions/edit.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
"use client"
|
||||
|
||||
import { deleteTransactionAction, saveTransactionAction } from "@/app/transactions/actions"
|
||||
import { FormSelectCategory } from "@/components/forms/select-category"
|
||||
import { FormSelectCurrency } from "@/components/forms/select-currency"
|
||||
import { FormSelectProject } from "@/components/forms/select-project"
|
||||
import { FormSelectType } from "@/components/forms/select-type"
|
||||
import { FormInput, FormTextarea } from "@/components/forms/simple"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Category, Currency, Field, Project, Transaction } from "@prisma/client"
|
||||
import { format } from "date-fns"
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { startTransition, useActionState, useEffect, useState } from "react"
|
||||
|
||||
export default function TransactionEditForm({
|
||||
transaction,
|
||||
categories,
|
||||
projects,
|
||||
currencies,
|
||||
fields,
|
||||
settings,
|
||||
}: {
|
||||
transaction: Transaction
|
||||
categories: Category[]
|
||||
projects: Project[]
|
||||
currencies: Currency[]
|
||||
fields: Field[]
|
||||
settings: Record<string, string>
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const [deleteState, deleteAction, isDeleting] = useActionState(deleteTransactionAction, null)
|
||||
const [saveState, saveAction, isSaving] = useActionState(saveTransactionAction, null)
|
||||
|
||||
const extraFields = fields.filter((field) => field.isExtra)
|
||||
const [formData, setFormData] = useState({
|
||||
name: transaction.name || "",
|
||||
merchant: transaction.merchant || "",
|
||||
description: transaction.description || "",
|
||||
total: transaction.total ? transaction.total / 100 : 0.0,
|
||||
currencyCode: transaction.currencyCode || settings.default_currency,
|
||||
convertedTotal: transaction.convertedTotal ? transaction.convertedTotal / 100 : 0.0,
|
||||
convertedCurrencyCode: transaction.convertedCurrencyCode,
|
||||
type: transaction.type || "expense",
|
||||
categoryCode: transaction.categoryCode || settings.default_category,
|
||||
projectCode: transaction.projectCode || settings.default_project,
|
||||
issuedAt: transaction.issuedAt ? format(transaction.issuedAt, "yyyy-MM-dd") : "",
|
||||
note: transaction.note || "",
|
||||
...extraFields.reduce((acc, field) => {
|
||||
acc[field.code] = transaction.extra?.[field.code as keyof typeof transaction.extra] || ""
|
||||
return acc
|
||||
}, {} as Record<string, any>),
|
||||
})
|
||||
|
||||
const handleDelete = async () => {
|
||||
startTransition(async () => {
|
||||
await deleteAction(transaction.id)
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (deleteState?.success) {
|
||||
router.push("/transactions")
|
||||
}
|
||||
}, [deleteState, router])
|
||||
|
||||
useEffect(() => {
|
||||
if (saveState?.success) {
|
||||
router.push("/transactions")
|
||||
}
|
||||
}, [saveState, router])
|
||||
|
||||
return (
|
||||
<form action={saveAction} className="space-y-4">
|
||||
<input type="hidden" name="transactionId" value={transaction.id} />
|
||||
|
||||
<FormInput title="Name" name="name" defaultValue={formData.name} />
|
||||
|
||||
<FormInput title="Merchant" name="merchant" defaultValue={formData.merchant} />
|
||||
|
||||
<FormInput title="Description" name="description" defaultValue={formData.description} />
|
||||
|
||||
<div className="flex flex-row gap-4">
|
||||
<FormInput
|
||||
title="Total"
|
||||
type="number"
|
||||
step="0.01"
|
||||
name="total"
|
||||
defaultValue={formData.total.toFixed(2)}
|
||||
className="w-32"
|
||||
/>
|
||||
|
||||
<FormSelectCurrency
|
||||
title="Currency"
|
||||
name="currencyCode"
|
||||
value={formData.currencyCode}
|
||||
onValueChange={(value) => {
|
||||
setFormData({ ...formData, currencyCode: value })
|
||||
}}
|
||||
currencies={currencies}
|
||||
/>
|
||||
|
||||
<FormSelectType title="Type" name="type" defaultValue={formData.type} />
|
||||
</div>
|
||||
|
||||
{formData.currencyCode !== settings.default_currency || formData.convertedTotal !== 0 ? (
|
||||
<div className="flex flex-row gap-4">
|
||||
<FormInput
|
||||
title={`Total converted to ${formData.convertedCurrencyCode || "UNKNOWN CURRENCY"}`}
|
||||
type="number"
|
||||
step="0.01"
|
||||
name="convertedTotal"
|
||||
defaultValue={formData.convertedTotal.toFixed(2)}
|
||||
/>
|
||||
{(!formData.convertedCurrencyCode || formData.convertedCurrencyCode !== settings.default_currency) && (
|
||||
<FormSelectCurrency
|
||||
title="Convert to"
|
||||
name="convertedCurrencyCode"
|
||||
defaultValue={formData.convertedCurrencyCode || settings.default_currency}
|
||||
currencies={currencies}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
<div className="flex flex-row flex-grow gap-4">
|
||||
<FormInput title="Issued At" type="date" name="issuedAt" defaultValue={formData.issuedAt} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-4">
|
||||
<FormSelectCategory
|
||||
title="Category"
|
||||
categories={categories}
|
||||
name="categoryCode"
|
||||
defaultValue={formData.categoryCode}
|
||||
/>
|
||||
|
||||
<FormSelectProject title="Project" projects={projects} name="projectCode" defaultValue={formData.projectCode} />
|
||||
</div>
|
||||
|
||||
<FormTextarea title="Note" name="note" defaultValue={formData.note} className="h-24" />
|
||||
{extraFields.map((field) => (
|
||||
<FormInput
|
||||
key={field.code}
|
||||
type={field.type}
|
||||
title={field.name}
|
||||
name={field.code}
|
||||
defaultValue={formData[field.code as keyof typeof formData] || ""}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="flex justify-end space-x-4 pt-6">
|
||||
<Button type="button" onClick={handleDelete} variant="outline" disabled={isDeleting}>
|
||||
{isDeleting ? "⏳ Deleting..." : "Delete Transaction"}
|
||||
</Button>
|
||||
|
||||
<Button type="submit" disabled={isSaving}>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
"Save"
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{deleteState?.error && <span className="text-red-500">⚠️ {deleteState.error}</span>}
|
||||
{saveState?.error && <span className="text-red-500">⚠️ {saveState.error}</span>}
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
135
components/transactions/filters.tsx
Normal file
135
components/transactions/filters.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
"use client"
|
||||
|
||||
import { DateRangePicker } from "@/components/forms/date-range-picker"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { TransactionFilters } from "@/data/transactions"
|
||||
import { Category, Project } from "@prisma/client"
|
||||
import { format } from "date-fns"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
export function TransactionSearchAndFilters({ categories, projects }: { categories: Category[]; projects: Project[] }) {
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
|
||||
const [filters, setFilters] = useState<TransactionFilters>({
|
||||
search: searchParams.get("search") || "",
|
||||
dateFrom: searchParams.get("dateFrom") || "",
|
||||
dateTo: searchParams.get("dateTo") || "",
|
||||
categoryCode: searchParams.get("categoryCode") || "",
|
||||
projectCode: searchParams.get("projectCode") || "",
|
||||
})
|
||||
|
||||
const handleFilterChange = (name: keyof TransactionFilters, value: any) => {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
[name]: value,
|
||||
}))
|
||||
}
|
||||
|
||||
const applyFilters = () => {
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
if (filters.search) {
|
||||
params.set("search", filters.search)
|
||||
} else {
|
||||
params.delete("search")
|
||||
}
|
||||
|
||||
if (filters.dateFrom) {
|
||||
params.set("dateFrom", format(new Date(filters.dateFrom), "yyyy-MM-dd"))
|
||||
} else {
|
||||
params.delete("dateFrom")
|
||||
}
|
||||
|
||||
if (filters.dateTo) {
|
||||
params.set("dateTo", format(new Date(filters.dateTo), "yyyy-MM-dd"))
|
||||
} else {
|
||||
params.delete("dateTo")
|
||||
}
|
||||
|
||||
if (filters.categoryCode && filters.categoryCode !== "-") {
|
||||
params.set("categoryCode", filters.categoryCode)
|
||||
} else {
|
||||
params.delete("categoryCode")
|
||||
}
|
||||
|
||||
if (filters.projectCode && filters.projectCode !== "-") {
|
||||
params.set("projectCode", filters.projectCode)
|
||||
} else {
|
||||
params.delete("projectCode")
|
||||
}
|
||||
|
||||
router.push(`/transactions?${params.toString()}`)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
applyFilters()
|
||||
}, [filters])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<Input
|
||||
placeholder="Search transactions..."
|
||||
defaultValue={filters.search}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleFilterChange("search", (e.target as HTMLInputElement).value)
|
||||
}
|
||||
}}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select value={filters.categoryCode} onValueChange={(value) => handleFilterChange("categoryCode", value)}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="All categories" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="-">All categories</SelectItem>
|
||||
{categories.map((category) => (
|
||||
<SelectItem key={category.code} value={category.code}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: category.color }} />
|
||||
{category.name}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{projects.length > 1 && (
|
||||
<Select value={filters.projectCode} onValueChange={(value) => handleFilterChange("projectCode", value)}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="All projects" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="-">All projects</SelectItem>
|
||||
{projects.map((project) => (
|
||||
<SelectItem key={project.code} value={project.code}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: project.color }} />
|
||||
{project.name}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
<DateRangePicker
|
||||
defaultDate={{
|
||||
from: filters.dateFrom ? new Date(filters.dateFrom) : undefined,
|
||||
to: filters.dateTo ? new Date(filters.dateTo) : undefined,
|
||||
}}
|
||||
onChange={(date) => {
|
||||
handleFilterChange("dateFrom", date ? date.from : undefined)
|
||||
handleFilterChange("dateTo", date ? date.to : undefined)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
241
components/transactions/list.tsx
Normal file
241
components/transactions/list.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
"use client"
|
||||
|
||||
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, 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, useState } from "react"
|
||||
|
||||
export const transactionsTable = [
|
||||
{
|
||||
name: "Name",
|
||||
db: "name",
|
||||
classes: "font-medium max-w-[300px] min-w-[120px] overflow-hidden",
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: "Merchant",
|
||||
db: "merchant",
|
||||
classes: "max-w-[200px] max-h-[20px] min-w-[120px] overflow-hidden",
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: "Date",
|
||||
db: "issuedAt",
|
||||
classes: "min-w-[100px]",
|
||||
format: (transaction: Transaction) => (transaction.issuedAt ? formatDate(transaction.issuedAt, "yyyy-MM-dd") : ""),
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: "Project",
|
||||
db: "projectCode",
|
||||
format: (transaction: Transaction & { project: Project }) =>
|
||||
transaction.projectCode ? (
|
||||
<Badge className="whitespace-nowrap" style={{ backgroundColor: transaction.project?.color }}>
|
||||
{transaction.project?.name || ""}
|
||||
</Badge>
|
||||
) : (
|
||||
"-"
|
||||
),
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: "Category",
|
||||
db: "categoryCode",
|
||||
format: (transaction: Transaction & { category: Category }) =>
|
||||
transaction.categoryCode ? (
|
||||
<Badge className="whitespace-nowrap" style={{ backgroundColor: transaction.category?.color }}>
|
||||
{transaction.category?.name || ""}
|
||||
</Badge>
|
||||
) : (
|
||||
"-"
|
||||
),
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: "Files",
|
||||
db: "files",
|
||||
format: (transaction: Transaction) => (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<File className="w-4 h-4" />
|
||||
{(transaction.files as string[]).length}
|
||||
</div>
|
||||
),
|
||||
sortable: false,
|
||||
},
|
||||
{
|
||||
name: "Total",
|
||||
db: "total",
|
||||
classes: "text-right",
|
||||
format: (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>
|
||||
),
|
||||
sortable: true,
|
||||
footer: (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>
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
export function TransactionList({ transactions }: { transactions: Transaction[] }) {
|
||||
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 toggleAll = () => {
|
||||
if (selectedIds.length === transactions.length) {
|
||||
setSelectedIds([])
|
||||
} else {
|
||||
setSelectedIds(transactions.map((transaction) => transaction.id))
|
||||
}
|
||||
}
|
||||
|
||||
const toggleOne = (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,
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px] select-none">
|
||||
<Checkbox checked={selectedIds.length === transactions.length} onCheckedChange={toggleAll} />
|
||||
</TableHead>
|
||||
{transactionsTable.map((field) => (
|
||||
<TableHead
|
||||
key={field.db}
|
||||
className={cn(field.classes, field.sortable && "hover:cursor-pointer hover:bg-accent select-none")}
|
||||
onClick={() => field.sortable && handleSort(field.db)}
|
||||
>
|
||||
{field.name}
|
||||
{field.sortable && getSortIcon(field.db)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{transactions.map((transaction: any) => (
|
||||
<TableRow
|
||||
key={transaction.id}
|
||||
className={cn(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") {
|
||||
toggleOne({ stopPropagation: () => {} } as React.MouseEvent, transaction.id)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
{transactionsTable.map((field) => (
|
||||
<TableCell key={field.db} className={field.classes}>
|
||||
{field.format ? field.format(transaction) : transaction[field.db]}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
<TableFooter>
|
||||
<TableRow>
|
||||
<TableCell></TableCell>
|
||||
{transactionsTable.map((field) => (
|
||||
<TableCell key={field.db} className={field.classes}>
|
||||
{field.footer ? field.footer(transactions) : ""}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
39
components/transactions/new.tsx
Normal file
39
components/transactions/new.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { getCategories } from "@/data/categories"
|
||||
import { getCurrencies } from "@/data/currencies"
|
||||
import { getProjects } from "@/data/projects"
|
||||
import { getSettings } from "@/data/settings"
|
||||
import TransactionCreateForm from "./create"
|
||||
|
||||
export async function NewTransactionDialog({ children }: { children: React.ReactNode }) {
|
||||
const categories = await getCategories()
|
||||
const currencies = await getCurrencies()
|
||||
const settings = await getSettings()
|
||||
const projects = await getProjects()
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent className="max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-bold">New Transaction</DialogTitle>
|
||||
<DialogDescription>Create a new transaction</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<TransactionCreateForm
|
||||
categories={categories}
|
||||
currencies={currencies}
|
||||
settings={settings}
|
||||
projects={projects}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
72
components/transactions/transaction-files.tsx
Normal file
72
components/transactions/transaction-files.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
"use client"
|
||||
|
||||
import { deleteTransactionFileAction, uploadTransactionFileAction } from "@/app/transactions/actions"
|
||||
import { FilePreview } from "@/components/files/preview"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { FILE_ACCEPTED_MIMETYPES } from "@/lib/files"
|
||||
import { File, Transaction } from "@prisma/client"
|
||||
import { Loader2, Upload } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
|
||||
export default function TransactionFiles({ transaction, files }: { transaction: Transaction; files: File[] }) {
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
|
||||
const handleDeleteFile = async (fileId: string) => {
|
||||
await deleteTransactionFileAction(transaction.id, fileId)
|
||||
}
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setIsUploading(true)
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
const formData = new FormData()
|
||||
formData.append("transactionId", transaction.id)
|
||||
formData.append("file", e.target.files[0])
|
||||
await uploadTransactionFileAction(formData)
|
||||
setIsUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{files.map((file) => (
|
||||
<Card key={file.id} className="p-4">
|
||||
<FilePreview file={file} />
|
||||
|
||||
<Button type="button" onClick={() => handleDeleteFile(file.id)} variant="destructive" className="w-full">
|
||||
Delete File
|
||||
</Button>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<Card className="relative h-32 p-4">
|
||||
<input type="hidden" name="transactionId" value={transaction.id} />
|
||||
<label
|
||||
className="h-full w-full flex flex-col items-center justify-center p-4 border-2 border-dashed border-gray-300 rounded-lg cursor-pointer hover:border-primary transition-colors"
|
||||
onDragEnter={(e) => {
|
||||
e.currentTarget.classList.add("border-primary")
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
e.currentTarget.classList.remove("border-primary")
|
||||
}}
|
||||
>
|
||||
{isUploading ? (
|
||||
<Loader2 className="w-8 h-8 text-gray-400 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Upload className="w-8 h-8 text-gray-400" />
|
||||
<p className="text-sm text-gray-500">Add more files to this invoice</p>
|
||||
</>
|
||||
)}
|
||||
<input
|
||||
type="file"
|
||||
name="file"
|
||||
className="absolute inset-0 top-0 left-0 w-full h-full opacity-0"
|
||||
onChange={handleFileChange}
|
||||
accept={FILE_ACCEPTED_MIMETYPES}
|
||||
/>
|
||||
</label>
|
||||
</Card>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user