feat: initial project setup

- Add .NET 8 backend with Clean Architecture
- Add React + Vite + TypeScript frontend
- Implement authentication with JWT
- Implement Azure Blob Storage client
- Implement OCR integration
- Implement supplier matching service
- Implement voucher generation
- Implement Fortnox provider
- Add unit and integration tests
- Add Docker Compose configuration
This commit is contained in:
Invoice Master
2026-02-04 20:14:34 +01:00
commit 05ea67144f
250 changed files with 50402 additions and 0 deletions

36
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,36 @@
import { Routes, Route } from 'react-router-dom'
import { Toaster } from 'react-hot-toast'
import Layout from './components/layout/Layout'
import Home from './pages/Home'
import Login from './pages/Login'
import Register from './pages/Register'
import Upload from './pages/Upload'
import History from './pages/History'
import Connect from './pages/Connect'
import Review from './pages/Review'
import Settings from './pages/Settings'
import NotFound from './pages/NotFound'
import ProtectedRoute from './components/auth/ProtectedRoute'
function App() {
return (
<>
<Toaster position="top-right" />
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/" element={<ProtectedRoute><Layout /></ProtectedRoute>}>
<Route index element={<Home />} />
<Route path="upload" element={<Upload />} />
<Route path="history" element={<History />} />
<Route path="connect" element={<Connect />} />
<Route path="review/:id" element={<Review />} />
<Route path="settings" element={<Settings />} />
</Route>
<Route path="*" element={<NotFound />} />
</Routes>
</>
)
}
export default App

45
frontend/src/api/auth.ts Normal file
View File

@@ -0,0 +1,45 @@
import apiClient from './client'
export interface LoginCredentials {
email: string
password: string
}
export interface RegisterData extends LoginCredentials {
fullName?: string
}
export interface AuthResponse {
user: {
id: string
email: string
fullName?: string
createdAt: string
}
tokens: {
accessToken: string
refreshToken: string
expiresIn: number
}
}
export const authApi = {
login: async (credentials: LoginCredentials): Promise<AuthResponse> => {
const response = await apiClient.post('/auth/login', credentials)
return response.data.data
},
register: async (data: RegisterData): Promise<AuthResponse> => {
const response = await apiClient.post('/auth/register', data)
return response.data.data
},
refresh: async (refreshToken: string) => {
const response = await apiClient.post('/auth/refresh', { refreshToken })
return response.data.data
},
logout: async () => {
await apiClient.post('/auth/logout')
},
}

View File

@@ -0,0 +1,49 @@
import axios from 'axios'
import { useAuthStore } from '../stores/authStore'
const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:5000/api/v1',
headers: {
'Content-Type': 'application/json',
},
})
apiClient.interceptors.request.use((config) => {
const token = useAuthStore.getState().accessToken
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true
try {
const refreshToken = useAuthStore.getState().refreshToken
const response = await axios.post(`${apiClient.defaults.baseURL}/auth/refresh`, {
refreshToken,
})
const { accessToken, refreshToken: newRefreshToken } = response.data.data.tokens
useAuthStore.getState().updateTokens(accessToken, newRefreshToken)
originalRequest.headers.Authorization = `Bearer ${accessToken}`
return apiClient(originalRequest)
} catch (refreshError) {
useAuthStore.getState().logout()
window.location.href = '/login'
return Promise.reject(refreshError)
}
}
return Promise.reject(error)
}
)
export default apiClient

View File

@@ -0,0 +1,16 @@
import { Navigate } from 'react-router-dom'
import { useAuthStore } from '../stores/authStore'
interface ProtectedRouteProps {
children: React.ReactNode
}
export default function ProtectedRoute({ children }: ProtectedRouteProps) {
const { isAuthenticated } = useAuthStore()
if (!isAuthenticated) {
return <Navigate to="/login" replace />
}
return <>{children}</>
}

View File

@@ -0,0 +1,28 @@
import { Link } from 'react-router-dom'
import { useAuthStore } from '../../stores/authStore'
import { FileText, User, LogOut } from 'lucide-react'
export default function Header() {
const { user, logout } = useAuthStore()
return (
<header className="bg-white border-b border-gray-200">
<div className="px-6 py-4 flex items-center justify-between">
<Link to="/" className="flex items-center gap-2">
<FileText className="w-8 h-8 text-primary-600" />
<span className="text-xl font-bold text-gray-900">Invoice Master</span>
</Link>
<div className="flex items-center gap-4">
<span className="text-sm text-gray-600">{user?.email}</span>
<button
onClick={logout}
className="p-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors"
>
<LogOut className="w-5 h-5" />
</button>
</div>
</div>
</header>
)
}

View File

@@ -0,0 +1,17 @@
import { Outlet } from 'react-router-dom'
import Header from './Header'
import Sidebar from './Sidebar'
export default function Layout() {
return (
<div className="min-h-screen bg-gray-50">
<Header />
<div className="flex">
<Sidebar />
<main className="flex-1 p-6">
<Outlet />
</main>
</div>
</div>
)
}

View File

@@ -0,0 +1,35 @@
import { NavLink } from 'react-router-dom'
import { Home, Upload, History, Settings, Link2 } from 'lucide-react'
const navItems = [
{ to: '/', icon: Home, label: 'Dashboard' },
{ to: '/upload', icon: Upload, label: 'Upload' },
{ to: '/history', icon: History, label: 'History' },
{ to: '/connect', icon: Link2, label: 'Connect' },
{ to: '/settings', icon: Settings, label: 'Settings' },
]
export default function Sidebar() {
return (
<aside className="w-64 bg-white border-r border-gray-200 min-h-[calc(100vh-73px)]">
<nav className="p-4 space-y-1">
{navItems.map(({ to, icon: Icon, label }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
`flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${
isActive
? 'bg-primary-50 text-primary-700'
: 'text-gray-700 hover:bg-gray-100'
}`
}
>
<Icon className="w-5 h-5" />
<span className="font-medium">{label}</span>
</NavLink>
))}
</nav>
</aside>
)
}

9
frontend/src/index.css Normal file
View File

@@ -0,0 +1,9 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html {
font-family: system-ui, sans-serif;
}
}

25
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,25 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import './index.css'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5,
retry: 1,
},
},
})
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</React.StrictMode>,
)

View File

@@ -0,0 +1,15 @@
export default function Connect() {
return (
<div>
<h1 className="text-2xl font-bold text-gray-900 mb-6">Connect Accounting System</h1>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Fortnox</h2>
<p className="text-gray-600 mb-4">Connect your Fortnox account to import invoices directly.</p>
<button className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors">
Connect Fortnox
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,8 @@
export default function History() {
return (
<div>
<h1 className="text-2xl font-bold text-gray-900 mb-6">Invoice History</h1>
<p className="text-gray-600">No invoices yet. Upload your first invoice!</p>
</div>
)
}

View File

@@ -0,0 +1,54 @@
import { Link } from 'react-router-dom'
import { Upload, FileText, CheckCircle, ArrowRight } from 'lucide-react'
export default function Home() {
return (
<div className="space-y-8">
<div className="bg-gradient-to-r from-primary-600 to-primary-700 rounded-2xl p-8 text-white">
<h1 className="text-3xl font-bold mb-4">Welcome to Invoice Master</h1>
<p className="text-primary-100 text-lg mb-6">
Upload invoices and automatically import them to your accounting system
</p>
<Link
to="/upload"
className="inline-flex items-center gap-2 bg-white text-primary-600 px-6 py-3 rounded-lg font-medium hover:bg-primary-50 transition-colors"
>
<Upload className="w-5 h-5" />
Upload Invoice
<ArrowRight className="w-5 h-5" />
</Link>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-200">
<div className="w-12 h-12 bg-primary-100 rounded-lg flex items-center justify-center mb-4">
<FileText className="w-6 h-6 text-primary-600" />
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">Upload Invoices</h3>
<p className="text-gray-600">Upload PDF invoices and let AI extract the data</p>
</div>
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-200">
<div className="w-12 h-12 bg-primary-100 rounded-lg flex items-center justify-center mb-4">
<CheckCircle className="w-6 h-6 text-primary-600" />
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">Review & Confirm</h3>
<p className="text-gray-600">Review extracted data and confirm before importing</p>
</div>
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-200">
<div className="w-12 h-12 bg-primary-100 rounded-lg flex items-center justify-center mb-4">
<Upload className="w-6 h-6 text-primary-600" />
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">Auto Import</h3>
<p className="text-gray-600">Import directly to Fortnox with one click</p>
</div>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 className="text-xl font-bold text-gray-900 mb-4">Recent Activity</h2>
<p className="text-gray-600">No recent activity. Upload your first invoice to get started!</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,90 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useMutation } from '@tanstack/react-query'
import { authApi } from '../api/auth'
import { useAuthStore } from '../stores/authStore'
import toast from 'react-hot-toast'
export default function Login() {
const navigate = useNavigate()
const { setAuth } = useAuthStore()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const loginMutation = useMutation({
mutationFn: authApi.login,
onSuccess: (data) => {
setAuth(data.user, data.tokens.accessToken, data.tokens.refreshToken)
toast.success('Welcome back!')
navigate('/')
},
onError: () => {
toast.error('Invalid email or password')
},
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
loginMutation.mutate({ email, password })
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full space-y-8 p-8 bg-white rounded-xl shadow-lg">
<div>
<h2 className="text-3xl font-bold text-center text-gray-900">Sign in</h2>
<p className="mt-2 text-center text-gray-600">
Welcome back to Invoice Master
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<div className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email
</label>
<input
id="email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-primary-500 focus:border-primary-500"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Password
</label>
<input
id="password"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-primary-500 focus:border-primary-500"
/>
</div>
</div>
<button
type="submit"
disabled={loginMutation.isPending}
className="w-full flex justify-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50"
>
{loginMutation.isPending ? 'Signing in...' : 'Sign in'}
</button>
</form>
<p className="mt-4 text-center text-sm text-gray-600">
Don't have an account?{' '}
<Link to="/register" className="font-medium text-primary-600 hover:text-primary-500">
Sign up
</Link>
</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,18 @@
import { Link } from 'react-router-dom'
export default function NotFound() {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<h1 className="text-6xl font-bold text-gray-900 mb-4">404</h1>
<p className="text-xl text-gray-600 mb-8">Page not found</p>
<Link
to="/"
className="inline-flex items-center px-6 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
>
Go Home
</Link>
</div>
</div>
)
}

View File

@@ -0,0 +1,104 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useMutation } from '@tanstack/react-query'
import { authApi } from '../api/auth'
import { useAuthStore } from '../stores/authStore'
import toast from 'react-hot-toast'
export default function Register() {
const navigate = useNavigate()
const { setAuth } = useAuthStore()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [fullName, setFullName] = useState('')
const registerMutation = useMutation({
mutationFn: authApi.register,
onSuccess: (data) => {
setAuth(data.user, data.tokens.accessToken, data.tokens.refreshToken)
toast.success('Account created successfully!')
navigate('/')
},
onError: () => {
toast.error('Failed to create account')
},
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
registerMutation.mutate({ email, password, fullName })
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full space-y-8 p-8 bg-white rounded-xl shadow-lg">
<div>
<h2 className="text-3xl font-bold text-center text-gray-900">Create account</h2>
<p className="mt-2 text-center text-gray-600">
Start managing your invoices
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<div className="space-y-4">
<div>
<label htmlFor="fullName" className="block text-sm font-medium text-gray-700">
Full Name
</label>
<input
id="fullName"
type="text"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-primary-500 focus:border-primary-500"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email
</label>
<input
id="email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-primary-500 focus:border-primary-500"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Password
</label>
<input
id="password"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-primary-500 focus:border-primary-500"
/>
</div>
</div>
<button
type="submit"
disabled={registerMutation.isPending}
className="w-full flex justify-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50"
>
{registerMutation.isPending ? 'Creating account...' : 'Create account'}
</button>
</form>
<p className="mt-4 text-center text-sm text-gray-600">
Already have an account?{' '}
<Link to="/login" className="font-medium text-primary-600 hover:text-primary-500">
Sign in
</Link>
</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,8 @@
export default function Review() {
return (
<div>
<h1 className="text-2xl font-bold text-gray-900 mb-6">Review Invoice</h1>
<p className="text-gray-600">Review and confirm invoice details before importing.</p>
</div>
)
}

View File

@@ -0,0 +1,8 @@
export default function Settings() {
return (
<div>
<h1 className="text-2xl font-bold text-gray-900 mb-6">Settings</h1>
<p className="text-gray-600">Manage your account settings here.</p>
</div>
)
}

View File

@@ -0,0 +1,62 @@
import { useCallback } from 'react'
import { useDropzone } from 'react-dropzone'
import { Upload, File } from 'lucide-react'
import toast from 'react-hot-toast'
export default function UploadPage() {
const onDrop = useCallback((acceptedFiles: File[]) => {
const file = acceptedFiles[0]
if (file) {
if (file.type !== 'application/pdf') {
toast.error('Please upload a PDF file')
return
}
toast.success(`File "${file.name}" selected`)
}
}, [])
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: {
'application/pdf': ['.pdf'],
},
maxFiles: 1,
})
return (
<div className="max-w-2xl mx-auto">
<h1 className="text-2xl font-bold text-gray-900 mb-6">Upload Invoice</h1>
<div
{...getRootProps()}
className={`border-2 border-dashed rounded-xl p-12 text-center cursor-pointer transition-colors ${
isDragActive
? 'border-primary-500 bg-primary-50'
: 'border-gray-300 hover:border-gray-400'
}`}
>
<input {...getInputProps()} />
<div className="flex flex-col items-center gap-4">
<div className="w-16 h-16 bg-primary-100 rounded-full flex items-center justify-center">
<Upload className="w-8 h-8 text-primary-600" />
</div>
{isDragActive ? (
<p className="text-lg text-primary-600">Drop the file here...</p>
) : (
<>
<p className="text-lg text-gray-700">Drag & drop a PDF file here</p>
<p className="text-gray-500">or click to select a file</p>
</>
)}
<div className="flex items-center gap-2 text-sm text-gray-500 mt-4">
<File className="w-4 h-4" />
<span>Supports PDF files up to 10MB</span>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,38 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface User {
id: string
email: string
fullName?: string
}
interface AuthState {
user: User | null
accessToken: string | null
refreshToken: string | null
isAuthenticated: boolean
setAuth: (user: User, accessToken: string, refreshToken: string) => void
logout: () => void
updateTokens: (accessToken: string, refreshToken: string) => void
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
accessToken: null,
refreshToken: null,
isAuthenticated: false,
setAuth: (user, accessToken, refreshToken) =>
set({ user, accessToken, refreshToken, isAuthenticated: true }),
logout: () =>
set({ user: null, accessToken: null, refreshToken: null, isAuthenticated: false }),
updateTokens: (accessToken, refreshToken) =>
set({ accessToken, refreshToken }),
}),
{
name: 'auth-storage',
}
)
)