first commit
11
apps/web/.env.example
Normal file
@@ -0,0 +1,11 @@
|
||||
# Firebase Configuration
|
||||
# Get these values from your Firebase project settings
|
||||
VITE_FIREBASE_API_KEY=your_api_key_here
|
||||
VITE_FIREBASE_AUTH_DOMAIN=your_project_id.firebaseapp.com
|
||||
VITE_FIREBASE_PROJECT_ID=your_project_id
|
||||
VITE_FIREBASE_STORAGE_BUCKET=your_project_id.appspot.com
|
||||
VITE_FIREBASE_MESSAGING_SENDER_ID=your_sender_id
|
||||
VITE_FIREBASE_APP_ID=your_app_id
|
||||
|
||||
# API Configuration
|
||||
VITE_API_URL=http://localhost:3000
|
||||
24
apps/web/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
69
apps/web/README.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default tseslint.config([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
...tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
...tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default tseslint.config([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
22
apps/web/components.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.cjs",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {}
|
||||
}
|
||||
23
apps/web/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { globalIgnores } from 'eslint/config'
|
||||
|
||||
export default tseslint.config([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs['recommended-latest'],
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
13
apps/web/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/src/assets/images/logo-icon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Tabungin - Personal Finance Manager</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
6937
apps/web/package-lock.json
generated
Normal file
57
apps/web/package.json
Normal file
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"name": "web",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"axios": "^1.11.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"firebase": "^12.3.0",
|
||||
"lucide-react": "^0.545.0",
|
||||
"react": "^19.1.1",
|
||||
"react-day-picker": "^9.11.0",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-hook-form": "^7.64.0",
|
||||
"recharts": "^2.15.4",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.33.0",
|
||||
"@tailwindcss/postcss": "^4.1.11",
|
||||
"@types/node": "^24.2.1",
|
||||
"@types/react": "^19.1.10",
|
||||
"@types/react-dom": "^19.1.7",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^9.33.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.3.0",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"typescript": "^5.9.2",
|
||||
"typescript-eslint": "^8.39.1",
|
||||
"vite": "^7.1.2"
|
||||
}
|
||||
}
|
||||
6
apps/web/postcss.config.cjs
Normal file
@@ -0,0 +1,6 @@
|
||||
// apps/web/postcss.config.cjs
|
||||
module.exports = {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
};
|
||||
1
apps/web/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
42
apps/web/src/App.css
Normal file
@@ -0,0 +1,42 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
64
apps/web/src/App.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useEffect } from "react";
|
||||
import axios from "axios";
|
||||
import { useAuth } from "./hooks/useAuth";
|
||||
import { AuthForm } from "./components/AuthForm";
|
||||
import { Dashboard } from "./components/Dashboard";
|
||||
import { ThemeProvider } from "./components/ThemeProvider";
|
||||
|
||||
|
||||
function AppContent() {
|
||||
// ---- Authentication (MUST be at top level) ----
|
||||
const { user, loading: authLoading, getIdToken } = useAuth();
|
||||
|
||||
// ---- Effects ----
|
||||
|
||||
// ---- Setup Axios Interceptor for Auth ----
|
||||
useEffect(() => {
|
||||
const interceptor = axios.interceptors.request.use(async (config) => {
|
||||
if (user && getIdToken) {
|
||||
try {
|
||||
const token = await getIdToken();
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to get auth token:', error);
|
||||
}
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
return () => {
|
||||
axios.interceptors.request.eject(interceptor);
|
||||
};
|
||||
}, [getIdToken, user]);
|
||||
|
||||
|
||||
// Show loading screen while checking auth
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show auth form if not authenticated
|
||||
if (!user) {
|
||||
return <AuthForm />;
|
||||
}
|
||||
|
||||
// Show dashboard if authenticated
|
||||
return <Dashboard />;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<ThemeProvider defaultTheme="system" storageKey="tabungin-ui-theme">
|
||||
<AppContent />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
BIN
apps/web/src/assets/images/logo-dark.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
apps/web/src/assets/images/logo-icon.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
apps/web/src/assets/images/logo-large-dark.png
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
BIN
apps/web/src/assets/images/logo-large.png
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
BIN
apps/web/src/assets/images/logo.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
19
apps/web/src/assets/images/logo.svg
Normal file
@@ -0,0 +1,19 @@
|
||||
<svg version="1.2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1080 1080" width="512" height="512">
|
||||
<defs>
|
||||
<clipPath clipPathUnits="userSpaceOnUse" id="cp1">
|
||||
<path d="m442.08 277.74l-91.98 13.6-41.55-281.34 593.95 152.78-23.18 90.05-458.33-117.9z"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<style>
|
||||
.s0 { fill: #34cc99 }
|
||||
.s1 { fill: #2f9168 }
|
||||
</style>
|
||||
<g id="Folder 1">
|
||||
<rect width="1080" height="1080" id="Layer 1" style="opacity: .5;fill: #ffffff"/>
|
||||
<path id="Path 2" class="s0" d="m442.08 277.74l-91.98 13.6-41.55-281.34 593.95 152.78-23.18 90.05-458.33-117.9z"/>
|
||||
<g id="Mask" clip-path="url(#cp1)">
|
||||
<path id="Path 4" class="s1" d="m882.28-28.16l-612.08 34.91 44.56 253.25 612.08-34.89z"/>
|
||||
</g>
|
||||
<path id="Path 6" fill-rule="evenodd" class="s0" d="m161.75 476.81c-10.24-16.38-17.35-35.11-20.36-55.45-11.67-79.01 42.91-152.53 121.92-164.2l637.25-94.15 94.35 638.61c13.6 92.11-50.03 177.82-142.15 191.44l-512.53 75.72c-92.11 13.6-177.81-50.04-191.43-142.15l-64.75-438.33zm18.02 23.19l61.02 413.04c6.1 41.3 44.53 69.85 85.85 63.73l512.53-75.72c41.31-6.09 69.85-44.52 63.75-85.83l-80.76-546.63-545.27 80.55c-28.2 4.16-47.68 30.41-43.52 58.63 4.17 28.19 30.41 47.68 58.61 43.52l302.8-44.73 13.58 91.98-302.77 44.73c-48.28 7.14-94.5-10.46-125.82-43.27z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
1
apps/web/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
141
apps/web/src/components/AuthForm.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import { useState } from 'react';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { Logo } from './Logo';
|
||||
import { ThemeToggle } from './ThemeToggle';
|
||||
|
||||
export const AuthForm = () => {
|
||||
const [isLogin, setIsLogin] = useState(true);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const { signIn, signUp, signInWithGoogle, loading, error } = useAuth();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
if (isLogin) {
|
||||
await signIn(email, password);
|
||||
} else {
|
||||
await signUp(email, password);
|
||||
}
|
||||
} catch {
|
||||
// Error is handled by useAuth hook
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoogleSignIn = async () => {
|
||||
try {
|
||||
await signInWithGoogle();
|
||||
} catch {
|
||||
// Error is handled by useAuth hook
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background py-12 px-4 sm:px-6 lg:px-8 relative">
|
||||
{/* Theme Toggle - positioned in top right */}
|
||||
<div className="absolute top-4 right-4">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div className="text-center">
|
||||
<Logo variant="large" className="mx-auto mb-6" />
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-foreground">
|
||||
{isLogin ? 'Sign in to your account' : 'Create your account'}
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-muted-foreground">
|
||||
Welcome to Tabungin
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 border border-destructive/20 p-4">
|
||||
<div className="flex">
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-destructive">
|
||||
Authentication Error
|
||||
</h3>
|
||||
<div className="mt-2 text-sm text-destructive/80">
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-foreground">
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="mt-1 flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
placeholder="Enter your email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-foreground">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete={isLogin ? "current-password" : "new-password"}
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="mt-1 flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
placeholder="Enter your password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2 w-full"
|
||||
>
|
||||
{loading ? 'Loading...' : (isLogin ? 'Sign in' : 'Sign up')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGoogleSignIn}
|
||||
disabled={loading}
|
||||
className="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-destructive/80 h-10 px-4 py-2 w-full"
|
||||
>
|
||||
<svg className="w-5 h-5 mr-2" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
|
||||
<path fill="currentColor" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
|
||||
<path fill="currentColor" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
|
||||
<path fill="currentColor" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
|
||||
</svg>
|
||||
Continue with Google
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsLogin(!isLogin)}
|
||||
className="text-primary hover:text-primary/80 text-sm underline-offset-4 hover:underline"
|
||||
>
|
||||
{isLogin ? "Don't have an account? Sign up" : "Already have an account? Sign in"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
32
apps/web/src/components/Breadcrumb.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { ChevronRight, Home } from "lucide-react"
|
||||
|
||||
interface BreadcrumbProps {
|
||||
currentPage: string
|
||||
}
|
||||
|
||||
export function Breadcrumb({ currentPage }: BreadcrumbProps) {
|
||||
const getPageTitle = (page: string) => {
|
||||
switch (page) {
|
||||
case '/':
|
||||
return 'Overview'
|
||||
case '/wallets':
|
||||
return 'Wallets'
|
||||
case '/transactions':
|
||||
return 'Transactions'
|
||||
default:
|
||||
return page.charAt(0).toUpperCase() + page.slice(1)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className="flex items-center space-x-1 text-sm text-muted-foreground">
|
||||
<div className="flex items-center space-x-1">
|
||||
<Home className="h-4 w-4" />
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
<span className="font-medium text-foreground">
|
||||
{getPageTitle(currentPage)}
|
||||
</span>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
28
apps/web/src/components/Dashboard.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useState } from "react"
|
||||
import { DashboardLayout } from "./layout/DashboardLayout"
|
||||
import { Overview } from "./pages/Overview"
|
||||
import { Wallets } from "./pages/Wallets"
|
||||
import { Transactions } from "./pages/Transactions"
|
||||
|
||||
export function Dashboard() {
|
||||
const [currentPage, setCurrentPage] = useState("/")
|
||||
|
||||
const renderPage = () => {
|
||||
switch (currentPage) {
|
||||
case "/":
|
||||
return <Overview />
|
||||
case "/wallets":
|
||||
return <Wallets />
|
||||
case "/transactions":
|
||||
return <Transactions />
|
||||
default:
|
||||
return <Overview />
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardLayout currentPage={currentPage} onNavigate={setCurrentPage}>
|
||||
{renderPage()}
|
||||
</DashboardLayout>
|
||||
)
|
||||
}
|
||||
52
apps/web/src/components/Logo.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import logoLight from '../assets/images/logo.png';
|
||||
import logoDark from '../assets/images/logo-dark.png';
|
||||
import logoLargeLight from '../assets/images/logo-large.png';
|
||||
import logoLargeDark from '../assets/images/logo-large-dark.png';
|
||||
import logoIcon from '../assets/images/logo-icon.png';
|
||||
|
||||
interface LogoProps {
|
||||
variant?: 'header' | 'large' | 'icon';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const Logo = ({ variant = 'header', className = '' }: LogoProps) => {
|
||||
const getLogoSrc = () => {
|
||||
switch (variant) {
|
||||
case 'large':
|
||||
return {
|
||||
light: logoLargeLight,
|
||||
dark: logoLargeDark,
|
||||
};
|
||||
case 'icon':
|
||||
return {
|
||||
light: logoIcon,
|
||||
dark: logoIcon,
|
||||
};
|
||||
default: // header
|
||||
return {
|
||||
light: logoLight,
|
||||
dark: logoDark,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const logos = getLogoSrc();
|
||||
const baseClassName = variant === 'icon' ? 'w-8 h-8' : variant === 'large' ? 'h-12' : 'h-8';
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Light mode logo */}
|
||||
<img
|
||||
src={logos.light}
|
||||
alt="Tabungin"
|
||||
className={`${baseClassName} ${className} block dark:hidden`}
|
||||
/>
|
||||
{/* Dark mode logo */}
|
||||
<img
|
||||
src={logos.dark}
|
||||
alt="Tabungin"
|
||||
className={`${baseClassName} ${className} hidden dark:block`}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
55
apps/web/src/components/ThemeProvider.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ThemeProviderContext, type Theme } from '../hooks/useTheme'
|
||||
|
||||
type ThemeProviderProps = {
|
||||
children: React.ReactNode
|
||||
defaultTheme?: Theme
|
||||
storageKey?: string
|
||||
}
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = 'system',
|
||||
storageKey = 'tabungin-ui-theme',
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, setTheme] = useState<Theme>(
|
||||
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
|
||||
)
|
||||
|
||||
const [actualTheme, setActualTheme] = useState<'dark' | 'light'>('light')
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement
|
||||
|
||||
root.classList.remove('light', 'dark')
|
||||
|
||||
if (theme === 'system') {
|
||||
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
.matches
|
||||
? 'dark'
|
||||
: 'light'
|
||||
|
||||
root.classList.add(systemTheme)
|
||||
setActualTheme(systemTheme)
|
||||
return
|
||||
}
|
||||
|
||||
root.classList.add(theme)
|
||||
setActualTheme(theme)
|
||||
}, [theme])
|
||||
|
||||
const value = {
|
||||
theme,
|
||||
setTheme: (theme: Theme) => {
|
||||
localStorage.setItem(storageKey, theme)
|
||||
setTheme(theme)
|
||||
},
|
||||
actualTheme,
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeProviderContext.Provider value={value}>
|
||||
{children}
|
||||
</ThemeProviderContext.Provider>
|
||||
)
|
||||
}
|
||||
47
apps/web/src/components/ThemeToggle.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Moon, Sun, Monitor } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { useTheme } from "../hooks/useTheme"
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { setTheme, actualTheme } = useTheme()
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
title="Toggle theme"
|
||||
>
|
||||
{actualTheme === 'dark' ? (
|
||||
<Moon className="h-4 w-4" />
|
||||
) : (
|
||||
<Sun className="h-4 w-4" />
|
||||
)}
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setTheme('light')}>
|
||||
<Sun className="mr-2 h-4 w-4" />
|
||||
<span>Light</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme('dark')}>
|
||||
<Moon className="mr-2 h-4 w-4" />
|
||||
<span>Dark</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme('system')}>
|
||||
<Monitor className="mr-2 h-4 w-4" />
|
||||
<span>System</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
322
apps/web/src/components/dialogs/TransactionDialog.tsx
Normal file
@@ -0,0 +1,322 @@
|
||||
import { useState, useEffect } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { MultipleSelector, type Option } from "@/components/ui/multiselector"
|
||||
import { DatePicker } from "@/components/ui/date-picker"
|
||||
import axios from "axios"
|
||||
|
||||
interface Wallet {
|
||||
id: string
|
||||
name: string
|
||||
kind: "money" | "asset"
|
||||
currency?: string | null
|
||||
unit?: string | null
|
||||
deletedAt?: string | null
|
||||
}
|
||||
|
||||
interface Transaction {
|
||||
id: string
|
||||
walletId: string
|
||||
date: string
|
||||
amount: number
|
||||
direction: "in" | "out"
|
||||
category?: string | null
|
||||
memo?: string | null
|
||||
}
|
||||
|
||||
interface TransactionDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
transaction?: Transaction | null
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
const API = "/api"
|
||||
|
||||
export function TransactionDialog({ open, onOpenChange, transaction, onSuccess }: TransactionDialogProps) {
|
||||
const [wallets, setWallets] = useState<Wallet[]>([])
|
||||
const [categoryOptions, setCategoryOptions] = useState<Option[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [amount, setAmount] = useState(transaction?.amount?.toString() || "")
|
||||
const [walletId, setWalletId] = useState(transaction?.walletId || "")
|
||||
const [direction, setDirection] = useState<"in" | "out">(transaction?.direction || "out")
|
||||
const [categories, setCategories] = useState<string[]>(
|
||||
transaction?.category ? transaction.category.split(',').map(c => c.trim()) : []
|
||||
)
|
||||
const [memo, setMemo] = useState(transaction?.memo || "")
|
||||
const [selectedDate, setSelectedDate] = useState<Date>(
|
||||
transaction?.date ? new Date(transaction.date) : new Date()
|
||||
)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const isEditing = !!transaction
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
loadWallets()
|
||||
loadCategories()
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const loadWallets = async () => {
|
||||
try {
|
||||
const response = await axios.get(`${API}/wallets`)
|
||||
setWallets(response.data.filter((w: Wallet) => !w.deletedAt))
|
||||
} catch (error) {
|
||||
console.error('Failed to load wallets:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadCategories = async () => {
|
||||
try {
|
||||
// Get categories from the dedicated Category table
|
||||
const response = await axios.get(`${API}/categories`)
|
||||
const categories = response.data
|
||||
|
||||
const options: Option[] = categories.map((cat: any) => ({
|
||||
label: cat.name,
|
||||
value: cat.name
|
||||
}))
|
||||
|
||||
setCategoryOptions(options)
|
||||
} catch (error) {
|
||||
console.error('Failed to load categories:', error)
|
||||
// Fallback: extract from existing transactions if categories endpoint fails
|
||||
try {
|
||||
const txResponse = await axios.get(`${API}/wallets/transactions`)
|
||||
const allTransactions = txResponse.data
|
||||
|
||||
const uniqueCategories = new Set<string>()
|
||||
allTransactions.forEach((tx: Transaction) => {
|
||||
if (tx.category) {
|
||||
tx.category.split(',').forEach(cat => {
|
||||
const trimmed = cat.trim()
|
||||
if (trimmed) uniqueCategories.add(trimmed)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const fallbackOptions: Option[] = Array.from(uniqueCategories).map(cat => ({
|
||||
label: cat,
|
||||
value: cat
|
||||
}))
|
||||
|
||||
setCategoryOptions(fallbackOptions)
|
||||
} catch (fallbackError) {
|
||||
console.error('Failed to load categories from transactions:', fallbackError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
const amountNum = parseFloat(amount)
|
||||
if (!amountNum || amountNum <= 0) {
|
||||
setError("Amount must be a positive number")
|
||||
return
|
||||
}
|
||||
|
||||
if (!walletId) {
|
||||
setError("Please select a wallet")
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
// First, create any new categories
|
||||
if (categories.length > 0) {
|
||||
for (const category of categories) {
|
||||
try {
|
||||
await axios.post(`${API}/categories`, { name: category })
|
||||
} catch (error: any) {
|
||||
// Ignore if category already exists (409 conflict)
|
||||
if (error.response?.status !== 409) {
|
||||
console.error('Failed to create category:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const data = {
|
||||
amount: amountNum,
|
||||
direction,
|
||||
category: categories.join(', ').trim() || undefined,
|
||||
memo: memo.trim() || undefined,
|
||||
date: selectedDate.toISOString()
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
await axios.put(`${API}/wallets/${walletId}/transactions/${transaction.id}`, data)
|
||||
} else {
|
||||
await axios.post(`${API}/wallets/${walletId}/transactions`, data)
|
||||
}
|
||||
|
||||
onSuccess()
|
||||
onOpenChange(false)
|
||||
|
||||
// Reset form
|
||||
resetForm()
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to save transaction'
|
||||
setError(message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
setAmount("")
|
||||
setWalletId("")
|
||||
setDirection("out")
|
||||
setCategories([])
|
||||
setMemo("")
|
||||
setSelectedDate(new Date())
|
||||
setError(null)
|
||||
}
|
||||
|
||||
// Reset form when dialog opens/closes
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
onOpenChange(newOpen)
|
||||
if (!newOpen) {
|
||||
setError(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Update form when transaction prop changes
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (transaction) {
|
||||
setAmount(transaction.amount.toString())
|
||||
setWalletId(transaction.walletId)
|
||||
setDirection(transaction.direction)
|
||||
setCategories(transaction.category ? transaction.category.split(',').map(c => c.trim()) : [])
|
||||
setMemo(transaction.memo || "")
|
||||
setSelectedDate(new Date(transaction.date))
|
||||
} else {
|
||||
resetForm()
|
||||
}
|
||||
}
|
||||
}, [open, transaction])
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEditing ? "Edit Transaction" : "Add New Transaction"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEditing ? "Update your transaction details." : "Record a new transaction."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wallet">Wallet</Label>
|
||||
<Select value={walletId} onValueChange={setWalletId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a wallet" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{wallets.map(wallet => (
|
||||
<SelectItem key={wallet.id} value={wallet.id}>
|
||||
{wallet.name} ({wallet.currency || wallet.unit})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="amount">Amount</Label>
|
||||
<Input
|
||||
id="amount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
placeholder="0.00"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="direction">Type</Label>
|
||||
<Select value={direction} onValueChange={(value: "in" | "out") => setDirection(value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="in">Income</SelectItem>
|
||||
<SelectItem value="out">Expense</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="date">Date</Label>
|
||||
<DatePicker
|
||||
date={selectedDate}
|
||||
onDateChange={(date) => date && setSelectedDate(date)}
|
||||
placeholder="Select date"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="category">Categories</Label>
|
||||
<MultipleSelector
|
||||
options={categoryOptions}
|
||||
selected={categories}
|
||||
onChange={setCategories}
|
||||
placeholder="Select or create categories..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="memo">Memo</Label>
|
||||
<Input
|
||||
id="memo"
|
||||
value={memo}
|
||||
onChange={(e) => setMemo(e.target.value)}
|
||||
placeholder="Optional description"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-red-600 bg-red-50 p-2 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => handleOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? "Saving..." : isEditing ? "Update" : "Create"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
282
apps/web/src/components/dialogs/WalletDialog.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
import { useState, useEffect } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
import { Check, ChevronsUpDown } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CURRENCIES } from "@/constants/currencies"
|
||||
import axios from "axios"
|
||||
|
||||
interface Wallet {
|
||||
id: string
|
||||
name: string
|
||||
kind: "money" | "asset"
|
||||
currency?: string | null
|
||||
unit?: string | null
|
||||
initialAmount?: number | null
|
||||
pricePerUnit?: number | null
|
||||
}
|
||||
|
||||
interface WalletDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
wallet?: Wallet | null
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
const API = "/api"
|
||||
|
||||
export function WalletDialog({ open, onOpenChange, wallet, onSuccess }: WalletDialogProps) {
|
||||
const [name, setName] = useState(wallet?.name || "")
|
||||
const [kind, setKind] = useState<"money" | "asset">(wallet?.kind || "money")
|
||||
const [currency, setCurrency] = useState(wallet?.currency || "IDR")
|
||||
const [unit, setUnit] = useState(wallet?.unit || "")
|
||||
const [initialAmount, setInitialAmount] = useState(wallet?.initialAmount?.toString() || "")
|
||||
const [pricePerUnit, setPricePerUnit] = useState(wallet?.pricePerUnit?.toString() || "")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [currencyOpen, setCurrencyOpen] = useState(false)
|
||||
|
||||
const isEditing = !!wallet
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!name.trim()) {
|
||||
setError("Name is required")
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const initialAmountNum = initialAmount ? parseFloat(initialAmount) : null
|
||||
const pricePerUnitNum = pricePerUnit ? parseFloat(pricePerUnit) : null
|
||||
const data = {
|
||||
name: name.trim(),
|
||||
kind,
|
||||
...(kind === "money" ? { currency } : { unit: unit.trim() }),
|
||||
...(initialAmountNum && initialAmountNum > 0 ? { initialAmount: initialAmountNum } : {}),
|
||||
...(kind === "asset" && pricePerUnitNum && pricePerUnitNum > 0 ? { pricePerUnit: pricePerUnitNum } : {})
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
await axios.put(`${API}/wallets/${wallet.id}`, data)
|
||||
} else {
|
||||
await axios.post(`${API}/wallets`, data)
|
||||
}
|
||||
|
||||
onSuccess()
|
||||
onOpenChange(false)
|
||||
|
||||
// Reset form
|
||||
setName("")
|
||||
setKind("money")
|
||||
setCurrency("IDR")
|
||||
setUnit("")
|
||||
setInitialAmount("")
|
||||
setPricePerUnit("")
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to save wallet'
|
||||
setError(message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Reset form when dialog opens/closes
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
onOpenChange(newOpen)
|
||||
if (!newOpen) {
|
||||
setError(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Update form when wallet prop changes
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (wallet) {
|
||||
setName(wallet.name)
|
||||
setKind(wallet.kind)
|
||||
setCurrency(wallet.currency || "IDR")
|
||||
setUnit(wallet.unit || "")
|
||||
setInitialAmount(wallet.initialAmount?.toString() || "")
|
||||
setPricePerUnit(wallet.pricePerUnit?.toString() || "")
|
||||
} else {
|
||||
setName("")
|
||||
setKind("money")
|
||||
setCurrency("IDR")
|
||||
setUnit("")
|
||||
setInitialAmount("")
|
||||
setPricePerUnit("")
|
||||
}
|
||||
setError(null)
|
||||
}
|
||||
}, [open, wallet])
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEditing ? "Edit Wallet" : "Add New Wallet"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEditing ? "Update your wallet details." : "Create a new wallet to track your finances."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., My Bank Account"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="kind">Type</Label>
|
||||
<Select value={kind} onValueChange={(value: "money" | "asset") => setKind(value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="money">Money</SelectItem>
|
||||
<SelectItem value="asset">Asset</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{kind === "money" ? (
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="currency">Currency</Label>
|
||||
<Popover open={currencyOpen} onOpenChange={setCurrencyOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={currencyOpen}
|
||||
className="w-full justify-between"
|
||||
>
|
||||
{currency
|
||||
? CURRENCIES.find((curr) => curr.code === currency)?.code + " - " + CURRENCIES.find((curr) => curr.code === currency)?.name
|
||||
: "Select currency..."}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search currency..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No currency found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{CURRENCIES.map((curr) => (
|
||||
<CommandItem
|
||||
key={curr.code}
|
||||
value={curr.code + " " + curr.name}
|
||||
onSelect={() => {
|
||||
setCurrency(curr.code)
|
||||
setCurrencyOpen(false)
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
currency === curr.code ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{curr.code} - {curr.name}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="unit">Unit</Label>
|
||||
<Input
|
||||
id="unit"
|
||||
value={unit}
|
||||
onChange={(e) => setUnit(e.target.value)}
|
||||
placeholder="e.g., shares, kg, pieces"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="pricePerUnit">Price per Unit (IDR)</Label>
|
||||
<Input
|
||||
id="pricePerUnit"
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={pricePerUnit}
|
||||
onChange={(e) => setPricePerUnit(e.target.value)}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="initialAmount">Initial Amount (Optional)</Label>
|
||||
<Input
|
||||
id="initialAmount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={initialAmount}
|
||||
onChange={(e) => setInitialAmount(e.target.value)}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-red-600 bg-red-50 p-2 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => handleOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? "Saving..." : isEditing ? "Update" : "Create"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
90
apps/web/src/components/layout/AppSidebar.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { Home, Wallet, Receipt, LogOut } from "lucide-react"
|
||||
import { Logo } from "../Logo"
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar"
|
||||
import { useAuth } from "@/hooks/useAuth"
|
||||
|
||||
// Menu items
|
||||
const items = [
|
||||
{
|
||||
title: "Overview",
|
||||
url: "/",
|
||||
icon: Home,
|
||||
},
|
||||
{
|
||||
title: "Wallets",
|
||||
url: "/wallets",
|
||||
icon: Wallet,
|
||||
},
|
||||
{
|
||||
title: "Transactions",
|
||||
url: "/transactions",
|
||||
icon: Receipt,
|
||||
},
|
||||
]
|
||||
|
||||
interface AppSidebarProps {
|
||||
currentPage: string
|
||||
onNavigate: (page: string) => void
|
||||
}
|
||||
|
||||
export function AppSidebar({ currentPage, onNavigate }: AppSidebarProps) {
|
||||
const { user, signOut } = useAuth()
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
<SidebarHeader className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Logo variant="large"/>
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Navigation</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{items.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={currentPage === item.url}
|
||||
onClick={() => onNavigate(item.url)}
|
||||
>
|
||||
<button className="w-full">
|
||||
<item.icon />
|
||||
<span>{item.title}</span>
|
||||
</button>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
<SidebarFooter className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">{user?.email}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={signOut}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
title="Sign out"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
||||
33
apps/web/src/components/layout/DashboardLayout.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"
|
||||
import { AppSidebar } from "./AppSidebar"
|
||||
import { ThemeToggle } from "@/components/ThemeToggle"
|
||||
import { Breadcrumb } from "@/components/Breadcrumb"
|
||||
|
||||
interface DashboardLayoutProps {
|
||||
children: React.ReactNode
|
||||
currentPage: string
|
||||
onNavigate: (page: string) => void
|
||||
}
|
||||
|
||||
export function DashboardLayout({ children, currentPage, onNavigate }: DashboardLayoutProps) {
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar currentPage={currentPage} onNavigate={onNavigate} />
|
||||
<main className="flex-1 overflow-hidden">
|
||||
<div className="flex h-screen flex-col">
|
||||
<header className="flex h-16 shrink-0 items-center gap-4 border-b px-4 bg-background">
|
||||
<SidebarTrigger className="-ml-1 md:h-8 md:w-8 h-10 w-10" />
|
||||
<Breadcrumb currentPage={currentPage} />
|
||||
<div className="flex-1" />
|
||||
<ThemeToggle />
|
||||
</header>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="container mx-auto max-w-7xl p-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
1110
apps/web/src/components/pages/Overview.tsx
Normal file
541
apps/web/src/components/pages/Transactions.tsx
Normal file
@@ -0,0 +1,541 @@
|
||||
import { useState, useEffect, useMemo } from "react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from "@/components/ui/table"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Plus, Search, Edit, Trash2, Receipt, TrendingUp, TrendingDown, Filter } from "lucide-react"
|
||||
import axios from "axios"
|
||||
import { formatCurrency } from "@/constants/currencies"
|
||||
import { formatLargeNumber } from "@/utils/numberFormat"
|
||||
import { fetchExchangeRates, convertToIDR } from "@/utils/exchangeRate"
|
||||
import { TransactionDialog } from "@/components/dialogs/TransactionDialog"
|
||||
import { DatePicker } from "@/components/ui/date-picker"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
|
||||
interface Wallet {
|
||||
id: string
|
||||
name: string
|
||||
kind: "money" | "asset"
|
||||
currency?: string | null
|
||||
unit?: string | null
|
||||
initialAmount?: number | null
|
||||
pricePerUnit?: number | null
|
||||
deletedAt?: string | null
|
||||
}
|
||||
|
||||
interface Transaction {
|
||||
id: string
|
||||
walletId: string
|
||||
date: string
|
||||
amount: number
|
||||
direction: "in" | "out"
|
||||
category?: string | null
|
||||
memo?: string | null
|
||||
}
|
||||
|
||||
const API = "/api"
|
||||
|
||||
export function Transactions() {
|
||||
const [wallets, setWallets] = useState<Wallet[]>([])
|
||||
const [transactions, setTransactions] = useState<Transaction[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
// Filters
|
||||
const [searchTerm, setSearchTerm] = useState("")
|
||||
const [walletFilter, setWalletFilter] = useState<string>("all")
|
||||
const [directionFilter, setDirectionFilter] = useState<string>("all")
|
||||
const [amountMin, setAmountMin] = useState("")
|
||||
const [amountMax, setAmountMax] = useState("")
|
||||
const [dateFrom, setDateFrom] = useState<Date | undefined>(undefined)
|
||||
const [dateTo, setDateTo] = useState<Date | undefined>(undefined)
|
||||
const [transactionDialogOpen, setTransactionDialogOpen] = useState(false)
|
||||
const [editingTransaction, setEditingTransaction] = useState<Transaction | null>(null)
|
||||
const [showFilters, setShowFilters] = useState(false)
|
||||
const [exchangeRates, setExchangeRates] = useState<Record<string, number>>({})
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
loadExchangeRates()
|
||||
}, [])
|
||||
|
||||
const loadExchangeRates = async () => {
|
||||
try {
|
||||
const rates = await fetchExchangeRates()
|
||||
setExchangeRates(rates)
|
||||
} catch (error) {
|
||||
console.error('Failed to load exchange rates:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
|
||||
// Load wallets first
|
||||
const walletsRes = await axios.get(`${API}/wallets`)
|
||||
const activeWallets = walletsRes.data.filter((w: Wallet) => !w.deletedAt)
|
||||
setWallets(activeWallets)
|
||||
|
||||
// Load transactions from all wallets
|
||||
const transactionPromises = activeWallets.map((wallet: Wallet) =>
|
||||
axios.get(`${API}/wallets/${wallet.id}/transactions`)
|
||||
.then(res => res.data.map((tx: Transaction) => ({
|
||||
...tx,
|
||||
amount: Number(tx.amount)
|
||||
})))
|
||||
.catch(() => [])
|
||||
)
|
||||
|
||||
const transactionArrays = await Promise.all(transactionPromises)
|
||||
const allTransactions = transactionArrays.flat()
|
||||
setTransactions(allTransactions)
|
||||
} catch (error) {
|
||||
console.error('Failed to load data:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteTransaction = async (walletId: string, transactionId: string) => {
|
||||
|
||||
try {
|
||||
await axios.delete(`${API}/wallets/${walletId}/transactions/${transactionId}`)
|
||||
await loadData()
|
||||
} catch (error) {
|
||||
console.error('Failed to delete transaction:', error)
|
||||
alert('Failed to delete transaction')
|
||||
}
|
||||
}
|
||||
|
||||
const handleEditTransaction = (transaction: Transaction) => {
|
||||
setEditingTransaction(transaction)
|
||||
setTransactionDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleDialogClose = () => {
|
||||
setTransactionDialogOpen(false)
|
||||
setEditingTransaction(null)
|
||||
}
|
||||
|
||||
// Filter transactions
|
||||
const filteredTransactions = useMemo(() => {
|
||||
return transactions.filter(transaction => {
|
||||
|
||||
// Search in memo
|
||||
const matchesSearch = !searchTerm ||
|
||||
(transaction.memo?.toLowerCase().includes(searchTerm.toLowerCase()) ?? false)
|
||||
|
||||
// Wallet filter
|
||||
const matchesWallet = walletFilter === "all" || transaction.walletId === walletFilter
|
||||
|
||||
// Direction filter
|
||||
const matchesDirection = directionFilter === "all" || transaction.direction === directionFilter
|
||||
|
||||
// Amount filter
|
||||
const matchesAmount = (!amountMin || transaction.amount >= Number(amountMin)) &&
|
||||
(!amountMax || transaction.amount <= Number(amountMax))
|
||||
|
||||
// Date filter
|
||||
const transactionDate = new Date(transaction.date)
|
||||
const matchesDate = (!dateFrom || transactionDate >= dateFrom) &&
|
||||
(!dateTo || transactionDate <= dateTo)
|
||||
|
||||
return matchesSearch && matchesWallet && matchesDirection && matchesAmount && matchesDate
|
||||
})
|
||||
}, [transactions, searchTerm, walletFilter, directionFilter, amountMin, amountMax, dateFrom, dateTo])
|
||||
|
||||
// Calculate stats for filtered transactions
|
||||
const stats = useMemo(() => {
|
||||
const totalTransactions = filteredTransactions.length
|
||||
let totalIncome = 0
|
||||
let totalExpense = 0
|
||||
|
||||
filteredTransactions.forEach(tx => {
|
||||
const wallet = wallets.find(w => w.id === tx.walletId)
|
||||
if (!wallet) return
|
||||
|
||||
let idrAmount = 0
|
||||
if (wallet.kind === 'money') {
|
||||
const currency = wallet.currency || 'IDR'
|
||||
idrAmount = convertToIDR(tx.amount, currency, exchangeRates)
|
||||
} else {
|
||||
// For assets, multiply by price per unit and convert to IDR if needed
|
||||
const pricePerUnit = wallet.pricePerUnit || 1
|
||||
const assetCurrency = wallet.currency || 'IDR'
|
||||
const assetValueInCurrency = tx.amount * pricePerUnit
|
||||
idrAmount = convertToIDR(assetValueInCurrency, assetCurrency, exchangeRates)
|
||||
}
|
||||
|
||||
if (tx.direction === 'in') {
|
||||
totalIncome += idrAmount
|
||||
} else {
|
||||
totalExpense += idrAmount
|
||||
}
|
||||
})
|
||||
|
||||
const netAmount = totalIncome - totalExpense
|
||||
|
||||
return { totalTransactions, totalIncome, totalExpense, netAmount }
|
||||
}, [filteredTransactions, wallets, exchangeRates])
|
||||
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="h-8 w-48 bg-gray-200 rounded animate-pulse" />
|
||||
<div className="grid gap-4 grid-cols-2 lg:grid-cols-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader>
|
||||
<div className="h-4 w-20 bg-gray-200 rounded animate-pulse" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-8 w-16 bg-gray-200 rounded animate-pulse" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Transactions</h1>
|
||||
<p className="text-muted-foreground">
|
||||
View and manage all your transactions
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2 sm:flex-shrink-0">
|
||||
<Button variant="outline" onClick={() => setShowFilters(!showFilters)}>
|
||||
<Filter className="mr-2 h-4 w-4" />
|
||||
{showFilters ? 'Hide Filters' : 'Show Filters'}
|
||||
</Button>
|
||||
<Button onClick={() => setTransactionDialogOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Transaction
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Transactions</CardTitle>
|
||||
<Receipt className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats.totalTransactions}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{transactions.length > filteredTransactions.length &&
|
||||
`Filtered from ${transactions.length} total`
|
||||
}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Income</CardTitle>
|
||||
<TrendingUp className="h-4 w-4 text-[var(--color-primary)]" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-[var(--color-primary)]">
|
||||
{formatLargeNumber(stats.totalIncome, 'IDR')}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Expense</CardTitle>
|
||||
<TrendingDown className="h-4 w-4 text-[var(--color-destructive)]" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-[var(--color-destructive)]">
|
||||
{formatLargeNumber(stats.totalExpense, 'IDR')}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Net Amount</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className={`text-2xl font-bold ${stats.netAmount >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{formatLargeNumber(stats.netAmount, 'IDR')}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
{showFilters && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Filters</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{/* Search */}
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">Search Memo</label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search in memo..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Wallet Filter */}
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">Wallet</label>
|
||||
<Select value={walletFilter} onValueChange={setWalletFilter}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="All wallets" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Wallets</SelectItem>
|
||||
{wallets.map(wallet => (
|
||||
<SelectItem key={wallet.id} value={wallet.id}>
|
||||
{wallet.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Direction Filter */}
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">Direction</label>
|
||||
<Select value={directionFilter} onValueChange={setDirectionFilter}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="All directions" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Directions</SelectItem>
|
||||
<SelectItem value="in">Income</SelectItem>
|
||||
<SelectItem value="out">Expense</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Amount Range */}
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">Min Amount</label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="0"
|
||||
value={amountMin}
|
||||
onChange={(e) => setAmountMin(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">Max Amount</label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="No limit"
|
||||
value={amountMax}
|
||||
onChange={(e) => setAmountMax(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Date Range */}
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">From Date</label>
|
||||
<DatePicker
|
||||
date={dateFrom}
|
||||
onDateChange={setDateFrom}
|
||||
placeholder="Select start date"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">To Date</label>
|
||||
<DatePicker
|
||||
date={dateTo}
|
||||
onDateChange={setDateTo}
|
||||
placeholder="Select end date"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Clear Filters */}
|
||||
<div className="flex items-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSearchTerm("")
|
||||
setWalletFilter("all")
|
||||
setDirectionFilter("all")
|
||||
setAmountMin("")
|
||||
setAmountMax("")
|
||||
setDateFrom(undefined)
|
||||
setDateTo(undefined)
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
Clear Filters
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Transactions Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Transactions ({filteredTransactions.length})</CardTitle>
|
||||
<CardDescription>
|
||||
{filteredTransactions.length !== transactions.length
|
||||
? `Filtered from ${transactions.length} total transactions`
|
||||
: "All your transactions"
|
||||
}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="px-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead className="text-nowrap">Wallet</TableHead>
|
||||
<TableHead className="text-center">Direction</TableHead>
|
||||
<TableHead className="text-right">Amount</TableHead>
|
||||
<TableHead>Category</TableHead>
|
||||
<TableHead>Memo</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredTransactions.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-8">
|
||||
{transactions.length === 0
|
||||
? "No transactions found. Add your first transaction!"
|
||||
: "No transactions match your filters"
|
||||
}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredTransactions
|
||||
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
|
||||
.map((transaction) => {
|
||||
const wallet = wallets.find(w => w.id === transaction.walletId)
|
||||
return (
|
||||
<TableRow key={transaction.id}>
|
||||
<TableCell>
|
||||
{new Date(transaction.date).toLocaleDateString()}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-nowrap">{wallet?.name}</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{wallet?.currency || wallet?.unit}
|
||||
</Badge>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge
|
||||
variant={`outline`}
|
||||
className={transaction.direction === 'in' ? 'bg-[var(--color-primary)]/10 text-[var(--color-primary)] stroke-[var(--color-primary)] ring-1 ring-[var(--color-primary)]/75' : 'bg-[var(--color-destructive)]/10 text-[var(--color-destructive)] stroke-[var(--color-destructive)] ring-1 ring-[var(--color-destructive)]/75'}
|
||||
>
|
||||
{transaction.direction === 'in' ? 'Income' : 'Expense'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-right text-nowrap">
|
||||
{formatCurrency(transaction.amount, wallet?.currency || wallet?.unit || 'IDR')}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{transaction.category && (
|
||||
<Badge variant="outline">{transaction.category}</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[200px] truncate">
|
||||
{transaction.memo}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => handleEditTransaction(transaction)}>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Transaction</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete this transaction? This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => deleteTransaction(transaction.walletId, transaction.id)}>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Dialog */}
|
||||
<TransactionDialog
|
||||
open={transactionDialogOpen}
|
||||
onOpenChange={handleDialogClose}
|
||||
transaction={editingTransaction}
|
||||
onSuccess={loadData}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
332
apps/web/src/components/pages/Wallets.tsx
Normal file
@@ -0,0 +1,332 @@
|
||||
import { useState, useEffect, useMemo } from "react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from "@/components/ui/table"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Plus, Search, Edit, Trash2, Wallet } from "lucide-react"
|
||||
import axios from "axios"
|
||||
import { WalletDialog } from "@/components/dialogs/WalletDialog"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
|
||||
interface Wallet {
|
||||
id: string
|
||||
name: string
|
||||
kind: "money" | "asset"
|
||||
currency?: string | null
|
||||
unit?: string | null
|
||||
deletedAt?: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
const API = "/api"
|
||||
|
||||
export function Wallets() {
|
||||
const [wallets, setWallets] = useState<Wallet[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [searchTerm, setSearchTerm] = useState("")
|
||||
const [currencyFilter, setCurrencyFilter] = useState<string>("all")
|
||||
const [walletDialogOpen, setWalletDialogOpen] = useState(false)
|
||||
const [editingWallet, setEditingWallet] = useState<Wallet | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadWallets()
|
||||
}, [])
|
||||
|
||||
const loadWallets = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await axios.get(`${API}/wallets`)
|
||||
setWallets(response.data.filter((w: Wallet) => !w.deletedAt))
|
||||
} catch (error) {
|
||||
console.error('Failed to load wallets:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteWallet = async (id: string) => {
|
||||
try {
|
||||
await axios.delete(`${API}/wallets/${id}`)
|
||||
await loadWallets()
|
||||
} catch (error) {
|
||||
console.error('Failed to delete wallet:', error)
|
||||
alert('Failed to delete wallet')
|
||||
}
|
||||
}
|
||||
|
||||
const handleEditWallet = (wallet: Wallet) => {
|
||||
setEditingWallet(wallet)
|
||||
setWalletDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleDialogClose = () => {
|
||||
setWalletDialogOpen(false)
|
||||
setEditingWallet(null)
|
||||
}
|
||||
|
||||
// Filter wallets
|
||||
const filteredWallets = useMemo(() => {
|
||||
return wallets.filter(wallet => {
|
||||
const matchesSearch = wallet.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
const matchesCurrency = currencyFilter === "all" || wallet.currency === currencyFilter
|
||||
return matchesSearch && matchesCurrency
|
||||
})
|
||||
}, [wallets, searchTerm, currencyFilter])
|
||||
|
||||
// Get unique currencies for filter
|
||||
const availableCurrencies = useMemo(() => {
|
||||
const currencies = new Set(wallets.map(w => w.currency).filter(Boolean) as string[])
|
||||
return Array.from(currencies).sort()
|
||||
}, [wallets])
|
||||
|
||||
// Calculate stats
|
||||
const stats = useMemo(() => {
|
||||
const totalWallets = filteredWallets.length
|
||||
const moneyWallets = filteredWallets.filter(w => w.kind === 'money').length
|
||||
const assetWallets = filteredWallets.filter(w => w.kind === 'asset').length
|
||||
const currencyCount = new Set(filteredWallets.map(w => w.currency).filter(Boolean)).size
|
||||
|
||||
return { totalWallets, moneyWallets, assetWallets, currencyCount }
|
||||
}, [filteredWallets])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="h-8 w-48 bg-gray-200 rounded animate-pulse" />
|
||||
<div className="grid gap-4 grid-cols-2 lg:grid-cols-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader>
|
||||
<div className="h-4 w-20 bg-gray-200 rounded animate-pulse" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-8 w-16 bg-gray-200 rounded animate-pulse" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Wallets</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage your wallets and accounts
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2 sm:flex-shrink-0">
|
||||
<Button onClick={() => setWalletDialogOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Wallet
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid gap-4 grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Wallets</CardTitle>
|
||||
<Wallet className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats.totalWallets}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Money Wallets</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats.moneyWallets}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Asset Wallets</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats.assetWallets}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Currencies</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats.currencyCount}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Filters</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search wallets..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Select value={currencyFilter} onValueChange={setCurrencyFilter}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Filter by currency" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Currencies</SelectItem>
|
||||
{availableCurrencies.map(currency => (
|
||||
<SelectItem key={currency} value={currency}>
|
||||
{currency}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Wallets Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Wallets ({filteredWallets.length})</CardTitle>
|
||||
<CardDescription>
|
||||
{searchTerm || currencyFilter !== "all"
|
||||
? `Filtered from ${wallets.length} total wallets`
|
||||
: "All your wallets"
|
||||
}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="px-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Currency/Unit</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredWallets.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center py-8">
|
||||
{searchTerm || currencyFilter !== "all"
|
||||
? "No wallets match your filters"
|
||||
: "No wallets found. Create your first wallet!"
|
||||
}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredWallets.map((wallet) => (
|
||||
<TableRow key={wallet.id}>
|
||||
<TableCell className="font-medium text-nowrap">{wallet.name}</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-nowrap ${wallet.kind === 'money' ? 'bg-[var(--chart-1)]/20 text-[var(--chart-1)] ring-1 ring-[var(--chart-1)]' : 'bg-[var(--chart-2)]/20 text-[var(--chart-2)] ring-1 ring-[var(--chart-2)]'}`}
|
||||
>
|
||||
{wallet.kind}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{wallet.kind === 'money' ? (
|
||||
<Badge variant="outline" className="text-nowrap">{wallet.currency}</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-nowrap">{wallet.unit}</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{new Date(wallet.createdAt).toLocaleDateString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => handleEditWallet(wallet)}>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Wallet</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete "{wallet.name}"? This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => deleteWallet(wallet.id)}>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Dialog */}
|
||||
<WalletDialog
|
||||
open={walletDialogOpen}
|
||||
onOpenChange={handleDialogClose}
|
||||
wallet={editingWallet}
|
||||
onSuccess={loadWallets}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
0
apps/web/src/components/test/CalendarTest.tsx
Normal file
142
apps/web/src/components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root
|
||||
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
|
||||
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/40 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
))
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
style={{ backgroundColor: `var(--background)` }}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border text-foreground p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
))
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||
|
||||
const AlertDialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogHeader.displayName = "AlertDialogHeader"
|
||||
|
||||
const AlertDialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogFooter.displayName = "AlertDialogFooter"
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogDescription.displayName =
|
||||
AlertDialogPrimitive.Description.displayName
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action
|
||||
ref={ref}
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"mt-2 sm:mt-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
||||
38
apps/web/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge }
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export { badgeVariants }
|
||||
59
apps/web/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button }
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export { buttonVariants }
|
||||
54
apps/web/src/components/ui/calendar.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { DayPicker } from "react-day-picker"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
|
||||
export type CalendarProps = React.ComponentProps<typeof DayPicker>
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
...props
|
||||
}: CalendarProps) {
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn("rdp-root p-3", className)}
|
||||
classNames={{
|
||||
months: "rdp-months flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
|
||||
month: "rdp-month space-y-4",
|
||||
caption: "rdp-month_caption flex justify-center pt-1 relative items-center",
|
||||
caption_label: "rdp-caption_label text-sm font-medium",
|
||||
nav: "rdp-nav space-x-1 flex items-center",
|
||||
nav_button: cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"rdp-button_previous rdp-button_next h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
|
||||
),
|
||||
nav_button_previous: "rdp-button_previous absolute left-1",
|
||||
nav_button_next: "rdp-button_next absolute right-1",
|
||||
table: "rdp-month_grid w-full border-collapse space-y-1",
|
||||
head_row: "flex",
|
||||
head_cell: "rdp-weekday text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
|
||||
row: "flex w-full mt-2",
|
||||
cell: "rdp-day h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
|
||||
day: "rdp-day_button",
|
||||
day_range_end: "rdp-range_end day-range-end",
|
||||
day_selected: "rdp-selected",
|
||||
day_today: "rdp-today",
|
||||
day_outside: "rdp-outside day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30",
|
||||
day_disabled: "rdp-disabled text-muted-foreground opacity-50",
|
||||
day_range_middle: "rdp-range_middle aria-selected:bg-accent aria-selected:text-accent-foreground",
|
||||
day_hidden: "rdp-hidden invisible",
|
||||
...classNames,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Calendar.displayName = "Calendar"
|
||||
|
||||
export { Calendar }
|
||||
92
apps/web/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
367
apps/web/src/components/ui/chart.tsx
Normal file
@@ -0,0 +1,367 @@
|
||||
import * as React from "react"
|
||||
import * as RechartsPrimitive from "recharts"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = { light: "", dark: ".dark" } as const
|
||||
|
||||
export type ChartConfig = {
|
||||
[k in string]: {
|
||||
label?: React.ReactNode
|
||||
icon?: React.ComponentType
|
||||
} & (
|
||||
| { color?: string; theme?: never }
|
||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||
)
|
||||
}
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig
|
||||
}
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null)
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useChart must be used within a <ChartContainer />")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
const ChartContainer = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & {
|
||||
config: ChartConfig
|
||||
children: React.ComponentProps<
|
||||
typeof RechartsPrimitive.ResponsiveContainer
|
||||
>["children"]
|
||||
}
|
||||
>(({ id, className, children, config, ...props }, ref) => {
|
||||
const uniqueId = React.useId()
|
||||
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-chart={chartId}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_line]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_line]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
)
|
||||
})
|
||||
ChartContainer.displayName = "Chart"
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(
|
||||
([, config]) => config.theme || config.color
|
||||
)
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Object.entries(THEMES)
|
||||
.map(
|
||||
([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color =
|
||||
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
||||
itemConfig.color
|
||||
return color ? ` --color-${key}: ${color};` : null
|
||||
})
|
||||
.join("\n")}
|
||||
}
|
||||
`
|
||||
)
|
||||
.join("\n"),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip
|
||||
|
||||
const ChartTooltipContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||
React.ComponentProps<"div"> & {
|
||||
hideLabel?: boolean
|
||||
hideIndicator?: boolean
|
||||
indicator?: "line" | "dot" | "dashed"
|
||||
nameKey?: string
|
||||
labelKey?: string
|
||||
}
|
||||
>(
|
||||
(
|
||||
{
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = "dot",
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const { config } = useChart()
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [item] = payload
|
||||
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const value =
|
||||
!labelKey && typeof label === "string"
|
||||
? config[label as keyof typeof config]?.label || label
|
||||
: itemConfig?.label
|
||||
|
||||
if (labelFormatter) {
|
||||
return (
|
||||
<div className={cn("font-medium", labelClassName)}>
|
||||
{labelFormatter(value, payload)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>
|
||||
}, [
|
||||
label,
|
||||
labelFormatter,
|
||||
payload,
|
||||
hideLabel,
|
||||
labelClassName,
|
||||
config,
|
||||
labelKey,
|
||||
])
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== "dot"
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item, index) => {
|
||||
const key = `${nameKey || item.name || item.dataKey || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const indicatorColor = color || item.payload.fill || item.color
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.dataKey}
|
||||
className={cn(
|
||||
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
||||
indicator === "dot" && "items-center"
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
|
||||
{
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||
indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
}
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center"
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label || item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value && (
|
||||
<span className="font-mono font-medium tabular-nums text-foreground">
|
||||
{item.value.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
ChartTooltipContent.displayName = "ChartTooltip"
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend
|
||||
|
||||
const ChartLegendContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> &
|
||||
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
||||
hideIcon?: boolean
|
||||
nameKey?: string
|
||||
}
|
||||
>(
|
||||
(
|
||||
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
|
||||
ref
|
||||
) => {
|
||||
const { config } = useChart()
|
||||
|
||||
if (!payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-4",
|
||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item) => {
|
||||
const key = `${nameKey || item.dataKey || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.value}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
ChartLegendContent.displayName = "ChartLegend"
|
||||
|
||||
// Helper to extract item config from a payload.
|
||||
function getPayloadConfigFromPayload(
|
||||
config: ChartConfig,
|
||||
payload: unknown,
|
||||
key: string
|
||||
) {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
"payload" in payload &&
|
||||
typeof payload.payload === "object" &&
|
||||
payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined
|
||||
|
||||
let configLabelKey: string = key
|
||||
|
||||
if (
|
||||
key in payload &&
|
||||
typeof payload[key as keyof typeof payload] === "string"
|
||||
) {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||
) {
|
||||
configLabelKey = payloadPayload[
|
||||
key as keyof typeof payloadPayload
|
||||
] as string
|
||||
}
|
||||
|
||||
return configLabelKey in config
|
||||
? config[configLabelKey]
|
||||
: config[key as keyof typeof config]
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle
|
||||
}
|
||||
153
apps/web/src/components/ui/command.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { type DialogProps } from "@radix-ui/react-dialog"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
import { Search } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog"
|
||||
|
||||
const Command = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full flex-col overflow-hidden rounded-md bg-background text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Command.displayName = CommandPrimitive.displayName
|
||||
|
||||
const CommandDialog = ({ children, ...props }: DialogProps) => {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogContent className="overflow-hidden p-0">
|
||||
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
const CommandInput = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
|
||||
CommandInput.displayName = CommandPrimitive.Input.displayName
|
||||
|
||||
const CommandList = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandList.displayName = CommandPrimitive.List.displayName
|
||||
|
||||
const CommandEmpty = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||
>((props, ref) => (
|
||||
<CommandPrimitive.Empty
|
||||
ref={ref}
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
|
||||
|
||||
const CommandGroup = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Group>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Group
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandGroup.displayName = CommandPrimitive.Group.displayName
|
||||
|
||||
const CommandSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
|
||||
|
||||
const CommandItem = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandItem.displayName = CommandPrimitive.Item.displayName
|
||||
|
||||
const CommandShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
CommandShortcut.displayName = "CommandShortcut"
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
||||
59
apps/web/src/components/ui/date-picker.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
"use client"
|
||||
|
||||
import { format } from "date-fns"
|
||||
import { Calendar as CalendarIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Calendar } from "@/components/ui/calendar"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
|
||||
interface DatePickerProps {
|
||||
date?: Date
|
||||
onDateChange?: (date: Date | undefined) => void
|
||||
placeholder?: string
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function DatePicker({
|
||||
date,
|
||||
onDateChange,
|
||||
placeholder = "Pick a date",
|
||||
className,
|
||||
disabled = false,
|
||||
}: DatePickerProps) {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
data-empty={!date}
|
||||
className={cn(
|
||||
"data-[empty=true]:text-muted-foreground w-[280px] justify-start text-left font-normal",
|
||||
className
|
||||
)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{date ? format(date, "PPP") : <span>{placeholder}</span>}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0 rounded-md border bg-background shadow-md">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={date}
|
||||
onSelect={onDateChange}
|
||||
captionLayout="dropdown"
|
||||
fromYear={1990}
|
||||
toYear={2030}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
142
apps/web/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
style={{ backgroundColor: `var(--background)` }}
|
||||
className={cn(
|
||||
"bg-background text-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
199
apps/web/src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
style={{backgroundColor: "var(--background)"}}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border-2 border-border bg-card p-1 text-card-foreground shadow-xl data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
}
|
||||
179
apps/web/src/components/ui/form.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import {
|
||||
Controller,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
type ControllerProps,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
} from "react-hook-form"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
const Form = FormProvider
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
> = {
|
||||
name: TName
|
||||
}
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue
|
||||
)
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext)
|
||||
const itemContext = React.useContext(FormItemContext)
|
||||
const { getFieldState, formState } = useFormContext()
|
||||
|
||||
const fieldState = getFieldState(fieldContext.name, formState)
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>")
|
||||
}
|
||||
|
||||
const { id } = itemContext
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
}
|
||||
}
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string
|
||||
}
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue
|
||||
)
|
||||
|
||||
const FormItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const id = React.useId()
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div ref={ref} className={cn("space-y-2", className)} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
})
|
||||
FormItem.displayName = "FormItem"
|
||||
|
||||
const FormLabel = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { error, formItemId } = useFormField()
|
||||
|
||||
return (
|
||||
<Label
|
||||
ref={ref}
|
||||
className={cn(error && "text-destructive", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormLabel.displayName = "FormLabel"
|
||||
|
||||
const FormControl = React.forwardRef<
|
||||
React.ElementRef<typeof Slot>,
|
||||
React.ComponentPropsWithoutRef<typeof Slot>
|
||||
>(({ ...props }, ref) => {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
|
||||
return (
|
||||
<Slot
|
||||
ref={ref}
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormControl.displayName = "FormControl"
|
||||
|
||||
const FormDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { formDescriptionId } = useFormField()
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formDescriptionId}
|
||||
className={cn("text-[0.8rem] text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormDescription.displayName = "FormDescription"
|
||||
|
||||
const FormMessage = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const body = error ? String(error?.message ?? "") : children
|
||||
|
||||
if (!body) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formMessageId}
|
||||
className={cn("text-[0.8rem] font-medium text-destructive", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
)
|
||||
})
|
||||
FormMessage.displayName = "FormMessage"
|
||||
|
||||
export {
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
}
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export { useFormField }
|
||||
22
apps/web/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
24
apps/web/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
164
apps/web/src/components/ui/multi-select.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface Option {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
interface MultiSelectProps {
|
||||
options: Option[]
|
||||
selected: string[]
|
||||
onChange: (selected: string[]) => void
|
||||
placeholder?: string
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
function MultiSelect({
|
||||
options,
|
||||
selected,
|
||||
onChange,
|
||||
placeholder = "Select items...",
|
||||
className,
|
||||
disabled = false,
|
||||
}: MultiSelectProps) {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [inputValue, setInputValue] = React.useState("")
|
||||
|
||||
const handleUnselect = (item: string) => {
|
||||
onChange(selected.filter((i) => i !== item))
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const input = e.target as HTMLInputElement
|
||||
if (input.value === "") {
|
||||
if (e.key === "Backspace") {
|
||||
onChange(selected.slice(0, -1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const selectables = options.filter((option) => !selected.includes(option.value))
|
||||
|
||||
// Handle creating new option when user types something not in the list
|
||||
const handleSelect = (value: string) => {
|
||||
if (value === inputValue && !options.find(option => option.value === value)) {
|
||||
// Create new option
|
||||
onChange([...selected, value])
|
||||
} else {
|
||||
onChange([...selected, value])
|
||||
}
|
||||
setInputValue("")
|
||||
}
|
||||
|
||||
return (
|
||||
<Command
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn("overflow-visible bg-transparent", className)}
|
||||
>
|
||||
<div className="group rounded-md border border-input px-3 py-2 text-sm ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{selected.map((item) => {
|
||||
const option = options.find((opt) => opt.value === item)
|
||||
return (
|
||||
<Badge key={item} variant="secondary">
|
||||
{option?.label || item}
|
||||
<button
|
||||
className="ml-1 rounded-full outline-none ring-offset-background focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleUnselect(item)
|
||||
}
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onClick={() => handleUnselect(item)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<X className="h-3 w-3 text-muted-foreground hover:text-foreground" />
|
||||
</button>
|
||||
</Badge>
|
||||
)
|
||||
})}
|
||||
<CommandInput
|
||||
value={inputValue}
|
||||
onValueChange={setInputValue}
|
||||
onBlur={() => setOpen(false)}
|
||||
onFocus={() => setOpen(true)}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
className="ml-2 flex-1 bg-transparent outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative mt-2">
|
||||
<CommandList>
|
||||
{open && (inputValue.length > 0 || selectables.length > 0) ? (
|
||||
<div className="absolute top-0 z-10 w-full rounded-md border bg-popover text-popover-foreground shadow-md outline-none animate-in">
|
||||
<CommandGroup className="h-full overflow-auto">
|
||||
{/* Show option to create new category if input doesn't match existing options */}
|
||||
{inputValue.length > 0 && !options.find(option =>
|
||||
option.label.toLowerCase().includes(inputValue.toLowerCase()) ||
|
||||
option.value.toLowerCase().includes(inputValue.toLowerCase())
|
||||
) && (
|
||||
<CommandItem
|
||||
key={inputValue}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onSelect={() => {
|
||||
handleSelect(inputValue)
|
||||
setOpen(false)
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
Create "{inputValue}"
|
||||
</CommandItem>
|
||||
)}
|
||||
|
||||
{/* Show existing options that match the search */}
|
||||
{selectables
|
||||
.filter(option =>
|
||||
option.label.toLowerCase().includes(inputValue.toLowerCase()) ||
|
||||
option.value.toLowerCase().includes(inputValue.toLowerCase())
|
||||
)
|
||||
.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onSelect={() => {
|
||||
handleSelect(option.value)
|
||||
setOpen(false)
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{option.label}
|
||||
</CommandItem>
|
||||
))}
|
||||
|
||||
{selectables.length === 0 && inputValue.length === 0 && (
|
||||
<CommandEmpty>No more options available.</CommandEmpty>
|
||||
)}
|
||||
</CommandGroup>
|
||||
</div>
|
||||
) : null}
|
||||
</CommandList>
|
||||
</div>
|
||||
</Command>
|
||||
)
|
||||
}
|
||||
|
||||
export { MultiSelect }
|
||||
159
apps/web/src/components/ui/multiselector.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Check, ChevronsUpDown, X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
|
||||
export interface Option {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
interface MultipleSelectorProps {
|
||||
options: Option[]
|
||||
selected: string[]
|
||||
onChange: (selected: string[]) => void
|
||||
placeholder?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function MultipleSelector({
|
||||
options,
|
||||
selected,
|
||||
onChange,
|
||||
placeholder = "Select options...",
|
||||
className
|
||||
}: MultipleSelectorProps) {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [inputValue, setInputValue] = React.useState("")
|
||||
|
||||
const handleSetValue = (val: string) => {
|
||||
if (selected.includes(val)) {
|
||||
onChange(selected.filter((item) => item !== val))
|
||||
} else {
|
||||
onChange([...selected, val])
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
if (event.key === "Enter" && inputValue.trim()) {
|
||||
event.preventDefault()
|
||||
const newValue = inputValue.trim()
|
||||
if (!selected.includes(newValue)) {
|
||||
onChange([...selected, newValue])
|
||||
}
|
||||
setInputValue("")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className={cn("w-full justify-between min-h-[40px] h-auto", className)}
|
||||
>
|
||||
<div className="flex gap-2 justify-start flex-wrap">
|
||||
{selected?.length ? (
|
||||
selected.map((val, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-xl border bg-slate-200 text-xs font-medium"
|
||||
>
|
||||
<span>{options.find((option) => option.value === val)?.label || val}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleSetValue(val)
|
||||
}}
|
||||
className="ml-1 hover:bg-slate-300 rounded-full p-0.5 transition-colors"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<span className="text-muted-foreground">{placeholder}</span>
|
||||
)}
|
||||
</div>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="Search options..."
|
||||
value={inputValue}
|
||||
onValueChange={setInputValue}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
<CommandEmpty>No option found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandList>
|
||||
{/* Show typed text as option when no matches found */}
|
||||
{inputValue && !options.some(option =>
|
||||
option.label.toLowerCase().includes(inputValue.toLowerCase())
|
||||
) && (
|
||||
<CommandItem
|
||||
value={inputValue}
|
||||
onSelect={() => {
|
||||
handleSetValue(inputValue)
|
||||
setInputValue("")
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
selected.includes(inputValue) ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<span>{inputValue}</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground bg-blue-100 px-1.5 py-0.5 rounded">
|
||||
New
|
||||
</span>
|
||||
</CommandItem>
|
||||
)}
|
||||
|
||||
{/* Show existing options */}
|
||||
{options.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
onSelect={() => {
|
||||
handleSetValue(option.value)
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
selected.includes(option.value) ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{option.label}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandList>
|
||||
</CommandGroup>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
32
apps/web/src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Popover = PopoverPrimitive.Root
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||
|
||||
const PopoverAnchor = PopoverPrimitive.Anchor
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
style={{backgroundColor: "var(--background)"}}
|
||||
className={cn(
|
||||
"z-50 w-72 rounded-md border-2 border-border bg-card p-4 text-card-foreground shadow-xl outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-popover-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
))
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||
158
apps/web/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
style={{backgroundColor: "var(--background)"}}
|
||||
className={cn(
|
||||
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border-2 border-border bg-card text-card-foreground shadow-xl data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
31
apps/web/src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(
|
||||
(
|
||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||
ref
|
||||
) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||
|
||||
export { Separator }
|
||||
138
apps/web/src/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import * as React from "react"
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Sheet = SheetPrimitive.Root
|
||||
|
||||
const SheetTrigger = SheetPrimitive.Trigger
|
||||
|
||||
const SheetClose = SheetPrimitive.Close
|
||||
|
||||
const SheetPortal = SheetPrimitive.Portal
|
||||
|
||||
const SheetOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/40 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
))
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
|
||||
|
||||
const sheetVariants = cva(
|
||||
"fixed z-50 gap-4 bg-background text-foreground p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||
bottom:
|
||||
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||
right:
|
||||
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
side: "right",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
interface SheetContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> {}
|
||||
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||
SheetContentProps
|
||||
>(({ side = "right", className, children, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(sheetVariants({ side }), className)}
|
||||
{...props}
|
||||
>
|
||||
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
{children}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
))
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName
|
||||
|
||||
const SheetHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
SheetHeader.displayName = "SheetHeader"
|
||||
|
||||
const SheetFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
SheetFooter.displayName = "SheetFooter"
|
||||
|
||||
const SheetTitle = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SheetTitle.displayName = SheetPrimitive.Title.displayName
|
||||
|
||||
const SheetDescription = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SheetDescription.displayName = SheetPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetPortal,
|
||||
SheetOverlay,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
772
apps/web/src/components/ui/sidebar.tsx
Normal file
@@ -0,0 +1,772 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { PanelLeft } from "lucide-react"
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
||||
const SIDEBAR_WIDTH = "16rem"
|
||||
const SIDEBAR_WIDTH_MOBILE = "18rem"
|
||||
const SIDEBAR_WIDTH_ICON = "3rem"
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
||||
|
||||
type SidebarContextProps = {
|
||||
state: "expanded" | "collapsed"
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
openMobile: boolean
|
||||
setOpenMobile: (open: boolean) => void
|
||||
isMobile: boolean
|
||||
toggleSidebar: () => void
|
||||
}
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
|
||||
|
||||
function useSidebar() {
|
||||
const context = React.useContext(SidebarContext)
|
||||
if (!context) {
|
||||
throw new Error("useSidebar must be used within a SidebarProvider.")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
const SidebarProvider = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & {
|
||||
defaultOpen?: boolean
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
>(
|
||||
(
|
||||
{
|
||||
defaultOpen = true,
|
||||
open: openProp,
|
||||
onOpenChange: setOpenProp,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const isMobile = useIsMobile()
|
||||
const [openMobile, setOpenMobile] = React.useState(false)
|
||||
|
||||
// This is the internal state of the sidebar.
|
||||
// We use openProp and setOpenProp for control from outside the component.
|
||||
const [_open, _setOpen] = React.useState(defaultOpen)
|
||||
const open = openProp ?? _open
|
||||
const setOpen = React.useCallback(
|
||||
(value: boolean | ((value: boolean) => boolean)) => {
|
||||
const openState = typeof value === "function" ? value(open) : value
|
||||
if (setOpenProp) {
|
||||
setOpenProp(openState)
|
||||
} else {
|
||||
_setOpen(openState)
|
||||
}
|
||||
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
||||
},
|
||||
[setOpenProp, open]
|
||||
)
|
||||
|
||||
// Helper to toggle the sidebar.
|
||||
const toggleSidebar = React.useCallback(() => {
|
||||
return isMobile
|
||||
? setOpenMobile((open) => !open)
|
||||
: setOpen((open) => !open)
|
||||
}, [isMobile, setOpen, setOpenMobile])
|
||||
|
||||
// Adds a keyboard shortcut to toggle the sidebar.
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||
(event.metaKey || event.ctrlKey)
|
||||
) {
|
||||
event.preventDefault()
|
||||
toggleSidebar()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [toggleSidebar])
|
||||
|
||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||
// This makes it easier to style the sidebar with Tailwind classes.
|
||||
const state = open ? "expanded" : "collapsed"
|
||||
|
||||
const contextValue = React.useMemo<SidebarContextProps>(
|
||||
() => ({
|
||||
state,
|
||||
open,
|
||||
setOpen,
|
||||
isMobile,
|
||||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
}),
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
|
||||
)
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<div
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH,
|
||||
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
||||
...style,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</SidebarContext.Provider>
|
||||
)
|
||||
}
|
||||
)
|
||||
SidebarProvider.displayName = "SidebarProvider"
|
||||
|
||||
const Sidebar = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & {
|
||||
side?: "left" | "right"
|
||||
variant?: "sidebar" | "floating" | "inset"
|
||||
collapsible?: "offcanvas" | "icon" | "none"
|
||||
}
|
||||
>(
|
||||
(
|
||||
{
|
||||
side = "left",
|
||||
variant = "sidebar",
|
||||
collapsible = "offcanvas",
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||
|
||||
if (collapsible === "none") {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||
<SheetContent
|
||||
data-sidebar="sidebar"
|
||||
data-mobile="true"
|
||||
className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
side={side}
|
||||
>
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Sidebar</SheetTitle>
|
||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex h-full w-full flex-col">{children}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="group peer hidden text-sidebar-foreground md:block"
|
||||
data-state={state}
|
||||
data-collapsible={state === "collapsed" ? collapsible : ""}
|
||||
data-variant={variant}
|
||||
data-side={side}
|
||||
>
|
||||
{/* This is what handles the sidebar gap on desktop */}
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-[--sidebar-width] bg-transparent transition-[width] duration-200 ease-linear",
|
||||
"group-data-[collapsible=offcanvas]:w-0",
|
||||
"group-data-[side=right]:rotate-180",
|
||||
variant === "floating" || variant === "inset"
|
||||
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]"
|
||||
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]"
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] duration-200 ease-linear md:flex",
|
||||
side === "left"
|
||||
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
|
||||
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
|
||||
// Adjust the padding for floating and inset variants.
|
||||
variant === "floating" || variant === "inset"
|
||||
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
|
||||
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
Sidebar.displayName = "Sidebar"
|
||||
|
||||
const SidebarTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof Button>,
|
||||
React.ComponentProps<typeof Button>
|
||||
>(({ className, onClick, ...props }, ref) => {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
data-sidebar="trigger"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("h-7 w-7 md:hidden", className)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event)
|
||||
toggleSidebar()
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<PanelLeft />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
)
|
||||
})
|
||||
SidebarTrigger.displayName = "SidebarTrigger"
|
||||
|
||||
const SidebarRail = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button">
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
data-sidebar="rail"
|
||||
aria-label="Toggle Sidebar"
|
||||
tabIndex={-1}
|
||||
onClick={toggleSidebar}
|
||||
title="Toggle Sidebar"
|
||||
className={cn(
|
||||
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
|
||||
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
|
||||
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar",
|
||||
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
SidebarRail.displayName = "SidebarRail"
|
||||
|
||||
const SidebarInset = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"main">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<main
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full flex-1 flex-col bg-background",
|
||||
"md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
SidebarInset.displayName = "SidebarInset"
|
||||
|
||||
const SidebarInput = React.forwardRef<
|
||||
React.ElementRef<typeof Input>,
|
||||
React.ComponentProps<typeof Input>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<Input
|
||||
ref={ref}
|
||||
data-sidebar="input"
|
||||
className={cn(
|
||||
"h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
SidebarInput.displayName = "SidebarInput"
|
||||
|
||||
const SidebarHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="header"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
SidebarHeader.displayName = "SidebarHeader"
|
||||
|
||||
const SidebarFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="footer"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
SidebarFooter.displayName = "SidebarFooter"
|
||||
|
||||
const SidebarSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof Separator>,
|
||||
React.ComponentProps<typeof Separator>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<Separator
|
||||
ref={ref}
|
||||
data-sidebar="separator"
|
||||
className={cn("mx-2 w-auto bg-sidebar-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
SidebarSeparator.displayName = "SidebarSeparator"
|
||||
|
||||
const SidebarContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
SidebarContent.displayName = "SidebarContent"
|
||||
|
||||
const SidebarGroup = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="group"
|
||||
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
SidebarGroup.displayName = "SidebarGroup"
|
||||
|
||||
const SidebarGroupLabel = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & { asChild?: boolean }
|
||||
>(({ className, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "div"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-sidebar="group-label"
|
||||
className={cn(
|
||||
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
SidebarGroupLabel.displayName = "SidebarGroupLabel"
|
||||
|
||||
const SidebarGroupAction = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button"> & { asChild?: boolean }
|
||||
>(({ className, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-sidebar="group-action"
|
||||
className={cn(
|
||||
"absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 after:md:hidden",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
SidebarGroupAction.displayName = "SidebarGroupAction"
|
||||
|
||||
const SidebarGroupContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="group-content"
|
||||
className={cn("w-full text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SidebarGroupContent.displayName = "SidebarGroupContent"
|
||||
|
||||
const SidebarMenu = React.forwardRef<
|
||||
HTMLUListElement,
|
||||
React.ComponentProps<"ul">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ul
|
||||
ref={ref}
|
||||
data-sidebar="menu"
|
||||
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SidebarMenu.displayName = "SidebarMenu"
|
||||
|
||||
const SidebarMenuItem = React.forwardRef<
|
||||
HTMLLIElement,
|
||||
React.ComponentProps<"li">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<li
|
||||
ref={ref}
|
||||
data-sidebar="menu-item"
|
||||
className={cn("group/menu-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SidebarMenuItem.displayName = "SidebarMenuItem"
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
outline:
|
||||
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
||||
},
|
||||
size: {
|
||||
default: "h-8 text-sm",
|
||||
sm: "h-7 text-xs",
|
||||
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const SidebarMenuButton = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button"> & {
|
||||
asChild?: boolean
|
||||
isActive?: boolean
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>
|
||||
>(
|
||||
(
|
||||
{
|
||||
asChild = false,
|
||||
isActive = false,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
tooltip,
|
||||
className,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
const { isMobile, state } = useSidebar()
|
||||
|
||||
const button = (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-sidebar="menu-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
if (!tooltip) {
|
||||
return button
|
||||
}
|
||||
|
||||
if (typeof tooltip === "string") {
|
||||
tooltip = {
|
||||
children: tooltip,
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
hidden={state !== "collapsed" || isMobile}
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
)
|
||||
SidebarMenuButton.displayName = "SidebarMenuButton"
|
||||
|
||||
const SidebarMenuAction = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button"> & {
|
||||
asChild?: boolean
|
||||
showOnHover?: boolean
|
||||
}
|
||||
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-sidebar="menu-action"
|
||||
className={cn(
|
||||
"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 after:md:hidden",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
showOnHover &&
|
||||
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
SidebarMenuAction.displayName = "SidebarMenuAction"
|
||||
|
||||
const SidebarMenuBadge = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="menu-badge"
|
||||
className={cn(
|
||||
"pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground",
|
||||
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SidebarMenuBadge.displayName = "SidebarMenuBadge"
|
||||
|
||||
const SidebarMenuSkeleton = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & {
|
||||
showIcon?: boolean
|
||||
}
|
||||
>(({ className, showIcon = false, ...props }, ref) => {
|
||||
// Random width between 50 to 90%.
|
||||
const width = React.useMemo(() => {
|
||||
return `${Math.floor(Math.random() * 40) + 50}%`
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="menu-skeleton"
|
||||
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{showIcon && (
|
||||
<Skeleton
|
||||
className="size-4 rounded-md"
|
||||
data-sidebar="menu-skeleton-icon"
|
||||
/>
|
||||
)}
|
||||
<Skeleton
|
||||
className="h-4 max-w-[--skeleton-width] flex-1"
|
||||
data-sidebar="menu-skeleton-text"
|
||||
style={
|
||||
{
|
||||
"--skeleton-width": width,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton"
|
||||
|
||||
const SidebarMenuSub = React.forwardRef<
|
||||
HTMLUListElement,
|
||||
React.ComponentProps<"ul">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ul
|
||||
ref={ref}
|
||||
data-sidebar="menu-sub"
|
||||
className={cn(
|
||||
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SidebarMenuSub.displayName = "SidebarMenuSub"
|
||||
|
||||
const SidebarMenuSubItem = React.forwardRef<
|
||||
HTMLLIElement,
|
||||
React.ComponentProps<"li">
|
||||
>(({ ...props }, ref) => <li ref={ref} {...props} />)
|
||||
SidebarMenuSubItem.displayName = "SidebarMenuSubItem"
|
||||
|
||||
const SidebarMenuSubButton = React.forwardRef<
|
||||
HTMLAnchorElement,
|
||||
React.ComponentProps<"a"> & {
|
||||
asChild?: boolean
|
||||
size?: "sm" | "md"
|
||||
isActive?: boolean
|
||||
}
|
||||
>(({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "a"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-sidebar="menu-sub-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
|
||||
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||
size === "sm" && "text-xs",
|
||||
size === "md" && "text-sm",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
SidebarMenuSubButton.displayName = "SidebarMenuSubButton"
|
||||
|
||||
export {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupAction,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInput,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSkeleton,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarProvider,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
}
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export { useSidebar }
|
||||
13
apps/web/src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("bg-accent animate-pulse rounded-md", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
120
apps/web/src/components/ui/table.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
Table.displayName = "Table"
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = "TableHeader"
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableBody.displayName = "TableBody"
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableFooter.displayName = "TableFooter"
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableRow.displayName = "TableRow"
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableHead.displayName = "TableHead"
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCell.displayName = "TableCell"
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCaption.displayName = "TableCaption"
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
59
apps/web/src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
54
apps/web/src/constants/currencies.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
export const CURRENCIES = [
|
||||
{ code: 'IDR', name: 'Indonesian Rupiah', symbol: 'Rp' },
|
||||
{ code: 'USD', name: 'US Dollar', symbol: '$' },
|
||||
{ code: 'EUR', name: 'Euro', symbol: '€' },
|
||||
{ code: 'GBP', name: 'British Pound', symbol: '£' },
|
||||
{ code: 'JPY', name: 'Japanese Yen', symbol: '¥' },
|
||||
{ code: 'AUD', name: 'Australian Dollar', symbol: 'A$' },
|
||||
{ code: 'CAD', name: 'Canadian Dollar', symbol: 'C$' },
|
||||
{ code: 'CHF', name: 'Swiss Franc', symbol: 'CHF' },
|
||||
{ code: 'CNY', name: 'Chinese Yuan', symbol: '¥' },
|
||||
{ code: 'SGD', name: 'Singapore Dollar', symbol: 'S$' },
|
||||
{ code: 'HKD', name: 'Hong Kong Dollar', symbol: 'HK$' },
|
||||
{ code: 'MYR', name: 'Malaysian Ringgit', symbol: 'RM' },
|
||||
{ code: 'THB', name: 'Thai Baht', symbol: '฿' },
|
||||
{ code: 'KRW', name: 'South Korean Won', symbol: '₩' },
|
||||
{ code: 'INR', name: 'Indian Rupee', symbol: '₹' },
|
||||
{ code: 'PHP', name: 'Philippine Peso', symbol: '₱' },
|
||||
{ code: 'NZD', name: 'New Zealand Dollar', symbol: 'NZ$' },
|
||||
{ code: 'NOK', name: 'Norwegian Krone', symbol: 'kr' },
|
||||
{ code: 'SEK', name: 'Swedish Krona', symbol: 'kr' },
|
||||
{ code: 'DKK', name: 'Danish Krone', symbol: 'kr' },
|
||||
{ code: 'PLN', name: 'Polish Zloty', symbol: 'zł' },
|
||||
{ code: 'CZK', name: 'Czech Koruna', symbol: 'Kč' },
|
||||
{ code: 'HUF', name: 'Hungarian Forint', symbol: 'Ft' },
|
||||
{ code: 'BGN', name: 'Bulgarian Lev', symbol: 'лв' },
|
||||
{ code: 'RON', name: 'Romanian Leu', symbol: 'lei' },
|
||||
{ code: 'HRK', name: 'Croatian Kuna', symbol: 'kn' },
|
||||
{ code: 'RUB', name: 'Russian Ruble', symbol: '₽' },
|
||||
{ code: 'TRY', name: 'Turkish Lira', symbol: '₺' },
|
||||
{ code: 'BRL', name: 'Brazilian Real', symbol: 'R$' },
|
||||
{ code: 'MXN', name: 'Mexican Peso', symbol: '$' },
|
||||
{ code: 'ZAR', name: 'South African Rand', symbol: 'R' },
|
||||
{ code: 'ILS', name: 'Israeli Shekel', symbol: '₪' },
|
||||
{ code: 'ISK', name: 'Icelandic Krona', symbol: 'kr' },
|
||||
] as const;
|
||||
|
||||
export type CurrencyCode = typeof CURRENCIES[number]['code'];
|
||||
|
||||
export const getCurrencyByCode = (code: string) => {
|
||||
return CURRENCIES.find(currency => currency.code === code);
|
||||
};
|
||||
|
||||
export const formatCurrency = (amount: number, currencyCode: string) => {
|
||||
const currency = getCurrencyByCode(currencyCode);
|
||||
if (!currency) return `${amount} ${(amount === 1) ? currencyCode : currencyCode + 's'}`;
|
||||
|
||||
// For IDR, format without decimals
|
||||
if (currencyCode === 'IDR') {
|
||||
return `${currency.symbol} ${amount.toLocaleString('id-ID', { maximumFractionDigits: 0 })}`;
|
||||
}
|
||||
|
||||
// For other currencies, use 2 decimal places
|
||||
return `${currency.symbol} ${amount.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
};
|
||||
19
apps/web/src/hooks/use-mobile.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import * as React from "react"
|
||||
|
||||
const MOBILE_BREAKPOINT = 768
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
}
|
||||
mql.addEventListener("change", onChange)
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
return () => mql.removeEventListener("change", onChange)
|
||||
}, [])
|
||||
|
||||
return !!isMobile
|
||||
}
|
||||
127
apps/web/src/hooks/useAuth.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
type User,
|
||||
onAuthStateChanged,
|
||||
signInWithEmailAndPassword,
|
||||
createUserWithEmailAndPassword,
|
||||
signInWithPopup,
|
||||
signOut as firebaseSignOut
|
||||
} from 'firebase/auth';
|
||||
import { auth, googleProvider } from '../lib/firebase';
|
||||
|
||||
export const useAuth = () => {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = onAuthStateChanged(auth, (user) => {
|
||||
setUser(user);
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, []);
|
||||
|
||||
const signIn = async (email: string, password: string) => {
|
||||
try {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
await signInWithEmailAndPassword(auth, email, password);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'An error occurred';
|
||||
setError(message);
|
||||
throw error;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const signUp = async (email: string, password: string) => {
|
||||
try {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
await createUserWithEmailAndPassword(auth, email, password);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'An error occurred';
|
||||
setError(message);
|
||||
throw error;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
const signInWithGoogle = async () => {
|
||||
try {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
const result = await signInWithPopup(auth, googleProvider);
|
||||
console.log('✅ Google sign-in successful:', result.user.email);
|
||||
} catch (error: unknown) {
|
||||
// Handle user cancellation gracefully
|
||||
const errorWithCode = error as { code?: string; message?: string };
|
||||
|
||||
if (errorWithCode.code === 'auth/popup-closed-by-user' ||
|
||||
errorWithCode.code === 'auth/cancelled-popup-request') {
|
||||
// User cancelled - don't show error, just reset loading
|
||||
console.log('ℹ️ User cancelled Google sign-in');
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle common Firebase auth errors
|
||||
let userFriendlyMessage = 'Failed to sign in with Google';
|
||||
|
||||
switch (errorWithCode.code) {
|
||||
case 'auth/popup-blocked':
|
||||
userFriendlyMessage = 'Popup was blocked. Please allow popups for this site and try again.';
|
||||
break;
|
||||
case 'auth/network-request-failed':
|
||||
userFriendlyMessage = 'Network error. Please check your internet connection.';
|
||||
break;
|
||||
case 'auth/too-many-requests':
|
||||
userFriendlyMessage = 'Too many failed attempts. Please try again later.';
|
||||
break;
|
||||
case 'auth/configuration-not-found':
|
||||
userFriendlyMessage = 'Google sign-in is not properly configured. Please contact support.';
|
||||
break;
|
||||
default:
|
||||
userFriendlyMessage = errorWithCode.message || 'An error occurred during Google sign-in';
|
||||
}
|
||||
|
||||
console.error('❌ Google sign-in error:', errorWithCode);
|
||||
setError(userFriendlyMessage);
|
||||
throw error;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const signOut = async () => {
|
||||
try {
|
||||
setError(null);
|
||||
await firebaseSignOut(auth);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'An error occurred';
|
||||
setError(message);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const getIdToken = async () => {
|
||||
if (user) {
|
||||
return await user.getIdToken();
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return {
|
||||
user,
|
||||
loading,
|
||||
error,
|
||||
signIn,
|
||||
signUp,
|
||||
signInWithGoogle,
|
||||
signOut,
|
||||
getIdToken,
|
||||
};
|
||||
};
|
||||
23
apps/web/src/hooks/useTheme.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { createContext, useContext } from 'react'
|
||||
|
||||
type Theme = 'dark' | 'light' | 'system'
|
||||
|
||||
type ThemeProviderContextType = {
|
||||
theme: Theme
|
||||
setTheme: (theme: Theme) => void
|
||||
actualTheme: 'dark' | 'light'
|
||||
}
|
||||
|
||||
const ThemeProviderContext = createContext<ThemeProviderContextType | undefined>(undefined)
|
||||
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeProviderContext)
|
||||
|
||||
if (context === undefined)
|
||||
throw new Error('useTheme must be used within a ThemeProvider')
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
export { ThemeProviderContext }
|
||||
export type { Theme, ThemeProviderContextType }
|
||||
421
apps/web/src/index.css
Normal file
@@ -0,0 +1,421 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--radius: 0.625rem;
|
||||
|
||||
/* Light Theme Colors (based on Tabungin Logo) */
|
||||
--color-background: oklch(98.36% 0.005 257.69); /* Equivalent to #F8F9FA */
|
||||
--color-foreground: oklch(42.06% 0.031 254.38); /* Equivalent to #495D69 */
|
||||
--color-card: oklch(100% 0 0); /* Equivalent to #FFFFFF */
|
||||
--color-card-foreground: oklch(42.06% 0.031 254.38); /* Equivalent to #495D69 */
|
||||
--color-popover: oklch(100% 0 0); /* Equivalent to #FFFFFF */
|
||||
--color-popover-foreground: oklch(42.06% 0.031 254.38); /* Equivalent to #495D69 */
|
||||
--color-primary: oklch(71.91% 0.121 168.96); /* Primary Teal #44C1A2 */
|
||||
--color-primary-foreground: oklch(99% 0.005 168.96); /* Light text for high contrast on Teal */
|
||||
--color-secondary: oklch(95.03% 0.024 169.31); /* Light Teal #E0F2EE */
|
||||
--color-secondary-foreground: oklch(42.06% 0.031 254.38); /* Dark text on Light Teal */
|
||||
--color-muted: oklch(95.03% 0.024 169.31); /* Using Light Teal for muted backgrounds */
|
||||
--color-muted-foreground: oklch(51.52% 0.017 257.69); /* Gray Text #6C757D */
|
||||
--color-accent: oklch(82.69% 0.155 86.03); /* Accent Gold #FFC107 */
|
||||
--color-accent-foreground: oklch(42.06% 0.031 254.38); /* Dark text for high contrast on Gold */
|
||||
--color-destructive: oklch(57.49% 0.198 21.03); /* Danger Red #DC3545 */
|
||||
--color-destructive-foreground: oklch(98.36% 0.005 257.69); /* White text on Red */
|
||||
--color-border: oklch(90.73% 0.007 257.69); /* Light Gray Border #DEE2E6 */
|
||||
--color-input: oklch(90.73% 0.007 257.69); /* Light Gray Border #DEE2E6 */
|
||||
--color-ring: oklch(71.91% 0.121 168.96); /* Primary Teal for focus ring */
|
||||
--color-chart-1: oklch(71.91% 0.121 168.96); /* Chart: Primary Teal */
|
||||
--color-chart-2: oklch(82.69% 0.155 86.03); /* Chart: Accent Gold */
|
||||
--color-chart-3: oklch(42.06% 0.031 254.38); /* Chart: Primary Dark */
|
||||
--color-chart-4: oklch(63.98% 0.098 215.11); /* Chart: Info Blue (#17A2B8) */
|
||||
--color-chart-5: oklch(57.49% 0.198 21.03); /* Chart: Danger Red */
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: var(--color-background);
|
||||
--foreground: var(--color-foreground);
|
||||
--card: var(--color-card);
|
||||
--card-foreground: var(--color-card-foreground);
|
||||
--popover: var(--color-popover);
|
||||
--popover-foreground: var(--color-popover-foreground);
|
||||
--primary: var(--color-primary);
|
||||
--primary-foreground: var(--color-primary-foreground);
|
||||
--secondary: var(--color-secondary);
|
||||
--secondary-foreground: var(--color-secondary-foreground);
|
||||
--muted: var(--color-muted);
|
||||
--muted-foreground: var(--color-muted-foreground);
|
||||
--accent: var(--color-accent);
|
||||
--accent-foreground: var(--color-accent-foreground);
|
||||
--destructive: var(--color-destructive);
|
||||
--destructive-foreground: var(--color-destructive-foreground);
|
||||
--border: var(--color-border);
|
||||
--input: var(--color-input);
|
||||
--ring: var(--color-ring);
|
||||
--chart-1: var(--color-chart-1);
|
||||
--chart-2: var(--color-chart-2);
|
||||
--chart-3: var(--color-chart-3);
|
||||
--chart-4: var(--color-chart-4);
|
||||
--chart-5: var(--color-chart-5);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--color-background: oklch(25.4% 0.027 254.38); /* Dark Slate Gray */
|
||||
--color-foreground: oklch(92.82% 0.012 257.69); /* Light Gray text */
|
||||
--color-card: oklch(29.61% 0.03 254.38); /* Slightly lighter card background */
|
||||
--color-card-foreground: oklch(92.82% 0.012 257.69);
|
||||
--color-popover: oklch(29.61% 0.03 254.38);
|
||||
--color-popover-foreground: oklch(92.82% 0.012 257.69);
|
||||
--color-primary: oklch(71.91% 0.121 168.96); /* Primary Teal stands out nicely */
|
||||
--color-primary-foreground: oklch(25.4% 0.027 254.38); /* Dark text on Teal for dark mode */
|
||||
--color-secondary: oklch(36.14% 0.038 254.38); /* Darker secondary background */
|
||||
--color-secondary-foreground: oklch(92.82% 0.012 257.69);
|
||||
--color-muted: oklch(36.14% 0.038 254.38);
|
||||
--color-muted-foreground: oklch(62.59% 0.013 257.69); /* Muted gray text */
|
||||
--color-accent: oklch(82.69% 0.155 86.03); /* Accent Gold also stands out */
|
||||
--color-accent-foreground: oklch(29.61% 0.03 254.38); /* Dark text on Gold */
|
||||
--color-destructive: oklch(57.49% 0.198 21.03);
|
||||
--color-destructive-foreground: oklch(92.82% 0.012 257.69);
|
||||
--color-border: oklch(40.4% 0.04 254.38); /* Subtle border for dark mode */
|
||||
--color-input: oklch(40.4% 0.04 254.38);
|
||||
--color-ring: oklch(71.91% 0.121 168.96);
|
||||
}
|
||||
|
||||
* {
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
|
||||
/* React Day Picker Base Styles */
|
||||
.rdp-root {
|
||||
--rdp-accent-color: var(--color-primary);
|
||||
--rdp-accent-background-color: color-mix(in srgb, var(--color-primary) 10%, transparent);
|
||||
--rdp-day-height: 36px;
|
||||
--rdp-day-width: 36px;
|
||||
--rdp-day_button-border-radius: 6px;
|
||||
--rdp-day_button-border: 2px solid transparent;
|
||||
--rdp-day_button-height: 32px;
|
||||
--rdp-day_button-width: 32px;
|
||||
--rdp-selected-border: 2px solid var(--color-primary);
|
||||
--rdp-disabled-opacity: 0.5;
|
||||
--rdp-outside-opacity: 0.5;
|
||||
--rdp-today-color: var(--color-primary);
|
||||
--rdp-dropdown-gap: 0.5rem;
|
||||
--rdp-nav-height: 2.5rem;
|
||||
--rdp-nav_button-width: 2rem;
|
||||
--rdp-nav_button-height: 2rem;
|
||||
--rdp-nav_button-disabled-opacity: 0.5;
|
||||
--rdp-months-gap: 2rem;
|
||||
--rdp-weekday-opacity: 0.75;
|
||||
--rdp-weekday-padding: 0.5rem;
|
||||
--rdp-weekday-text-align: center;
|
||||
--rdp-weekday-text-transform: none;
|
||||
--rdp-week_number-opacity: 0.75;
|
||||
--rdp-week_number-height: var(--rdp-day-height);
|
||||
--rdp-week_number-width: var(--rdp-day-width);
|
||||
--rdp-week_number-border: 2px solid transparent;
|
||||
--rdp-week_number-border-radius: 6px;
|
||||
--rdp-weeknumber-text-align: center;
|
||||
}
|
||||
|
||||
.rdp-day_button {
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
-moz-appearance: none;
|
||||
-webkit-appearance: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
appearance: none;
|
||||
width: var(--rdp-day_button-width);
|
||||
height: var(--rdp-day_button-height);
|
||||
border: var(--rdp-day_button-border);
|
||||
border-radius: var(--rdp-day_button-border-radius);
|
||||
}
|
||||
|
||||
.rdp-day_button:disabled {
|
||||
cursor: revert;
|
||||
}
|
||||
|
||||
.rdp-caption_label {
|
||||
z-index: 1;
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.rdp-dropdown:focus-visible ~ .rdp-caption_label {
|
||||
outline: 2px solid var(--color-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.rdp-button_next,
|
||||
.rdp-button_previous {
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
-moz-appearance: none;
|
||||
-webkit-appearance: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
appearance: none;
|
||||
width: var(--rdp-nav_button-width);
|
||||
height: var(--rdp-nav_button-height);
|
||||
}
|
||||
|
||||
.rdp-button_next:disabled,
|
||||
.rdp-button_next[aria-disabled="true"],
|
||||
.rdp-button_previous:disabled,
|
||||
.rdp-button_previous[aria-disabled="true"] {
|
||||
cursor: revert;
|
||||
opacity: var(--rdp-nav_button-disabled-opacity);
|
||||
}
|
||||
|
||||
.rdp-chevron {
|
||||
display: inline-block;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.rdp-root[dir="rtl"] .rdp-nav .rdp-chevron {
|
||||
transform: rotate(180deg);
|
||||
transform-origin: 50%;
|
||||
}
|
||||
|
||||
.rdp-dropdowns {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--rdp-dropdown-gap);
|
||||
}
|
||||
|
||||
.rdp-dropdown {
|
||||
z-index: 2;
|
||||
opacity: 0;
|
||||
appearance: none;
|
||||
position: absolute;
|
||||
inset-block-start: 0;
|
||||
inset-block-end: 0;
|
||||
inset-inline-start: 0;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
cursor: inherit;
|
||||
border: none;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.rdp-dropdown_root {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.rdp-dropdown_root[data-disabled="true"] .rdp-chevron {
|
||||
opacity: var(--rdp-disabled-opacity);
|
||||
}
|
||||
|
||||
.rdp-month_caption {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-content: center;
|
||||
height: var(--rdp-nav-height);
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.rdp-months {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--rdp-months-gap);
|
||||
max-width: fit-content;
|
||||
}
|
||||
|
||||
.rdp-month_grid {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.rdp-nav {
|
||||
position: absolute;
|
||||
inset-block-start: 0;
|
||||
inset-inline-end: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: var(--rdp-nav-height);
|
||||
width: calc(100% + 1rem);
|
||||
margin: 0 -.5rem;
|
||||
}
|
||||
|
||||
.rdp-nav svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.rdp-weekdays {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.rdp-weekday {
|
||||
opacity: var(--rdp-weekday-opacity);
|
||||
padding: var(--rdp-weekday-padding);
|
||||
font-weight: 500;
|
||||
font-size: 0.75rem;
|
||||
text-align: var(--rdp-weekday-text-align);
|
||||
text-transform: var(--rdp-weekday-text-transform);
|
||||
width: var(--rdp-day_button-width);
|
||||
}
|
||||
|
||||
.rdp-week_number {
|
||||
opacity: var(--rdp-week_number-opacity);
|
||||
font-weight: 400;
|
||||
font-size: 0.75rem;
|
||||
height: var(--rdp-week_number-height);
|
||||
width: var(--rdp-week_number-width);
|
||||
border: var(--rdp-week_number-border);
|
||||
border-radius: var(--rdp-week_number-border-radius);
|
||||
text-align: var(--rdp-weeknumber-text-align);
|
||||
}
|
||||
|
||||
/* Day Modifiers */
|
||||
.rdp-today:not(.rdp-outside) {
|
||||
color: var(--rdp-today-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rdp-selected {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rdp-selected .rdp-day_button {
|
||||
background-color: var(--color-primary);
|
||||
color: var(--color-primary-foreground);
|
||||
border: var(--rdp-selected-border);
|
||||
}
|
||||
|
||||
.rdp-outside {
|
||||
opacity: var(--rdp-outside-opacity);
|
||||
}
|
||||
|
||||
.rdp-disabled {
|
||||
opacity: var(--rdp-disabled-opacity);
|
||||
}
|
||||
|
||||
.rdp-hidden {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.rdp-focusable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rdp-day_button:hover:not(.rdp-selected):not([disabled]) {
|
||||
background-color: var(--color-accent);
|
||||
color: var(--color-accent-foreground);
|
||||
}
|
||||
|
||||
/* Dropdown Layout Specific Styles */
|
||||
.rdp-root[data-caption-layout="dropdown"] .rdp-month_caption {
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.rdp-root[data-caption-layout="dropdown"] .rdp-dropdowns {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.rdp-dropdown_root {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background-color: var(--color-background);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
padding: 0.25rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rdp-dropdown_root:hover {
|
||||
background-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.rdp-dropdown_root:focus-within {
|
||||
outline: 2px solid var(--color-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Calendar dropdown styling fixes for react-day-picker v9+ */
|
||||
.rdp-dropdown_month,
|
||||
.rdp-dropdown_year {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
background-color: var(--color-background);
|
||||
padding: 0.25rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
|
||||
.rdp-dropdown_month:focus,
|
||||
.rdp-dropdown_year:focus {
|
||||
outline: 2px solid var(--color-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.rdp-caption_dropdowns {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
/* Navigation button positioning for dropdown layout */
|
||||
.rdp-root[data-caption-layout="dropdown"] .rdp-nav {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.rdp-root[data-caption-layout="dropdown"] .rdp-button_previous,
|
||||
.rdp-root[data-caption-layout="dropdown"] .rdp-button_next {
|
||||
position: static;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
background-color: var(--color-background);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.rdp-root[data-caption-layout="dropdown"] .rdp-button_previous:hover,
|
||||
.rdp-root[data-caption-layout="dropdown"] .rdp-button_next:hover {
|
||||
background-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.rdp-vhidden {
|
||||
display: none;
|
||||
}
|
||||
49
apps/web/src/lib/firebase.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { initializeApp } from 'firebase/app';
|
||||
import { getAuth, GoogleAuthProvider } from 'firebase/auth';
|
||||
|
||||
// Validate required environment variables
|
||||
const requiredEnvVars = [
|
||||
'VITE_FIREBASE_API_KEY',
|
||||
'VITE_FIREBASE_AUTH_DOMAIN',
|
||||
'VITE_FIREBASE_PROJECT_ID',
|
||||
'VITE_FIREBASE_STORAGE_BUCKET',
|
||||
'VITE_FIREBASE_MESSAGING_SENDER_ID',
|
||||
'VITE_FIREBASE_APP_ID'
|
||||
];
|
||||
|
||||
const missingVars = requiredEnvVars.filter(varName => !import.meta.env[varName]);
|
||||
|
||||
if (missingVars.length > 0) {
|
||||
console.error('❌ Missing Firebase environment variables:', missingVars);
|
||||
console.error('Please check your .env.local file and ensure all Firebase config variables are set.');
|
||||
console.error('See .env.example for the required variables.');
|
||||
}
|
||||
|
||||
const firebaseConfig = {
|
||||
apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
|
||||
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
|
||||
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
|
||||
storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
|
||||
messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
|
||||
appId: import.meta.env.VITE_FIREBASE_APP_ID,
|
||||
};
|
||||
|
||||
// Initialize Firebase
|
||||
const app = initializeApp(firebaseConfig);
|
||||
|
||||
// Initialize Firebase Authentication and get a reference to the service
|
||||
export const auth = getAuth(app);
|
||||
|
||||
// Initialize Google Auth Provider with additional configuration
|
||||
export const googleProvider = new GoogleAuthProvider();
|
||||
|
||||
// Configure Google provider for better UX
|
||||
googleProvider.setCustomParameters({
|
||||
prompt: 'select_account', // Always show account selection
|
||||
});
|
||||
|
||||
// Add additional scopes if needed
|
||||
googleProvider.addScope('email');
|
||||
googleProvider.addScope('profile');
|
||||
|
||||
export default app;
|
||||
6
apps/web/src/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))
|
||||
}
|
||||
10
apps/web/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
78
apps/web/src/utils/exchangeRate.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
interface ExchangeRateResponse {
|
||||
[currencyCode: string]: number;
|
||||
}
|
||||
|
||||
let exchangeRateCache: ExchangeRateResponse | null = null;
|
||||
let lastFetchTime = 0;
|
||||
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
export async function fetchExchangeRates(): Promise<ExchangeRateResponse> {
|
||||
const now = Date.now();
|
||||
|
||||
// Return cached data if it's still fresh
|
||||
if (exchangeRateCache && (now - lastFetchTime) < CACHE_DURATION) {
|
||||
return exchangeRateCache;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = import.meta.env.VITE_EXCHANGE_RATE_URL;
|
||||
if (!url) {
|
||||
throw new Error('Exchange rate URL not configured');
|
||||
}
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: ExchangeRateResponse = await response.json();
|
||||
|
||||
// Cache the result
|
||||
exchangeRateCache = data;
|
||||
lastFetchTime = now;
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch exchange rates:', error);
|
||||
|
||||
// Return cached data if available, even if stale
|
||||
if (exchangeRateCache) {
|
||||
return exchangeRateCache;
|
||||
}
|
||||
|
||||
// Fallback: return IDR as base currency
|
||||
return { IDR: 1 };
|
||||
}
|
||||
}
|
||||
|
||||
export function convertToIDR(amount: number, fromCurrency: string, exchangeRates: ExchangeRateResponse): number {
|
||||
if (fromCurrency === 'IDR') {
|
||||
return amount;
|
||||
}
|
||||
|
||||
const rate = exchangeRates[fromCurrency];
|
||||
if (!rate) {
|
||||
console.warn(`Exchange rate not found for ${fromCurrency}, using 1:1 ratio`);
|
||||
return amount;
|
||||
}
|
||||
|
||||
// Convert to IDR: amount / rate = IDR amount
|
||||
// Rate represents how much IDR equals 1 unit of foreign currency
|
||||
return amount / rate;
|
||||
}
|
||||
|
||||
export function convertFromIDR(idrAmount: number, toCurrency: string, exchangeRates: ExchangeRateResponse): number {
|
||||
if (toCurrency === 'IDR') {
|
||||
return idrAmount;
|
||||
}
|
||||
|
||||
const rate = exchangeRates[toCurrency];
|
||||
if (!rate) {
|
||||
console.warn(`Exchange rate not found for ${toCurrency}, using 1:1 ratio`);
|
||||
return idrAmount;
|
||||
}
|
||||
|
||||
// Convert from IDR: idrAmount * rate = target currency amount
|
||||
// Rate represents how much IDR equals 1 unit of foreign currency
|
||||
return idrAmount / rate;
|
||||
}
|
||||
72
apps/web/src/utils/numberFormat.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Format large numbers with "k" suffix for thousands
|
||||
* @param value - The number to format
|
||||
* @param currency - The currency code (optional)
|
||||
* @returns Formatted string with "k" suffix if >= 10 digits
|
||||
*/
|
||||
export function formatLargeNumber(value: number, currency?: string): string {
|
||||
// Check if the number has 10 or more digits
|
||||
const absValue = Math.abs(value)
|
||||
const digitCount = Math.floor(Math.log10(absValue)) + 1
|
||||
|
||||
if (digitCount >= 10) {
|
||||
// Divide by 1000 and add "k" suffix
|
||||
const thousands = value / 1000
|
||||
const formatted = thousands.toLocaleString('id-ID', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 1
|
||||
})
|
||||
|
||||
if (currency) {
|
||||
return `${getCurrencySymbol(currency)} ${formatted}k`
|
||||
}
|
||||
return `${formatted}k`
|
||||
}
|
||||
|
||||
// For numbers with less than 10 digits, use regular formatting
|
||||
if (currency) {
|
||||
return formatCurrency(value, currency)
|
||||
}
|
||||
|
||||
return value.toLocaleString('id-ID')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get currency symbol for display
|
||||
*/
|
||||
function getCurrencySymbol(currency: string): string {
|
||||
switch (currency) {
|
||||
case 'IDR':
|
||||
return 'Rp'
|
||||
case 'USD':
|
||||
return '$'
|
||||
case 'EUR':
|
||||
return '€'
|
||||
case 'GBP':
|
||||
return '£'
|
||||
case 'JPY':
|
||||
return '¥'
|
||||
case 'SGD':
|
||||
return 'S$'
|
||||
case 'MYR':
|
||||
return 'RM'
|
||||
case 'THB':
|
||||
return '฿'
|
||||
default:
|
||||
return currency
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format currency with proper symbol and locale
|
||||
*/
|
||||
function formatCurrency(amount: number, currency: string): string {
|
||||
const symbol = getCurrencySymbol(currency)
|
||||
const formatted = Math.abs(amount).toLocaleString('id-ID', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2
|
||||
})
|
||||
|
||||
const sign = amount < 0 ? '-' : ''
|
||||
return `${sign}${symbol} ${formatted}`
|
||||
}
|
||||
1
apps/web/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
30
apps/web/tsconfig.app.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Path mapping */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
1
apps/web/tsconfig.app.tsbuildinfo
Normal file
@@ -0,0 +1 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/authform.tsx","./src/components/breadcrumb.tsx","./src/components/dashboard.tsx","./src/components/logo.tsx","./src/components/themetoggle.tsx","./src/components/dialogs/transactiondialog.tsx","./src/components/dialogs/walletdialog.tsx","./src/components/layout/appsidebar.tsx","./src/components/layout/dashboardlayout.tsx","./src/components/pages/overview.tsx","./src/components/pages/transactions.tsx","./src/components/pages/wallets.tsx","./src/components/ui/alert-dialog.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/calendar.tsx","./src/components/ui/card.tsx","./src/components/ui/chart.tsx","./src/components/ui/command.tsx","./src/components/ui/date-picker.tsx","./src/components/ui/dialog.tsx","./src/components/ui/form.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/multi-select.tsx","./src/components/ui/multiselector.tsx","./src/components/ui/popover.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/sheet.tsx","./src/components/ui/sidebar.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/table.tsx","./src/components/ui/tooltip.tsx","./src/constants/currencies.ts","./src/hooks/use-mobile.ts","./src/hooks/useauth.ts","./src/lib/firebase.ts","./src/lib/utils.ts","./src/utils/exchangerate.ts","./src/utils/numberformat.ts"],"version":"5.9.2"}
|
||||
13
apps/web/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
],
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
22
apps/web/tsconfig.node.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
1
apps/web/tsconfig.node.tsbuildinfo
Normal file
@@ -0,0 +1 @@
|
||||
{"root":["./vite.config.ts"],"version":"5.9.2"}
|
||||
21
apps/web/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:3000',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||