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:
2
frontend/.env.example
Normal file
2
frontend/.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
VITE_API_URL=http://localhost:5000/api/v1
|
||||
VITE_APP_NAME="Invoice Master"
|
||||
19
frontend/.eslintrc.cjs
Normal file
19
frontend/.eslintrc.cjs
Normal file
@@ -0,0 +1,19 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2020: true },
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
],
|
||||
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['react-refresh'],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
},
|
||||
}
|
||||
12
frontend/Dockerfile
Normal file
12
frontend/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 5173
|
||||
|
||||
CMD ["npm", "run", "dev", "--", "--host"]
|
||||
37
frontend/README.md
Normal file
37
frontend/README.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Invoice Master - Frontend
|
||||
|
||||
React + TypeScript + Vite frontend for Invoice Master.
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 18+
|
||||
- npm or yarn
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Copy `.env.example` to `.env.local` and configure:
|
||||
|
||||
```
|
||||
VITE_API_URL=http://localhost:5000/api/v1
|
||||
VITE_APP_NAME="Invoice Master"
|
||||
```
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Invoice Master</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
42
frontend/package.json
Normal file
42
frontend/package.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "invoice-master-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.20.1",
|
||||
"@tanstack/react-query": "^5.13.4",
|
||||
"axios": "^1.6.2",
|
||||
"zustand": "^4.4.7",
|
||||
"react-dropzone": "^14.2.3",
|
||||
"date-fns": "^3.0.0",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"lucide-react": "^0.294.0",
|
||||
"clsx": "^2.0.0",
|
||||
"tailwind-merge": "^2.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
||||
"@typescript-eslint/parser": "^6.14.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"eslint": "^8.55.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.5",
|
||||
"postcss": "^8.4.32",
|
||||
"tailwindcss": "^3.3.6",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.0.8"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
36
frontend/src/App.tsx
Normal file
36
frontend/src/App.tsx
Normal 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
45
frontend/src/api/auth.ts
Normal 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')
|
||||
},
|
||||
}
|
||||
49
frontend/src/api/client.ts
Normal file
49
frontend/src/api/client.ts
Normal 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
|
||||
16
frontend/src/components/auth/ProtectedRoute.tsx
Normal file
16
frontend/src/components/auth/ProtectedRoute.tsx
Normal 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}</>
|
||||
}
|
||||
28
frontend/src/components/layout/Header.tsx
Normal file
28
frontend/src/components/layout/Header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
17
frontend/src/components/layout/Layout.tsx
Normal file
17
frontend/src/components/layout/Layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
35
frontend/src/components/layout/Sidebar.tsx
Normal file
35
frontend/src/components/layout/Sidebar.tsx
Normal 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
9
frontend/src/index.css
Normal 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
25
frontend/src/main.tsx
Normal 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>,
|
||||
)
|
||||
15
frontend/src/pages/Connect.tsx
Normal file
15
frontend/src/pages/Connect.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
8
frontend/src/pages/History.tsx
Normal file
8
frontend/src/pages/History.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
54
frontend/src/pages/Home.tsx
Normal file
54
frontend/src/pages/Home.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
90
frontend/src/pages/Login.tsx
Normal file
90
frontend/src/pages/Login.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
18
frontend/src/pages/NotFound.tsx
Normal file
18
frontend/src/pages/NotFound.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
104
frontend/src/pages/Register.tsx
Normal file
104
frontend/src/pages/Register.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
8
frontend/src/pages/Review.tsx
Normal file
8
frontend/src/pages/Review.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
8
frontend/src/pages/Settings.tsx
Normal file
8
frontend/src/pages/Settings.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
62
frontend/src/pages/Upload.tsx
Normal file
62
frontend/src/pages/Upload.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
38
frontend/src/stores/authStore.ts
Normal file
38
frontend/src/stores/authStore.ts
Normal 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',
|
||||
}
|
||||
)
|
||||
)
|
||||
26
frontend/tailwind.config.js
Normal file
26
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,26 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
200: '#bfdbfe',
|
||||
300: '#93c5fd',
|
||||
400: '#60a5fa',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
800: '#1e40af',
|
||||
900: '#1e3a8a',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
25
frontend/tsconfig.json
Normal file
25
frontend/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
21
frontend/vite.config.ts
Normal file
21
frontend/vite.config.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:5000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user