first commit

This commit is contained in:
dwindown
2025-10-09 12:52:41 +07:00
commit 0da6071eb3
205 changed files with 30980 additions and 0 deletions

11
apps/web/.env.example Normal file
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

57
apps/web/package.json Normal file
View 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"
}
}

View File

@@ -0,0 +1,6 @@
// apps/web/postcss.config.cjs
module.exports = {
plugins: {
'@tailwindcss/postcss': {},
},
};

1
apps/web/public/vite.svg Normal file
View 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
View 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
View 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>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

View 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

View 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

View 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>
);
};

View 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>
)
}

View 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>
)
}

View 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`}
/>
</>
);
};

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

File diff suppressed because it is too large Load Diff

View 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>
)
}

View 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>
)
}

View 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,
}

View 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 }

View 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 }

View 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 }

View 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,
}

View 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
}

View 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,
}

View 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>
)
}

View 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,
}

View 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,
}

View 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 }

View 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 }

View 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 }

View 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 }

View 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>
)
}

View 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 }

View 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,
}

View 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 }

View 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,
}

View 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 }

View 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 }

View 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,
}

View 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 }

View 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 })}`;
};

View 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
}

View 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,
};
};

View 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
View 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;
}

View 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;

View File

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

10
apps/web/src/main.tsx Normal file
View 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>,
)

View 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;
}

View 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
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View 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"]
}

View 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
View File

@@ -0,0 +1,13 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

View 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"]
}

View File

@@ -0,0 +1 @@
{"root":["./vite.config.ts"],"version":"5.9.2"}

21
apps/web/vite.config.ts Normal file
View 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
}
}
}
})