chore: init from v0

This commit is contained in:
Julien Froidefond
2025-11-27 09:51:18 +01:00
commit e9e44916fd
109 changed files with 15966 additions and 0 deletions

67
lib/hooks.ts Normal file
View File

@@ -0,0 +1,67 @@
"use client"
import { useState, useEffect, useCallback } from "react"
import type { BankingData } from "./types"
import { loadData } from "./store-db"
export function useBankingData() {
const [data, setData] = useState<BankingData | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
const fetchData = useCallback(async () => {
try {
setIsLoading(true)
setError(null)
const fetchedData = await loadData()
setData(fetchedData)
} catch (err) {
setError(err instanceof Error ? err : new Error("Failed to load data"))
console.error("Error loading banking data:", err)
} finally {
setIsLoading(false)
}
}, [])
useEffect(() => {
fetchData()
}, [fetchData])
const refresh = useCallback(() => {
fetchData()
}, [fetchData])
const update = useCallback((newData: BankingData) => {
// Optimistic update - the actual save happens in individual operations
setData(newData)
}, [])
return { data, isLoading, error, refresh, update }
}
export function useLocalStorage<T>(key: string, initialValue: T) {
const [storedValue, setStoredValue] = useState<T>(initialValue)
useEffect(() => {
try {
const item = window.localStorage.getItem(key)
if (item) {
setStoredValue(JSON.parse(item))
}
} catch (error) {
console.error(error)
}
}, [key])
const setValue = (value: T | ((val: T) => T)) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value
setStoredValue(valueToStore)
window.localStorage.setItem(key, JSON.stringify(valueToStore))
} catch (error) {
console.error(error)
}
}
return [storedValue, setValue] as const
}

91
lib/ofx-parser.tsx Normal file
View File

@@ -0,0 +1,91 @@
import type { OFXAccount, OFXTransaction } from "./types"
export function parseOFX(content: string): OFXAccount | null {
try {
// Remove SGML header and clean up
const xmlStart = content.indexOf("<OFX>")
if (xmlStart === -1) return null
let xml = content.substring(xmlStart)
// Convert SGML to XML-like format
xml = xml.replace(/<(\w+)>([^<]+)(?=<)/g, "<$1>$2</$1>")
// Extract account info
const bankId = extractValue(xml, "BANKID") || extractValue(xml, "ORG") || "UNKNOWN"
const accountId = extractValue(xml, "ACCTID") || "UNKNOWN"
const accountType = extractValue(xml, "ACCTTYPE") || "CHECKING"
const balanceStr = extractValue(xml, "BALAMT") || "0"
const balance = Number.parseFloat(balanceStr)
const balanceDate = extractValue(xml, "DTASOF") || new Date().toISOString()
const currency = extractValue(xml, "CURDEF") || "EUR"
// Extract transactions
const transactions: OFXTransaction[] = []
const stmtTrnRegex = /<STMTTRN>([\s\S]*?)<\/STMTTRN>/gi
let match
while ((match = stmtTrnRegex.exec(xml)) !== null) {
const trnXml = match[1]
const fitId = extractValue(trnXml, "FITID") || `${Date.now()}-${Math.random()}`
const dateStr = extractValue(trnXml, "DTPOSTED") || ""
const amountStr = extractValue(trnXml, "TRNAMT") || "0"
const name = extractValue(trnXml, "NAME") || extractValue(trnXml, "MEMO") || "Unknown"
const memo = extractValue(trnXml, "MEMO")
const checkNum = extractValue(trnXml, "CHECKNUM")
const type = extractValue(trnXml, "TRNTYPE") || "OTHER"
transactions.push({
fitId,
date: parseOFXDate(dateStr),
amount: Number.parseFloat(amountStr),
name: cleanString(name),
memo: memo ? cleanString(memo) : undefined,
checkNum,
type,
})
}
return {
bankId,
accountId,
accountType: mapAccountType(accountType),
balance,
balanceDate: parseOFXDate(balanceDate),
currency,
transactions,
}
} catch (error) {
console.error("Error parsing OFX:", error)
return null
}
}
function extractValue(xml: string, tag: string): string | null {
const regex = new RegExp(`<${tag}>([^<]+)`, "i")
const match = xml.match(regex)
return match ? match[1].trim() : null
}
function parseOFXDate(dateStr: string): string {
if (!dateStr || dateStr.length < 8) return new Date().toISOString()
const year = dateStr.substring(0, 4)
const month = dateStr.substring(4, 6)
const day = dateStr.substring(6, 8)
return `${year}-${month}-${day}`
}
function mapAccountType(type: string): "CHECKING" | "SAVINGS" | "CREDIT_CARD" | "OTHER" {
const upper = type.toUpperCase()
if (upper.includes("CHECK") || upper.includes("CURRENT")) return "CHECKING"
if (upper.includes("SAV")) return "SAVINGS"
if (upper.includes("CREDIT")) return "CREDIT_CARD"
return "OTHER"
}
function cleanString(str: string): string {
return str.replace(/\s+/g, " ").trim()
}

14
lib/prisma.ts Normal file
View File

@@ -0,0 +1,14 @@
import { PrismaClient } from "@prisma/client"
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === "development" ? ["query", "error", "warn"] : ["error"],
})
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma

156
lib/store-db.ts Normal file
View File

@@ -0,0 +1,156 @@
"use client"
import type { BankingData, Account, Transaction, Folder, Category } from "./types"
const API_BASE = "/api/banking"
export async function loadData(): Promise<BankingData> {
const response = await fetch(API_BASE)
if (!response.ok) {
throw new Error("Failed to load data")
}
return response.json()
}
export async function addAccount(account: Omit<Account, "id">): Promise<Account> {
const response = await fetch(`${API_BASE}/accounts`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(account),
})
if (!response.ok) {
throw new Error("Failed to add account")
}
return response.json()
}
export async function updateAccount(account: Account): Promise<Account> {
const response = await fetch(`${API_BASE}/accounts`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(account),
})
if (!response.ok) {
throw new Error("Failed to update account")
}
return response.json()
}
export async function deleteAccount(accountId: string): Promise<void> {
const response = await fetch(`${API_BASE}/accounts?id=${accountId}`, {
method: "DELETE",
})
if (!response.ok) {
throw new Error("Failed to delete account")
}
}
export async function addTransactions(transactions: Transaction[]): Promise<{ count: number; transactions: Transaction[] }> {
const response = await fetch(`${API_BASE}/transactions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(transactions),
})
if (!response.ok) {
throw new Error("Failed to add transactions")
}
return response.json()
}
export async function updateTransaction(transaction: Transaction): Promise<Transaction> {
const response = await fetch(`${API_BASE}/transactions`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(transaction),
})
if (!response.ok) {
throw new Error("Failed to update transaction")
}
return response.json()
}
export async function addFolder(folder: Omit<Folder, "id">): Promise<Folder> {
const response = await fetch(`${API_BASE}/folders`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(folder),
})
if (!response.ok) {
throw new Error("Failed to add folder")
}
return response.json()
}
export async function updateFolder(folder: Folder): Promise<Folder> {
const response = await fetch(`${API_BASE}/folders`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(folder),
})
if (!response.ok) {
throw new Error("Failed to update folder")
}
return response.json()
}
export async function deleteFolder(folderId: string): Promise<void> {
const response = await fetch(`${API_BASE}/folders?id=${folderId}`, {
method: "DELETE",
})
if (!response.ok) {
throw new Error("Failed to delete folder")
}
}
export async function addCategory(category: Omit<Category, "id">): Promise<Category> {
const response = await fetch(`${API_BASE}/categories`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(category),
})
if (!response.ok) {
throw new Error("Failed to add category")
}
return response.json()
}
export async function updateCategory(category: Category): Promise<Category> {
const response = await fetch(`${API_BASE}/categories`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(category),
})
if (!response.ok) {
throw new Error("Failed to update category")
}
return response.json()
}
export async function deleteCategory(categoryId: string): Promise<void> {
const response = await fetch(`${API_BASE}/categories?id=${categoryId}`, {
method: "DELETE",
})
if (!response.ok) {
throw new Error("Failed to delete category")
}
}
// Auto-categorize a transaction based on keywords
export function autoCategorize(description: string, categories: Category[]): string | null {
const lowerDesc = description.toLowerCase()
for (const category of categories) {
for (const keyword of category.keywords) {
if (lowerDesc.includes(keyword.toLowerCase())) {
return category.id
}
}
}
return null
}
export function generateId(): string {
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
}

224
lib/store.ts Normal file
View File

@@ -0,0 +1,224 @@
"use client"
import type { BankingData, Account, Transaction, Folder, Category } from "./types"
const STORAGE_KEY = "banking-app-data"
const defaultCategories: Category[] = [
{
id: "cat-1",
name: "Alimentation",
color: "#22c55e",
icon: "shopping-cart",
keywords: ["carrefour", "leclerc", "auchan", "lidl", "supermarche", "boulangerie", "restaurant"],
parentId: null,
},
{
id: "cat-2",
name: "Transport",
color: "#3b82f6",
icon: "car",
keywords: ["sncf", "ratp", "uber", "essence", "total", "parking", "peage"],
parentId: null,
},
{
id: "cat-3",
name: "Logement",
color: "#f59e0b",
icon: "home",
keywords: ["loyer", "edf", "engie", "eau", "assurance habitation"],
parentId: null,
},
{
id: "cat-4",
name: "Loisirs",
color: "#ec4899",
icon: "gamepad",
keywords: ["cinema", "netflix", "spotify", "fnac", "amazon"],
parentId: null,
},
{
id: "cat-5",
name: "Santé",
color: "#ef4444",
icon: "heart",
keywords: ["pharmacie", "medecin", "docteur", "hopital", "mutuelle"],
parentId: null,
},
{
id: "cat-6",
name: "Revenus",
color: "#10b981",
icon: "wallet",
keywords: ["salaire", "virement recu", "remboursement"],
parentId: null,
},
{
id: "cat-7",
name: "Abonnements",
color: "#8b5cf6",
icon: "repeat",
keywords: ["free", "orange", "sfr", "bouygues", "internet", "telephone"],
parentId: null,
},
{
id: "cat-8",
name: "Shopping",
color: "#06b6d4",
icon: "bag",
keywords: ["zara", "h&m", "decathlon", "ikea"],
parentId: null,
},
]
const defaultData: BankingData = {
accounts: [],
transactions: [],
folders: [{ id: "folder-root", name: "Mes Comptes", parentId: null, color: "#6366f1", icon: "folder" }],
categories: defaultCategories,
categoryRules: [],
}
export function loadData(): BankingData {
if (typeof window === "undefined") return defaultData
const stored = localStorage.getItem(STORAGE_KEY)
if (!stored) {
saveData(defaultData)
return defaultData
}
try {
return JSON.parse(stored)
} catch {
return defaultData
}
}
export function saveData(data: BankingData): void {
if (typeof window === "undefined") return
localStorage.setItem(STORAGE_KEY, JSON.stringify(data))
}
export function addAccount(account: Account): BankingData {
const data = loadData()
data.accounts.push(account)
saveData(data)
return data
}
export function updateAccount(account: Account): BankingData {
const data = loadData()
const index = data.accounts.findIndex((a) => a.id === account.id)
if (index !== -1) {
data.accounts[index] = account
saveData(data)
}
return data
}
export function deleteAccount(accountId: string): BankingData {
const data = loadData()
data.accounts = data.accounts.filter((a) => a.id !== accountId)
data.transactions = data.transactions.filter((t) => t.accountId !== accountId)
saveData(data)
return data
}
export function addTransactions(transactions: Transaction[]): BankingData {
const data = loadData()
// Filter out duplicates based on fitId
const existingFitIds = new Set(data.transactions.map((t) => `${t.accountId}-${t.fitId}`))
const newTransactions = transactions.filter((t) => !existingFitIds.has(`${t.accountId}-${t.fitId}`))
data.transactions.push(...newTransactions)
saveData(data)
return data
}
export function updateTransaction(transaction: Transaction): BankingData {
const data = loadData()
const index = data.transactions.findIndex((t) => t.id === transaction.id)
if (index !== -1) {
data.transactions[index] = transaction
saveData(data)
}
return data
}
export function addFolder(folder: Folder): BankingData {
const data = loadData()
data.folders.push(folder)
saveData(data)
return data
}
export function updateFolder(folder: Folder): BankingData {
const data = loadData()
const index = data.folders.findIndex((f) => f.id === folder.id)
if (index !== -1) {
data.folders[index] = folder
saveData(data)
}
return data
}
export function deleteFolder(folderId: string): BankingData {
const data = loadData()
// Move accounts to root
data.accounts = data.accounts.map((a) => (a.folderId === folderId ? { ...a, folderId: "folder-root" } : a))
// Move subfolders to parent
const folder = data.folders.find((f) => f.id === folderId)
if (folder) {
data.folders = data.folders.map((f) => (f.parentId === folderId ? { ...f, parentId: folder.parentId } : f))
}
data.folders = data.folders.filter((f) => f.id !== folderId)
saveData(data)
return data
}
export function addCategory(category: Category): BankingData {
const data = loadData()
data.categories.push(category)
saveData(data)
return data
}
export function updateCategory(category: Category): BankingData {
const data = loadData()
const index = data.categories.findIndex((c) => c.id === category.id)
if (index !== -1) {
data.categories[index] = category
saveData(data)
}
return data
}
export function deleteCategory(categoryId: string): BankingData {
const data = loadData()
data.categories = data.categories.filter((c) => c.id !== categoryId)
// Remove category from transactions
data.transactions = data.transactions.map((t) => (t.categoryId === categoryId ? { ...t, categoryId: null } : t))
saveData(data)
return data
}
// Auto-categorize a transaction based on keywords
export function autoCategorize(description: string, categories: Category[]): string | null {
const lowerDesc = description.toLowerCase()
for (const category of categories) {
for (const keyword of category.keywords) {
if (lowerDesc.includes(keyword.toLowerCase())) {
return category.id
}
}
}
return null
}
export function generateId(): string {
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
}

80
lib/types.ts Normal file
View File

@@ -0,0 +1,80 @@
// Types for the banking management application
export interface Transaction {
id: string
accountId: string
date: string
amount: number
description: string
type: "DEBIT" | "CREDIT"
categoryId: string | null
isReconciled: boolean
fitId: string // OFX unique transaction ID
memo?: string
checkNum?: string
}
export interface Account {
id: string
name: string
bankId: string
accountNumber: string
type: "CHECKING" | "SAVINGS" | "CREDIT_CARD" | "OTHER"
folderId: string | null
balance: number
currency: string
lastImport: string | null
}
export interface Folder {
id: string
name: string
parentId: string | null
color: string
icon: string
}
export interface Category {
id: string
name: string
color: string
icon: string
keywords: string[] // For auto-categorization
parentId: string | null
}
export interface CategoryRule {
id: string
categoryId: string
pattern: string
isRegex: boolean
}
export interface BankingData {
accounts: Account[]
transactions: Transaction[]
folders: Folder[]
categories: Category[]
categoryRules: CategoryRule[]
}
// OFX Parsed types
export interface OFXTransaction {
fitId: string
date: string
amount: number
name: string
memo?: string
checkNum?: string
type: string
}
export interface OFXAccount {
bankId: string
accountId: string
accountType: string
balance: number
balanceDate: string
currency: string
transactions: OFXTransaction[]
}

6
lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}