(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:
Vasily Zubarev
2025-03-13 00:30:47 +01:00
commit 0b98a2c307
153 changed files with 17271 additions and 0 deletions

34
data/categories.ts Normal file
View File

@@ -0,0 +1,34 @@
import { prisma } from "@/lib/db"
import { codeFromName } from "@/lib/utils"
import { Prisma } from "@prisma/client"
import { cache } from "react"
export const getCategories = cache(async () => {
return await prisma.category.findMany({
orderBy: {
name: "asc",
},
})
})
export const createCategory = async (category: Prisma.CategoryCreateInput) => {
if (!category.code) {
category.code = codeFromName(category.name as string)
}
return await prisma.category.create({
data: category,
})
}
export const updateCategory = async (code: string, category: Prisma.CategoryUpdateInput) => {
return await prisma.category.update({
where: { code },
data: category,
})
}
export const deleteCategory = async (code: string) => {
return await prisma.category.delete({
where: { code },
})
}

30
data/currencies.ts Normal file
View File

@@ -0,0 +1,30 @@
import { prisma } from "@/lib/db"
import { Prisma } from "@prisma/client"
import { cache } from "react"
export const getCurrencies = cache(async () => {
return await prisma.currency.findMany({
orderBy: {
code: "asc",
},
})
})
export const createCurrency = async (currency: Prisma.CurrencyCreateInput) => {
return await prisma.currency.create({
data: currency,
})
}
export const updateCurrency = async (code: string, currency: Prisma.CurrencyUpdateInput) => {
return await prisma.currency.update({
where: { code },
data: currency,
})
}
export const deleteCurrency = async (code: string) => {
return await prisma.currency.delete({
where: { code },
})
}

5
data/export.ts Normal file
View File

@@ -0,0 +1,5 @@
import { TransactionFilters } from "./transactions"
export type ExportFilters = TransactionFilters
export type ExportFields = string[]

30
data/fields.ts Normal file
View File

@@ -0,0 +1,30 @@
import { prisma } from "@/lib/db"
import { codeFromName } from "@/lib/utils"
import { Prisma } from "@prisma/client"
import { cache } from "react"
export const getFields = cache(async () => {
return await prisma.field.findMany()
})
export const createField = async (field: Prisma.FieldCreateInput) => {
if (!field.code) {
field.code = codeFromName(field.name as string)
}
return await prisma.field.create({
data: field,
})
}
export const updateField = async (code: string, field: Prisma.FieldUpdateInput) => {
return await prisma.field.update({
where: { code },
data: field,
})
}
export const deleteField = async (code: string) => {
return await prisma.field.delete({
where: { code },
})
}

70
data/files.ts Normal file
View File

@@ -0,0 +1,70 @@
"use server"
import { prisma } from "@/lib/db"
import { unlink } from "fs/promises"
import path from "path"
import { cache } from "react"
import { getTransactionById } from "./transactions"
export const getUnsortedFiles = cache(async () => {
return await prisma.file.findMany({
where: {
isReviewed: false,
},
orderBy: {
createdAt: "desc",
},
})
})
export const getUnsortedFilesCount = cache(async () => {
return await prisma.file.count({
where: {
isReviewed: false,
},
})
})
export const getFileById = cache(async (id: string) => {
return await prisma.file.findFirst({
where: { id },
})
})
export const getFilesByTransactionId = cache(async (id: string) => {
const transaction = await getTransactionById(id)
if (transaction && transaction.files) {
return await prisma.file.findMany({
where: {
id: {
in: transaction.files as string[],
},
},
})
}
return []
})
export const createFile = async (data: any) => {
return await prisma.file.create({
data,
})
}
export const updateFile = async (id: string, data: any) => {
return await prisma.file.update({
where: { id },
data,
})
}
export const deleteFile = async (id: string) => {
const file = await getFileById(id)
if (file) {
await unlink(path.resolve(file.path))
return await prisma.file.delete({
where: { id },
})
}
}

34
data/projects.ts Normal file
View File

@@ -0,0 +1,34 @@
import { prisma } from "@/lib/db"
import { codeFromName } from "@/lib/utils"
import { Prisma } from "@prisma/client"
import { cache } from "react"
export const getProjects = cache(async () => {
return await prisma.project.findMany({
orderBy: {
name: "asc",
},
})
})
export const createProject = async (project: Prisma.ProjectCreateInput) => {
if (!project.code) {
project.code = codeFromName(project.name as string)
}
return await prisma.project.create({
data: project,
})
}
export const updateProject = async (code: string, project: Prisma.ProjectUpdateInput) => {
return await prisma.project.update({
where: { code },
data: project,
})
}
export const deleteProject = async (code: string) => {
return await prisma.project.delete({
where: { code },
})
}

24
data/settings.ts Normal file
View File

@@ -0,0 +1,24 @@
import { prisma } from "@/lib/db"
import { cache } from "react"
export type SettingsMap = Record<string, string>
export const getSettings = cache(async (): Promise<SettingsMap> => {
const settings = await prisma.setting.findMany()
return settings.reduce((acc, setting) => {
acc[setting.code] = setting.value || ""
return acc
}, {} as SettingsMap)
})
export const updateSettings = cache(async (code: string, value: string) => {
return await prisma.setting.upsert({
where: { code },
update: { value },
create: {
code,
value,
name: code,
},
})
})

83
data/stats.ts Normal file
View File

@@ -0,0 +1,83 @@
import { prisma } from "@/lib/db"
import { calcTotalPerCurrency } from "@/lib/stats"
import { Prisma } from "@prisma/client"
import { cache } from "react"
export type StatsFilters = {
dateFrom?: string
dateTo?: string
}
export type DashboardStats = {
totalIncomePerCurrency: Record<string, number>
totalExpensesPerCurrency: Record<string, number>
profitPerCurrency: Record<string, number>
invoicesProcessed: number
}
export const getDashboardStats = cache(async (filters: StatsFilters = {}): Promise<DashboardStats> => {
const where: Prisma.TransactionWhereInput = {}
if (filters.dateFrom || filters.dateTo) {
where.issuedAt = {
gte: filters.dateFrom ? new Date(filters.dateFrom) : undefined,
lte: filters.dateTo ? new Date(filters.dateTo) : undefined,
}
}
const transactions = await prisma.transaction.findMany({ where })
const totalIncomePerCurrency = calcTotalPerCurrency(transactions.filter((t) => t.type === "income"))
const totalExpensesPerCurrency = calcTotalPerCurrency(transactions.filter((t) => t.type === "expense"))
const profitPerCurrency = Object.fromEntries(
Object.keys(totalIncomePerCurrency).map((currency) => [
currency,
totalIncomePerCurrency[currency] - totalExpensesPerCurrency[currency],
])
)
const invoicesProcessed = transactions.length
return {
totalIncomePerCurrency,
totalExpensesPerCurrency,
profitPerCurrency,
invoicesProcessed,
}
})
export type ProjectStats = {
totalIncomePerCurrency: Record<string, number>
totalExpensesPerCurrency: Record<string, number>
profitPerCurrency: Record<string, number>
invoicesProcessed: number
}
export const getProjectStats = cache(async (projectId: string, filters: StatsFilters = {}) => {
const where: Prisma.TransactionWhereInput = {
projectCode: projectId,
}
if (filters.dateFrom || filters.dateTo) {
where.issuedAt = {
gte: filters.dateFrom ? new Date(filters.dateFrom) : undefined,
lte: filters.dateTo ? new Date(filters.dateTo) : undefined,
}
}
const transactions = await prisma.transaction.findMany({ where })
const totalIncomePerCurrency = calcTotalPerCurrency(transactions.filter((t) => t.type === "income"))
const totalExpensesPerCurrency = calcTotalPerCurrency(transactions.filter((t) => t.type === "expense"))
const profitPerCurrency = Object.fromEntries(
Object.keys(totalIncomePerCurrency).map((currency) => [
currency,
totalIncomePerCurrency[currency] - totalExpensesPerCurrency[currency],
])
)
const invoicesProcessed = transactions.length
return {
totalIncomePerCurrency,
totalExpensesPerCurrency,
profitPerCurrency,
invoicesProcessed,
}
})

147
data/transactions.ts Normal file
View File

@@ -0,0 +1,147 @@
import { prisma } from "@/lib/db"
import { Field, Prisma, Transaction } from "@prisma/client"
import { cache } from "react"
import { getFields } from "./fields"
import { deleteFile } from "./files"
export type TransactionData = {
[key: string]: unknown
}
export type TransactionFilters = {
search?: string
dateFrom?: string
dateTo?: string
ordering?: string
categoryCode?: string
projectCode?: string
}
export const getTransactions = cache(async (filters?: TransactionFilters): Promise<Transaction[]> => {
const where: Prisma.TransactionWhereInput = {}
let orderBy: Prisma.TransactionOrderByWithRelationInput = { issuedAt: "desc" }
if (filters) {
if (filters.search) {
where.OR = [
{ name: { contains: filters.search } },
{ merchant: { contains: filters.search } },
{ description: { contains: filters.search } },
{ note: { contains: filters.search } },
{ text: { contains: filters.search } },
]
}
if (filters.dateFrom || filters.dateTo) {
where.issuedAt = {
gte: filters.dateFrom ? new Date(filters.dateFrom) : undefined,
lte: filters.dateTo ? new Date(filters.dateTo) : undefined,
}
}
if (filters.categoryCode) {
where.categoryCode = filters.categoryCode
}
if (filters.projectCode) {
where.projectCode = filters.projectCode
}
if (filters.ordering) {
const isDesc = filters.ordering.startsWith("-")
const field = isDesc ? filters.ordering.slice(1) : filters.ordering
orderBy = { [field]: isDesc ? "desc" : "asc" }
}
}
return await prisma.transaction.findMany({
where,
include: {
category: true,
project: true,
},
orderBy,
})
})
export const getTransactionById = cache(async (id: string): Promise<Transaction | null> => {
return await prisma.transaction.findUnique({
where: { id },
include: {
category: true,
project: true,
},
})
})
export const createTransaction = async (data: TransactionData): Promise<Transaction> => {
const { standard, extra } = await splitTransactionDataExtraFields(data)
return await prisma.transaction.create({
data: {
...standard,
extra: extra,
},
})
}
export const updateTransaction = async (id: string, data: TransactionData): Promise<Transaction> => {
const { standard, extra } = await splitTransactionDataExtraFields(data)
return await prisma.transaction.update({
where: { id },
data: {
...standard,
extra: extra,
},
})
}
export const updateTransactionFiles = async (id: string, files: string[]): Promise<Transaction> => {
return await prisma.transaction.update({
where: { id },
data: { files },
})
}
export const deleteTransaction = async (id: string): Promise<Transaction | undefined> => {
const transaction = await getTransactionById(id)
if (transaction) {
const files = Array.isArray(transaction.files) ? transaction.files : []
for (const fileId of files as string[]) {
await deleteFile(fileId)
}
return await prisma.transaction.delete({
where: { id },
})
}
}
const splitTransactionDataExtraFields = async (
data: TransactionData
): Promise<{ standard: TransactionData; extra: Prisma.InputJsonValue }> => {
const fields = await getFields()
const fieldMap = fields.reduce((acc, field) => {
acc[field.code] = field
return acc
}, {} as Record<string, Field>)
const standard: Omit<Partial<Transaction>, "extra"> = {}
const extra: Record<string, unknown> = {}
Object.entries(data).forEach(([key, value]) => {
const fieldDef = fieldMap[key]
if (fieldDef) {
if (fieldDef.isExtra) {
extra[key] = value
} else {
standard[key as keyof Omit<Transaction, "extra">] = value as any
}
}
})
return { standard, extra: extra as Prisma.InputJsonValue }
}