chore: init from v0
This commit is contained in:
67
lib/hooks.ts
Normal file
67
lib/hooks.ts
Normal 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
91
lib/ofx-parser.tsx
Normal 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
14
lib/prisma.ts
Normal 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
156
lib/store-db.ts
Normal 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
224
lib/store.ts
Normal 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
80
lib/types.ts
Normal 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
6
lib/utils.ts
Normal 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))
|
||||
}
|
||||
Reference in New Issue
Block a user