pushing all
Some checks failed
Build & Deploy Frontend / build-push-deploy (push) Failing after 15s

This commit is contained in:
Ali 2025-08-30 11:51:11 +05:30
parent 2643cd4621
commit 20e95c2fb6
211 changed files with 45970 additions and 0 deletions

24
my-access-hub-main/.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?

View File

@ -0,0 +1,73 @@
# Welcome to your Lovable project
## Project info
**URL**: https://lovable.dev/projects/23cc993d-953c-42de-a37d-9b15ac7f094d
## How can I edit this code?
There are several ways of editing your application.
**Use Lovable**
Simply visit the [Lovable Project](https://lovable.dev/projects/23cc993d-953c-42de-a37d-9b15ac7f094d) and start prompting.
Changes made via Lovable will be committed automatically to this repo.
**Use your preferred IDE**
If you want to work locally using your own IDE, you can clone this repo and push changes. Pushed changes will also be reflected in Lovable.
The only requirement is having Node.js & npm installed - [install with nvm](https://github.com/nvm-sh/nvm#installing-and-updating)
Follow these steps:
```sh
# Step 1: Clone the repository using the project's Git URL.
git clone <YOUR_GIT_URL>
# Step 2: Navigate to the project directory.
cd <YOUR_PROJECT_NAME>
# Step 3: Install the necessary dependencies.
npm i
# Step 4: Start the development server with auto-reloading and an instant preview.
npm run dev
```
**Edit a file directly in GitHub**
- Navigate to the desired file(s).
- Click the "Edit" button (pencil icon) at the top right of the file view.
- Make your changes and commit the changes.
**Use GitHub Codespaces**
- Navigate to the main page of your repository.
- Click on the "Code" button (green button) near the top right.
- Select the "Codespaces" tab.
- Click on "New codespace" to launch a new Codespace environment.
- Edit files directly within the Codespace and commit and push your changes once you're done.
## What technologies are used for this project?
This project is built with:
- Vite
- TypeScript
- React
- shadcn-ui
- Tailwind CSS
## How can I deploy this project?
Simply open [Lovable](https://lovable.dev/projects/23cc993d-953c-42de-a37d-9b15ac7f094d) and click on Share -> Publish.
## Can I connect a custom domain to my Lovable project?
Yes, you can!
To connect a domain, navigate to Project > Settings > Domains and click Connect Domain.
Read more here: [Setting up a custom domain](https://docs.lovable.dev/tips-tricks/custom-domain#step-by-step-guide)

Binary file not shown.

View File

@ -0,0 +1,20 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/index.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}

View File

@ -0,0 +1,29 @@
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";
export default tseslint.config(
{ ignores: ["dist"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
],
"@typescript-eslint/no-unused-vars": "off",
},
}
);

View File

@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>my-access-hub</title>
<meta name="description" content="Lovable Generated Project" />
<meta name="author" content="Lovable" />
<!-- PWA-related additions START -->
<meta name="theme-color" content="#ffffff" />
<link rel="apple-touch-icon" href="/pwa-192x192.png" />
<!-- PWA-related additions END -->
<meta property="og:title" content="my-access-hub" />
<meta property="og:description" content="Lovable Generated Project" />
<meta property="og:type" content="website" />
<meta property="og:image" content="https://lovable.dev/opengraph-image-p98pqg.png" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:site" content="@lovable_dev" />
<meta name="twitter:image" content="https://lovable.dev/opengraph-image-p98pqg.png" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

11732
my-access-hub-main/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,87 @@
{
"name": "vite_react_shadcn_ts",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"build:dev": "vite build --mode development",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@hookform/resolvers": "^3.10.0",
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-aspect-ratio": "^1.1.7",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-context-menu": "^2.2.15",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-hover-card": "^1.1.14",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-menubar": "^1.1.15",
"@radix-ui/react-navigation-menu": "^1.2.13",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-radio-group": "^1.3.7",
"@radix-ui/react-scroll-area": "^1.2.9",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.3.5",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-toast": "^1.2.14",
"@radix-ui/react-toggle": "^1.1.9",
"@radix-ui/react-toggle-group": "^1.1.10",
"@radix-ui/react-tooltip": "^1.2.7",
"@supabase/supabase-js": "^2.54.0",
"@tanstack/react-query": "^5.83.0",
"@xyflow/react": "^12.8.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.462.0",
"next-themes": "^0.3.0",
"react": "^18.3.1",
"react-day-picker": "^8.10.1",
"react-dom": "^18.3.1",
"react-dropzone": "^14.3.8",
"react-hook-form": "^7.61.1",
"react-resizable-panels": "^2.1.9",
"react-router-dom": "^6.30.1",
"recharts": "^3.1.2",
"sonner": "^1.7.4",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"vaul": "^0.9.9",
"zod": "^3.25.76"
},
"devDependencies": {
"@eslint/js": "^9.32.0",
"@tailwindcss/typography": "^0.5.16",
"@types/node": "^22.16.5",
"@types/react": "^18.3.23",
"@types/react-dom": "^18.3.7",
"@vitejs/plugin-react-swc": "^3.11.0",
"autoprefixer": "^10.4.21",
"eslint": "^9.32.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^15.15.0",
"lovable-tagger": "^1.1.9",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.17",
"typescript": "^5.8.3",
"typescript-eslint": "^8.38.0",
"vite": "^5.4.19",
"vite-plugin-pwa": "^1.0.3"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1200" fill="none"><rect width="1200" height="1200" fill="#EAEAEA" rx="3"/><g opacity=".5"><g opacity=".5"><path fill="#FAFAFA" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/></g><path stroke="url(#a)" stroke-width="2.418" d="M0-1.209h553.581" transform="scale(1 -1) rotate(45 1163.11 91.165)"/><path stroke="url(#b)" stroke-width="2.418" d="M404.846 598.671h391.726"/><path stroke="url(#c)" stroke-width="2.418" d="M599.5 795.742V404.017"/><path stroke="url(#d)" stroke-width="2.418" d="m795.717 796.597-391.441-391.44"/><path fill="#fff" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/><g clip-path="url(#e)"><path fill="#666" fill-rule="evenodd" d="M616.426 586.58h-31.434v16.176l3.553-3.554.531-.531h9.068l.074-.074 8.463-8.463h2.565l7.18 7.181V586.58Zm-15.715 14.654 3.698 3.699 1.283 1.282-2.565 2.565-1.282-1.283-5.2-5.199h-6.066l-5.514 5.514-.073.073v2.876a2.418 2.418 0 0 0 2.418 2.418h26.598a2.418 2.418 0 0 0 2.418-2.418v-8.317l-8.463-8.463-7.181 7.181-.071.072Zm-19.347 5.442v4.085a6.045 6.045 0 0 0 6.046 6.045h26.598a6.044 6.044 0 0 0 6.045-6.045v-7.108l1.356-1.355-1.282-1.283-.074-.073v-17.989h-38.689v23.43l-.146.146.146.147Z" clip-rule="evenodd"/></g><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/></g><defs><linearGradient id="a" x1="554.061" x2="-.48" y1=".083" y2=".087" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="b" x1="796.912" x2="404.507" y1="599.963" y2="599.965" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="c" x1="600.792" x2="600.794" y1="403.677" y2="796.082" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="d" x1="404.85" x2="796.972" y1="403.903" y2="796.02" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><clipPath id="e"><path fill="#fff" d="M581.364 580.535h38.689v38.689h-38.689z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@ -0,0 +1,14 @@
User-agent: Googlebot
Allow: /
User-agent: Bingbot
Allow: /
User-agent: Twitterbot
Allow: /
User-agent: facebookexternalhit
Allow: /
User-agent: *
Allow: /

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

View File

@ -0,0 +1,155 @@
import { Toaster } from "@/components/ui/toaster";
import { Toaster as Sonner } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { AuthProvider, useAuth } from "@/hooks/useAuth";
import { ThemeProvider } from "@/components/layout/ThemeProvider";
import { Layout } from "@/components/layout/Layout";
import { Suspense } from "react";
// Core pages (not module-specific)
import LoginLanding from "./pages/LoginLanding";
import Documentation from "./pages/Documentation";
import Profile from "./pages/Profile";
import UserManagement from "./pages/admin/UserManagement";
import SystemSettings from "./pages/admin/SystemSettings";
import SubscriptionSettings from "./pages/admin/SubscriptionSettings";
import AssetSettings from "./pages/admin/AssetSettings";
import CompanySettings from "./pages/admin/CompanySettings";
import ModuleManager from "./pages/admin/ModuleManager";
import NotFound from "./pages/NotFound";
import Vendors from "./pages/Vendors";
import PageBuilder from "./pages/PageBuilder";
import Home from "./pages/Home";
// Module system
import { initializeModules, moduleManager } from "@/modules";
// Initialize modules on app startup
initializeModules();
const queryClient = new QueryClient();
const AppContent = () => {
// Get enabled routes from module manager
const moduleRoutes = moduleManager.getEnabledRoutes();
const { user, loading } = useAuth();
// If loading, show loading state without Layout to avoid nested useAuth calls
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">Loading...</div>
</div>
);
}
// If not authenticated, show login landing page without Layout
if (!user) {
return (
<Routes>
<Route path="/" element={<LoginLanding />} />
<Route path="/docs" element={<Documentation />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}
// If authenticated, show main app with Layout
return (
<Layout>
<Suspense fallback={<div className="flex items-center justify-center h-64">Loading...</div>}>
<Routes>
{/* Redirect to home for authenticated users */}
<Route
path="/"
element={<Navigate to="/home" replace />}
/>
<Route
path="/home"
element={<Home />}
/>
{/* Protected module routes */}
{moduleRoutes.map((route, index) => (
<Route
key={`${route.path}-${index}`}
path={route.path}
element={<route.component />}
/>
))}
{/* Core/Shared routes - protected */}
<Route
path="/page-builder"
element={<PageBuilder />}
/>
<Route
path="/vendors"
element={<Vendors />}
/>
<Route
path="/profile"
element={<Profile />}
/>
{/* Admin routes - protected */}
<Route
path="/admin/users"
element={<UserManagement />}
/>
<Route
path="/admin/settings"
element={<SystemSettings />}
/>
<Route
path="/admin/subscription-settings"
element={<SubscriptionSettings />}
/>
<Route
path="/admin/asset-settings"
element={<AssetSettings />}
/>
<Route
path="/admin/company-settings"
element={<CompanySettings />}
/>
<Route
path="/admin/modules"
element={<ModuleManager />}
/>
{/* Catch-all route */}
<Route path="*" element={<NotFound />} />
</Routes>
</Suspense>
</Layout>
);
};
const App = () => {
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<AuthProvider>
<TooltipProvider>
<Toaster />
<Sonner />
<BrowserRouter>
<AppContent />
</BrowserRouter>
</TooltipProvider>
</AuthProvider>
</ThemeProvider>
</QueryClientProvider>
);
};
export default App;

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

View File

@ -0,0 +1,157 @@
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useToast } from '@/hooks/use-toast';
import { useCreateUser } from '@/hooks/useUsers';
const createUserSchema = z.object({
email: z.string().email('Please enter a valid email address'),
password: z.string().min(6, 'Password must be at least 6 characters'),
display_name: z.string().min(1, 'Display name is required'),
role: z.enum(['admin', 'finance', 'viewer'], {
required_error: 'Please select a role',
}),
});
type CreateUserFormData = z.infer<typeof createUserSchema>;
interface CreateUserFormProps {
onSuccess?: () => void;
}
export function CreateUserForm({ onSuccess }: CreateUserFormProps) {
const { toast } = useToast();
const createUser = useCreateUser();
const [isSubmitting, setIsSubmitting] = useState(false);
const {
register,
handleSubmit,
formState: { errors },
reset,
setValue,
watch,
} = useForm<CreateUserFormData>({
resolver: zodResolver(createUserSchema),
defaultValues: {
email: '',
password: '',
display_name: '',
role: 'viewer',
},
});
const selectedRole = watch('role');
const onSubmit = async (data: CreateUserFormData) => {
setIsSubmitting(true);
try {
await createUser.mutateAsync({
email: data.email,
password: data.password,
display_name: data.display_name,
role: data.role,
});
toast({
title: 'User created successfully',
description: `${data.display_name} has been added to the system.`,
});
reset();
onSuccess?.();
} catch (error: any) {
toast({
title: 'Error creating user',
description: error.message,
variant: 'destructive',
});
} finally {
setIsSubmitting(false);
}
};
const roleDescriptions = {
admin: 'Full access to all features including user management',
finance: 'Can manage services, payments, vendors, and view reports',
viewer: 'Read-only access to view data and reports',
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="display_name">Display Name *</Label>
<Input
id="display_name"
{...register('display_name')}
placeholder="Enter user's full name"
/>
{errors.display_name && (
<p className="text-sm text-red-600">{errors.display_name.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="email">Email Address *</Label>
<Input
id="email"
type="email"
{...register('email')}
placeholder="user@example.com"
/>
{errors.email && (
<p className="text-sm text-red-600">{errors.email.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="password">Temporary Password *</Label>
<Input
id="password"
type="password"
{...register('password')}
placeholder="At least 6 characters"
/>
{errors.password && (
<p className="text-sm text-red-600">{errors.password.message}</p>
)}
<p className="text-xs text-muted-foreground">
User will be able to change this password after first login
</p>
</div>
<div className="space-y-2">
<Label htmlFor="role">Role *</Label>
<Select onValueChange={(value) => setValue('role', value as 'admin' | 'finance' | 'viewer')} defaultValue={selectedRole}>
<SelectTrigger>
<SelectValue placeholder="Select a role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="viewer">Viewer</SelectItem>
<SelectItem value="finance">Finance</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
{errors.role && (
<p className="text-sm text-red-600">{errors.role.message}</p>
)}
{selectedRole && (
<p className="text-xs text-muted-foreground">
{roleDescriptions[selectedRole]}
</p>
)}
</div>
<Button
type="submit"
className="w-full"
disabled={isSubmitting}
>
{isSubmitting ? 'Creating User...' : 'Create User'}
</Button>
</form>
);
}

View File

@ -0,0 +1,243 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
import { useToast } from '@/hooks/use-toast';
import { supabase } from '@/integrations/supabase/client';
import { Trash2, AlertTriangle, Database } from 'lucide-react';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
interface ClearOptions {
services: boolean;
payments: boolean;
vendors: boolean;
categories: boolean;
confirmText: string;
}
export function DataClear() {
const { toast } = useToast();
const [isClearing, setIsClearing] = useState(false);
const [clearOptions, setClearOptions] = useState<ClearOptions>({
services: false,
payments: false,
vendors: false,
categories: false,
confirmText: ''
});
const clearSelectedData = async () => {
if (clearOptions.confirmText !== 'DELETE ALL DATA') {
toast({
title: "Confirmation required",
description: "Please type 'DELETE ALL DATA' to confirm",
variant: "destructive"
});
return;
}
setIsClearing(true);
const results = [];
try {
// Clear in correct order to respect foreign key constraints
if (clearOptions.payments) {
const { error } = await supabase
.from('payments')
.delete()
.neq('id', '00000000-0000-0000-0000-000000000000'); // Delete all records
if (error) throw new Error(`Failed to clear payments: ${error.message}`);
results.push('Payments cleared');
}
if (clearOptions.services) {
const { error } = await supabase
.from('services')
.delete()
.neq('id', '00000000-0000-0000-0000-000000000000'); // Delete all records
if (error) throw new Error(`Failed to clear services: ${error.message}`);
results.push('Services cleared');
}
if (clearOptions.vendors) {
const { error } = await supabase
.from('vendors')
.delete()
.neq('id', '00000000-0000-0000-0000-000000000000'); // Delete all records
if (error) throw new Error(`Failed to clear vendors: ${error.message}`);
results.push('Vendors cleared');
}
if (clearOptions.categories) {
// Don't delete the default category
const { error } = await supabase
.from('categories')
.delete()
.neq('id', '39904b37-b9ff-4a5f-af6f-88cb1169f6ab'); // Keep default category
if (error) throw new Error(`Failed to clear categories: ${error.message}`);
results.push('Categories cleared');
}
toast({
title: "Data cleared successfully",
description: results.join(', ')
});
// Reset form
setClearOptions({
services: false,
payments: false,
vendors: false,
categories: false,
confirmText: ''
});
} catch (error) {
console.error('Clear data error:', error);
toast({
title: "Clear data failed",
description: error instanceof Error ? error.message : "Unknown error occurred",
variant: "destructive"
});
} finally {
setIsClearing(false);
}
};
const hasSelection = clearOptions.services || clearOptions.payments || clearOptions.vendors || clearOptions.categories;
return (
<Card className="border-destructive/20">
<CardHeader>
<CardTitle className="flex items-center text-destructive">
<Trash2 className="mr-2 h-5 w-5" />
Clear Data
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Alert className="border-destructive/20">
<AlertTriangle className="h-4 w-4" />
<AlertDescription className="text-destructive">
<strong>Danger Zone:</strong> This action cannot be undone. All selected data will be permanently deleted.
</AlertDescription>
</Alert>
<div className="space-y-3">
<Label className="text-sm font-medium">Select data to clear:</Label>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Checkbox
id="clear-payments"
checked={clearOptions.payments}
onCheckedChange={(checked) =>
setClearOptions(prev => ({ ...prev, payments: !!checked }))
}
/>
<Label htmlFor="clear-payments" className="text-sm">
All Payments Data
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="clear-services"
checked={clearOptions.services}
onCheckedChange={(checked) =>
setClearOptions(prev => ({ ...prev, services: !!checked }))
}
/>
<Label htmlFor="clear-services" className="text-sm">
All Services Data
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="clear-vendors"
checked={clearOptions.vendors}
onCheckedChange={(checked) =>
setClearOptions(prev => ({ ...prev, vendors: !!checked }))
}
/>
<Label htmlFor="clear-vendors" className="text-sm">
All Vendors Data
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="clear-categories"
checked={clearOptions.categories}
onCheckedChange={(checked) =>
setClearOptions(prev => ({ ...prev, categories: !!checked }))
}
/>
<Label htmlFor="clear-categories" className="text-sm">
Custom Categories (keeps default)
</Label>
</div>
</div>
</div>
{hasSelection && (
<div className="space-y-2">
<Label htmlFor="confirm-text" className="text-sm font-medium">
Type "DELETE ALL DATA" to confirm:
</Label>
<input
id="confirm-text"
type="text"
value={clearOptions.confirmText}
onChange={(e) => setClearOptions(prev => ({ ...prev, confirmText: e.target.value }))}
className="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="DELETE ALL DATA"
/>
</div>
)}
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
disabled={!hasSelection || clearOptions.confirmText !== 'DELETE ALL DATA' || isClearing}
className="w-full"
>
<Database className="mr-2 h-4 w-4" />
{isClearing ? 'Clearing Data...' : 'Clear Selected Data'}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="text-destructive">Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. The following data will be permanently deleted:
<ul className="list-disc list-inside mt-2 space-y-1">
{clearOptions.payments && <li>All payment records</li>}
{clearOptions.services && <li>All service subscriptions</li>}
{clearOptions.vendors && <li>All vendor information</li>}
{clearOptions.categories && <li>All custom categories</li>}
</ul>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={clearSelectedData}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Yes, delete permanently
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,531 @@
import { useState, useRef, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useToast } from '@/hooks/use-toast';
import { supabase } from '@/integrations/supabase/client';
import { Upload, FileText, AlertTriangle, File, X } from 'lucide-react';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { cn } from '@/lib/utils';
interface ImportResult {
success: number;
failed: number;
errors: string[];
}
interface DragState {
isDragOver: boolean;
isDragActive: boolean;
}
export function DataImport() {
const { toast } = useToast();
const [isImporting, setIsImporting] = useState(false);
const [importResults, setImportResults] = useState<ImportResult | null>(null);
const [dragState, setDragState] = useState<DragState>({ isDragOver: false, isDragActive: false });
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const parseCSV = (csvText: string): any[] => {
const lines = csvText.split('\n').filter(line => line.trim());
if (lines.length < 2) return [];
const headers = lines[0].split(',').map(h => h.trim().replace(/"/g, ''));
const data = [];
for (let i = 1; i < lines.length; i++) {
const values = lines[i].split(',').map(v => v.trim().replace(/"/g, ''));
if (values.length === headers.length) {
const row: any = {};
headers.forEach((header, index) => {
row[header] = values[index] || null;
});
data.push(row);
}
}
return data;
};
const importServices = async (data: any[]): Promise<ImportResult> => {
const result: ImportResult = { success: 0, failed: 0, errors: [] };
for (const row of data) {
try {
// More flexible column mapping for service_name (required field)
const serviceName = row.service_name || row.name || row.serviceName || row.service || row.title;
const provider = row.provider || row.vendor || row.company || row.supplier;
// Validate required fields
if (!serviceName) {
result.failed++;
result.errors.push(`Row ${result.success + result.failed}: Missing required service_name/name field. Available columns: ${Object.keys(row).join(', ')}`);
continue;
}
if (!provider) {
result.failed++;
result.errors.push(`Row ${result.success + result.failed}: Missing required provider field. Available columns: ${Object.keys(row).join(', ')}`);
continue;
}
// Get current user ID instead of hardcoded value
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
result.failed++;
result.errors.push(`Row ${result.success + result.failed}: User not authenticated`);
continue;
}
// Map CSV columns to database columns with better fallbacks
const service = {
service_name: serviceName,
provider: provider,
plan_name: row.plan_name || row.plan || row.planName,
amount: parseFloat(row.amount || row.cost || row.price) || 0,
currency: row.currency || 'USD',
billing_cycle: row.billing_cycle || row.billingCycle || row.cycle || 'Monthly',
status: row.status || 'Active',
start_date: row.start_date || row.startDate || new Date().toISOString().split('T')[0],
dashboard_url: row.dashboard_url || row.dashboardUrl || row.url,
account_email: row.account_email || row.accountEmail || row.email,
auto_renew: row.auto_renew === 'true' || row.auto_renew === '1' || row.autoRenew === 'true',
user_id: user.id,
category_id: '39904b37-b9ff-4a5f-af6f-88cb1169f6ab' // Default category
};
const { error } = await supabase
.from('services')
.insert(service);
if (error) {
result.failed++;
result.errors.push(`Service ${service.service_name}: ${error.message}`);
} else {
result.success++;
}
} catch (error) {
result.failed++;
result.errors.push(`Row ${result.success + result.failed}: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
return result;
};
const importPayments = async (data: any[]): Promise<ImportResult> => {
const result: ImportResult = { success: 0, failed: 0, errors: [] };
for (const row of data) {
try {
const payment = {
service_id: row.service_id,
amount: parseFloat(row.amount) || 0,
currency: row.currency || 'USD',
payment_date: row.payment_date,
invoice_number: row.invoice_number,
paid_by: row.paid_by,
remarks: row.remarks,
user_id: row.user_id || '00000000-0000-0000-0000-000000000000'
};
const { error } = await supabase
.from('payments')
.insert(payment);
if (error) {
result.failed++;
result.errors.push(`Payment ${payment.invoice_number || 'Unknown'}: ${error.message}`);
} else {
result.success++;
}
} catch (error) {
result.failed++;
result.errors.push(`Row ${result.success + result.failed}: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
return result;
};
const importVendors = async (data: any[]): Promise<ImportResult> => {
const result: ImportResult = { success: 0, failed: 0, errors: [] };
for (const row of data) {
try {
const vendor = {
name: row.name,
website: row.website,
support_email: row.support_email,
support_phone: row.support_phone,
notes: row.notes
};
const { error } = await supabase
.from('vendors')
.insert(vendor);
if (error) {
result.failed++;
result.errors.push(`Vendor ${vendor.name}: ${error.message}`);
} else {
result.success++;
}
} catch (error) {
result.failed++;
result.errors.push(`Row ${result.success + result.failed}: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
return result;
};
const validateFile = (file: File): boolean => {
const validTypes = ['text/csv', 'application/json', 'text/plain'];
const validExtensions = ['.csv', '.json'];
const hasValidType = validTypes.includes(file.type);
const hasValidExtension = validExtensions.some(ext => file.name.toLowerCase().endsWith(ext));
if (!hasValidType && !hasValidExtension) {
toast({
title: "Invalid file type",
description: "Please upload a CSV or JSON file",
variant: "destructive"
});
return false;
}
if (file.size > 10 * 1024 * 1024) { // 10MB limit
toast({
title: "File too large",
description: "Please upload a file smaller than 10MB",
variant: "destructive"
});
return false;
}
return true;
};
const processFile = async (file: File) => {
if (!validateFile(file)) return;
setSelectedFile(file);
setIsImporting(true);
setImportResults(null);
try {
const text = await file.text();
let data: any[] = [];
let result: ImportResult;
if (file.name.toLowerCase().endsWith('.csv')) {
data = parseCSV(text);
} else if (file.name.toLowerCase().endsWith('.json')) {
const jsonData = JSON.parse(text);
data = Array.isArray(jsonData) ? jsonData : [jsonData];
} else {
throw new Error('Unsupported file format. Please use CSV or JSON.');
}
if (data.length === 0) {
throw new Error('No valid data found in file');
}
// Check if this is a system export file (nested structure)
const firstRow = data[0];
if (firstRow.services || firstRow.payments || firstRow.vendors) {
// This is a system export file, extract the appropriate data
const fileName = file.name.toLowerCase();
if (fileName.includes('service') && firstRow.services) {
data = Array.isArray(firstRow.services) ? firstRow.services : [firstRow.services];
} else if (fileName.includes('payment') && firstRow.payments) {
data = Array.isArray(firstRow.payments) ? firstRow.payments : [firstRow.payments];
} else if (fileName.includes('vendor') && firstRow.vendors) {
data = Array.isArray(firstRow.vendors) ? firstRow.vendors : [firstRow.vendors];
} else {
// Auto-detect based on which data is available and has content
if (firstRow.services && Array.isArray(firstRow.services) && firstRow.services.length > 0) {
data = firstRow.services;
toast({
title: "System export detected",
description: "Importing services data from system export file"
});
} else if (firstRow.payments && Array.isArray(firstRow.payments) && firstRow.payments.length > 0) {
data = firstRow.payments;
toast({
title: "System export detected",
description: "Importing payments data from system export file"
});
} else if (firstRow.vendors && Array.isArray(firstRow.vendors) && firstRow.vendors.length > 0) {
data = firstRow.vendors;
toast({
title: "System export detected",
description: "Importing vendors data from system export file"
});
} else {
throw new Error('System export file detected but no importable data found. Available sections: ' + Object.keys(firstRow).filter(key => Array.isArray(firstRow[key]) && firstRow[key].length > 0).join(', ') + '. Please ensure your export file contains data in the services, payments, or vendors sections.');
}
}
if (data.length === 0) {
throw new Error('No data found in the selected section of the export file');
}
}
// Determine import type based on file name or data structure
const fileName = file.name.toLowerCase();
if (fileName.includes('service')) {
result = await importServices(data);
} else if (fileName.includes('payment')) {
result = await importPayments(data);
} else if (fileName.includes('vendor')) {
result = await importVendors(data);
} else {
// Try to auto-detect based on data structure
const firstRow = data[0];
const keys = Object.keys(firstRow);
// More flexible detection patterns
const hasServiceFields = keys.some(key =>
key.toLowerCase().includes('service') ||
key.toLowerCase().includes('provider') ||
key.toLowerCase().includes('subscription') ||
key.toLowerCase().includes('renewal')
);
const hasPaymentFields = keys.some(key =>
key.toLowerCase().includes('payment') ||
key.toLowerCase().includes('invoice') ||
key.toLowerCase().includes('amount') ||
key.toLowerCase().includes('transaction')
);
const hasVendorFields = keys.some(key =>
key.toLowerCase().includes('vendor') ||
key.toLowerCase().includes('supplier') ||
key.toLowerCase().includes('company') ||
(key.toLowerCase().includes('name') && (
keys.some(k => k.toLowerCase().includes('website')) ||
keys.some(k => k.toLowerCase().includes('email')) ||
keys.some(k => k.toLowerCase().includes('contact'))
))
);
if (hasServiceFields) {
result = await importServices(data);
} else if (hasPaymentFields) {
result = await importPayments(data);
} else if (hasVendorFields) {
result = await importVendors(data);
} else {
throw new Error(`Could not determine data type from file structure. Available columns: ${keys.join(', ')}. Please name your file with "service", "payment", or "vendor" keyword, or ensure your data has recognizable column names.`);
}
}
setImportResults(result);
toast({
title: "Import completed",
description: `${result.success} records imported successfully, ${result.failed} failed`
});
} catch (error) {
console.error('Import error:', error);
toast({
title: "Import failed",
description: error instanceof Error ? error.message : "Unknown error occurred",
variant: "destructive"
});
} finally {
setIsImporting(false);
}
};
const handleFileInput = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
await processFile(file);
};
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragState({ isDragOver: true, isDragActive: true });
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragState({ isDragOver: false, isDragActive: false });
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const handleDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragState({ isDragOver: false, isDragActive: false });
const files = Array.from(e.dataTransfer.files);
if (files.length === 0) return;
const file = files[0];
await processFile(file);
}, []);
const removeSelectedFile = () => {
setSelectedFile(null);
setImportResults(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const openFileDialog = () => {
fileInputRef.current?.click();
};
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center">
<Upload className="mr-2 h-5 w-5" />
Data Import
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Alert>
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
Supported formats: CSV, JSON. File should contain services, payments, or vendors data.
Name your file with "service", "payment", or "vendor" keyword for auto-detection.
</AlertDescription>
</Alert>
{/* Drag and Drop Zone */}
<div
className={cn(
"relative border-2 border-dashed rounded-lg p-6 transition-all duration-200 cursor-pointer",
dragState.isDragOver
? "border-primary bg-primary/5 scale-[1.02]"
: "border-muted-foreground/25 hover:border-muted-foreground/50",
isImporting && "opacity-50 cursor-not-allowed"
)}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
onClick={!isImporting ? openFileDialog : undefined}
>
<input
ref={fileInputRef}
type="file"
accept=".csv,.json"
onChange={handleFileInput}
className="hidden"
disabled={isImporting}
/>
<div className="flex flex-col items-center justify-center space-y-3">
{dragState.isDragOver ? (
<>
<Upload className="h-8 w-8 text-primary animate-bounce" />
<p className="text-sm font-medium text-primary">Drop your file here!</p>
</>
) : selectedFile ? (
<>
<div className="flex items-center space-x-2 p-3 bg-muted rounded-lg">
<File className="h-5 w-5 text-muted-foreground" />
<span className="text-sm font-medium">{selectedFile.name}</span>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
removeSelectedFile();
}}
className="h-6 w-6 p-0"
>
<X className="h-3 w-3" />
</Button>
</div>
<p className="text-xs text-muted-foreground">
Click to select a different file or drag & drop to replace
</p>
</>
) : (
<>
<Upload className="h-8 w-8 text-muted-foreground" />
<div className="text-center">
<p className="text-sm font-medium">
Drag & drop your file here, or click to browse
</p>
<p className="text-xs text-muted-foreground mt-1">
CSV or JSON files up to 10MB
</p>
</div>
</>
)}
</div>
</div>
{isImporting && (
<div className="flex items-center justify-center space-x-2 text-sm text-muted-foreground py-4">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-primary"></div>
<span>Processing file...</span>
</div>
)}
{importResults && (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-4 text-sm">
<div className="flex items-center space-x-2">
<div className="w-3 h-3 bg-green-500 rounded-full"></div>
<span>Successful: {importResults.success}</span>
</div>
<div className="flex items-center space-x-2">
<div className="w-3 h-3 bg-red-500 rounded-full"></div>
<span>Failed: {importResults.failed}</span>
</div>
</div>
{importResults.errors.length > 0 && (
<div className="space-y-2">
<Label className="text-sm font-medium text-destructive">Errors:</Label>
<div className="max-h-32 overflow-y-auto space-y-1">
{importResults.errors.slice(0, 10).map((error, index) => (
<div key={index} className="text-xs text-destructive bg-destructive/10 p-2 rounded">
{error}
</div>
))}
{importResults.errors.length > 10 && (
<div className="text-xs text-muted-foreground">
... and {importResults.errors.length - 10} more errors
</div>
)}
</div>
</div>
)}
</div>
)}
<div className="space-y-2">
<Label className="text-sm font-medium">Sample CSV Format:</Label>
<div className="text-xs bg-muted p-2 rounded font-mono">
<div>Services: service_name,provider,amount,currency,billing_cycle,status</div>
<div>Payments: service_id,amount,currency,payment_date,invoice_number</div>
<div>Vendors: name,website,support_email,support_phone</div>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,312 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
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 {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import {
Search,
MoreHorizontal,
Edit,
Trash2,
UserPlus,
Mail,
Calendar,
Shield,
ShieldCheck,
Eye
} from 'lucide-react';
import { useUsers, useUpdateUserRole, useDeleteUser } from '@/hooks/useUsers';
import { useToast } from '@/hooks/use-toast';
import { useAuth } from '@/hooks/useAuth';
import { format } from 'date-fns';
export function UsersTable() {
const [searchTerm, setSearchTerm] = useState('');
const [roleFilter, setRoleFilter] = useState<string>('all');
const { toast } = useToast();
const { user: currentUser } = useAuth();
const { data: users, isLoading } = useUsers();
const updateUserRole = useUpdateUserRole();
const deleteUser = useDeleteUser();
const filteredUsers = users?.filter(user => {
const matchesSearch = user.display_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
user.email?.toLowerCase().includes(searchTerm.toLowerCase());
const matchesRole = roleFilter === 'all' || user.role === roleFilter;
return matchesSearch && matchesRole;
}) || [];
const handleRoleChange = async (userId: string, newRole: 'admin' | 'finance' | 'viewer') => {
try {
await updateUserRole.mutateAsync({ userId, role: newRole });
toast({
title: 'Role updated',
description: 'User role has been updated successfully.',
});
} catch (error: any) {
toast({
title: 'Error',
description: error.message,
variant: 'destructive',
});
}
};
const handleDeleteUser = async (userId: string, displayName: string) => {
try {
await deleteUser.mutateAsync(userId);
toast({
title: 'User deleted',
description: `${displayName} has been removed from the system.`,
});
} catch (error: any) {
toast({
title: 'Error',
description: error.message,
variant: 'destructive',
});
}
};
const getRoleIcon = (role: string) => {
switch (role) {
case 'admin':
return <ShieldCheck className="h-3 w-3" />;
case 'finance':
return <Shield className="h-3 w-3" />;
default:
return <Eye className="h-3 w-3" />;
}
};
const getRoleVariant = (role: string) => {
switch (role) {
case 'admin':
return 'destructive';
case 'finance':
return 'default';
default:
return 'secondary';
}
};
if (isLoading) {
return (
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
<p className="ml-2 text-muted-foreground">Loading users...</p>
</div>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center">
<UserPlus className="mr-2 h-5 w-5" />
User Management
</CardTitle>
<div className="text-right">
<p className="text-sm text-muted-foreground">Total Users</p>
<p className="text-lg font-semibold">{filteredUsers.length}</p>
</div>
</div>
<div className="flex flex-col sm:flex-row gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search users..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
<Select value={roleFilter} onValueChange={setRoleFilter}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="Filter by role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Roles</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="finance">Finance</SelectItem>
<SelectItem value="viewer">Viewer</SelectItem>
</SelectContent>
</Select>
</div>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead>Created</TableHead>
<TableHead className="w-[100px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredUsers.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-12">
<div className="text-muted-foreground">
{searchTerm || roleFilter !== 'all'
? 'No users match your filters'
: 'No users found.'}
</div>
</TableCell>
</TableRow>
) : (
filteredUsers.map((user) => (
<TableRow key={user.id}>
<TableCell>
<div className="flex items-center">
<div className="h-8 w-8 rounded-full bg-primary flex items-center justify-center mr-3">
<span className="text-xs font-medium text-primary-foreground">
{user.display_name?.charAt(0)?.toUpperCase() || user.email?.charAt(0)?.toUpperCase()}
</span>
</div>
<div>
<div className="font-medium">{user.display_name || 'No name'}</div>
{user.user_id === currentUser?.id && (
<Badge variant="outline" className="text-xs mt-1">You</Badge>
)}
</div>
</div>
</TableCell>
<TableCell>
<div className="flex items-center">
<Mail className="h-3 w-3 mr-1 text-muted-foreground" />
{user.email}
</div>
</TableCell>
<TableCell>
<Badge variant={getRoleVariant(user.role)} className="flex items-center w-fit">
{getRoleIcon(user.role)}
<span className="ml-1 capitalize">{user.role}</span>
</Badge>
</TableCell>
<TableCell>
<div className="flex items-center text-sm text-muted-foreground">
<Calendar className="h-3 w-3 mr-1" />
{format(new Date(user.created_at), 'MMM dd, yyyy')}
</div>
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
{user.user_id !== currentUser?.id && (
<>
<DropdownMenuItem asChild>
<Select onValueChange={(value) => handleRoleChange(user.user_id, value as 'admin' | 'finance' | 'viewer')}>
<SelectTrigger className="w-full border-0 p-0 h-auto">
<div className="flex items-center w-full">
<Edit className="mr-2 h-4 w-4" />
Change Role
</div>
</SelectTrigger>
<SelectContent>
<SelectItem value="viewer">Viewer</SelectItem>
<SelectItem value="finance">Finance</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
</DropdownMenuItem>
<DropdownMenuSeparator />
<AlertDialog>
<AlertDialogTrigger asChild>
<DropdownMenuItem onSelect={(e) => e.preventDefault()} className="text-red-600">
<Trash2 className="mr-2 h-4 w-4" />
Delete User
</DropdownMenuItem>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete User</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete {user.display_name || user.email}?
This action cannot be undone and will permanently remove their account and all associated data.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => handleDeleteUser(user.user_id, user.display_name || user.email || 'User')}
className="bg-red-600 hover:bg-red-700"
>
Delete User
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)}
{user.user_id === currentUser?.id && (
<DropdownMenuItem disabled>
Cannot modify your own account
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,335 @@
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Asset, useAssets } from '@/hooks/useAssets';
const assetSchema = z.object({
asset_code: z.string().min(1, 'Asset code is required'),
name: z.string().min(1, 'Asset name is required'),
description: z.string().optional(),
asset_category_id: z.string().optional(),
condition: z.enum(['new', 'used', 'refurbished', 'damaged']),
status: z.enum(['in_use', 'available', 'maintenance', 'disposed', 'lost']),
assigned_to_department: z.string().optional(),
location_id: z.string().optional(),
purchase_cost: z.number().min(0, 'Purchase cost must be positive'),
current_book_value: z.number().min(0, 'Book value must be positive'),
model_number: z.string().optional(),
serial_number: z.string().optional(),
sub_category: z.string().optional(),
});
type AssetFormData = z.infer<typeof assetSchema>;
interface AssetFormProps {
asset?: Asset;
onSuccess?: () => void;
onCancel?: () => void;
}
export function AssetForm({ asset, onSuccess, onCancel }: AssetFormProps) {
const { createAsset, updateAsset, categories, locations, isCreating, isUpdating } = useAssets();
const [isSubmitting, setIsSubmitting] = useState(false);
const form = useForm<AssetFormData>({
resolver: zodResolver(assetSchema),
defaultValues: {
asset_code: asset?.asset_code || '',
name: asset?.name || '',
description: asset?.description || '',
asset_category_id: asset?.asset_category_id || '',
condition: asset?.condition || 'new',
status: asset?.status || 'available',
assigned_to_department: asset?.assigned_to_department || '',
location_id: asset?.location_id || '',
purchase_cost: asset?.purchase_cost || 0,
current_book_value: asset?.current_book_value || 0,
model_number: asset?.model_number || '',
serial_number: asset?.serial_number || '',
sub_category: asset?.sub_category || '',
},
});
const onSubmit = async (data: AssetFormData) => {
setIsSubmitting(true);
try {
if (asset) {
updateAsset({ id: asset.id, ...data } as Partial<Asset> & { id: string });
} else {
createAsset(data as Omit<Asset, 'id' | 'user_id' | 'created_at' | 'updated_at'>);
}
onSuccess?.();
} catch (error) {
console.error('Error saving asset:', error);
} finally {
setIsSubmitting(false);
}
};
return (
<Card className="w-full max-w-2xl mx-auto">
<CardHeader>
<CardTitle>{asset ? 'Edit Asset' : 'Create New Asset'}</CardTitle>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="asset_code"
render={({ field }) => (
<FormItem>
<FormLabel>Asset Code *</FormLabel>
<FormControl>
<Input placeholder="e.g., LAP-001" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Asset Name *</FormLabel>
<FormControl>
<Input placeholder="e.g., Dell Laptop" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="Detailed description of the asset..."
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="asset_category_id"
render={({ field }) => (
<FormItem>
<FormLabel>Category</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select category" />
</SelectTrigger>
</FormControl>
<SelectContent>
{categories?.map((category) => (
<SelectItem key={category.id} value={category.id}>
{category.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="location_id"
render={({ field }) => (
<FormItem>
<FormLabel>Location</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select location" />
</SelectTrigger>
</FormControl>
<SelectContent>
{locations?.map((location) => (
<SelectItem key={location.id} value={location.id}>
{location.location_name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="condition"
render={({ field }) => (
<FormItem>
<FormLabel>Condition</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select condition" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="new">New</SelectItem>
<SelectItem value="used">Used</SelectItem>
<SelectItem value="refurbished">Refurbished</SelectItem>
<SelectItem value="damaged">Damaged</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>Status</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select status" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="available">Available</SelectItem>
<SelectItem value="in_use">In Use</SelectItem>
<SelectItem value="maintenance">Maintenance</SelectItem>
<SelectItem value="disposed">Disposed</SelectItem>
<SelectItem value="lost">Lost</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="purchase_cost"
render={({ field }) => (
<FormItem>
<FormLabel>Purchase Cost</FormLabel>
<FormControl>
<Input
type="number"
step="0.01"
{...field}
onChange={(e) => field.onChange(parseFloat(e.target.value) || 0)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="current_book_value"
render={({ field }) => (
<FormItem>
<FormLabel>Current Book Value</FormLabel>
<FormControl>
<Input
type="number"
step="0.01"
{...field}
onChange={(e) => field.onChange(parseFloat(e.target.value) || 0)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="model_number"
render={({ field }) => (
<FormItem>
<FormLabel>Model Number</FormLabel>
<FormControl>
<Input placeholder="e.g., Latitude 5520" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="serial_number"
render={({ field }) => (
<FormItem>
<FormLabel>Serial Number</FormLabel>
<FormControl>
<Input placeholder="e.g., DL5520123456" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex justify-end space-x-2">
{onCancel && (
<Button type="button" variant="outline" onClick={onCancel}>
Cancel
</Button>
)}
<Button
type="submit"
disabled={isSubmitting || isCreating || isUpdating}
>
{isSubmitting || isCreating || isUpdating ? 'Saving...' : 'Save Asset'}
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,73 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Package, CheckCircle, Wrench, DollarSign } from 'lucide-react';
import { useAssets } from '@/hooks/useAssets';
export function AssetStats() {
const { assetStats } = useAssets();
const stats = [
{
title: 'Total Assets',
value: assetStats.total,
icon: Package,
description: 'All assets in inventory'
},
{
title: 'In Use',
value: assetStats.inUse,
icon: CheckCircle,
description: 'Currently deployed assets'
},
{
title: 'Available',
value: assetStats.available,
icon: Package,
description: 'Ready for deployment'
},
{
title: 'Maintenance',
value: assetStats.maintenance,
icon: Wrench,
description: 'Under maintenance'
}
];
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{stats.map((stat) => (
<Card key={stat.title}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
{stat.title}
</CardTitle>
<stat.icon className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stat.value}</div>
<p className="text-xs text-muted-foreground">
{stat.description}
</p>
</CardContent>
</Card>
))}
<Card className="md:col-span-2 lg:col-span-4">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Total Asset Value
</CardTitle>
<DollarSign className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
${assetStats.totalValue.toLocaleString()}
</div>
<p className="text-xs text-muted-foreground">
Current book value of all assets
</p>
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,188 @@
import { useState } from 'react';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { MoreHorizontal, Edit, Trash2, Eye, Search } from 'lucide-react';
import { Asset, useAssets } from '@/hooks/useAssets';
const statusColors = {
in_use: 'bg-green-100 text-green-800',
available: 'bg-blue-100 text-blue-800',
maintenance: 'bg-yellow-100 text-yellow-800',
disposed: 'bg-red-100 text-red-800',
lost: 'bg-gray-100 text-gray-800'
};
const conditionColors = {
new: 'bg-emerald-100 text-emerald-800',
used: 'bg-blue-100 text-blue-800',
refurbished: 'bg-purple-100 text-purple-800',
damaged: 'bg-red-100 text-red-800'
};
interface AssetsTableProps {
onEdit?: (asset: Asset) => void;
onView?: (asset: Asset) => void;
searchTerm?: string;
}
export function AssetsTable({ onEdit, onView, searchTerm: externalSearchTerm }: AssetsTableProps) {
const { assets, isLoading, deleteAsset } = useAssets();
const [internalSearchTerm, setInternalSearchTerm] = useState('');
// Use external search term if provided, otherwise use internal
const searchTerm = externalSearchTerm !== undefined ? externalSearchTerm : internalSearchTerm;
const filteredAssets = assets?.filter(asset =>
asset.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
asset.asset_code.toLowerCase().includes(searchTerm.toLowerCase()) ||
asset.description?.toLowerCase().includes(searchTerm.toLowerCase())
) || [];
if (isLoading) {
return <div>Loading assets...</div>;
}
return (
<div className="space-y-4">
{/* Only show search input if external search is not provided */}
{externalSearchTerm === undefined && (
<div className="flex items-center space-x-2">
<Search className="h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search assets by name, code, or description..."
value={internalSearchTerm}
onChange={(e) => setInternalSearchTerm(e.target.value)}
className="max-w-sm"
/>
</div>
)}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[120px]">Asset Code</TableHead>
<TableHead className="w-[200px]">Name</TableHead>
<TableHead className="w-[120px]">Category</TableHead>
<TableHead className="w-[100px]">Status</TableHead>
<TableHead className="w-[100px]">Condition</TableHead>
<TableHead className="w-[120px]">Location</TableHead>
<TableHead className="w-[120px]">Book Value</TableHead>
<TableHead className="w-[80px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredAssets.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-8">
<div className="text-muted-foreground">
{searchTerm ? 'No assets match your search.' : 'No assets found. Create your first asset!'}
</div>
</TableCell>
</TableRow>
) : (
filteredAssets.map((asset) => (
<TableRow key={asset.id} className="hover:bg-muted/50 transition-colors">
<TableCell className="py-3">
<div className="font-medium text-sm text-blue-600">
{asset.asset_code}
</div>
</TableCell>
<TableCell className="py-3">
<div className="space-y-1">
<div className="font-medium text-sm">{asset.name}</div>
{asset.description && (
<div className="text-xs text-muted-foreground truncate max-w-[180px]">
{asset.description}
</div>
)}
</div>
</TableCell>
<TableCell className="py-3">
<Badge variant="outline" className="text-xs font-normal">
{asset.asset_categories?.name || 'Uncategorized'}
</Badge>
</TableCell>
<TableCell className="py-3">
<Badge
className={`${statusColors[asset.status]} text-xs font-normal`}
variant="secondary"
>
{asset.status.replace('_', ' ')}
</Badge>
</TableCell>
<TableCell className="py-3">
<Badge
className={`${conditionColors[asset.condition]} text-xs font-normal`}
variant="secondary"
>
{asset.condition}
</Badge>
</TableCell>
<TableCell className="py-3">
<div className="text-sm">
{asset.asset_locations?.location_name || 'Not assigned'}
</div>
</TableCell>
<TableCell className="py-3">
<div className="font-semibold text-sm">
${asset.current_book_value.toLocaleString()}
</div>
</TableCell>
<TableCell className="py-3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-7 w-7 p-0 hover:bg-muted">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="bg-background border shadow-md">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
{onView && (
<DropdownMenuItem onClick={() => onView(asset)}>
<Eye className="mr-2 h-4 w-4 text-blue-500" />
View Details
</DropdownMenuItem>
)}
{onEdit && (
<DropdownMenuItem onClick={() => onEdit(asset)}>
<Edit className="mr-2 h-4 w-4 text-green-500" />
Edit Asset
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => deleteAsset(asset.id)}
className="text-red-600"
>
<Trash2 className="mr-2 h-4 w-4 text-red-500" />
Delete Asset
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
);
}

View File

@ -0,0 +1,352 @@
import React, { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { AlertCircle, Eye, EyeOff, Mail, Lock, User, RefreshCw } from 'lucide-react';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { useAuth } from '@/hooks/useAuth';
const loginSchema = z.object({
email: z.string().email('Please enter a valid email address'),
password: z.string().min(6, 'Password must be at least 6 characters'),
});
const signupSchema = z.object({
email: z.string().email('Please enter a valid email address'),
password: z.string().min(6, 'Password must be at least 6 characters'),
confirmPassword: z.string().min(6, 'Please confirm your password'),
displayName: z.string().min(2, 'Display name must be at least 2 characters'),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
});
type LoginFormData = z.infer<typeof loginSchema>;
type SignupFormData = z.infer<typeof signupSchema>;
interface AuthFormsProps {
onClose?: () => void;
}
export const AuthForms: React.FC<AuthFormsProps> = ({ onClose }) => {
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [authError, setAuthError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [activeTab, setActiveTab] = useState('login');
// Use the auth context properly
const authContext = useAuth();
if (!authContext) {
throw new Error('AuthForms must be used within an AuthProvider');
}
const { signIn, signUp } = authContext;
const loginForm = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
defaultValues: {
email: '',
password: '',
},
});
const signupForm = useForm<SignupFormData>({
resolver: zodResolver(signupSchema),
defaultValues: {
email: '',
password: '',
confirmPassword: '',
displayName: '',
},
});
const onLogin = async (data: LoginFormData) => {
setIsLoading(true);
setAuthError(null);
try {
const { error } = await signIn(data.email, data.password);
if (error) {
setAuthError(error.message);
} else {
onClose?.();
}
} catch (error) {
setAuthError('An unexpected error occurred');
} finally {
setIsLoading(false);
}
};
const onSignup = async (data: SignupFormData) => {
setIsLoading(true);
setAuthError(null);
try {
const { error } = await signUp(data.email, data.password, {
data: {
display_name: data.displayName,
}
});
if (error) {
setAuthError(error.message);
} else {
setAuthError(null);
setActiveTab('login');
// Show success message
setAuthError('Account created successfully! Please check your email to verify your account, then log in.');
}
} catch (error) {
setAuthError('An unexpected error occurred');
} finally {
setIsLoading(false);
}
};
return (
<Card className="w-full max-w-md mx-auto">
<CardHeader className="text-center">
<CardTitle className="text-2xl font-bold">Welcome to Page Builder</CardTitle>
<CardDescription>
Sign in to your account or create a new one to start building amazing pages
</CardDescription>
</CardHeader>
<CardContent>
{authError && (
<Alert className={`mb-4 ${authError.includes('successfully') ? 'border-green-200 bg-green-50' : ''}`}>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
{authError}
</AlertDescription>
</Alert>
)}
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="login">Sign In</TabsTrigger>
<TabsTrigger value="signup">Sign Up</TabsTrigger>
</TabsList>
<TabsContent value="login" className="space-y-4">
<Form {...loginForm}>
<form onSubmit={loginForm.handleSubmit(onLogin)} className="space-y-4">
<FormField
control={loginForm.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<div className="relative">
<Mail className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
placeholder="your@email.com"
className="pl-10"
{...field}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={loginForm.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<div className="relative">
<Lock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
type={showPassword ? 'text' : 'password'}
placeholder="Your password"
className="pl-10 pr-10"
{...field}
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
className="w-full"
disabled={isLoading}
>
{isLoading ? (
<>
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
Signing in...
</>
) : (
'Sign In'
)}
</Button>
</form>
</Form>
</TabsContent>
<TabsContent value="signup" className="space-y-4">
<Form {...signupForm}>
<form onSubmit={signupForm.handleSubmit(onSignup)} className="space-y-4">
<FormField
control={signupForm.control}
name="displayName"
render={({ field }) => (
<FormItem>
<FormLabel>Display Name</FormLabel>
<FormControl>
<div className="relative">
<User className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Your display name"
className="pl-10"
{...field}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={signupForm.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<div className="relative">
<Mail className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
placeholder="your@email.com"
className="pl-10"
{...field}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={signupForm.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<div className="relative">
<Lock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
type={showPassword ? 'text' : 'password'}
placeholder="Create a password"
className="pl-10 pr-10"
{...field}
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={signupForm.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Confirm Password</FormLabel>
<FormControl>
<div className="relative">
<Lock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
type={showConfirmPassword ? 'text' : 'password'}
placeholder="Confirm your password"
className="pl-10 pr-10"
{...field}
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
>
{showConfirmPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
className="w-full"
disabled={isLoading}
>
{isLoading ? (
<>
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
Creating account...
</>
) : (
'Create Account'
)}
</Button>
</form>
</Form>
</TabsContent>
</Tabs>
</CardContent>
</Card>
);
};

View File

@ -0,0 +1,125 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { supabase } from '@/integrations/supabase/client';
import { useToast } from '@/hooks/use-toast';
export function LoginForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [isSignUp, setIsSignUp] = useState(false);
const { toast } = useToast();
const handleAuth = async (type: 'signin' | 'signup') => {
setLoading(true);
try {
if (type === 'signup') {
const { error } = await supabase.auth.signUp({ email, password });
if (error) throw error;
toast({
title: 'Check your email',
description: 'We sent you a confirmation link.',
});
} else {
const { error } = await supabase.auth.signInWithPassword({ email, password });
if (error) throw error;
toast({
title: 'Welcome back!',
description: 'You have successfully signed in.',
});
}
} catch (error: any) {
toast({
title: 'Error',
description: error.message,
variant: 'destructive',
});
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle className="text-2xl font-bold">SubsTracker</CardTitle>
<CardDescription>Manage your subscriptions and renewals</CardDescription>
</CardHeader>
<CardContent>
<Tabs value={isSignUp ? 'signup' : 'signin'} onValueChange={(value) => setIsSignUp(value === 'signup')}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="signin">Sign In</TabsTrigger>
<TabsTrigger value="signup">Sign Up</TabsTrigger>
</TabsList>
<TabsContent value="signin" className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="Enter your email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<Button
className="w-full"
onClick={() => handleAuth('signin')}
disabled={loading}
>
{loading ? 'Signing In...' : 'Sign In'}
</Button>
</TabsContent>
<TabsContent value="signup" className="space-y-4">
<div className="space-y-2">
<Label htmlFor="signup-email">Email</Label>
<Input
id="signup-email"
type="email"
placeholder="Enter your email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="signup-password">Password</Label>
<Input
id="signup-password"
type="password"
placeholder="Create a password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<Button
className="w-full"
onClick={() => handleAuth('signup')}
disabled={loading}
>
{loading ? 'Creating Account...' : 'Create Account'}
</Button>
</TabsContent>
</Tabs>
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,409 @@
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '@/components/ui/table';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import {
Plus,
Search,
MoreHorizontal,
Edit,
Trash2,
Tag,
ArrowUp,
ArrowDown
} from 'lucide-react';
import { useAllCategories, useDeleteCategory, useUpdateCategory } from '@/hooks/useCategories';
import { CategoryForm } from './CategoryForm';
import { useToast } from '@/hooks/use-toast';
import { usePermissions } from '@/hooks/usePermissions';
export function CategoriesTable() {
const [searchTerm, setSearchTerm] = useState('');
const [editingCategory, setEditingCategory] = useState<string | null>(null);
const [showAddDialog, setShowAddDialog] = useState(false);
const { toast } = useToast();
const queryClient = useQueryClient();
const { canManageData } = usePermissions();
const { data: categories, isLoading } = useAllCategories();
console.log('CategoriesTable: State:', { count: categories?.length, isLoading });
const deleteCategory = useDeleteCategory();
const updateCategory = useUpdateCategory();
// Add timeout fallback - moved before any conditional returns
React.useEffect(() => {
const timeout = setTimeout(() => {
if (isLoading) {
console.warn('Categories still loading after 10 seconds');
}
}, 10000);
return () => clearTimeout(timeout);
}, [isLoading]);
const filteredCategories = categories?.filter(category =>
category.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
category.description?.toLowerCase().includes(searchTerm.toLowerCase())
) || [];
const handleDelete = async (id: string, name: string) => {
if (window.confirm(`Are you sure you want to delete the category "${name}"?`)) {
try {
await deleteCategory.mutateAsync(id);
toast({
title: 'Category deleted',
description: `"${name}" has been deleted successfully.`,
});
} catch (error: any) {
toast({
title: 'Error',
description: error.message,
variant: 'destructive',
});
}
}
};
const handleToggleActive = async (id: string, isActive: boolean) => {
try {
await updateCategory.mutateAsync({
id,
updates: { is_active: !isActive }
});
toast({
title: 'Category updated',
description: `Category ${!isActive ? 'activated' : 'deactivated'} successfully.`,
});
} catch (error: any) {
toast({
title: 'Error',
description: error.message,
variant: 'destructive',
});
}
};
const handleMove = async (id: string, direction: 'up' | 'down') => {
const categoryIndex = filteredCategories.findIndex(c => c.id === id);
const category = filteredCategories[categoryIndex];
const targetIndex = direction === 'up' ? categoryIndex - 1 : categoryIndex + 1;
const targetCategory = filteredCategories[targetIndex];
if (!targetCategory) return;
try {
await Promise.all([
updateCategory.mutateAsync({
id: category.id,
updates: { sort_order: targetCategory.sort_order }
}),
updateCategory.mutateAsync({
id: targetCategory.id,
updates: { sort_order: category.sort_order }
})
]);
toast({
title: 'Category order updated',
description: 'Categories have been reordered successfully.',
});
} catch (error: any) {
toast({
title: 'Error',
description: error.message,
variant: 'destructive',
});
}
};
if (isLoading) {
console.log('CategoriesTable: Still loading...');
return (
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
<p className="ml-2 text-muted-foreground">Loading categories...</p>
</div>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader className="p-4 md:p-6">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<CardTitle className="flex items-center text-lg md:text-xl">
<Tag className="mr-2 h-4 w-4 md:h-5 md:w-5" />
Categories
</CardTitle>
{canManageData && (
<Dialog open={showAddDialog} onOpenChange={setShowAddDialog}>
<DialogTrigger asChild>
<Button size="sm" className="w-full md:w-auto">
<Plus className="mr-2 h-4 w-4" />
Add Category
</Button>
</DialogTrigger>
<DialogContent className="mx-3 md:mx-auto">
<DialogHeader>
<DialogTitle>Add New Category</DialogTitle>
</DialogHeader>
<CategoryForm onSuccess={() => setShowAddDialog(false)} />
</DialogContent>
</Dialog>
)}
</div>
<div className="flex items-center space-x-2 md:space-x-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search categories..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 text-sm"
/>
</div>
</div>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
<Table>
<TableHeader className="hidden md:table-header-group">
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Description</TableHead>
<TableHead>Status</TableHead>
<TableHead>Order</TableHead>
<TableHead className="w-[100px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredCategories.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-8 md:py-12">
<div className="text-muted-foreground text-sm md:text-base">
{searchTerm ? 'No categories match your search' : 'No categories found.'}
</div>
</TableCell>
</TableRow>
) : (
filteredCategories.map((category, index) => (
<TableRow key={category.id} className="border-b md:table-row block mb-4 md:mb-0">
{/* Mobile Card Layout */}
<TableCell className="md:hidden p-4 block">
<div className="space-y-3">
{/* Category Name and Icon */}
<div className="flex items-center justify-between">
<div className="flex items-center">
{category.icon && <span className="mr-2 text-lg">{category.icon}</span>}
<span className="font-medium text-base">{category.name}</span>
</div>
<Badge variant={category.is_active ? 'default' : 'secondary'} className="text-xs">
{category.is_active ? 'Active' : 'Inactive'}
</Badge>
</div>
{/* Description */}
{category.description && (
<p className="text-sm text-muted-foreground">{category.description}</p>
)}
{/* Order and Actions */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<span className="text-sm text-muted-foreground">Order: {category.sort_order}</span>
<div className="flex space-x-1">
<Button
size="sm"
variant="outline"
className="h-7 w-7 p-0"
onClick={() => handleMove(category.id, 'up')}
disabled={index === 0}
>
<ArrowUp className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="outline"
className="h-7 w-7 p-0"
onClick={() => handleMove(category.id, 'down')}
disabled={index === filteredCategories.length - 1}
>
<ArrowDown className="h-3 w-3" />
</Button>
</div>
</div>
{canManageData && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<Dialog>
<DialogTrigger asChild>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<Edit className="mr-2 h-4 w-4" />
Edit
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="mx-3 md:mx-auto">
<DialogHeader>
<DialogTitle>Edit Category</DialogTitle>
</DialogHeader>
<CategoryForm
category={category}
onSuccess={() => setEditingCategory(null)}
/>
</DialogContent>
</Dialog>
<DropdownMenuItem
onClick={() => handleToggleActive(category.id, category.is_active)}
>
{category.is_active ? 'Deactivate' : 'Activate'}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDelete(category.id, category.name)}
className="text-red-600"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
</TableCell>
{/* Desktop Table Layout */}
<TableCell className="font-medium hidden md:table-cell">
<div className="flex items-center">
{category.icon && <span className="mr-2">{category.icon}</span>}
{category.name}
</div>
</TableCell>
<TableCell className="hidden md:table-cell">
{category.description ? (
<span className="text-sm text-muted-foreground">
{category.description}
</span>
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableCell className="hidden md:table-cell">
<Badge variant={category.is_active ? 'default' : 'secondary'}>
{category.is_active ? 'Active' : 'Inactive'}
</Badge>
</TableCell>
<TableCell className="hidden md:table-cell">
<div className="flex items-center space-x-2">
<span className="text-sm">{category.sort_order}</span>
<div className="flex flex-col space-y-1">
<Button
size="sm"
variant="outline"
className="h-6 w-6 p-0"
onClick={() => handleMove(category.id, 'up')}
disabled={index === 0}
>
<ArrowUp className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="outline"
className="h-6 w-6 p-0"
onClick={() => handleMove(category.id, 'down')}
disabled={index === filteredCategories.length - 1}
>
<ArrowDown className="h-3 w-3" />
</Button>
</div>
</div>
</TableCell>
<TableCell className="hidden md:table-cell">
{canManageData ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<Dialog>
<DialogTrigger asChild>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<Edit className="mr-2 h-4 w-4" />
Edit
</DropdownMenuItem>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Category</DialogTitle>
</DialogHeader>
<CategoryForm
category={category}
onSuccess={() => setEditingCategory(null)}
/>
</DialogContent>
</Dialog>
<DropdownMenuItem
onClick={() => handleToggleActive(category.id, category.is_active)}
>
{category.is_active ? 'Deactivate' : 'Activate'}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDelete(category.id, category.name)}
className="text-red-600"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<span className="text-muted-foreground text-sm">View only</span>
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,210 @@
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { useCreateCategory, useUpdateCategory } from '@/hooks/useCategories';
import { useToast } from '@/hooks/use-toast';
import { Category } from '@/lib/types';
const categorySchema = z.object({
name: z.string().min(1, 'Category name is required'),
description: z.string().optional(),
icon: z.string().optional(),
color: z.string().optional(),
is_active: z.boolean().default(true),
sort_order: z.number().default(0),
});
type CategoryFormData = z.infer<typeof categorySchema>;
interface CategoryFormProps {
category?: Category;
onSuccess: () => void;
}
export function CategoryForm({ category, onSuccess }: CategoryFormProps) {
const { toast } = useToast();
const createCategory = useCreateCategory();
const updateCategory = useUpdateCategory();
const form = useForm<CategoryFormData>({
resolver: zodResolver(categorySchema),
defaultValues: {
name: category?.name || '',
description: category?.description || '',
icon: category?.icon || '',
color: category?.color || '',
is_active: category?.is_active ?? true,
sort_order: category?.sort_order || 0,
},
});
const onSubmit = async (data: CategoryFormData) => {
try {
if (category) {
await updateCategory.mutateAsync({
id: category.id,
updates: data
});
toast({
title: 'Category updated',
description: `"${data.name}" has been updated successfully.`,
});
} else {
const categoryData = {
name: data.name,
description: data.description || '',
icon: data.icon || '',
color: data.color || '',
is_active: data.is_active,
sort_order: data.sort_order,
};
await createCategory.mutateAsync(categoryData);
toast({
title: 'Category created',
description: `"${data.name}" has been created successfully.`,
});
}
onSuccess();
} catch (error: any) {
toast({
title: 'Error',
description: error.message,
variant: 'destructive',
});
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Category Name *</FormLabel>
<FormControl>
<Input placeholder="e.g., SaaS Tools, Analytics" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="Brief description of this category..."
className="resize-none"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="icon"
render={({ field }) => (
<FormItem>
<FormLabel>Icon (Emoji)</FormLabel>
<FormControl>
<Input placeholder="📊" {...field} />
</FormControl>
<FormDescription>
Optional emoji to display with the category
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="sort_order"
render={({ field }) => (
<FormItem>
<FormLabel>Sort Order</FormLabel>
<FormControl>
<Input
type="number"
placeholder="0"
{...field}
onChange={(e) => field.onChange(e.target.value ? parseInt(e.target.value) : 0)}
/>
</FormControl>
<FormDescription>
Lower numbers appear first
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="is_active"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">Active</FormLabel>
<FormDescription>
Active categories are available for selection
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<div className="flex items-center space-x-4 pt-4">
<Button
type="submit"
disabled={createCategory.isPending || updateCategory.isPending}
className="min-w-[120px]"
>
{createCategory.isPending || updateCategory.isPending
? (category ? 'Updating...' : 'Creating...')
: (category ? 'Update Category' : 'Create Category')
}
</Button>
<Button
type="button"
variant="outline"
onClick={onSuccess}
>
Cancel
</Button>
</div>
</form>
</Form>
);
}

View File

@ -0,0 +1,189 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import {
DollarSign,
TrendingUp,
Database,
AlertTriangle,
RefreshCw,
Activity
} from 'lucide-react';
import { useServiceStats } from '@/hooks/useServices';
import { formatCurrency } from '@/lib/currency';
export function DashboardStats() {
const { data: stats, isLoading } = useServiceStats();
if (isLoading) {
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{[...Array(8)].map((_, i) => (
<Card key={i} className="animate-pulse">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div className="h-4 bg-muted rounded w-24"></div>
<div className="h-4 w-4 bg-muted rounded"></div>
</CardHeader>
<CardContent>
<div className="h-8 bg-muted rounded w-20 mb-1"></div>
<div className="h-3 bg-muted rounded w-32"></div>
</CardContent>
</Card>
))}
</div>
);
}
return (
<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">Monthly Spend</CardTitle>
<DollarSign className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{stats?.totalMonthlySpend?.byCurrency?.map((curr, index) => (
<div key={curr.currency}>
{formatCurrency(curr.amount, curr.currency as 'INR' | 'USD' | 'EUR')}
{index < (stats.totalMonthlySpend?.byCurrency?.length || 0) - 1 && <span className="text-lg"> + </span>}
</div>
)) || formatCurrency(0, 'INR')}
</div>
<p className="text-xs text-muted-foreground">
Current month active subscriptions
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Projected Yearly</CardTitle>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{stats?.projectedYearlySpend?.byCurrency?.map((curr, index) => (
<div key={curr.currency}>
{formatCurrency(curr.amount, curr.currency as 'INR' | 'USD' | 'EUR')}
{index < (stats.projectedYearlySpend?.byCurrency?.length || 0) - 1 && <span className="text-lg"> + </span>}
</div>
)) || formatCurrency(0, 'INR')}
</div>
<p className="text-xs text-muted-foreground">
Based on current subscriptions
</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 Spent</CardTitle>
<DollarSign className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{stats?.totalSpent?.byCurrency?.map((curr, index) => (
<div key={curr.currency}>
{formatCurrency(curr.amount, curr.currency as 'INR' | 'USD' | 'EUR')}
{index < (stats.totalSpent?.byCurrency?.length || 0) - 1 && <span className="text-lg"> + </span>}
</div>
)) || formatCurrency(0, 'INR')}
</div>
<p className="text-xs text-muted-foreground">
All time actual payments
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">This Year Spent</CardTitle>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{stats?.yearlySpent?.byCurrency?.map((curr, index) => (
<div key={curr.currency}>
{formatCurrency(curr.amount, curr.currency as 'INR' | 'USD' | 'EUR')}
{index < (stats.yearlySpent?.byCurrency?.length || 0) - 1 && <span className="text-lg"> + </span>}
</div>
)) || formatCurrency(0, 'INR')}
</div>
<p className="text-xs text-muted-foreground">
{new Date().getFullYear()} actual payments
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Active Services</CardTitle>
<Database className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-emerald-600">
{stats?.activeServices || 0}
</div>
<p className="text-xs text-muted-foreground">
of {stats?.totalServices || 0} total services
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Expired Services</CardTitle>
<AlertTriangle className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-destructive">
{stats?.expiredServices || 0}
</div>
<p className="text-xs text-muted-foreground">
Need immediate attention
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Auto Renewal</CardTitle>
<RefreshCw className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-primary">
{stats?.autoRenewServices || 0}
</div>
<p className="text-xs text-muted-foreground">
Services with auto-renewal enabled
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Health Score</CardTitle>
<Activity className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="flex items-center space-x-2">
<div className="text-2xl font-bold">
{stats?.totalServices ? Math.round((stats.activeServices / stats.totalServices) * 100) : 0}%
</div>
<Badge variant={
stats?.totalServices && (stats.activeServices / stats.totalServices) > 0.8 ? "default" :
stats?.totalServices && (stats.activeServices / stats.totalServices) > 0.6 ? "secondary" : "destructive"
}>
{stats?.totalServices && (stats.activeServices / stats.totalServices) > 0.8 ? "Excellent" :
stats?.totalServices && (stats.activeServices / stats.totalServices) > 0.6 ? "Good" : "Needs Attention"}
</Badge>
</div>
<p className="text-xs text-muted-foreground">
Active vs total services ratio
</p>
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,163 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { ExternalLink, Calendar, DollarSign } from 'lucide-react';
import { useUpcomingRenewals, useOverdueServices } from '@/hooks/useServices';
import { format } from 'date-fns';
import { formatCurrency } from '@/lib/currency';
export function UpcomingRenewals() {
const { data: upcoming7Days } = useUpcomingRenewals(7);
const { data: upcoming30Days } = useUpcomingRenewals(30);
const { data: overdue } = useOverdueServices();
const getUrgencyColor = (renewalDate: string) => {
const days = Math.ceil((new Date(renewalDate).getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24));
if (days < 0) return 'destructive';
if (days <= 7) return 'destructive';
if (days <= 30) return 'secondary';
return 'default';
};
const getUrgencyLabel = (renewalDate: string) => {
const days = Math.ceil((new Date(renewalDate).getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24));
if (days < 0) return `${Math.abs(days)} days overdue`;
if (days === 0) return 'Due today';
if (days === 1) return 'Due tomorrow';
return `${days} days`;
};
return (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{/* Overdue Services */}
<Card className="border-destructive/30 bg-[hsl(var(--destructive)/0.08)] dark:bg-[hsl(var(--destructive)/0.12)]">
<CardHeader className="pb-4">
<CardTitle className="text-lg text-destructive flex items-center">
<Calendar className="mr-2 h-5 w-5" />
Overdue ({overdue?.length || 0})
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{overdue?.slice(0, 5).map((service) => (
<div key={service.id} className="flex items-center justify-between p-3 bg-background rounded-lg border">
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">{service.service_name}</p>
<p className="text-xs text-muted-foreground">{service.provider}</p>
<div className="flex items-center space-x-2 mt-1">
<Badge variant="destructive" className="text-xs">
{getUrgencyLabel(service.next_renewal_date!)}
</Badge>
<span className="text-xs font-medium">
{formatCurrency(service.next_renewal_amount ?? service.amount, service.currency)}
</span>
</div>
</div>
{service.dashboard_url && (
<Button size="sm" variant="outline" asChild>
<a href={service.dashboard_url} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-3 w-3" />
</a>
</Button>
)}
</div>
))}
{(overdue?.length || 0) === 0 && (
<p className="text-sm text-muted-foreground text-center py-4">
No overdue services
</p>
)}
</CardContent>
</Card>
{/* Next 7 Days */}
<Card className="border-accent/30 bg-[hsl(var(--accent)/0.08)] dark:bg-[hsl(var(--accent)/0.12)]">
<CardHeader className="pb-4">
<CardTitle className="text-lg text-accent flex items-center">
<Calendar className="mr-2 h-5 w-5" />
Next 7 Days ({upcoming7Days?.filter(s => !overdue?.find(o => o.id === s.id))?.length || 0})
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{upcoming7Days?.filter(s => !overdue?.find(o => o.id === s.id)).slice(0, 5).map((service) => (
<div key={service.id} className="flex items-center justify-between p-3 bg-background rounded-lg border">
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">{service.service_name}</p>
<p className="text-xs text-muted-foreground">{service.provider}</p>
<div className="flex items-center space-x-2 mt-1">
<Badge variant={getUrgencyColor(service.next_renewal_date!)} className="text-xs">
{format(new Date(service.next_renewal_date!), 'MMM dd')}
</Badge>
<span className="text-xs font-medium">
{formatCurrency(service.next_renewal_amount ?? service.amount, service.currency)}
</span>
</div>
</div>
{service.dashboard_url && (
<Button size="sm" variant="outline" asChild>
<a href={service.dashboard_url} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-3 w-3" />
</a>
</Button>
)}
</div>
))}
{(upcoming7Days?.filter(s => !overdue?.find(o => o.id === s.id))?.length || 0) === 0 && (
<p className="text-sm text-muted-foreground text-center py-4">
No renewals in next 7 days
</p>
)}
</CardContent>
</Card>
{/* Next 30 Days */}
<Card className="border-secondary/30 bg-[hsl(var(--secondary)/0.08)] dark:bg-[hsl(var(--secondary)/0.12)]">
<CardHeader className="pb-4">
<CardTitle className="text-lg text-secondary flex items-center">
<Calendar className="mr-2 h-5 w-5" />
Next 30 Days ({upcoming30Days?.filter(s =>
!overdue?.find(o => o.id === s.id) &&
!upcoming7Days?.find(u => u.id === s.id)
)?.length || 0})
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{upcoming30Days?.filter(s =>
!overdue?.find(o => o.id === s.id) &&
!upcoming7Days?.find(u => u.id === s.id)
).slice(0, 5).map((service) => (
<div key={service.id} className="flex items-center justify-between p-3 bg-background rounded-lg border">
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">{service.service_name}</p>
<p className="text-xs text-muted-foreground">{service.provider}</p>
<div className="flex items-center space-x-2 mt-1">
<Badge variant="secondary" className="text-xs">
{format(new Date(service.next_renewal_date!), 'MMM dd')}
</Badge>
<span className="text-xs font-medium">
{formatCurrency(service.next_renewal_amount ?? service.amount, service.currency)}
</span>
</div>
</div>
{service.dashboard_url && (
<Button size="sm" variant="outline" asChild>
<a href={service.dashboard_url} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-3 w-3" />
</a>
</Button>
)}
</div>
))}
{(upcoming30Days?.filter(s =>
!overdue?.find(o => o.id === s.id) &&
!upcoming7Days?.find(u => u.id === s.id)
)?.length || 0) === 0 && (
<p className="text-sm text-muted-foreground text-center py-4">
No renewals in next 30 days
</p>
)}
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,253 @@
import { useEffect, useState } from 'react';
import { useAuth } from '@/hooks/useAuth';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarHeader,
SidebarFooter,
useSidebar,
} from '@/components/ui/sidebar';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import {
BarChart3,
CreditCard,
Database,
Home,
LogOut,
Users,
User,
Tag,
Shield,
Package,
FileText,
ChevronRight,
Repeat,
TrendingDown,
Puzzle,
Building
} from 'lucide-react';
import { Link, useLocation } from 'react-router-dom';
import { supabase } from '@/integrations/supabase/client';
import { moduleManager } from '@/modules';
// Main navigation
const mainNavigation = [];
// Subscription Management Module
const subscriptionNavigation = [];
// Asset Management Module
const assetNavigation = [
{ name: 'Assets & Depreciation', href: '/assets-dashboard', icon: TrendingDown },
];
// Shared Resources Module
const sharedNavigation = [
{ name: 'Vendors', href: '/vendors', icon: Users },
];
const adminNavigation = [
{ name: 'User Management', href: '/admin/users', icon: Users },
{ name: 'Company Settings', href: '/admin/company-settings', icon: Building },
{ name: 'System Settings', href: '/admin/settings', icon: Shield },
{ name: 'Module Manager', href: '/admin/modules', icon: Puzzle },
];
export function AppSidebar() {
const { user, profile, signOut } = useAuth();
const location = useLocation();
const sidebar = useSidebar();
const [isMobile, setIsMobile] = useState(false);
const [subscriptionsOpen, setSubscriptionsOpen] = useState(false);
// Get enabled modules from module manager
const enabledModules = moduleManager.getEnabledModules();
// Check if we're on mobile screen size
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 768);
};
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
const isActive = (path: string) => location.pathname === path;
const isInSubscriptionSection = location.pathname.includes('/subscription-management') && location.pathname !== '/subscription-management';
// Show text on desktop or when sidebar is open on mobile
const showText = !isMobile || sidebar.open;
// Keep subscriptions open if user is in any subscription route
useEffect(() => {
if (isInSubscriptionSection) {
setSubscriptionsOpen(true);
}
}, [isInSubscriptionSection]);
return (
<Sidebar className="border-r border-border">
<SidebarHeader className="border-b border-border p-4">
<div className="flex items-center">
<h1 className={`font-bold text-foreground transition-all duration-200 ${
showText ? 'opacity-100' : 'opacity-0 hidden'
}`}>
Business Manager
</h1>
{!showText && (
<div className="h-8 w-8 rounded bg-primary flex items-center justify-center">
<span className="text-sm font-bold text-primary-foreground">B</span>
</div>
)}
</div>
</SidebarHeader>
<SidebarContent>
{/* Dynamic Module Navigation */}
{enabledModules.map((module) => (
<SidebarGroup key={module.id}>
<SidebarGroupLabel className={showText ? '' : 'sr-only'}>
{module.name}
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{module.sidebarItems.map((item, index) => {
const IconComponent = item.icon;
return (
<SidebarMenuItem key={`${module.id}-${index}`}>
<SidebarMenuButton
asChild
isActive={isActive(item.href)}
className="w-full"
>
<Link to={item.href}>
<IconComponent className="h-4 w-4" />
{showText && <span>{item.name}</span>}
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
))}
{/* Shared Resources */}
<SidebarGroup>
<SidebarGroupLabel className={showText ? '' : 'sr-only'}>
Shared
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{sharedNavigation.map((item) => (
<SidebarMenuItem key={item.name}>
<SidebarMenuButton
asChild
isActive={isActive(item.href)}
className="w-full"
>
<Link to={item.href}>
<item.icon className="h-4 w-4" />
{showText && <span>{item.name}</span>}
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
{/* Administration (Admin only) */}
{profile?.role === 'admin' && (
<SidebarGroup>
<SidebarGroupLabel className={showText ? '' : 'sr-only'}>
Administration
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{adminNavigation.map((item) => (
<SidebarMenuItem key={item.name}>
<SidebarMenuButton
asChild
isActive={isActive(item.href)}
className="w-full"
>
<Link to={item.href}>
<item.icon className="h-4 w-4" />
{showText && <span>{item.name}</span>}
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
)}
</SidebarContent>
<SidebarFooter className="border-t border-border p-4">
<div className={`space-y-3 ${!showText ? 'items-center' : ''}`}>
{/* User Info */}
<div className={`flex items-center ${!showText ? 'justify-center' : 'space-x-3'}`}>
<div className="h-8 w-8 rounded-full bg-primary flex items-center justify-center flex-shrink-0">
<span className="text-xs font-medium text-primary-foreground">
{user?.email?.charAt(0).toUpperCase()}
</span>
</div>
{showText && (
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground truncate">
{profile?.display_name || user?.email}
</p>
<Badge variant="secondary" className="text-xs capitalize">
{profile?.role || 'viewer'}
</Badge>
</div>
)}
</div>
{/* Action Buttons */}
<div className={`space-y-2 ${!showText ? 'w-8' : ''}`}>
<Button
variant="outline"
size="sm"
className={`${!showText ? 'w-8 h-8 p-0' : 'w-full justify-start'}`}
asChild
>
<Link to="/profile">
<User className="h-4 w-4" />
{showText && <span className="ml-2">Profile</span>}
</Link>
</Button>
<Button
variant="outline"
size="sm"
className={`${!showText ? 'w-8 h-8 p-0' : 'w-full justify-start'}`}
onClick={signOut}
>
<LogOut className="h-4 w-4" />
{showText && <span className="ml-2">Sign Out</span>}
</Button>
</div>
</div>
</SidebarFooter>
</Sidebar>
);
}

View File

@ -0,0 +1,86 @@
import React from 'react';
import { useAuth } from '@/hooks/useAuth';
import { LoginForm } from '@/components/auth/LoginForm';
import { SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar';
import { AppSidebar } from './AppSidebar';
import { ThemeToggle } from './ThemeToggle';
import { Menu } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
interface LayoutProps {
children: React.ReactNode;
}
export function Layout({ children }: LayoutProps) {
const { user, loading } = useAuth();
const { data: settings } = useQuery({
queryKey: ['system-settings'],
queryFn: async () => {
const { data } = await supabase
.from('system_settings')
.select('application_name')
.single();
return data as { application_name: string } | null;
},
});
console.log('Layout render:', { user: !!user, loading, timestamp: new Date().toISOString() });
// Add timeout to prevent infinite loading
const [timeoutReached, setTimeoutReached] = React.useState(false);
React.useEffect(() => {
const timeout = setTimeout(() => {
if (loading) {
console.warn('Auth loading timeout reached - forcing continue');
setTimeoutReached(true);
}
}, 5000); // 5 second timeout
return () => clearTimeout(timeout);
}, [loading]);
if (loading && !timeoutReached) {
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
<p className="mt-2 text-muted-foreground">Loading...</p>
<p className="text-xs text-muted-foreground mt-2">If this takes too long, try refreshing</p>
</div>
</div>
);
}
if (!user && !timeoutReached) {
console.log('No user found, showing login form');
return <LoginForm />;
}
return (
<SidebarProvider defaultOpen={true}>
<div className="min-h-screen flex w-full bg-background">
{/* Header with hamburger menu - visible on all screen sizes */}
<header className="fixed top-0 left-0 right-0 z-50 h-14 flex items-center justify-between bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b border-border px-4">
<div className="flex items-center gap-2">
<SidebarTrigger className="flex items-center gap-2">
<Menu className="h-5 w-5" />
</SidebarTrigger>
<span className="font-semibold">{settings?.application_name || 'SubsTracker'}</span>
</div>
<div className="flex items-center gap-2">
<ThemeToggle />
</div>
</header>
<AppSidebar />
<main className="flex-1 overflow-auto pt-14">
{children}
</main>
</div>
</SidebarProvider>
);
}

View File

@ -0,0 +1,132 @@
import { useAuth } from '@/hooks/useAuth';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
BarChart3,
CreditCard,
Database,
Home,
LogOut,
Settings,
Users,
User,
Tag,
Layout
} from 'lucide-react';
import { Link, useLocation } from 'react-router-dom';
const navigation = [
{ name: 'Home', href: '/home', icon: Home },
{ name: 'Services', href: '/services', icon: Database },
{ name: 'Payments', href: '/payments', icon: CreditCard },
{ name: 'Vendors', href: '/vendors', icon: Users },
{ name: 'Categories', href: '/categories', icon: Tag },
{ name: 'Reports', href: '/reports', icon: BarChart3 },
{ name: 'Page Builder', href: '/page-builder', icon: Layout },
];
const adminNavigation = [
{ name: 'User Management', href: '/admin/users', icon: Settings },
];
export function Sidebar() {
const { user, profile, signOut } = useAuth();
const location = useLocation();
return (
<div className="flex h-screen w-64 flex-col bg-card border-r border-border">
<div className="flex h-16 items-center px-6 border-b border-border">
<h1 className="text-xl font-bold text-foreground">SubsTracker</h1>
</div>
<nav className="flex-1 space-y-1 px-4 py-4">
{navigation.map((item) => {
const isActive = location.pathname === item.href;
return (
<Link
key={item.name}
to={item.href}
className={`flex items-center px-3 py-2 text-sm font-medium rounded-md transition-colors ${
isActive
? 'bg-primary text-primary-foreground'
: 'text-foreground hover:bg-accent hover:text-accent-foreground'
}`}
>
<item.icon className="mr-3 h-4 w-4" />
{item.name}
</Link>
);
})}
{profile?.role === 'admin' && (
<>
<div className="pt-4 pb-2">
<h3 className="px-3 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Administration
</h3>
</div>
{adminNavigation.map((item) => {
const isActive = location.pathname === item.href;
return (
<Link
key={item.name}
to={item.href}
className={`flex items-center px-3 py-2 text-sm font-medium rounded-md transition-colors ${
isActive
? 'bg-primary text-primary-foreground'
: 'text-foreground hover:bg-accent hover:text-accent-foreground'
}`}
>
<item.icon className="mr-3 h-4 w-4" />
{item.name}
</Link>
);
})}
</>
)}
</nav>
<div className="border-t border-border p-4">
<div className="flex items-center space-x-3 mb-4">
<div className="h-8 w-8 rounded-full bg-primary flex items-center justify-center">
<span className="text-xs font-medium text-primary-foreground">
{user?.email?.charAt(0).toUpperCase()}
</span>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground truncate">
{profile?.display_name || user?.email}
</p>
<div className="flex items-center space-x-2">
<Badge variant="secondary" className="text-xs">
{profile?.role || 'viewer'}
</Badge>
</div>
</div>
</div>
<div className="space-y-2">
<Button
variant="outline"
size="sm"
className="w-full justify-start"
asChild
>
<Link to="/profile">
<User className="mr-2 h-4 w-4" />
Profile Settings
</Link>
</Button>
<Button
variant="outline"
size="sm"
className="w-full justify-start"
onClick={signOut}
>
<LogOut className="mr-2 h-4 w-4" />
Sign Out
</Button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,6 @@
import { ThemeProvider as NextThemesProvider } from "next-themes"
import { type ThemeProviderProps } from "next-themes/dist/types"
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}

View File

@ -0,0 +1,51 @@
import { Moon, Sun, Monitor } from "lucide-react"
import { useTheme } from "next-themes"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
export function ThemeToggle() {
const { setTheme, theme } = useTheme()
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => setTheme("light")}
className="flex items-center gap-2"
>
<Sun className="h-4 w-4" />
Light
{theme === "light" && <span className="ml-auto text-xs"></span>}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setTheme("dark")}
className="flex items-center gap-2"
>
<Moon className="h-4 w-4" />
Dark
{theme === "dark" && <span className="ml-auto text-xs"></span>}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setTheme("system")}
className="flex items-center gap-2"
>
<Monitor className="h-4 w-4" />
System
{theme === "system" && <span className="ml-auto text-xs"></span>}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@ -0,0 +1,352 @@
import React, { useCallback, useMemo } from 'react';
import {
ReactFlow,
Background,
Controls,
MiniMap,
Node,
Edge,
ConnectionMode,
Panel
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
import {
Type,
Image,
MousePointer,
Square,
Save,
FolderOpen,
Trash2,
Plus,
RefreshCw,
Download,
Eye
} from 'lucide-react';
import TextNode from './nodes/TextNode';
import ImageNode from './nodes/ImageNode';
import ButtonNode from './nodes/ButtonNode';
import ContainerNode from './nodes/ContainerNode';
import { usePageBuilder } from '@/hooks/usePageBuilder';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
const nodeTypes = {
text: TextNode,
image: ImageNode,
button: ButtonNode,
container: ContainerNode,
};
interface PageBuilderProps {
onSave?: (nodes: Node[], edges: Edge[]) => void;
onPreview?: () => void;
className?: string;
}
export const PageBuilder: React.FC<PageBuilderProps> = ({
onSave,
onPreview,
className = ''
}) => {
const {
nodes,
edges,
layouts,
onNodesChange,
onEdgesChange,
onConnect,
addNode,
clearCanvas,
saveLayout,
loadLayout,
deleteLayout,
isSaving,
isLoading,
isDeleting
} = usePageBuilder();
const [layoutName, setLayoutName] = React.useState('');
const [selectedLayoutId, setSelectedLayoutId] = React.useState('');
// Handle drag and drop from toolbar
const onDragOver = useCallback((event: React.DragEvent) => {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
}, []);
const onDrop = useCallback(
(event: React.DragEvent) => {
event.preventDefault();
const reactFlowBounds = (event.target as Element).getBoundingClientRect();
const type = event.dataTransfer.getData('application/reactflow');
if (typeof type === 'undefined' || !type) {
return;
}
const position = {
x: event.clientX - reactFlowBounds.left - 125,
y: event.clientY - reactFlowBounds.top - 50,
};
addNode(type, position);
},
[addNode]
);
const onDragStart = (event: React.DragEvent, nodeType: string) => {
event.dataTransfer.setData('application/reactflow', nodeType);
event.dataTransfer.effectAllowed = 'move';
};
const handleSaveLayout = () => {
if (!layoutName.trim()) {
return;
}
saveLayout({
name: layoutName,
nodes,
edges
});
setLayoutName('');
};
const handleLoadLayout = () => {
if (!selectedLayoutId) {
return;
}
loadLayout(selectedLayoutId);
};
const handleDeleteLayout = () => {
if (!selectedLayoutId) {
return;
}
deleteLayout(selectedLayoutId);
setSelectedLayoutId('');
};
const handleExport = () => {
const data = {
nodes,
edges,
timestamp: new Date().toISOString()
};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `page-layout-${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
};
const nodeClassName = useCallback((node: Node) => {
return node.type || 'default';
}, []);
return (
<div className={`flex h-full w-full ${className}`}>
{/* Sidebar - Node Palette & Controls */}
<div className="w-80 bg-gray-50 border-r border-gray-200 flex flex-col">
{/* Node Palette */}
<Card className="m-4 mb-2">
<CardHeader className="pb-3">
<CardTitle className="text-lg">Elements</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid grid-cols-2 gap-2">
{/* Text Node */}
<div
className="flex flex-col items-center p-3 border-2 border-dashed border-gray-300 rounded-lg cursor-grab hover:border-blue-400 hover:bg-blue-50 transition-colors"
draggable
onDragStart={(event) => onDragStart(event, 'text')}
>
<Type className="h-6 w-6 text-gray-600 mb-1" />
<span className="text-xs font-medium">Text</span>
</div>
{/* Image Node */}
<div
className="flex flex-col items-center p-3 border-2 border-dashed border-gray-300 rounded-lg cursor-grab hover:border-purple-400 hover:bg-purple-50 transition-colors"
draggable
onDragStart={(event) => onDragStart(event, 'image')}
>
<Image className="h-6 w-6 text-gray-600 mb-1" />
<span className="text-xs font-medium">Image</span>
</div>
{/* Button Node */}
<div
className="flex flex-col items-center p-3 border-2 border-dashed border-gray-300 rounded-lg cursor-grab hover:border-green-400 hover:bg-green-50 transition-colors"
draggable
onDragStart={(event) => onDragStart(event, 'button')}
>
<MousePointer className="h-6 w-6 text-gray-600 mb-1" />
<span className="text-xs font-medium">Button</span>
</div>
{/* Container Node */}
<div
className="flex flex-col items-center p-3 border-2 border-dashed border-gray-300 rounded-lg cursor-grab hover:border-orange-400 hover:bg-orange-50 transition-colors"
draggable
onDragStart={(event) => onDragStart(event, 'container')}
>
<Square className="h-6 w-6 text-gray-600 mb-1" />
<span className="text-xs font-medium">Container</span>
</div>
</div>
</CardContent>
</Card>
<Separator className="mx-4" />
{/* Layout Management */}
<Card className="m-4 mb-2">
<CardHeader className="pb-3">
<CardTitle className="text-lg">Layout Management</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{/* Save Layout */}
<div className="space-y-2">
<Label htmlFor="layout-name" className="text-sm">Save Current Layout</Label>
<div className="flex gap-2">
<Input
id="layout-name"
placeholder="Layout name..."
value={layoutName}
onChange={(e) => setLayoutName(e.target.value)}
className="text-sm"
/>
<Button
onClick={handleSaveLayout}
disabled={!layoutName.trim() || isSaving}
size="sm"
className="shrink-0"
>
{isSaving ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
</Button>
</div>
</div>
{/* Load Layout */}
<div className="space-y-2">
<Label htmlFor="layout-select" className="text-sm">Load Saved Layout</Label>
<div className="flex gap-2">
<Select value={selectedLayoutId} onValueChange={setSelectedLayoutId}>
<SelectTrigger className="text-sm">
<SelectValue placeholder="Select layout..." />
</SelectTrigger>
<SelectContent>
{layouts?.map((layout) => (
<SelectItem key={layout.id} value={layout.id!}>
{layout.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
onClick={handleLoadLayout}
disabled={!selectedLayoutId || isLoading}
size="sm"
variant="outline"
className="shrink-0"
>
{isLoading ? <RefreshCw className="h-4 w-4 animate-spin" /> : <FolderOpen className="h-4 w-4" />}
</Button>
<Button
onClick={handleDeleteLayout}
disabled={!selectedLayoutId || isDeleting}
size="sm"
variant="outline"
className="shrink-0 text-red-600 hover:text-red-700"
>
{isDeleting ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
</Button>
</div>
</div>
</CardContent>
</Card>
<Separator className="mx-4" />
{/* Actions */}
<Card className="m-4 mb-4">
<CardHeader className="pb-3">
<CardTitle className="text-lg">Actions</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<Button onClick={clearCanvas} variant="outline" size="sm" className="w-full justify-start">
<Trash2 className="h-4 w-4 mr-2" />
Clear Canvas
</Button>
<Button onClick={handleExport} variant="outline" size="sm" className="w-full justify-start">
<Download className="h-4 w-4 mr-2" />
Export Layout
</Button>
{onPreview && (
<Button onClick={onPreview} variant="outline" size="sm" className="w-full justify-start">
<Eye className="h-4 w-4 mr-2" />
Preview Page
</Button>
)}
</CardContent>
</Card>
</div>
{/* Main Canvas */}
<div className="flex-1 relative">
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onDrop={onDrop}
onDragOver={onDragOver}
nodeTypes={nodeTypes}
connectionMode={ConnectionMode.Loose}
fitView
fitViewOptions={{ padding: 0.2 }}
style={{ backgroundColor: '#f8fafc' }}
>
<Background color="#e2e8f0" size={1} />
<Controls />
<MiniMap
nodeColor={nodeClassName}
nodeStrokeColor="#374151"
nodeStrokeWidth={2}
maskColor="rgba(0, 0, 0, 0.1)"
style={{
backgroundColor: '#ffffff',
border: '1px solid #e5e7eb',
borderRadius: '8px'
}}
/>
<Panel position="top-center" className="bg-white/90 backdrop-blur-sm rounded-lg px-4 py-2 border border-gray-200">
<div className="flex items-center gap-4 text-sm text-gray-600">
<span>Drag elements from the sidebar to build your page</span>
<Separator orientation="vertical" className="h-4" />
<span>{nodes.length} elements</span>
</div>
</Panel>
</ReactFlow>
</div>
</div>
);
};

View File

@ -0,0 +1,46 @@
import { memo } from 'react';
import { Handle, Position } from '@xyflow/react';
import { Button } from '@/components/ui/button';
interface ButtonNodeData {
text: string;
href: string;
variant: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
size: 'default' | 'sm' | 'lg' | 'icon';
className: string;
}
interface ButtonNodeProps {
data: ButtonNodeData;
id: string;
}
const ButtonNode = memo(({ data, id }: ButtonNodeProps) => {
const handleClick = (e: React.MouseEvent) => {
e.preventDefault();
if (data.href) {
window.open(data.href, '_blank');
}
};
return (
<div className="relative">
<Handle type="target" position={Position.Left} className="w-2 h-2" />
<div className="p-2">
<Button
variant={data.variant || 'default'}
size={data.size || 'default'}
onClick={handleClick}
className={data.className}
>
{data.text || 'Click me'}
</Button>
</div>
<Handle type="source" position={Position.Right} className="w-2 h-2" />
</div>
);
});
ButtonNode.displayName = 'ButtonNode';
export default ButtonNode;

View File

@ -0,0 +1,50 @@
import { memo } from 'react';
import { Handle, Position } from '@xyflow/react';
interface ContainerNodeData {
backgroundColor: string;
padding: string;
margin: string;
borderRadius: string;
border: string;
width: string;
height: string;
flexDirection: string;
justifyContent: string;
alignItems: string;
}
interface ContainerNodeProps {
data: ContainerNodeData;
id: string;
}
const ContainerNode = memo(({ data, id }: ContainerNodeProps) => {
return (
<div className="relative">
<Handle type="target" position={Position.Left} className="w-2 h-2" />
<div
className="border-2 border-dashed border-muted-foreground/25 rounded flex items-center justify-center"
style={{
backgroundColor: data.backgroundColor || '#ffffff',
padding: data.padding || '16px',
margin: data.margin || '0',
borderRadius: data.borderRadius || '8px',
width: data.width || '300px',
height: data.height || '200px',
flexDirection: data.flexDirection as any || 'column',
justifyContent: data.justifyContent || 'center',
alignItems: data.alignItems || 'center'
}}
>
<span className="text-muted-foreground text-sm">Container</span>
<span className="text-muted-foreground text-xs">Drop elements here</span>
</div>
<Handle type="source" position={Position.Right} className="w-2 h-2" />
</div>
);
});
ContainerNode.displayName = 'ContainerNode';
export default ContainerNode;

View File

@ -0,0 +1,53 @@
import { memo } from 'react';
import { Handle, Position } from '@xyflow/react';
interface ImageNodeData {
src: string;
alt: string;
width: string;
height: string;
objectFit: string;
}
interface ImageNodeProps {
data: ImageNodeData;
id: string;
}
const ImageNode = memo(({ data, id }: ImageNodeProps) => {
return (
<div className="relative">
<Handle type="target" position={Position.Left} className="w-2 h-2" />
<div className="p-2 bg-background border border-border rounded-lg">
{data.src ? (
<img
src={data.src}
alt={data.alt || 'Image'}
className={`rounded ${data.objectFit === 'cover' ? 'object-cover' : 'object-contain'}`}
style={{
width: data.width === 'auto' ? 'auto' : data.width || '200px',
height: data.height === 'auto' ? 'auto' : data.height || '150px',
maxWidth: '300px',
maxHeight: '300px'
}}
/>
) : (
<div
className="bg-muted border-2 border-dashed border-muted-foreground/25 rounded flex items-center justify-center text-muted-foreground"
style={{
width: data.width === 'auto' ? '200px' : data.width || '200px',
height: data.height === 'auto' ? '150px' : data.height || '150px'
}}
>
<span className="text-sm">Click to add image</span>
</div>
)}
</div>
<Handle type="source" position={Position.Right} className="w-2 h-2" />
</div>
);
});
ImageNode.displayName = 'ImageNode';
export default ImageNode;

View File

@ -0,0 +1,34 @@
import { memo } from 'react';
import { Handle, Position } from '@xyflow/react';
interface TextNodeData {
text: string;
fontSize: string;
fontWeight: string;
textAlign: string;
color: string;
}
interface TextNodeProps {
data: TextNodeData;
id: string;
}
const TextNode = memo(({ data, id }: TextNodeProps) => {
return (
<div className="relative">
<Handle type="target" position={Position.Left} className="w-2 h-2" />
<div
className={`p-4 min-w-[200px] bg-background border border-border rounded-lg ${data.fontSize} ${data.fontWeight} ${data.textAlign} ${data.color}`}
style={{ color: data.color === 'custom' ? '#000' : undefined }}
>
{data.text || 'Enter your text here'}
</div>
<Handle type="source" position={Position.Right} className="w-2 h-2" />
</div>
);
});
TextNode.displayName = 'TextNode';
export default TextNode;

View File

@ -0,0 +1,282 @@
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { FileUpload } from '@/components/ui/file-upload';
import { useToast } from '@/hooks/use-toast';
import { useCreatePayment, useUpdatePayment } from '@/hooks/usePayments';
import { useServices } from '@/hooks/useServices';
import { useAuth } from '@/hooks/useAuth';
import { Payment } from '@/lib/types';
import { format } from 'date-fns';
import { supabase } from '@/integrations/supabase/client';
const paymentSchema = z.object({
service_id: z.string().min(1, 'Please select a service'),
payment_date: z.string().min(1, 'Payment date is required'),
amount: z.number().min(0.01, 'Amount must be greater than 0'),
currency: z.enum(['USD', 'EUR', 'INR'], {
required_error: 'Please select a currency',
}),
paid_by: z.string().optional(),
invoice_number: z.string().optional(),
remarks: z.string().optional(),
});
type PaymentFormData = z.infer<typeof paymentSchema>;
interface PaymentFormProps {
payment?: Payment;
onSuccess?: () => void;
}
export function PaymentForm({ payment, onSuccess }: PaymentFormProps) {
const { toast } = useToast();
const createPayment = useCreatePayment();
const updatePayment = useUpdatePayment();
const { data: services } = useServices();
const { user } = useAuth();
const [isSubmitting, setIsSubmitting] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [uploadingFile, setUploadingFile] = useState(false);
const {
register,
handleSubmit,
formState: { errors },
reset,
setValue,
watch,
} = useForm<PaymentFormData>({
resolver: zodResolver(paymentSchema),
defaultValues: payment ? {
service_id: payment.service_id,
payment_date: format(new Date(payment.payment_date), 'yyyy-MM-dd'),
amount: payment.amount,
currency: payment.currency,
paid_by: payment.paid_by || '',
invoice_number: payment.invoice_number || '',
remarks: payment.remarks || '',
} : {
service_id: '',
payment_date: format(new Date(), 'yyyy-MM-dd'),
amount: 0,
currency: 'USD' as const,
paid_by: '',
invoice_number: '',
remarks: '',
},
});
const selectedServiceId = watch('service_id');
const onSubmit = async (data: PaymentFormData) => {
setIsSubmitting(true);
try {
let invoiceFileUrl = payment?.invoice_file_url || null;
// Upload file if a new one is selected
if (selectedFile) {
setUploadingFile(true);
const fileExt = selectedFile.name.split('.').pop();
const fileName = `${Date.now()}.${fileExt}`;
const filePath = `${user?.id}/${fileName}`;
const { error: uploadError } = await supabase.storage
.from('invoices')
.upload(filePath, selectedFile);
if (uploadError) {
throw new Error('Failed to upload invoice file');
}
const { data: { publicUrl } } = supabase.storage
.from('invoices')
.getPublicUrl(filePath);
invoiceFileUrl = publicUrl;
setUploadingFile(false);
}
const paymentData = {
service_id: data.service_id,
payment_date: data.payment_date,
amount: data.amount,
currency: data.currency,
user_id: user?.id || '',
paid_by: data.paid_by || null,
invoice_number: data.invoice_number || null,
invoice_file_url: invoiceFileUrl,
remarks: data.remarks || null,
};
if (payment) {
await updatePayment.mutateAsync({
id: payment.id,
updates: paymentData,
});
toast({
title: 'Payment updated',
description: 'The payment has been updated successfully.',
});
} else {
await createPayment.mutateAsync(paymentData);
toast({
title: 'Payment recorded',
description: 'The payment has been recorded successfully.',
});
reset();
setSelectedFile(null);
}
onSuccess?.();
} catch (error: any) {
toast({
title: 'Error',
description: error.message,
variant: 'destructive',
});
} finally {
setIsSubmitting(false);
setUploadingFile(false);
}
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="service_id">Service *</Label>
<Select onValueChange={(value) => setValue('service_id', value)} defaultValue={selectedServiceId}>
<SelectTrigger>
<SelectValue placeholder="Select a service" />
</SelectTrigger>
<SelectContent>
{services?.map((service) => (
<SelectItem key={service.id} value={service.id}>
{service.service_name} - {service.provider}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.service_id && (
<p className="text-sm text-red-600">{errors.service_id.message}</p>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="payment_date">Payment Date *</Label>
<Input
id="payment_date"
type="date"
{...register('payment_date')}
/>
{errors.payment_date && (
<p className="text-sm text-red-600">{errors.payment_date.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="currency">Currency *</Label>
<Select onValueChange={(value) => setValue('currency', value as 'USD' | 'EUR' | 'INR')} defaultValue={watch('currency')}>
<SelectTrigger>
<SelectValue placeholder="Select currency" />
</SelectTrigger>
<SelectContent>
<SelectItem value="USD">USD ($)</SelectItem>
<SelectItem value="EUR">EUR ()</SelectItem>
<SelectItem value="INR">INR ()</SelectItem>
</SelectContent>
</Select>
{errors.currency && (
<p className="text-sm text-red-600">{errors.currency.message}</p>
)}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="amount">Amount *</Label>
<Input
id="amount"
type="number"
step="0.01"
min="0.01"
{...register('amount', { valueAsNumber: true })}
placeholder="0.00"
/>
{errors.amount && (
<p className="text-sm text-red-600">{errors.amount.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="paid_by">Payment Method</Label>
<Select onValueChange={(value) => setValue('paid_by', value)} defaultValue={watch('paid_by')}>
<SelectTrigger>
<SelectValue placeholder="Select payment method" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Credit Card">Credit Card</SelectItem>
<SelectItem value="Debit Card">Debit Card</SelectItem>
<SelectItem value="UPI">UPI</SelectItem>
<SelectItem value="NetBanking">Net Banking</SelectItem>
<SelectItem value="Bank Transfer">Bank Transfer</SelectItem>
<SelectItem value="PayPal">PayPal</SelectItem>
<SelectItem value="Other">Other</SelectItem>
</SelectContent>
</Select>
{errors.paid_by && (
<p className="text-sm text-red-600">{errors.paid_by.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="invoice_number">Invoice Number</Label>
<Input
id="invoice_number"
{...register('invoice_number')}
placeholder="INV-001"
/>
{errors.invoice_number && (
<p className="text-sm text-red-600">{errors.invoice_number.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="invoice_file">Invoice File</Label>
<FileUpload
onFileSelect={setSelectedFile}
onFileRemove={() => setSelectedFile(null)}
selectedFile={selectedFile}
currentFileUrl={payment?.invoice_file_url}
/>
</div>
<div className="space-y-2">
<Label htmlFor="remarks">Remarks</Label>
<Textarea
id="remarks"
{...register('remarks')}
placeholder="Additional notes about this payment..."
rows={3}
/>
{errors.remarks && (
<p className="text-sm text-red-600">{errors.remarks.message}</p>
)}
</div>
<Button
type="submit"
className="w-full"
disabled={isSubmitting || uploadingFile}
>
{uploadingFile ? 'Uploading...' : isSubmitting ? 'Saving...' : payment ? 'Update Payment' : 'Record Payment'}
</Button>
</form>
);
}

View File

@ -0,0 +1,433 @@
import { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '@/components/ui/table';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Search,
DollarSign,
FileText,
Calendar,
MoreHorizontal,
Edit,
Trash2,
Eye,
ExternalLink,
User
} from 'lucide-react';
import { supabase } from '@/integrations/supabase/client';
import { Payment } from '@/lib/types';
import { format } from 'date-fns';
import { formatCurrency } from '@/lib/currency';
import { aggregateMultiCurrencyAmounts } from '@/lib/currencyConverter';
import { PaymentsTableMobile } from './PaymentsTableMobile';
import { PaymentForm } from './PaymentForm';
import { usePermissions } from '@/hooks/usePermissions';
import { useDeletePayment } from '@/hooks/usePayments';
import { useToast } from '@/hooks/use-toast';
interface PaymentWithService extends Payment {
service: {
service_name: string;
provider: string;
};
}
interface PaymentsTableProps {
searchTerm?: string;
}
export function PaymentsTable({ searchTerm: externalSearchTerm }: PaymentsTableProps) {
const [internalSearchTerm, setInternalSearchTerm] = useState('');
const [currencyFilter, setCurrencyFilter] = useState<string>('all');
const [monthFilter, setMonthFilter] = useState<string>('all');
const [isMobile, setIsMobile] = useState(false);
const [editingPayment, setEditingPayment] = useState<PaymentWithService | null>(null);
const [showEditDialog, setShowEditDialog] = useState(false);
// Use external search term if provided, otherwise use internal
const searchTerm = externalSearchTerm !== undefined ? externalSearchTerm : internalSearchTerm;
const { isAdmin } = usePermissions();
const deletePayment = useDeletePayment();
const { toast } = useToast();
const handleEditPayment = (payment: PaymentWithService) => {
setEditingPayment(payment);
setShowEditDialog(true);
};
const handleDeletePayment = async (paymentId: string) => {
try {
await deletePayment.mutateAsync(paymentId);
toast({
title: 'Payment deleted',
description: 'The payment has been deleted successfully.',
});
} catch (error: any) {
toast({
title: 'Error',
description: error.message,
variant: 'destructive',
});
}
};
const handleViewInvoice = (invoiceUrl: string) => {
window.open(invoiceUrl, '_blank');
};
// Check if we're on mobile screen size
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 768);
};
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
const { data: payments, isLoading } = useQuery({
queryKey: ['payments'],
queryFn: async (): Promise<PaymentWithService[]> => {
const { data, error } = await supabase
.from('payments')
.select(`
*,
service:services(service_name, provider)
`)
.order('payment_date', { ascending: false });
if (error) throw error;
return data || [];
},
});
const filteredPayments = payments?.filter(payment => {
const matchesSearch = payment.service.service_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
payment.service.provider.toLowerCase().includes(searchTerm.toLowerCase()) ||
payment.invoice_number?.toLowerCase().includes(searchTerm.toLowerCase());
const matchesCurrency = currencyFilter === 'all' || payment.currency === currencyFilter;
const paymentMonth = new Date(payment.payment_date).getMonth();
const currentMonth = new Date().getMonth();
const matchesMonth = monthFilter === 'all' ||
(monthFilter === 'current' && paymentMonth === currentMonth) ||
(monthFilter === 'last' && paymentMonth === currentMonth - 1);
return matchesSearch && matchesCurrency && matchesMonth;
}) || [];
const amounts = filteredPayments.map(p => ({ amount: p.amount, currency: p.currency }));
const totalsAgg = aggregateMultiCurrencyAmounts(amounts);
const totalsByCurrency = Object.fromEntries(totalsAgg.totals.map(t => [t.currency, t.amount]));
const totalINR = totalsAgg.convertedToINR;
const totalUSD = totalsAgg.convertedToUSD;
const primaryCurrency = currencyFilter !== 'all' ? (currencyFilter as 'INR' | 'USD' | 'EUR') : 'INR';
const primaryAmount = currencyFilter !== 'all'
? (totalsByCurrency[primaryCurrency] || 0)
: totalINR;
if (isLoading) {
return (
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Payments</CardTitle>
<div className="text-right">
<p className="text-sm text-muted-foreground">Total (filtered)</p>
<p className="text-lg font-semibold">{formatCurrency(primaryAmount, primaryCurrency)}</p>
<p className="text-xs text-muted-foreground">
{primaryCurrency === 'INR'
? formatCurrency(totalUSD, 'USD')
: `${formatCurrency(totalINR, 'INR')}${formatCurrency(totalUSD, 'USD')}`}
</p>
</div>
</div>
{/* Search and Filters */}
<div className="flex flex-col sm:flex-row gap-4">
{/* Only show search input if external search is not provided */}
{externalSearchTerm === undefined && (
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search payments..."
value={internalSearchTerm}
onChange={(e) => setInternalSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
)}
{/* Always show filters */}
<div className="flex gap-2">
<Select value={currencyFilter} onValueChange={setCurrencyFilter}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="Currency" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Currencies</SelectItem>
<SelectItem value="USD">USD</SelectItem>
<SelectItem value="EUR">EUR</SelectItem>
<SelectItem value="INR">INR</SelectItem>
</SelectContent>
</Select>
<Select value={monthFilter} onValueChange={setMonthFilter}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="Period" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Time</SelectItem>
<SelectItem value="current">This Month</SelectItem>
<SelectItem value="last">Last Month</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardHeader>
<CardContent>
{isMobile ? (
<PaymentsTableMobile
payments={filteredPayments}
onEdit={handleEditPayment}
onDelete={handleDeletePayment}
onViewInvoice={handleViewInvoice}
/>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Service</TableHead>
<TableHead>Provider</TableHead>
<TableHead>Date</TableHead>
<TableHead>Amount</TableHead>
<TableHead>Method</TableHead>
<TableHead>Invoice</TableHead>
<TableHead>Remarks</TableHead>
{isAdmin && <TableHead className="w-[70px]">Actions</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{filteredPayments.length === 0 ? (
<TableRow>
<TableCell colSpan={isAdmin ? 8 : 7} className="text-center py-12">
<div className="text-muted-foreground">
{searchTerm || currencyFilter !== 'all' || monthFilter !== 'all'
? 'No payments match your filters'
: 'No payments recorded yet.'}
</div>
</TableCell>
</TableRow>
) : (
filteredPayments.map((payment) => (
<TableRow key={payment.id} className="hover:bg-muted/50 transition-colors">
<TableCell className="py-3">
<div className="space-y-1">
<div className="font-medium text-sm">
{payment.service.service_name}
</div>
<div className="text-xs text-muted-foreground">
{payment.service.provider}
</div>
</div>
</TableCell>
<TableCell className="py-3">
<Badge variant="outline" className="text-xs font-normal">
{payment.service.provider}
</Badge>
</TableCell>
<TableCell className="py-3">
<div className="flex items-center space-x-2">
<Calendar className="h-3 w-3 text-orange-500" />
<div>
<div className="text-sm font-medium">
{format(new Date(payment.payment_date), 'MMM dd, yyyy')}
</div>
<div className="text-xs text-muted-foreground">
{format(new Date(payment.payment_date), 'EEE')}
</div>
</div>
</div>
</TableCell>
<TableCell className="py-3">
<div className="text-right">
<div className="font-semibold text-sm">
{formatCurrency(payment.amount, payment.currency)}
</div>
<Badge variant="secondary" className="text-xs mt-1">
{payment.currency}
</Badge>
</div>
</TableCell>
<TableCell className="py-3">
<div className="flex items-center space-x-2">
<User className="h-3 w-3 text-blue-500" />
{payment.paid_by ? (
<Badge variant="outline" className="text-xs font-normal">
{payment.paid_by}
</Badge>
) : (
<span className="text-muted-foreground text-xs italic">Not specified</span>
)}
</div>
</TableCell>
<TableCell className="py-3">
<div className="flex items-center space-x-2">
{payment.invoice_file_url ? (
<Button
variant="outline"
size="sm"
onClick={() => handleViewInvoice(payment.invoice_file_url!)}
className="h-7 px-2 text-xs"
>
<ExternalLink className="h-3 w-3 mr-1 text-green-500" />
View
</Button>
) : payment.invoice_number ? (
<div className="flex items-center space-x-1">
<FileText className="h-3 w-3 text-blue-500" />
<span className="text-xs font-medium">{payment.invoice_number}</span>
</div>
) : (
<span className="text-muted-foreground text-xs italic">No invoice</span>
)}
</div>
</TableCell>
<TableCell className="py-3 max-w-[150px]">
{payment.remarks ? (
<div
className="text-xs bg-muted/50 rounded-md p-2 cursor-help truncate"
title={payment.remarks}
>
{payment.remarks.length > 30
? `${payment.remarks.substring(0, 30)}...`
: payment.remarks}
</div>
) : (
<span className="text-muted-foreground text-xs italic">No remarks</span>
)}
</TableCell>
{isAdmin && (
<TableCell className="py-3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-7 w-7 p-0 hover:bg-muted">
<MoreHorizontal className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="bg-background border shadow-md">
<DropdownMenuItem
onClick={() => handleEditPayment(payment)}
className="cursor-pointer"
>
<Edit className="mr-2 h-4 w-4 text-blue-500" />
Edit Payment
</DropdownMenuItem>
{payment.invoice_file_url && (
<DropdownMenuItem onClick={() => handleViewInvoice(payment.invoice_file_url!)}>
<ExternalLink className="mr-2 h-4 w-4 text-green-500" />
View Invoice
</DropdownMenuItem>
)}
<AlertDialog>
<AlertDialogTrigger asChild>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onSelect={(e) => e.preventDefault()}
>
<Trash2 className="mr-2 h-4 w-4 text-red-500" />
Delete
</DropdownMenuItem>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Payment</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this payment? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => handleDeletePayment(payment.id)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
)}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
)}
</CardContent>
{/* Edit Payment Dialog */}
<Dialog open={showEditDialog} onOpenChange={setShowEditDialog}>
<DialogContent className="mx-3 max-w-2xl md:mx-auto">
<DialogHeader>
<DialogTitle>Edit Payment</DialogTitle>
</DialogHeader>
{editingPayment && (
<PaymentForm
payment={editingPayment}
onSuccess={() => {
setShowEditDialog(false);
setEditingPayment(null);
}}
/>
)}
</DialogContent>
</Dialog>
</Card>
);
}

View File

@ -0,0 +1,228 @@
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import {
Calendar,
DollarSign,
FileText,
MoreHorizontal,
Edit,
Trash2,
ExternalLink,
User,
CreditCard
} from 'lucide-react';
import { formatCurrency } from '@/lib/currency';
import { format } from 'date-fns';
import { usePermissions } from '@/hooks/usePermissions';
interface PaymentWithService {
id: string;
payment_date: string;
amount: number;
currency: string;
paid_by?: string;
invoice_number?: string;
invoice_file_url?: string;
remarks?: string;
service: {
service_name: string;
provider: string;
};
}
interface PaymentsTableMobileProps {
payments: PaymentWithService[];
onEdit?: (payment: PaymentWithService) => void;
onDelete?: (paymentId: string) => void;
onViewInvoice?: (url: string) => void;
}
export function PaymentsTableMobile({
payments,
onEdit,
onDelete,
onViewInvoice
}: PaymentsTableMobileProps) {
const { isAdmin } = usePermissions();
if (payments.length === 0) {
return (
<Card>
<CardContent className="p-8 text-center">
<CreditCard className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
<p className="text-muted-foreground text-lg">No payments found</p>
<p className="text-sm text-muted-foreground mt-1">
Try adjusting your filters or add a new payment
</p>
</CardContent>
</Card>
);
}
return (
<div className="space-y-3">
{payments.map((payment) => (
<Card key={payment.id} className="hover:shadow-md transition-shadow">
<CardContent className="p-4">
<div className="space-y-4">
{/* Header Row with Service and Amount */}
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0 space-y-1">
<h3 className="font-semibold text-base leading-tight">
{payment.service.service_name}
</h3>
<div className="flex items-center text-sm text-muted-foreground">
<span className="truncate">{payment.service.provider}</span>
</div>
</div>
<div className="ml-3 text-right">
<div className="flex items-center font-bold text-lg">
{formatCurrency(payment.amount, payment.currency)}
</div>
<Badge variant="secondary" className="text-xs mt-1">
{payment.currency}
</Badge>
</div>
</div>
{/* Details Grid */}
<div className="grid grid-cols-2 gap-3 text-sm">
{/* Date */}
<div className="flex items-center space-x-2">
<Calendar className="h-4 w-4 text-orange-500 flex-shrink-0" />
<div>
<div className="font-medium">
{format(new Date(payment.payment_date), 'MMM dd, yyyy')}
</div>
<div className="text-xs text-muted-foreground">Payment Date</div>
</div>
</div>
{/* Payment Method */}
<div className="flex items-center space-x-2">
<User className="h-4 w-4 text-blue-500 flex-shrink-0" />
<div>
<div className="font-medium">
{payment.paid_by || 'Not specified'}
</div>
<div className="text-xs text-muted-foreground">Paid By</div>
</div>
</div>
</div>
{/* Invoice and Additional Info */}
{(payment.invoice_number || payment.invoice_file_url || payment.remarks) && (
<div className="space-y-2 pt-3 border-t border-muted">
{(payment.invoice_number || payment.invoice_file_url) && (
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2 text-sm">
<FileText className="h-4 w-4 text-green-500" />
<span className="font-medium">
{payment.invoice_number || 'Invoice attached'}
</span>
</div>
{payment.invoice_file_url && onViewInvoice && (
<Button
variant="outline"
size="sm"
onClick={() => onViewInvoice(payment.invoice_file_url!)}
className="h-7 px-2"
>
<ExternalLink className="h-3 w-3 mr-1 text-green-500" />
View
</Button>
)}
</div>
)}
{payment.remarks && (
<div className="text-sm bg-muted/50 rounded-md p-2">
<div className="font-medium text-muted-foreground mb-1">Remarks:</div>
<div className="text-foreground">{payment.remarks}</div>
</div>
)}
</div>
)}
{/* Actions */}
{isAdmin && (onEdit || onDelete) && (
<div className="flex justify-end pt-2 border-t border-muted">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 px-2">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{onEdit && (
<DropdownMenuItem onClick={() => onEdit(payment)}>
<Edit className="mr-2 h-4 w-4 text-blue-500" />
Edit Payment
</DropdownMenuItem>
)}
{payment.invoice_file_url && onViewInvoice && (
<DropdownMenuItem onClick={() => onViewInvoice(payment.invoice_file_url!)}>
<ExternalLink className="mr-2 h-4 w-4 text-green-500" />
View Invoice
</DropdownMenuItem>
)}
{onDelete && (
<AlertDialog>
<AlertDialogTrigger asChild>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onSelect={(e) => e.preventDefault()}
>
<Trash2 className="mr-2 h-4 w-4 text-red-500" />
Delete Payment
</DropdownMenuItem>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Payment</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this payment for {payment.service.service_name}?
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => onDelete(payment.id)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</div>
</CardContent>
</Card>
))}
</div>
);
}

View File

@ -0,0 +1,430 @@
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import * as z from 'zod';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import { supabase } from '@/integrations/supabase/client';
import { useAuth } from '@/hooks/useAuth';
import { useToast } from '@/hooks/use-toast';
import { User, Settings, Key, Trash2 } from 'lucide-react';
const profileSchema = z.object({
display_name: z.string().min(1, 'Display name is required').max(100, 'Display name too long'),
email: z.string().email('Invalid email address'),
});
const passwordSchema = z.object({
currentPassword: z.string().min(1, 'Current password is required'),
newPassword: z.string().min(6, 'Password must be at least 6 characters'),
confirmPassword: z.string().min(6, 'Please confirm your password'),
}).refine((data) => data.newPassword === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
});
type ProfileFormData = z.infer<typeof profileSchema>;
type PasswordFormData = z.infer<typeof passwordSchema>;
export function ProfileSettings() {
const { user, profile } = useAuth();
const { toast } = useToast();
const queryClient = useQueryClient();
const [showPasswordForm, setShowPasswordForm] = useState(false);
const profileForm = useForm<ProfileFormData>({
resolver: zodResolver(profileSchema),
defaultValues: {
display_name: profile?.display_name || '',
email: user?.email || '',
},
});
const passwordForm = useForm<PasswordFormData>({
resolver: zodResolver(passwordSchema),
defaultValues: {
currentPassword: '',
newPassword: '',
confirmPassword: '',
},
});
// Reset form when profile data loads
useState(() => {
if (profile && user) {
profileForm.reset({
display_name: profile.display_name || '',
email: user.email || '',
});
}
});
const updateProfileMutation = useMutation({
mutationFn: async (data: ProfileFormData) => {
// Update email in auth if changed
if (data.email !== user?.email) {
const { error: emailError } = await supabase.auth.updateUser({
email: data.email,
});
if (emailError) throw emailError;
}
// Update profile in database
const { error: profileError } = await supabase
.from('profiles')
.update({
display_name: data.display_name,
email: data.email,
})
.eq('user_id', user?.id);
if (profileError) throw profileError;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['profile'] });
toast({
title: 'Profile updated',
description: 'Your profile has been updated successfully.',
});
},
onError: (error: any) => {
toast({
title: 'Error',
description: error.message,
variant: 'destructive',
});
},
});
const updatePasswordMutation = useMutation({
mutationFn: async (data: PasswordFormData) => {
const { error } = await supabase.auth.updateUser({
password: data.newPassword,
});
if (error) throw error;
},
onSuccess: () => {
passwordForm.reset();
setShowPasswordForm(false);
toast({
title: 'Password updated',
description: 'Your password has been changed successfully.',
});
},
onError: (error: any) => {
toast({
title: 'Error',
description: error.message,
variant: 'destructive',
});
},
});
const deleteAccountMutation = useMutation({
mutationFn: async () => {
// Note: In a real app, you'd want to implement account deletion properly
// This is a placeholder that signs out the user
const { error } = await supabase.auth.signOut();
if (error) throw error;
},
onSuccess: () => {
toast({
title: 'Account deleted',
description: 'Your account has been deleted successfully.',
});
},
onError: (error: any) => {
toast({
title: 'Error',
description: error.message,
variant: 'destructive',
});
},
});
const onProfileSubmit = (data: ProfileFormData) => {
updateProfileMutation.mutate(data);
};
const onPasswordSubmit = (data: PasswordFormData) => {
updatePasswordMutation.mutate(data);
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
};
return (
<div className="p-6 space-y-6 max-w-4xl">
<div>
<h1 className="text-3xl font-bold text-foreground">Profile Settings</h1>
<p className="text-muted-foreground">
Manage your account settings and preferences
</p>
</div>
<div className="grid gap-6">
{/* Account Overview */}
<Card>
<CardHeader>
<CardTitle className="flex items-center">
<User className="mr-2 h-5 w-5" />
Account Overview
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label className="text-sm font-medium text-muted-foreground">User ID</Label>
<p className="text-sm font-mono bg-muted p-2 rounded">{user?.id}</p>
</div>
<div>
<Label className="text-sm font-medium text-muted-foreground">Role</Label>
<div className="mt-1">
<Badge variant={profile?.role === 'admin' ? 'default' : 'secondary'}>
{profile?.role || 'viewer'}
</Badge>
</div>
</div>
<div>
<Label className="text-sm font-medium text-muted-foreground">Account Created</Label>
<p className="text-sm">{user?.created_at ? formatDate(user.created_at) : 'Unknown'}</p>
</div>
<div>
<Label className="text-sm font-medium text-muted-foreground">Last Updated</Label>
<p className="text-sm">{profile?.updated_at ? formatDate(profile.updated_at) : 'Unknown'}</p>
</div>
</div>
</CardContent>
</Card>
{/* Profile Information */}
<Card>
<CardHeader>
<CardTitle className="flex items-center">
<Settings className="mr-2 h-5 w-5" />
Profile Information
</CardTitle>
</CardHeader>
<CardContent>
<Form {...profileForm}>
<form onSubmit={profileForm.handleSubmit(onProfileSubmit)} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={profileForm.control}
name="display_name"
render={({ field }) => (
<FormItem>
<FormLabel>Display Name</FormLabel>
<FormControl>
<Input placeholder="Enter your display name" {...field} />
</FormControl>
<FormDescription>
This is the name that will be displayed in the app
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={profileForm.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email Address</FormLabel>
<FormControl>
<Input placeholder="Enter your email" {...field} />
</FormControl>
<FormDescription>
This email is used for login and notifications
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<Button
type="submit"
disabled={updateProfileMutation.isPending}
className="w-full md:w-auto"
>
{updateProfileMutation.isPending ? 'Updating...' : 'Update Profile'}
</Button>
</form>
</Form>
</CardContent>
</Card>
{/* Password Settings */}
<Card>
<CardHeader>
<CardTitle className="flex items-center">
<Key className="mr-2 h-5 w-5" />
Password & Security
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{!showPasswordForm ? (
<div className="flex items-center justify-between p-4 border rounded-lg">
<div>
<h4 className="font-medium">Password</h4>
<p className="text-sm text-muted-foreground">
Last updated: {user?.updated_at ? formatDate(user.updated_at) : 'Unknown'}
</p>
</div>
<Button onClick={() => setShowPasswordForm(true)}>
Change Password
</Button>
</div>
) : (
<Form {...passwordForm}>
<form onSubmit={passwordForm.handleSubmit(onPasswordSubmit)} className="space-y-4">
<FormField
control={passwordForm.control}
name="currentPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Current Password</FormLabel>
<FormControl>
<Input type="password" placeholder="Enter current password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={passwordForm.control}
name="newPassword"
render={({ field }) => (
<FormItem>
<FormLabel>New Password</FormLabel>
<FormControl>
<Input type="password" placeholder="Enter new password" {...field} />
</FormControl>
<FormDescription>
Password must be at least 6 characters long
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={passwordForm.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Confirm New Password</FormLabel>
<FormControl>
<Input type="password" placeholder="Confirm new password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex items-center space-x-2">
<Button
type="submit"
disabled={updatePasswordMutation.isPending}
>
{updatePasswordMutation.isPending ? 'Updating...' : 'Update Password'}
</Button>
<Button
type="button"
variant="outline"
onClick={() => {
setShowPasswordForm(false);
passwordForm.reset();
}}
>
Cancel
</Button>
</div>
</form>
</Form>
)}
</CardContent>
</Card>
{/* Danger Zone */}
<Card className="border-red-200">
<CardHeader>
<CardTitle className="flex items-center text-red-600">
<Trash2 className="mr-2 h-5 w-5" />
Danger Zone
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="p-4 border border-red-200 rounded-lg bg-red-50/50">
<h4 className="font-medium text-red-800 mb-2">Delete Account</h4>
<p className="text-sm text-red-700 mb-4">
Once you delete your account, there is no going back. Please be certain.
All your services, payments, and data will be permanently deleted.
</p>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" size="sm">
Delete Account
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete your
account and remove all your data from our servers.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-red-600 hover:bg-red-700"
onClick={() => deleteAccountMutation.mutate()}
>
Delete Account
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@ -0,0 +1,382 @@
import { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Download, FileText, TrendingDown, Loader2 } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { supabase } from '@/integrations/supabase/client';
import { formatCurrency } from '@/lib/currency';
import { format } from 'date-fns';
interface ExportAnalysisProps {
summaryData: any;
}
export function ExportAnalysis({ summaryData }: ExportAnalysisProps) {
const [isExporting, setIsExporting] = useState(false);
const [isGeneratingReport, setIsGeneratingReport] = useState(false);
const [isAnalyzing, setIsAnalyzing] = useState(false);
const { toast } = useToast();
const downloadCSV = async () => {
setIsExporting(true);
try {
// Fetch all services with related data
const { data: services, error: servicesError } = await supabase
.from('services')
.select(`
*,
categories!inner(name),
vendors(name),
payments(payment_date, amount, currency, invoice_number)
`);
if (servicesError) throw servicesError;
// Create CSV content
const headers = [
'Service Name',
'Provider',
'Category',
'Vendor',
'Amount',
'Currency',
'Billing Cycle',
'Status',
'Start Date',
'Next Renewal',
'Auto Renew',
'Importance',
'Account Email',
'Last Payment Date',
'Last Payment Amount'
];
const csvContent = [
headers.join(','),
...services.map(service => [
`"${service.service_name}"`,
`"${service.provider}"`,
`"${service.categories?.name || ''}"`,
`"${service.vendors?.name || ''}"`,
service.amount,
service.currency,
service.billing_cycle,
service.status,
service.start_date,
service.next_renewal_date || '',
service.auto_renew,
service.importance,
`"${service.account_email || ''}"`,
service.payments?.[0]?.payment_date || '',
service.payments?.[0]?.amount || ''
].join(','))
].join('\n');
// Download file
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `services-export-${format(new Date(), 'yyyy-MM-dd')}.csv`;
link.click();
toast({
title: "Export successful",
description: "Services data has been downloaded as CSV"
});
} catch (error) {
console.error('Export error:', error);
toast({
title: "Export failed",
description: "Failed to export data. Please try again.",
variant: "destructive"
});
} finally {
setIsExporting(false);
}
};
const generateMonthlyReport = async () => {
setIsGeneratingReport(true);
try {
// Fetch detailed data for report
const { data: services } = await supabase
.from('services')
.select(`
*,
categories!inner(name),
vendors(name),
payments(payment_date, amount, currency)
`);
const { data: payments } = await supabase
.from('payments')
.select('*')
.gte('payment_date', new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0])
.order('payment_date');
// Generate comprehensive report content
const reportContent = `
MONTHLY SUBSCRIPTION REPORT
Generated: ${format(new Date(), 'PPpp')}
=== SUMMARY ===
Total Monthly Spend: ${formatCurrency(summaryData.totalMonthlySpend.inr, 'INR')} (${formatCurrency(summaryData.totalMonthlySpend.usd, 'USD')})
Active Services: ${summaryData.serviceCount}
Categories: ${summaryData.categoryBreakdown.length}
Top Category: ${summaryData.topCategory?.category || 'N/A'}
=== CATEGORY BREAKDOWN ===
${summaryData.categoryBreakdown.map(cat =>
`${cat.category}: ${formatCurrency(cat.amount)} (${cat.percentage}%)`
).join('\n')}
=== ACTIVE SERVICES ===
${services?.map(service =>
`${service.service_name} - ${service.provider}
Category: ${service.categories?.name}
Amount: ${formatCurrency(service.amount, service.currency)}
Billing: ${service.billing_cycle}
Status: ${service.status}
Next Renewal: ${service.next_renewal_date || 'Not set'}
---`
).join('\n')}
=== PAYMENT HISTORY (Last 12 Months) ===
${payments?.map(payment =>
`${payment.payment_date}: ${formatCurrency(payment.amount, payment.currency)} ${payment.invoice_number ? `(${payment.invoice_number})` : ''}`
).join('\n')}
`;
// Download report as text file
const blob = new Blob([reportContent], { type: 'text/plain;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `monthly-report-${format(new Date(), 'yyyy-MM-dd')}.txt`;
link.click();
toast({
title: "Report generated",
description: "Monthly report has been downloaded"
});
} catch (error) {
console.error('Report generation error:', error);
toast({
title: "Report generation failed",
description: "Failed to generate report. Please try again.",
variant: "destructive"
});
} finally {
setIsGeneratingReport(false);
}
};
const analyzeCosts = async () => {
setIsAnalyzing(true);
try {
// Fetch data for analysis
const { data: services } = await supabase
.from('services')
.select(`
*,
categories!inner(name),
vendors(name)
`);
if (!services) throw new Error('No services found');
// Perform cost analysis
const analysis = {
potentialSavings: 0,
duplicateServices: [] as string[],
unusedServices: [] as string[],
expensiveServices: [] as string[],
recommendations: [] as string[]
};
// Find duplicate categories
const categoryCount: Record<string, string[]> = {};
services.forEach(service => {
const category = service.categories?.name || 'Unknown';
if (!categoryCount[category]) categoryCount[category] = [];
categoryCount[category].push(service.service_name);
});
Object.entries(categoryCount).forEach(([category, serviceNames]) => {
if (serviceNames.length > 1) {
analysis.duplicateServices.push(`${category}: ${serviceNames.join(', ')}`);
analysis.recommendations.push(`Consider consolidating ${category} services to reduce costs`);
}
});
// Calculate actual monthly amounts for current month renewals
const currentDate = new Date();
const currentMonth = currentDate.getMonth();
const currentYear = currentDate.getFullYear();
const monthlyAmounts = services.map(service => {
if (!service.next_renewal_date) return null;
const nextRenewal = new Date(service.next_renewal_date);
const renewalMonth = nextRenewal.getMonth();
const renewalYear = nextRenewal.getFullYear();
// Only count if renewal is due in current month/year
if (renewalYear === currentYear && renewalMonth === currentMonth) {
return { service: service.service_name, amount: service.amount, currency: service.currency };
}
return null;
}).filter(Boolean) as Array<{ service: string; amount: number; currency: string }>;
const top20Percent = Math.ceil(monthlyAmounts.length * 0.2);
analysis.expensiveServices = monthlyAmounts.slice(0, top20Percent).map(s =>
`${s.service}: ${formatCurrency(s.amount, s.currency)}/month`
);
// Find services without recent payments (potentially unused)
const { data: recentPayments } = await supabase
.from('payments')
.select('service_id')
.gte('payment_date', new Date(Date.now() - 90 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]);
const recentPaymentServiceIds = new Set(recentPayments?.map(p => p.service_id) || []);
analysis.unusedServices = services
.filter(service => !recentPaymentServiceIds.has(service.id) && service.status === 'Active')
.map(service => service.service_name);
if (analysis.unusedServices.length > 0) {
analysis.recommendations.push('Review services without recent payments - they might be unused');
}
// Generate analysis report
const analysisContent = `
COST ANALYSIS REPORT
Generated: ${format(new Date(), 'PPpp')}
=== SUMMARY ===
Total Services Analyzed: ${services.length}
Potential Areas for Optimization: ${analysis.recommendations.length}
=== EXPENSIVE SERVICES (Top 20%) ===
${analysis.expensiveServices.join('\n')}
=== DUPLICATE CATEGORIES ===
${analysis.duplicateServices.length > 0 ? analysis.duplicateServices.join('\n') : 'No duplicate categories found'}
=== POTENTIALLY UNUSED SERVICES ===
${analysis.unusedServices.length > 0 ? analysis.unusedServices.join('\n') : 'All services have recent payment activity'}
=== RECOMMENDATIONS ===
${analysis.recommendations.length > 0 ? analysis.recommendations.map((rec, i) => `${i + 1}. ${rec}`).join('\n') : 'No specific recommendations at this time'}
=== NEXT STEPS ===
1. Review expensive services to ensure they provide adequate value
2. Consider annual billing for frequently used services (often cheaper)
3. Audit services without recent payments
4. Look for bundle opportunities with duplicate category services
5. Set up usage tracking for high-cost services
`;
// Download analysis report
const blob = new Blob([analysisContent], { type: 'text/plain;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `cost-analysis-${format(new Date(), 'yyyy-MM-dd')}.txt`;
link.click();
toast({
title: "Analysis complete",
description: `Found ${analysis.recommendations.length} optimization opportunities`
});
} catch (error) {
console.error('Cost analysis error:', error);
toast({
title: "Analysis failed",
description: "Failed to analyze costs. Please try again.",
variant: "destructive"
});
} finally {
setIsAnalyzing(false);
}
};
return (
<Card>
<CardHeader>
<CardTitle>Export & Analysis</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-4 md:grid-cols-3">
<div className="text-center p-4 border rounded-lg">
<div className="flex justify-center mb-3">
<FileText className="h-8 w-8 text-primary" />
</div>
<h4 className="font-medium mb-2">Monthly Report</h4>
<p className="text-sm text-muted-foreground mb-3">
Detailed breakdown by month, category, and vendor
</p>
<Button
onClick={generateMonthlyReport}
disabled={isGeneratingReport}
variant="outline"
size="sm"
className="w-full"
>
{isGeneratingReport ? (
<><Loader2 className="h-4 w-4 mr-2 animate-spin" /> Generating...</>
) : (
<><FileText className="h-4 w-4 mr-2" /> Generate Report</>
)}
</Button>
</div>
<div className="text-center p-4 border rounded-lg">
<div className="flex justify-center mb-3">
<Download className="h-8 w-8 text-primary" />
</div>
<h4 className="font-medium mb-2">Export CSV</h4>
<p className="text-sm text-muted-foreground mb-3">
Download all services and payments data
</p>
<Button
onClick={downloadCSV}
disabled={isExporting}
variant="outline"
size="sm"
className="w-full"
>
{isExporting ? (
<><Loader2 className="h-4 w-4 mr-2 animate-spin" /> Exporting...</>
) : (
<><Download className="h-4 w-4 mr-2" /> Download CSV</>
)}
</Button>
</div>
<div className="text-center p-4 border rounded-lg">
<div className="flex justify-center mb-3">
<TrendingDown className="h-8 w-8 text-primary" />
</div>
<h4 className="font-medium mb-2">Cost Analysis</h4>
<p className="text-sm text-muted-foreground mb-3">
Identify optimization opportunities
</p>
<Button
onClick={analyzeCosts}
disabled={isAnalyzing}
variant="outline"
size="sm"
className="w-full"
>
{isAnalyzing ? (
<><Loader2 className="h-4 w-4 mr-2 animate-spin" /> Analyzing...</>
) : (
<><TrendingDown className="h-4 w-4 mr-2" /> Analyze Costs</>
)}
</Button>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,259 @@
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { DollarSign, TrendingUp, BarChart3, PieChart } from 'lucide-react';
import { supabase } from '@/integrations/supabase/client';
import { formatCurrency } from '@/lib/currency';
import { aggregateMultiCurrencyAmounts, convertToUSD } from '@/lib/currencyConverter';
import { ExportAnalysis } from './ExportAnalysis';
export function ReportsOverview() {
const { data: summaryData, isLoading, error } = useQuery({
queryKey: ['reports-summary'],
queryFn: async () => {
console.log('Reports: Starting data fetch...');
// Get category breakdown - now we need to join with categories table
const { data: services, error: servicesError } = await supabase
.from('services')
.select(`
category_id,
amount,
billing_cycle,
custom_cycle_days,
status,
currency,
next_renewal_date,
categories!inner(name)
`)
.eq('status', 'Active');
console.log('Reports: Services fetch result:', { services: services?.length, error: servicesError });
if (servicesError) {
console.error('Reports: Error fetching services:', servicesError);
throw servicesError;
}
if (!services) return null;
// Calculate current month actual amounts (not average monthly)
const currentDate = new Date();
const currentMonth = currentDate.getMonth();
const currentYear = currentDate.getFullYear();
const monthlyAmounts = services.map(service => {
if (!service.next_renewal_date) return { amount: 0, currency: service.currency, category: service.categories?.name || 'Unknown' };
const nextRenewal = new Date(service.next_renewal_date);
const renewalMonth = nextRenewal.getMonth();
const renewalYear = nextRenewal.getFullYear();
// Only count if renewal is due in current month/year
if (renewalYear === currentYear && renewalMonth === currentMonth) {
return {
amount: service.amount,
currency: service.currency,
category: service.categories?.name || 'Unknown'
};
}
return { amount: 0, currency: service.currency, category: service.categories?.name || 'Unknown' };
});
// Aggregate totals by currency using the centralized converter
const { convertedToUSD, convertedToINR, totals } = aggregateMultiCurrencyAmounts(monthlyAmounts);
// Calculate category breakdown using the proper currency converter
const categoryTotals = monthlyAmounts.reduce((acc, item) => {
if (item.amount === 0) return acc;
const categoryName = item.category;
const usdAmount = item.currency === 'USD' ? item.amount :
convertToUSD(item.amount, item.currency);
acc[categoryName] = (acc[categoryName] || 0) + usdAmount;
return acc;
}, {} as Record<string, number>);
const topCategory = Object.entries(categoryTotals).sort(([,a], [,b]) => b - a)[0];
// Get payments for last 3 months trend
const { data: payments, error: paymentsError } = await supabase
.from('payments')
.select('payment_date, amount, currency')
.gte('payment_date', new Date(Date.now() - 90 * 24 * 60 * 60 * 1000).toISOString().split('T')[0])
.order('payment_date');
console.log('Reports: Payments fetch result:', { payments: payments?.length, error: paymentsError });
if (paymentsError) {
console.error('Reports: Error fetching payments:', paymentsError);
// Don't throw error for payments, just use empty array
}
const paymentsAmounts = payments?.map(p => ({ amount: p.amount, currency: p.currency })) || [];
const { convertedToINR: last3MonthsINR } = aggregateMultiCurrencyAmounts(paymentsAmounts);
return {
categoryBreakdown: Object.entries(categoryTotals).map(([category, amount]) => ({
category,
amount: Math.round(amount),
percentage: Math.round((amount / convertedToUSD) * 100)
})).sort((a, b) => b.amount - a.amount),
totalMonthlySpend: {
usd: Math.round(convertedToUSD),
inr: Math.round(convertedToINR)
},
topCategory: topCategory ? { category: topCategory[0], amount: Math.round(topCategory[1]) } : null,
last3MonthsTotal: Math.round(last3MonthsINR),
serviceCount: services.length,
currencyBreakdown: services.reduce((acc, service) => {
acc[service.currency] = (acc[service.currency] || 0) + 1;
return acc;
}, {} as Record<string, number>)
};
},
});
console.log('Reports: Component render', { isLoading, hasError: !!error, hasData: !!summaryData });
if (error) {
console.error('Reports: Query error:', error);
return (
<div className="text-center py-12">
<p className="text-red-600 mb-2">Error loading reports</p>
<p className="text-muted-foreground text-sm">{error.message}</p>
</div>
);
}
if (isLoading) {
return (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{[...Array(6)].map((_, i) => (
<Card key={i} className="animate-pulse">
<CardContent className="p-6">
<div className="h-20 bg-muted rounded"></div>
</CardContent>
</Card>
))}
</div>
);
}
if (!summaryData) {
return (
<div className="text-center py-12">
<p className="text-muted-foreground">
No data available. Add some services to see reports.
</p>
</div>
);
}
return (
<div className="space-y-6">
{/* Summary Cards */}
<div className="grid gap-6 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">Monthly Spend</CardTitle>
<DollarSign className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{formatCurrency(summaryData.totalMonthlySpend.inr, 'INR')}</div>
<div className="text-sm text-muted-foreground">{formatCurrency(summaryData.totalMonthlySpend.usd, 'USD')}</div>
<p className="text-xs text-muted-foreground">
From {summaryData.serviceCount} active services
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Top Category</CardTitle>
<BarChart3 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{summaryData.topCategory?.category || 'N/A'}
</div>
<p className="text-xs text-muted-foreground">
{summaryData.topCategory && formatCurrency(summaryData.topCategory.amount)} monthly
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Quarterly Spend</CardTitle>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{formatCurrency(summaryData.last3MonthsTotal)}</div>
<p className="text-xs text-muted-foreground">
Last 3 months actual payments
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Categories</CardTitle>
<PieChart className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{summaryData.categoryBreakdown.length}</div>
<p className="text-xs text-muted-foreground">
Different service types
</p>
</CardContent>
</Card>
</div>
{/* Category Breakdown */}
<div className="grid gap-6 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Spending by Category</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{summaryData.categoryBreakdown.map((item) => (
<div key={item.category} className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Badge variant="outline">{item.category}</Badge>
<span className="text-sm text-muted-foreground">{item.percentage}%</span>
</div>
<div className="font-medium">{formatCurrency(item.amount)}</div>
</div>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Currency Distribution</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{Object.entries(summaryData.currencyBreakdown).map(([currency, count]) => (
<div key={currency} className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Badge variant="secondary">{currency}</Badge>
</div>
<div className="font-medium">
{count} {count === 1 ? 'service' : 'services'}
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
{/* Export Options */}
<ExportAnalysis summaryData={summaryData} />
</div>
);
}

View File

@ -0,0 +1,206 @@
import { useState } from 'react';
import { Upload, File, X, Eye } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useToast } from '@/hooks/use-toast';
import { supabase } from '@/integrations/supabase/client';
interface InvoiceUploadProps {
serviceId?: string;
currentInvoiceUrl?: string;
existingUrl?: string | null;
onInvoiceChange?: (url: string | null) => void;
onFileUploaded?: (url: string | null) => void;
disabled?: boolean;
}
export function InvoiceUpload({
serviceId,
currentInvoiceUrl,
existingUrl,
onInvoiceChange,
onFileUploaded,
disabled
}: InvoiceUploadProps) {
const [uploading, setUploading] = useState(false);
const [uploadedFile, setUploadedFile] = useState<string | null>(currentInvoiceUrl || existingUrl || null);
const { toast } = useToast();
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
try {
setUploading(true);
if (!event.target.files || event.target.files.length === 0) {
return;
}
const file = event.target.files[0];
// Validate file type
const allowedTypes = ['application/pdf', 'image/jpeg', 'image/png', 'image/jpg'];
if (!allowedTypes.includes(file.type)) {
toast({
title: 'Invalid file type',
description: 'Please upload a PDF or image file (JPEG, PNG)',
variant: 'destructive',
});
return;
}
// Validate file size (5MB max)
if (file.size > 5 * 1024 * 1024) {
toast({
title: 'File too large',
description: 'Please upload a file smaller than 5MB',
variant: 'destructive',
});
return;
}
const { data: { user } } = await supabase.auth.getUser();
if (!user) throw new Error('Not authenticated');
// Create a unique filename
const fileExt = file.name.split('.').pop();
const fileName = `${user.id}/${serviceId || 'temp'}_${Date.now()}.${fileExt}`;
// Upload file to Supabase Storage
const { data, error } = await supabase.storage
.from('invoices')
.upload(fileName, file, {
cacheControl: '3600',
upsert: false
});
if (error) {
throw error;
}
// Get the public URL
const { data: { publicUrl } } = supabase.storage
.from('invoices')
.getPublicUrl(data.path);
setUploadedFile(publicUrl);
onInvoiceChange?.(publicUrl);
onFileUploaded?.(publicUrl);
toast({
title: 'Invoice uploaded',
description: 'Invoice file has been uploaded successfully',
});
} catch (error: any) {
toast({
title: 'Upload failed',
description: error.message,
variant: 'destructive',
});
} finally {
setUploading(false);
}
};
const handleRemoveFile = async () => {
if (!uploadedFile) return;
try {
// Extract file path from URL
const url = new URL(uploadedFile);
const pathParts = url.pathname.split('/');
const filePath = pathParts.slice(-2).join('/'); // user_id/filename
// Delete from storage
const { error } = await supabase.storage
.from('invoices')
.remove([filePath]);
if (error) {
console.error('Error deleting file:', error);
}
setUploadedFile(null);
onInvoiceChange?.(null);
onFileUploaded?.(null);
toast({
title: 'Invoice removed',
description: 'Invoice file has been removed',
});
} catch (error: any) {
toast({
title: 'Error',
description: 'Failed to remove invoice file',
variant: 'destructive',
});
}
};
const handleViewFile = () => {
if (uploadedFile) {
window.open(uploadedFile, '_blank');
}
};
return (
<div className="space-y-4">
<Label>Invoice File</Label>
{uploadedFile ? (
<div className="flex items-center space-x-2 p-3 bg-muted rounded-md">
<File className="h-4 w-4 text-muted-foreground" />
<span className="text-sm flex-1 truncate">Invoice file uploaded</span>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleViewFile}
disabled={disabled}
>
<Eye className="h-4 w-4 mr-1" />
View
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleRemoveFile}
disabled={disabled}
>
<X className="h-4 w-4" />
</Button>
</div>
) : (
<div className="space-y-2">
<div className="flex items-center justify-center w-full">
<label className="flex flex-col items-center justify-center w-full h-32 border-2 border-dashed border-muted-foreground/25 rounded-lg cursor-pointer bg-muted/10 hover:bg-muted/20 transition-colors">
<div className="flex flex-col items-center justify-center pt-5 pb-6">
<Upload className="w-8 h-8 mb-4 text-muted-foreground" />
<p className="mb-2 text-sm text-muted-foreground">
<span className="font-semibold">Click to upload</span> or drag and drop
</p>
<p className="text-xs text-muted-foreground">PDF, PNG, JPG or JPEG (max 5MB)</p>
</div>
<Input
type="file"
accept=".pdf,.jpg,.jpeg,.png"
onChange={handleFileUpload}
disabled={uploading || disabled}
className="hidden"
/>
</label>
</div>
</div>
)}
{uploading && (
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
<Upload className="h-4 w-4 animate-pulse" />
<span>Uploading...</span>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,818 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { format } from 'date-fns';
import { CalendarIcon } from 'lucide-react';
import * as z from 'zod';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch';
import { Calendar } from '@/components/ui/calendar';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { cn } from '@/lib/utils';
import { supabase } from '@/integrations/supabase/client';
import { useToast } from '@/hooks/use-toast';
import { useCategories } from '@/hooks/useCategories';
import { InvoiceUpload } from './InvoiceUpload';
const serviceSchema = z.object({
service_name: z.string().min(1, 'Service name is required'),
category_id: z.string().min(1, 'Category is required'),
provider: z.string().min(1, 'Provider is required'),
vendor_id: z.union([z.string(), z.literal('none')]).optional(),
plan_name: z.string().optional(),
account_email: z.string().email().optional().or(z.literal('')),
dashboard_url: z.string().url().optional().or(z.literal('')),
start_date: z.date(),
billing_cycle: z.enum(['Monthly', 'Quarterly', 'Semi-Annual', 'Annual', 'Custom_days']),
custom_cycle_days: z.number().positive().optional(),
amount: z.number().min(0, 'Amount cannot be negative'),
currency: z.enum(['INR', 'USD', 'EUR']).default('INR'),
exchange_rate: z.number().positive().optional(),
payment_method: z.union([
z.enum(['Card', 'UPI', 'NetBanking', 'Bank Transfer', 'PayPal', 'Other']),
z.literal('none')
]).optional(),
auto_renew: z.boolean().default(false),
next_renewal_date: z.date().optional(),
next_renewal_amount: z.number().min(0, 'Amount cannot be negative').optional(),
reminder_days_before: z.number().positive().default(7),
status: z.enum(['Active', 'Paused', 'Cancelled', 'Expired']).default('Active'),
importance: z.enum(['Critical', 'Normal', 'Nice-to-have']).default('Normal'),
tags: z.string().optional(),
notes: z.string().optional(),
}).refine((data) => {
if (data.billing_cycle === 'Custom_days' && !data.custom_cycle_days) {
return false;
}
return true;
}, {
message: "Custom cycle days is required when billing cycle is Custom_days",
path: ["custom_cycle_days"],
}).refine((data) => {
if (data.currency !== 'INR' && !data.exchange_rate) {
return false;
}
return true;
}, {
message: "Exchange rate is required for non-INR currencies",
path: ["exchange_rate"],
});
type ServiceFormData = z.infer<typeof serviceSchema>;
interface ServiceEditFormProps {
service: any;
}
export function ServiceEditForm({ service }: ServiceEditFormProps) {
const navigate = useNavigate();
const { toast } = useToast();
const queryClient = useQueryClient();
const [invoiceFileUrl, setInvoiceFileUrl] = useState<string | null>(service.invoice_file_url || null);
const form = useForm<ServiceFormData>({
resolver: zodResolver(serviceSchema),
defaultValues: {
service_name: service.service_name || '',
category_id: service.category_id || '',
provider: service.provider || '',
vendor_id: service.vendor_id || 'none',
plan_name: service.plan_name || '',
account_email: service.account_email || '',
dashboard_url: service.dashboard_url || '',
start_date: new Date(service.start_date),
billing_cycle: service.billing_cycle || 'Monthly',
custom_cycle_days: service.custom_cycle_days || undefined,
amount: service.amount || 0,
next_renewal_amount: service.next_renewal_amount || undefined,
currency: service.currency || 'INR',
exchange_rate: service.exchange_rate || undefined,
payment_method: service.payment_method || 'none',
auto_renew: service.auto_renew || false,
next_renewal_date: service.next_renewal_date ? new Date(service.next_renewal_date) : undefined,
reminder_days_before: service.reminder_days_before || 7,
status: service.status || 'Active',
importance: service.importance || 'Normal',
tags: service.tags ? service.tags.join(', ') : '',
notes: service.notes || '',
},
});
const billingCycle = form.watch('billing_cycle');
// Fetch categories for the dropdown
const { data: categories, isLoading: categoriesLoading, error: categoriesError } = useCategories();
// Fetch vendors for the dropdown
const { data: vendors } = useQuery({
queryKey: ['vendors'],
queryFn: async () => {
const { data, error } = await supabase
.from('vendors')
.select('id, name')
.order('name');
if (error) throw error;
return data || [];
},
});
const updateServiceMutation = useMutation({
mutationFn: async (data: ServiceFormData) => {
// Parse tags
const tagsArray = data.tags ?
data.tags.split(',').map(tag => tag.trim()).filter(tag => tag.length > 0) :
[];
// Calculate next renewal date if not provided
let nextRenewalDate = data.next_renewal_date;
if (!nextRenewalDate) {
const startDate = new Date(data.start_date);
switch (data.billing_cycle) {
case 'Monthly':
nextRenewalDate = new Date(startDate.setMonth(startDate.getMonth() + 1));
break;
case 'Quarterly':
nextRenewalDate = new Date(startDate.setMonth(startDate.getMonth() + 3));
break;
case 'Semi-Annual':
nextRenewalDate = new Date(startDate.setMonth(startDate.getMonth() + 6));
break;
case 'Annual':
nextRenewalDate = new Date(startDate.setFullYear(startDate.getFullYear() + 1));
break;
case 'Custom_days':
nextRenewalDate = new Date(startDate.setDate(startDate.getDate() + (data.custom_cycle_days || 30)));
break;
}
}
const serviceData = {
service_name: data.service_name,
category_id: data.category_id,
provider: data.provider,
vendor_id: data.vendor_id === 'none' ? null : data.vendor_id || null,
plan_name: data.plan_name || null,
account_email: data.account_email || null,
dashboard_url: data.dashboard_url || null,
start_date: data.start_date.toISOString().split('T')[0],
billing_cycle: data.billing_cycle,
custom_cycle_days: data.custom_cycle_days || null,
amount: data.amount,
currency: data.currency,
exchange_rate: data.exchange_rate || null,
payment_method: data.payment_method === 'none' ? null : data.payment_method || null,
auto_renew: data.auto_renew,
next_renewal_date: nextRenewalDate?.toISOString().split('T')[0] || null,
next_renewal_amount: data.next_renewal_amount || null,
reminder_days_before: data.reminder_days_before,
status: data.status,
importance: data.importance,
tags: tagsArray,
notes: data.notes || null,
invoice_file_url: invoiceFileUrl,
};
const { error } = await supabase
.from('services')
.update(serviceData)
.eq('id', service.id);
if (error) throw error;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['services'] });
queryClient.invalidateQueries({ queryKey: ['service', service.id] });
toast({
title: 'Service updated',
description: 'Your service has been updated successfully.',
});
navigate('/services');
},
onError: (error: any) => {
toast({
title: 'Error',
description: error.message,
variant: 'destructive',
});
},
});
const onSubmit = (data: ServiceFormData) => {
updateServiceMutation.mutate(data);
};
const currencies = ['INR', 'USD', 'EUR'];
const billingCycles = ['Monthly', 'Quarterly', 'Semi-Annual', 'Annual', 'Custom_days'];
const paymentMethods = ['Card', 'UPI', 'NetBanking', 'Bank Transfer', 'PayPal', 'Other'];
const statuses = ['Active', 'Paused', 'Cancelled', 'Expired'];
const importanceLevels = ['Critical', 'Normal', 'Nice-to-have'];
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<div className="grid gap-6 md:grid-cols-2">
{/* Basic Information */}
<Card>
<CardHeader>
<CardTitle>Basic Information</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="service_name"
render={({ field }) => (
<FormItem>
<FormLabel>Service Name *</FormLabel>
<FormControl>
<Input placeholder="e.g., AWS EC2, Google Workspace" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="category_id"
render={({ field }) => (
<FormItem>
<FormLabel>Category *</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a category" />
</SelectTrigger>
</FormControl>
<SelectContent>
{categoriesLoading ? (
<SelectItem value="loading" disabled>Loading categories...</SelectItem>
) : categoriesError ? (
<SelectItem value="error" disabled>Error loading categories</SelectItem>
) : categories?.length === 0 ? (
<SelectItem value="empty" disabled>No categories available</SelectItem>
) : (
categories?.map((category) => (
<SelectItem key={category.id} value={category.id}>
<div className="flex items-center">
{category.icon && <span className="mr-2">{category.icon}</span>}
{category.name}
</div>
</SelectItem>
))
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="provider"
render={({ field }) => (
<FormItem>
<FormLabel>Provider *</FormLabel>
<FormControl>
<Input placeholder="e.g., Amazon, Google, Microsoft" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="vendor_id"
render={({ field }) => (
<FormItem>
<FormLabel>Vendor (Optional)</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a vendor" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="none">No vendor</SelectItem>
{vendors?.map((vendor) => (
<SelectItem key={vendor.id} value={vendor.id}>
{vendor.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="plan_name"
render={({ field }) => (
<FormItem>
<FormLabel>Plan Name</FormLabel>
<FormControl>
<Input placeholder="e.g., Basic, Pro, Enterprise" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
{/* Contact & Access */}
<Card>
<CardHeader>
<CardTitle>Contact & Access</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="account_email"
render={({ field }) => (
<FormItem>
<FormLabel>Account Email</FormLabel>
<FormControl>
<Input type="email" placeholder="account@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="dashboard_url"
render={({ field }) => (
<FormItem>
<FormLabel>Dashboard URL</FormLabel>
<FormControl>
<Input placeholder="https://dashboard.example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="importance"
render={({ field }) => (
<FormItem>
<FormLabel>Importance</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select importance" />
</SelectTrigger>
</FormControl>
<SelectContent>
{importanceLevels.map((level) => (
<SelectItem key={level} value={level}>
{level}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>Status</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select status" />
</SelectTrigger>
</FormControl>
<SelectContent>
{statuses.map((status) => (
<SelectItem key={status} value={status}>
{status}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="tags"
render={({ field }) => (
<FormItem>
<FormLabel>Tags</FormLabel>
<FormControl>
<Input placeholder="production, critical, backup (comma-separated)" {...field} />
</FormControl>
<FormDescription>
Enter tags separated by commas
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
</div>
{/* Billing Information */}
<Card>
<CardHeader>
<CardTitle>Billing Information</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<FormField
control={form.control}
name="start_date"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Start Date *</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
className={cn(
"pl-3 text-left font-normal",
!field.value && "text-muted-foreground"
)}
>
{field.value ? (
format(field.value, "PPP")
) : (
<span>Pick a date</span>
)}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={field.value}
onSelect={field.onChange}
disabled={(date) =>
date > new Date() || date < new Date("1900-01-01")
}
initialFocus
/>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="billing_cycle"
render={({ field }) => (
<FormItem>
<FormLabel>Billing Cycle *</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select billing cycle" />
</SelectTrigger>
</FormControl>
<SelectContent>
{billingCycles.map((cycle) => (
<SelectItem key={cycle} value={cycle}>
{cycle.replace('_', ' ')}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{billingCycle === 'Custom_days' && (
<FormField
control={form.control}
name="custom_cycle_days"
render={({ field }) => (
<FormItem>
<FormLabel>Custom Cycle Days *</FormLabel>
<FormControl>
<Input
type="number"
placeholder="30"
{...field}
onChange={(e) => field.onChange(e.target.value ? parseInt(e.target.value) : undefined)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="amount"
render={({ field }) => (
<FormItem>
<FormLabel>Amount *</FormLabel>
<FormControl>
<Input
type="number"
step="0.01"
placeholder="99.99"
{...field}
onChange={(e) => field.onChange(e.target.value ? parseFloat(e.target.value) : undefined)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="currency"
render={({ field }) => (
<FormItem>
<FormLabel>Currency *</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select currency" />
</SelectTrigger>
</FormControl>
<SelectContent>
{currencies.map((currency) => (
<SelectItem key={currency} value={currency}>
{currency}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="exchange_rate"
render={({ field }) => (
<FormItem>
<FormLabel>
Exchange Rate to USD {form.watch('currency') !== 'INR' ? '*' : '(Optional)'}
</FormLabel>
<FormControl>
<Input
type="number"
step="0.01"
placeholder={form.watch('currency') === 'EUR' ? '0.85' : form.watch('currency') === 'USD' ? '1.00' : '83.50'}
{...field}
onChange={(e) => field.onChange(e.target.value ? parseFloat(e.target.value) : undefined)}
/>
</FormControl>
<FormDescription>
{form.watch('currency') !== 'INR'
? `Enter the current exchange rate: 1 ${form.watch('currency')} = X USD`
: 'Optional: Track exchange rate for historical analysis'}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="payment_method"
render={({ field }) => (
<FormItem>
<FormLabel>Payment Method</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select payment method" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="none">Not specified</SelectItem>
{paymentMethods.map((method) => (
<SelectItem key={method} value={method}>
{method}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
{/* Renewal Settings */}
<Card>
<CardHeader>
<CardTitle>Renewal Settings</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="next_renewal_date"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Next Renewal Date</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
className={cn(
"pl-3 text-left font-normal",
!field.value && "text-muted-foreground"
)}
>
{field.value ? (
format(field.value, "PPP")
) : (
<span>Auto-calculated from start date</span>
)}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={field.value}
onSelect={field.onChange}
disabled={(date) =>
date < new Date()
}
initialFocus
/>
</PopoverContent>
</Popover>
<FormDescription>
Leave empty to auto-calculate based on start date and billing cycle
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="next_renewal_amount"
render={({ field }) => (
<FormItem>
<FormLabel>Next Renewal Amount (optional)</FormLabel>
<FormControl>
<Input
type="number"
placeholder="e.g., 1499"
{...field}
onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
/>
</FormControl>
<FormDescription>
If the upcoming invoice amount differs from the current plan.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="reminder_days_before"
render={({ field }) => (
<FormItem>
<FormLabel>Reminder Days Before</FormLabel>
<FormControl>
<Input
type="number"
placeholder="7"
{...field}
onChange={(e) => field.onChange(e.target.value ? parseInt(e.target.value) : 7)}
/>
</FormControl>
<FormDescription>
Number of days before renewal to send reminder
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="auto_renew"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">
Auto Renew
</FormLabel>
<FormDescription>
Service will automatically renew
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</CardContent>
</Card>
{/* Additional Information */}
<Card>
<CardHeader>
<CardTitle>Additional Information</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="notes"
render={({ field }) => (
<FormItem>
<FormLabel>Notes</FormLabel>
<FormControl>
<Textarea
placeholder="Any additional notes about this service..."
className="min-h-[100px]"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-2">
<label className="text-sm font-medium">Invoice Upload</label>
<InvoiceUpload onFileUploaded={setInvoiceFileUrl} existingUrl={invoiceFileUrl} />
</div>
</CardContent>
</Card>
<div className="flex justify-end space-x-4">
<Button
type="button"
variant="outline"
onClick={() => navigate('/services')}
disabled={updateServiceMutation.isPending}
>
Cancel
</Button>
<Button type="submit" disabled={updateServiceMutation.isPending}>
{updateServiceMutation.isPending ? 'Updating...' : 'Update Service'}
</Button>
</div>
</form>
</Form>
);
}

View File

@ -0,0 +1,821 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { format } from 'date-fns';
import { CalendarIcon, ArrowLeft } from 'lucide-react';
import * as z from 'zod';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch';
import { Calendar } from '@/components/ui/calendar';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { cn } from '@/lib/utils';
import { supabase } from '@/integrations/supabase/client';
import { useToast } from '@/hooks/use-toast';
import { useCategories } from '@/hooks/useCategories';
import { InvoiceUpload } from './InvoiceUpload';
const serviceSchema = z.object({
service_name: z.string().min(1, 'Service name is required'),
category_id: z.string().min(1, 'Category is required'),
provider: z.string().min(1, 'Provider is required'),
vendor_id: z.union([z.string(), z.literal('none')]).optional(),
plan_name: z.string().optional(),
account_email: z.string().email().optional().or(z.literal('')),
dashboard_url: z.string().url().optional().or(z.literal('')),
start_date: z.date(),
billing_cycle: z.enum(['Monthly', 'Quarterly', 'Semi-Annual', 'Annual', 'Custom_days']),
custom_cycle_days: z.number().positive().optional(),
amount: z.number().min(0, 'Amount cannot be negative'),
currency: z.enum(['INR', 'USD', 'EUR']).default('INR'),
exchange_rate: z.number().positive().optional(),
payment_method: z.union([
z.enum(['Card', 'UPI', 'NetBanking', 'Bank Transfer', 'PayPal', 'Other']),
z.literal('none')
]).optional(),
auto_renew: z.boolean().default(false),
next_renewal_date: z.date().optional(),
next_renewal_amount: z.number().min(0, 'Amount cannot be negative').optional(),
reminder_days_before: z.number().positive().default(7),
status: z.enum(['Active', 'Paused', 'Cancelled', 'Expired']).default('Active'),
importance: z.enum(['Critical', 'Normal', 'Nice-to-have']).default('Normal'),
tags: z.string().optional(),
notes: z.string().optional(),
}).refine((data) => {
if (data.billing_cycle === 'Custom_days' && !data.custom_cycle_days) {
return false;
}
return true;
}, {
message: "Custom cycle days is required when billing cycle is Custom_days",
path: ["custom_cycle_days"],
}).refine((data) => {
if (data.currency !== 'INR' && !data.exchange_rate) {
return false;
}
return true;
}, {
message: "Exchange rate is required for non-INR currencies",
path: ["exchange_rate"],
});
type ServiceFormData = z.infer<typeof serviceSchema>;
export function ServiceForm() {
const navigate = useNavigate();
const { toast } = useToast();
const queryClient = useQueryClient();
const [invoiceFileUrl, setInvoiceFileUrl] = useState<string | null>(null);
const form = useForm<ServiceFormData>({
resolver: zodResolver(serviceSchema),
defaultValues: {
currency: 'INR',
auto_renew: false,
reminder_days_before: 7,
status: 'Active',
importance: 'Normal',
start_date: new Date(),
},
});
const billingCycle = form.watch('billing_cycle');
// Fetch categories for the dropdown
const { data: categories, isLoading: categoriesLoading, error: categoriesError } = useCategories();
console.log('ServiceForm: Categories state:', {
count: categories?.length,
loading: categoriesLoading,
error: categoriesError
});
// Fetch vendors for the dropdown
const { data: vendors } = useQuery({
queryKey: ['vendors'],
queryFn: async () => {
const { data, error } = await supabase
.from('vendors')
.select('id, name')
.order('name');
if (error) throw error;
return data || [];
},
});
const createServiceMutation = useMutation({
mutationFn: async (data: ServiceFormData) => {
const { data: user } = await supabase.auth.getUser();
if (!user.user) throw new Error('Not authenticated');
// Parse tags
const tagsArray = data.tags ?
data.tags.split(',').map(tag => tag.trim()).filter(tag => tag.length > 0) :
[];
// Calculate next renewal date if not provided
let nextRenewalDate = data.next_renewal_date;
if (!nextRenewalDate) {
const startDate = new Date(data.start_date);
switch (data.billing_cycle) {
case 'Monthly':
nextRenewalDate = new Date(startDate.setMonth(startDate.getMonth() + 1));
break;
case 'Quarterly':
nextRenewalDate = new Date(startDate.setMonth(startDate.getMonth() + 3));
break;
case 'Semi-Annual':
nextRenewalDate = new Date(startDate.setMonth(startDate.getMonth() + 6));
break;
case 'Annual':
nextRenewalDate = new Date(startDate.setFullYear(startDate.getFullYear() + 1));
break;
case 'Custom_days':
nextRenewalDate = new Date(startDate.setDate(startDate.getDate() + (data.custom_cycle_days || 30)));
break;
}
}
const serviceData = {
user_id: user.user.id,
service_name: data.service_name,
category_id: data.category_id,
provider: data.provider,
vendor_id: data.vendor_id === 'none' ? null : data.vendor_id || null,
plan_name: data.plan_name || null,
account_email: data.account_email || null,
dashboard_url: data.dashboard_url || null,
start_date: data.start_date.toISOString().split('T')[0],
billing_cycle: data.billing_cycle,
custom_cycle_days: data.custom_cycle_days || null,
amount: data.amount,
currency: data.currency,
payment_method: data.payment_method === 'none' ? null : data.payment_method || null,
auto_renew: data.auto_renew,
next_renewal_date: nextRenewalDate?.toISOString().split('T')[0] || null,
next_renewal_amount: data.next_renewal_amount || null,
reminder_days_before: data.reminder_days_before,
status: data.status,
importance: data.importance,
tags: tagsArray,
notes: data.notes || null,
invoice_file_url: invoiceFileUrl,
exchange_rate: data.exchange_rate || null,
};
const { error } = await supabase
.from('services')
.insert([serviceData]);
if (error) throw error;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['services'] });
toast({
title: 'Service created',
description: 'Your new service has been added successfully.',
});
navigate('/services');
},
onError: (error: any) => {
toast({
title: 'Error',
description: error.message,
variant: 'destructive',
});
},
});
const onSubmit = (data: ServiceFormData) => {
createServiceMutation.mutate(data);
};
const currencies = ['INR', 'USD', 'EUR'];
const billingCycles = ['Monthly', 'Quarterly', 'Semi-Annual', 'Annual', 'Custom_days'];
const paymentMethods = ['Card', 'UPI', 'NetBanking', 'Bank Transfer', 'PayPal', 'Other'];
const statuses = ['Active', 'Paused', 'Cancelled', 'Expired'];
const importanceLevels = ['Critical', 'Normal', 'Nice-to-have'];
return (
<div className="p-6 space-y-6">
<div className="flex items-center space-x-4">
<Button variant="outline" size="sm" onClick={() => navigate('/services')}>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Services
</Button>
<div>
<h1 className="text-3xl font-bold text-foreground">Add New Service</h1>
<p className="text-muted-foreground">
Add a new subscription or service to track
</p>
</div>
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<div className="grid gap-6 md:grid-cols-2">
{/* Basic Information */}
<Card>
<CardHeader>
<CardTitle>Basic Information</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="service_name"
render={({ field }) => (
<FormItem>
<FormLabel>Service Name *</FormLabel>
<FormControl>
<Input placeholder="e.g., AWS EC2, Google Workspace" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="category_id"
render={({ field }) => (
<FormItem>
<FormLabel>Category *</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a category" />
</SelectTrigger>
</FormControl>
<SelectContent>
{categoriesLoading ? (
<SelectItem value="loading" disabled>Loading categories...</SelectItem>
) : categoriesError ? (
<SelectItem value="error" disabled>Error loading categories</SelectItem>
) : categories?.length === 0 ? (
<SelectItem value="empty" disabled>No categories available</SelectItem>
) : (
categories?.map((category) => (
<SelectItem key={category.id} value={category.id}>
<div className="flex items-center">
{category.icon && <span className="mr-2">{category.icon}</span>}
{category.name}
</div>
</SelectItem>
))
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="provider"
render={({ field }) => (
<FormItem>
<FormLabel>Provider *</FormLabel>
<FormControl>
<Input placeholder="e.g., Amazon, Google, Microsoft" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="vendor_id"
render={({ field }) => (
<FormItem>
<FormLabel>Vendor (Optional)</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a vendor" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="none">No vendor</SelectItem>
{vendors?.map((vendor) => (
<SelectItem key={vendor.id} value={vendor.id}>
{vendor.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="plan_name"
render={({ field }) => (
<FormItem>
<FormLabel>Plan Name</FormLabel>
<FormControl>
<Input placeholder="e.g., Basic, Pro, Enterprise" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
{/* Contact & Access */}
<Card>
<CardHeader>
<CardTitle>Contact & Access</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="account_email"
render={({ field }) => (
<FormItem>
<FormLabel>Account Email</FormLabel>
<FormControl>
<Input type="email" placeholder="account@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="dashboard_url"
render={({ field }) => (
<FormItem>
<FormLabel>Dashboard URL</FormLabel>
<FormControl>
<Input placeholder="https://dashboard.example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="importance"
render={({ field }) => (
<FormItem>
<FormLabel>Importance</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select importance" />
</SelectTrigger>
</FormControl>
<SelectContent>
{importanceLevels.map((level) => (
<SelectItem key={level} value={level}>
{level}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>Status</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select status" />
</SelectTrigger>
</FormControl>
<SelectContent>
{statuses.map((status) => (
<SelectItem key={status} value={status}>
{status}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="tags"
render={({ field }) => (
<FormItem>
<FormLabel>Tags</FormLabel>
<FormControl>
<Input placeholder="production, critical, backup (comma-separated)" {...field} />
</FormControl>
<FormDescription>
Enter tags separated by commas
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
</div>
{/* Billing Information */}
<Card>
<CardHeader>
<CardTitle>Billing Information</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<FormField
control={form.control}
name="start_date"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Start Date *</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
className={cn(
"pl-3 text-left font-normal",
!field.value && "text-muted-foreground"
)}
>
{field.value ? (
format(field.value, "PPP")
) : (
<span>Pick a date</span>
)}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={field.value}
onSelect={field.onChange}
initialFocus
className={cn("p-3 pointer-events-auto")}
/>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="billing_cycle"
render={({ field }) => (
<FormItem>
<FormLabel>Billing Cycle *</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select billing cycle" />
</SelectTrigger>
</FormControl>
<SelectContent>
{billingCycles.map((cycle) => (
<SelectItem key={cycle} value={cycle}>
{cycle.replace('_', ' ')}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{billingCycle === 'Custom_days' && (
<FormField
control={form.control}
name="custom_cycle_days"
render={({ field }) => (
<FormItem>
<FormLabel>Custom Cycle Days *</FormLabel>
<FormControl>
<Input
type="number"
placeholder="30"
{...field}
onChange={(e) => field.onChange(e.target.value ? parseInt(e.target.value) : undefined)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="amount"
render={({ field }) => (
<FormItem>
<FormLabel>Amount *</FormLabel>
<FormControl>
<Input
type="number"
step="0.01"
placeholder="9.99"
{...field}
onChange={(e) => field.onChange(e.target.value ? parseFloat(e.target.value) : 0)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="currency"
render={({ field }) => (
<FormItem>
<FormLabel>Currency *</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select currency" />
</SelectTrigger>
</FormControl>
<SelectContent>
{currencies.map((currency) => (
<SelectItem key={currency} value={currency}>
{currency}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="exchange_rate"
render={({ field }) => (
<FormItem>
<FormLabel>
Exchange Rate to USD {form.watch('currency') !== 'INR' ? '*' : '(Optional)'}
</FormLabel>
<FormControl>
<Input
type="number"
step="0.01"
placeholder={form.watch('currency') === 'EUR' ? '0.85' : form.watch('currency') === 'USD' ? '1.00' : '83.50'}
{...field}
onChange={(e) => field.onChange(e.target.value ? parseFloat(e.target.value) : undefined)}
/>
</FormControl>
<FormDescription>
{form.watch('currency') !== 'INR'
? `Enter the current exchange rate: 1 ${form.watch('currency')} = X USD`
: 'Optional: Track exchange rate for historical analysis'}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="payment_method"
render={({ field }) => (
<FormItem>
<FormLabel>Payment Method</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select payment method" />
</SelectTrigger>
</FormControl>
<SelectContent className="bg-background border shadow-md z-50">
<SelectItem value="none">No payment method</SelectItem>
{paymentMethods.map((method) => (
<SelectItem key={method} value={method}>
{method}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
{/* Renewal Settings */}
<Card>
<CardHeader>
<CardTitle>Renewal Settings</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="next_renewal_date"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Next Renewal Date</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
className={cn(
"pl-3 text-left font-normal",
!field.value && "text-muted-foreground"
)}
>
{field.value ? (
format(field.value, "PPP")
) : (
<span>Auto-calculate from start date</span>
)}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={field.value}
onSelect={field.onChange}
disabled={(date) => date < new Date()}
initialFocus
className={cn("p-3 pointer-events-auto")}
/>
</PopoverContent>
</Popover>
<FormDescription>
Leave empty to auto-calculate based on start date and billing cycle
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="next_renewal_amount"
render={({ field }) => (
<FormItem>
<FormLabel>Next Renewal Amount (optional)</FormLabel>
<FormControl>
<Input
type="number"
placeholder="e.g., 1499"
{...field}
onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
/>
</FormControl>
<FormDescription>
If the upcoming invoice amount differs from the current plan.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="reminder_days_before"
render={({ field }) => (
<FormItem>
<FormLabel>Reminder Days Before</FormLabel>
<FormControl>
<Input
type="number"
placeholder="7"
{...field}
onChange={(e) => field.onChange(e.target.value ? parseInt(e.target.value) : 7)}
/>
</FormControl>
<FormDescription>
How many days before renewal to send reminders
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="auto_renew"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">Auto Renewal</FormLabel>
<FormDescription>
Enable automatic renewal for this service
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</CardContent>
</Card>
{/* Notes & Invoice */}
<Card>
<CardHeader>
<CardTitle>Additional Information</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="notes"
render={({ field }) => (
<FormItem>
<FormLabel>Notes</FormLabel>
<FormControl>
<Textarea
placeholder="Any additional information about this service..."
className="resize-none"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<InvoiceUpload
onInvoiceChange={setInvoiceFileUrl}
currentInvoiceUrl={invoiceFileUrl}
/>
</CardContent>
</Card>
{/* Submit Buttons */}
<div className="flex items-center space-x-4">
<Button
type="submit"
disabled={createServiceMutation.isPending}
className="min-w-[120px]"
>
{createServiceMutation.isPending ? 'Creating...' : 'Create Service'}
</Button>
<Button
type="button"
variant="outline"
onClick={() => navigate('/services')}
>
Cancel
</Button>
</div>
</form>
</Form>
</div>
);
}

View File

@ -0,0 +1,544 @@
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
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 {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Plus,
Search,
Filter,
MoreHorizontal,
ExternalLink,
Calendar,
DollarSign,
Edit,
Trash2,
RefreshCw,
FileText,
Eye
} from 'lucide-react';
import { supabase } from '@/integrations/supabase/client';
import { Service, ServiceWithPayments } from '@/lib/types';
import { useToast } from '@/hooks/use-toast';
import { format } from 'date-fns';
import { formatCurrency } from '@/lib/currency';
import { usePermissions } from '@/hooks/usePermissions';
import { Link } from 'react-router-dom';
import { ServicesTableMobile } from './ServicesTableMobile';
import {
Play,
Pause,
X
} from 'lucide-react';
interface ServicesTableProps {
searchTerm?: string;
}
export function ServicesTable({ searchTerm: externalSearchTerm }: ServicesTableProps) {
const [internalSearchTerm, setInternalSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [categoryFilter, setCategoryFilter] = useState<string>('all');
const [isMobile, setIsMobile] = useState(false);
const { canManageData } = usePermissions();
const { toast } = useToast();
const queryClient = useQueryClient();
// Use external search term if provided, otherwise use internal
const searchTerm = externalSearchTerm !== undefined ? externalSearchTerm : internalSearchTerm;
// Check if we're on mobile screen size
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 768);
};
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
const { data: services, isLoading } = useQuery({
queryKey: ['services'],
queryFn: async (): Promise<ServiceWithPayments[]> => {
const { data, error } = await supabase
.from('services')
.select(`
*,
vendor:vendors(*),
payments(*),
categories!inner(id, name, icon)
`)
.order('created_at', { ascending: false });
if (error) throw error;
return data || [];
},
});
const deleteServiceMutation = useMutation({
mutationFn: async (serviceId: string) => {
const { error } = await supabase
.from('services')
.delete()
.eq('id', serviceId);
if (error) throw error;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['services'] });
toast({
title: 'Service deleted',
description: 'The service has been removed successfully.',
});
},
onError: (error: any) => {
toast({
title: 'Error',
description: error.message,
variant: 'destructive',
});
},
});
const updateServiceStatusMutation = useMutation({
mutationFn: async ({ serviceId, status }: { serviceId: string; status: 'Active' | 'Paused' | 'Cancelled' | 'Expired' }) => {
const { error } = await supabase
.from('services')
.update({ status })
.eq('id', serviceId);
if (error) throw error;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['services'] });
toast({
title: 'Service updated',
description: 'Service status has been updated successfully.',
});
},
onError: (error: any) => {
toast({
title: 'Error',
description: error.message,
variant: 'destructive',
});
},
});
const filteredServices = services?.filter(service => {
const matchesSearch = service.service_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
service.provider.toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus = statusFilter === 'all' || service.status.toLowerCase() === statusFilter.toLowerCase();
const matchesCategory = categoryFilter === 'all' || service.categories?.name === categoryFilter;
return matchesSearch && matchesStatus && matchesCategory;
}) || [];
const getStatusBadgeVariant = (status: string) => {
switch (status.toLowerCase()) {
case 'active': return 'default';
case 'paused': return 'secondary';
case 'cancelled': return 'outline';
case 'expired': return 'destructive';
default: return 'default';
}
};
const getUrgencyColor = (renewalDate: string | null) => {
if (!renewalDate) return '';
const days = Math.ceil((new Date(renewalDate).getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24));
if (days < 0) return 'text-red-600';
if (days <= 7) return 'text-orange-600';
if (days <= 30) return 'text-yellow-600';
return 'text-green-600';
};
// Get categories for filtering
const { data: categories } = useQuery({
queryKey: ['categories-for-filter'],
queryFn: async () => {
const { data, error } = await supabase
.from('categories')
.select('id, name')
.eq('is_active', true)
.order('sort_order');
if (error) throw error;
return data || [];
},
});
const statuses = ['Active', 'Paused', 'Cancelled', 'Expired'];
if (isLoading) {
return (
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
</CardContent>
</Card>
);
}
return (
<div className="space-y-4">
{/* Search and Filters - Only show if external search is not provided */}
{externalSearchTerm === undefined && (
<Card>
<CardHeader>
<CardTitle>Services</CardTitle>
<div className="flex flex-col sm:flex-row gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search services..."
value={internalSearchTerm}
onChange={(e) => setInternalSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Filter by status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Statuses</SelectItem>
{statuses.map(status => (
<SelectItem key={status} value={status.toLowerCase()}>{status}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Filter by category" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Categories</SelectItem>
{categories?.map(category => (
<SelectItem key={category.id} value={category.name}>{category.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardHeader>
</Card>
)}
{/* Always show filters */}
<div className="flex gap-2 justify-end">
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Statuses</SelectItem>
{statuses.map(status => (
<SelectItem key={status} value={status.toLowerCase()}>{status}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="Category" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Categories</SelectItem>
{categories?.map(category => (
<SelectItem key={category.id} value={category.name}>{category.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Table Content */}
<Card className="animate-fade-in">
<CardContent className="p-0">
{isMobile ? (
<ServicesTableMobile
services={filteredServices}
canManageData={canManageData}
onDelete={(serviceId) => deleteServiceMutation.mutate(serviceId)}
onStatusUpdate={(serviceId, status) => updateServiceStatusMutation.mutate({ serviceId, status })}
/>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Service</TableHead>
<TableHead>Provider</TableHead>
<TableHead>Category</TableHead>
<TableHead>Amount</TableHead>
<TableHead>Billing</TableHead>
<TableHead>Next Renewal</TableHead>
<TableHead>Invoice</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredServices.length === 0 ? (
<TableRow>
<TableCell colSpan={9} className="text-center py-12">
<div className="text-muted-foreground">
{searchTerm || statusFilter !== 'all' || categoryFilter !== 'all'
? 'No services match your filters'
: 'No services yet. Add your first service to get started.'}
</div>
</TableCell>
</TableRow>
) : (
filteredServices.map((service) => (
<TableRow key={service.id} className="hover:bg-muted/50 transition-colors">
<TableCell className="py-3">
<div className="space-y-1">
<div className="font-medium text-sm">{service.service_name}</div>
{service.plan_name && (
<div className="text-xs text-muted-foreground">{service.plan_name}</div>
)}
{service.dashboard_url && (
<Button
variant="link"
size="sm"
className="p-0 h-auto text-xs"
asChild
>
<a href={service.dashboard_url} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-3 w-3 mr-1 text-indigo-500" />
Dashboard
</a>
</Button>
)}
</div>
</TableCell>
<TableCell className="py-3">
<Badge variant="outline" className="text-xs font-normal">
{service.provider}
</Badge>
</TableCell>
<TableCell className="py-3">
<Badge variant="outline" className="text-xs font-normal">
<div className="flex items-center">
{service.categories?.icon && <span className="mr-1 text-xs">{service.categories.icon}</span>}
{service.categories?.name || 'Unknown'}
</div>
</Badge>
</TableCell>
<TableCell className="py-3">
<div className="text-right">
<div className="font-semibold text-sm">
{formatCurrency(service.amount, service.currency)}
</div>
<Badge variant="secondary" className="text-xs mt-1">
{service.currency}
</Badge>
</div>
</TableCell>
<TableCell className="py-3">
<div className="flex items-center space-x-2">
<RefreshCw className="h-3 w-3 text-blue-500" />
<div>
<div className="text-sm font-medium">{service.billing_cycle}</div>
{service.auto_renew && (
<Badge variant="secondary" className="text-xs">Auto</Badge>
)}
</div>
</div>
</TableCell>
<TableCell className="py-3">
{service.next_renewal_date ? (
<div className="flex items-center space-x-2">
<Calendar className="h-3 w-3 text-orange-500" />
<div>
<div className={`text-sm font-medium ${getUrgencyColor(service.next_renewal_date)}`}>
{format(new Date(service.next_renewal_date), 'MMM dd, yyyy')}
</div>
<div className="text-xs text-muted-foreground">
{format(new Date(service.next_renewal_date), 'EEE')}
</div>
</div>
</div>
) : (
<span className="text-muted-foreground text-xs italic">Not set</span>
)}
</TableCell>
<TableCell>
{service.invoice_file_url ? (
<Button
variant="outline"
size="sm"
onClick={async () => {
try {
console.log('Original invoice URL:', service.invoice_file_url);
// Extract the file path from the URL
const url = new URL(service.invoice_file_url!);
console.log('Parsed URL pathname:', url.pathname);
// Extract everything after '/storage/v1/object/public/invoices/'
const pathParts = url.pathname.split('/storage/v1/object/public/invoices/');
const filePath = pathParts[1];
console.log('Extracted file path:', filePath);
if (!filePath) {
throw new Error('Could not extract file path from URL');
}
// Generate signed URL for private bucket
console.log('Creating signed URL for path:', filePath);
const { data, error } = await supabase.storage
.from('invoices')
.createSignedUrl(filePath, 300); // 5 minutes expiry
if (error) {
console.error('Error creating signed URL:', error);
toast({
title: "Error",
description: `Failed to load invoice: ${error.message}`,
variant: "destructive"
});
return;
}
console.log('Signed URL created successfully:', data.signedUrl);
window.open(data.signedUrl, '_blank');
} catch (error) {
console.error('Error viewing invoice:', error);
toast({
title: "Error",
description: `Failed to open invoice: ${error instanceof Error ? error.message : 'Unknown error'}`,
variant: "destructive"
});
}
}}
>
<Eye className="h-3 w-3 mr-1" />
View
</Button>
) : (
<div className="flex items-center text-muted-foreground">
<FileText className="h-3 w-3 mr-1" />
No invoice
</div>
)}
</TableCell>
<TableCell className="py-3">
<Badge
variant={getStatusBadgeVariant(service.status)}
className="text-xs font-normal"
>
{service.status}
</Badge>
</TableCell>
<TableCell className="py-3">
{canManageData ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-7 w-7 p-0 hover:bg-muted">
<MoreHorizontal className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="bg-background border shadow-md">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
{service.dashboard_url && (
<DropdownMenuItem asChild>
<a href={service.dashboard_url} target="_blank" rel="noopener noreferrer">
<ExternalLink className="mr-2 h-4 w-4" />
Open Dashboard
</a>
</DropdownMenuItem>
)}
<DropdownMenuItem asChild>
<Link to={`/services/edit/${service.id}`}>
<Edit className="mr-2 h-4 w-4" />
Edit Service
</Link>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => updateServiceStatusMutation.mutate({
serviceId: service.id,
status: service.status === 'Paused' ? 'Active' : 'Paused'
})}
>
{service.status === 'Paused' ? (
<>
<Play className="mr-2 h-4 w-4" />
Resume Service
</>
) : (
<>
<Pause className="mr-2 h-4 w-4" />
Pause Service
</>
)}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => updateServiceStatusMutation.mutate({
serviceId: service.id,
status: 'Cancelled'
})}
disabled={service.status === 'Cancelled'}
>
<X className="mr-2 h-4 w-4" />
Cancel Service
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => deleteServiceMutation.mutate(service.id)}
className="text-red-600"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete Service
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<div className="flex items-center space-x-2">
{service.dashboard_url && (
<Button variant="outline" size="sm" asChild>
<a href={service.dashboard_url} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-4 w-4" />
</a>
</Button>
)}
</div>
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,265 @@
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
MoreHorizontal,
ExternalLink,
Calendar,
DollarSign,
Edit,
Trash2,
RefreshCw,
FileText,
Eye,
Play,
Pause,
X
} from 'lucide-react';
import { ServiceWithPayments } from '@/lib/types';
import { formatCurrency } from '@/lib/currency';
import { format } from 'date-fns';
import { Link } from 'react-router-dom';
import { supabase } from '@/integrations/supabase/client';
import { useToast } from '@/hooks/use-toast';
interface ServicesTableMobileProps {
services: ServiceWithPayments[];
canManageData: boolean;
onDelete: (serviceId: string) => void;
onStatusUpdate: (serviceId: string, status: 'Active' | 'Paused' | 'Cancelled' | 'Expired') => void;
}
export function ServicesTableMobile({
services,
canManageData,
onDelete,
onStatusUpdate
}: ServicesTableMobileProps) {
const { toast } = useToast();
const getStatusBadgeVariant = (status: string) => {
switch (status.toLowerCase()) {
case 'active': return 'default';
case 'paused': return 'secondary';
case 'cancelled': return 'outline';
case 'expired': return 'destructive';
default: return 'default';
}
};
const getUrgencyColor = (renewalDate: string | null) => {
if (!renewalDate) return '';
const days = Math.ceil((new Date(renewalDate).getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24));
if (days < 0) return 'text-red-600';
if (days <= 7) return 'text-orange-600';
if (days <= 30) return 'text-yellow-600';
return 'text-green-600';
};
if (services.length === 0) {
return (
<Card>
<CardContent className="p-6 text-center">
<p className="text-muted-foreground">No services found</p>
</CardContent>
</Card>
);
}
return (
<div className="space-y-4">
{services.map((service) => (
<Card key={service.id}>
<CardContent className="p-4">
<div className="space-y-3">
{/* Header Row */}
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<h3 className="font-medium text-base truncate">{service.service_name}</h3>
<p className="text-sm text-muted-foreground">{service.provider}</p>
{service.plan_name && (
<p className="text-xs text-muted-foreground">{service.plan_name}</p>
)}
</div>
<div className="flex items-center space-x-2 ml-2">
<Badge variant={getStatusBadgeVariant(service.status)}>
{service.status}
</Badge>
{canManageData && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
{service.dashboard_url && (
<DropdownMenuItem asChild>
<a href={service.dashboard_url} target="_blank" rel="noopener noreferrer">
<ExternalLink className="mr-2 h-4 w-4" />
Open Dashboard
</a>
</DropdownMenuItem>
)}
<DropdownMenuItem asChild>
<Link to={`/services/edit/${service.id}`}>
<Edit className="mr-2 h-4 w-4" />
Edit Service
</Link>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onStatusUpdate(
service.id,
service.status === 'Paused' ? 'Active' : 'Paused'
)}
>
{service.status === 'Paused' ? (
<>
<Play className="mr-2 h-4 w-4" />
Resume Service
</>
) : (
<>
<Pause className="mr-2 h-4 w-4" />
Pause Service
</>
)}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onStatusUpdate(service.id, 'Cancelled')}
disabled={service.status === 'Cancelled'}
>
<X className="mr-2 h-4 w-4" />
Cancel Service
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onDelete(service.id)}
className="text-red-600"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete Service
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
{/* Category and Amount Row */}
<div className="flex items-center justify-between">
<Badge variant="outline" className="text-xs">
<div className="flex items-center">
{service.categories?.icon && <span className="mr-1">{service.categories.icon}</span>}
{service.categories?.name || 'Unknown'}
</div>
</Badge>
<div className="flex items-center font-medium">
<DollarSign className="h-3 w-3 mr-1" />
{formatCurrency(service.amount, service.currency)}
</div>
</div>
{/* Billing and Renewal Row */}
<div className="flex items-center justify-between text-sm">
<div className="flex items-center">
<RefreshCw className="h-3 w-3 mr-1" />
<span>{service.billing_cycle}</span>
{service.auto_renew && (
<Badge variant="secondary" className="ml-2 text-xs">Auto</Badge>
)}
</div>
{service.next_renewal_date ? (
<div className={`flex items-center ${getUrgencyColor(service.next_renewal_date)}`}>
<Calendar className="h-3 w-3 mr-1" />
{format(new Date(service.next_renewal_date), 'MMM dd, yyyy')}
</div>
) : (
<span className="text-muted-foreground">No renewal date</span>
)}
</div>
{/* Invoice and Actions Row */}
<div className="flex items-center justify-between pt-2 border-t">
<div className="flex items-center space-x-2">
{service.invoice_file_url ? (
<Button
variant="outline"
size="sm"
onClick={async () => {
try {
console.log('Original invoice URL:', service.invoice_file_url);
// Extract the file path from the URL
const url = new URL(service.invoice_file_url!);
console.log('Parsed URL pathname:', url.pathname);
// Extract everything after '/storage/v1/object/public/invoices/'
const pathParts = url.pathname.split('/storage/v1/object/public/invoices/');
const filePath = pathParts[1];
console.log('Extracted file path:', filePath);
if (!filePath) {
throw new Error('Could not extract file path from URL');
}
// Generate signed URL for private bucket
console.log('Creating signed URL for path:', filePath);
const { data, error } = await supabase.storage
.from('invoices')
.createSignedUrl(filePath, 300); // 5 minutes expiry
if (error) {
console.error('Error creating signed URL:', error);
toast({
title: "Error",
description: `Failed to load invoice: ${error.message}`,
variant: "destructive"
});
return;
}
console.log('Signed URL created successfully:', data.signedUrl);
window.open(data.signedUrl, '_blank');
} catch (error) {
console.error('Error viewing invoice:', error);
toast({
title: "Error",
description: `Failed to open invoice: ${error instanceof Error ? error.message : 'Unknown error'}`,
variant: "destructive"
});
}
}}
>
<Eye className="h-3 w-3 mr-1" />
View Invoice
</Button>
) : (
<div className="flex items-center text-muted-foreground text-sm">
<FileText className="h-3 w-3 mr-1" />
No invoice
</div>
)}
</div>
{!canManageData && service.dashboard_url && (
<Button variant="outline" size="sm" asChild>
<a href={service.dashboard_url} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-4 w-4" />
</a>
</Button>
)}
</div>
</div>
</CardContent>
</Card>
))}
</div>
);
}

View File

@ -0,0 +1,56 @@
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
const Accordion = AccordionPrimitive.Root
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("border-b", className)}
{...props}
/>
))
AccordionItem.displayName = "AccordionItem"
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
))
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
))
AccordionContent.displayName = AccordionPrimitive.Content.displayName
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@ -0,0 +1,139 @@
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/80 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}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background 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,59 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View File

@ -0,0 +1,5 @@
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
const AspectRatio = AspectRatioPrimitive.Root
export { AspectRatio }

View File

@ -0,0 +1,48 @@
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }

View File

@ -0,0 +1,36 @@
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-full 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 hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground 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, badgeVariants }

View File

@ -0,0 +1,115 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<"nav"> & {
separator?: React.ReactNode
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />)
Breadcrumb.displayName = "Breadcrumb"
const BreadcrumbList = React.forwardRef<
HTMLOListElement,
React.ComponentPropsWithoutRef<"ol">
>(({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
className
)}
{...props}
/>
))
BreadcrumbList.displayName = "BreadcrumbList"
const BreadcrumbItem = React.forwardRef<
HTMLLIElement,
React.ComponentPropsWithoutRef<"li">
>(({ className, ...props }, ref) => (
<li
ref={ref}
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
))
BreadcrumbItem.displayName = "BreadcrumbItem"
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<"a"> & {
asChild?: boolean
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a"
return (
<Comp
ref={ref}
className={cn("transition-colors hover:text-foreground", className)}
{...props}
/>
)
})
BreadcrumbLink.displayName = "BreadcrumbLink"
const BreadcrumbPage = React.forwardRef<
HTMLSpanElement,
React.ComponentPropsWithoutRef<"span">
>(({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
))
BreadcrumbPage.displayName = "BreadcrumbPage"
const BreadcrumbSeparator = ({
children,
className,
...props
}: React.ComponentProps<"li">) => (
<li
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
const BreadcrumbEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
role="presentation"
aria-hidden="true"
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
)
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}

View File

@ -0,0 +1,56 @@
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 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 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
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, buttonVariants }

View File

@ -0,0 +1,64 @@
import * as React from "react";
import { ChevronLeft, ChevronRight } from "lucide-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("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
month: "space-y-4",
caption: "flex justify-center pt-1 relative items-center",
caption_label: "text-sm font-medium",
nav: "space-x-1 flex items-center",
nav_button: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-y-1",
head_row: "flex",
head_cell:
"text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: "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: cn(
buttonVariants({ variant: "ghost" }),
"h-9 w-9 p-0 font-normal aria-selected:opacity-100"
),
day_range_end: "day-range-end",
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside:
"day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle:
"aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames,
}}
components={{
IconLeft: ({ ..._props }) => <ChevronLeft className="h-4 w-4" />,
IconRight: ({ ..._props }) => <ChevronRight className="h-4 w-4" />,
}}
{...props}
/>
);
}
Calendar.displayName = "Calendar";
export { Calendar };

View File

@ -0,0 +1,79 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@ -0,0 +1,260 @@
import * as React from "react"
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import { ArrowLeft, ArrowRight } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
setApi?: (api: CarouselApi) => void
}
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
const Carousel = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & CarouselProps
>(
(
{
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
},
ref
) => {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) {
return
}
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault()
scrollNext()
}
},
[scrollPrev, scrollNext]
)
React.useEffect(() => {
if (!api || !setApi) {
return
}
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) {
return
}
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
return () => {
api?.off("select", onSelect)
}
}, [api, onSelect])
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
ref={ref}
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
)
Carousel.displayName = "Carousel"
const CarouselContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel()
return (
<div ref={carouselRef} className="overflow-hidden">
<div
ref={ref}
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
)
})
CarouselContent.displayName = "CarouselContent"
const CarouselItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { orientation } = useCarousel()
return (
<div
ref={ref}
role="group"
aria-roledescription="slide"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
)
})
CarouselItem.displayName = "CarouselItem"
const CarouselPrevious = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-left-12 top-1/2 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span>
</Button>
)
})
CarouselPrevious.displayName = "CarouselPrevious"
const CarouselNext = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-right-12 top-1/2 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span>
</Button>
)
})
CarouselNext.displayName = "CarouselNext"
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
}

View File

@ -0,0 +1,28 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

View File

@ -0,0 +1,9 @@
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
const Collapsible = CollapsiblePrimitive.Root
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View File

@ -0,0 +1,153 @@
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-popover text-popover-foreground",
className
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
interface CommandDialogProps extends DialogProps {}
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<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-11 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 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50",
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,198 @@
import * as React from "react"
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const ContextMenu = ContextMenuPrimitive.Root
const ContextMenuTrigger = ContextMenuPrimitive.Trigger
const ContextMenuGroup = ContextMenuPrimitive.Group
const ContextMenuPortal = ContextMenuPrimitive.Portal
const ContextMenuSub = ContextMenuPrimitive.Sub
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup
const ContextMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.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 focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger>
))
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
const ContextMenuSubContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md 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}
/>
))
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 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}
/>
</ContextMenuPrimitive.Portal>
))
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<ContextMenuPrimitive.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 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">
<ContextMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
))
ContextMenuCheckboxItem.displayName =
ContextMenuPrimitive.CheckboxItem.displayName
const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<ContextMenuPrimitive.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 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">
<ContextMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
))
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName
const ContextMenuLabel = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold text-foreground",
inset && "pl-8",
className
)}
{...props}
/>
))
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
const ContextMenuSeparator = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
))
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
const ContextMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
ContextMenuShortcut.displayName = "ContextMenuShortcut"
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}

View File

@ -0,0 +1,120 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background 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}
>
{children}
<DialogPrimitive.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-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@ -0,0 +1,116 @@
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
const Drawer = ({
shouldScaleBackground = true,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerPrimitive.Root
shouldScaleBackground={shouldScaleBackground}
{...props}
/>
)
Drawer.displayName = "Drawer"
const DrawerTrigger = DrawerPrimitive.Trigger
const DrawerPortal = DrawerPrimitive.Portal
const DrawerClose = DrawerPrimitive.Close
const DrawerOverlay = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Overlay
ref={ref}
className={cn("fixed inset-0 z-50 bg-black/80", className)}
{...props}
/>
))
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
ref={ref}
className={cn(
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
className
)}
{...props}
>
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
))
DrawerContent.displayName = "DrawerContent"
const DrawerHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
{...props}
/>
)
DrawerHeader.displayName = "DrawerHeader"
const DrawerFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
DrawerFooter.displayName = "DrawerFooter"
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DrawerTitle.displayName = DrawerPrimitive.Title.displayName
const DrawerDescription = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DrawerDescription.displayName = DrawerPrimitive.Description.displayName
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}

View File

@ -0,0 +1,198 @@
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}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md 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,115 @@
import React, { useCallback, useState } from 'react';
import { useDropzone } from 'react-dropzone';
import { Upload, X, FileText } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
interface FileUploadProps {
onFileSelect: (file: File) => void;
onFileRemove: () => void;
selectedFile?: File;
currentFileUrl?: string;
accept?: string;
maxSize?: number;
className?: string;
}
export function FileUpload({
onFileSelect,
onFileRemove,
selectedFile,
currentFileUrl,
accept = '.pdf,.jpg,.jpeg,.png',
maxSize = 5 * 1024 * 1024, // 5MB
className
}: FileUploadProps) {
const [error, setError] = useState<string>('');
const onDrop = useCallback((acceptedFiles: File[], rejectedFiles: any[]) => {
setError('');
if (rejectedFiles.length > 0) {
const rejection = rejectedFiles[0];
if (rejection.errors[0]?.code === 'file-too-large') {
setError('File is too large. Maximum size is 5MB.');
} else if (rejection.errors[0]?.code === 'file-invalid-type') {
setError('Invalid file type. Please upload PDF, JPG, or PNG files.');
} else {
setError('File upload failed. Please try again.');
}
return;
}
if (acceptedFiles.length > 0) {
onFileSelect(acceptedFiles[0]);
}
}, [onFileSelect]);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: {
'application/pdf': ['.pdf'],
'image/jpeg': ['.jpg', '.jpeg'],
'image/png': ['.png']
},
maxSize,
multiple: false
});
const hasFile = selectedFile || currentFileUrl;
return (
<div className={cn('space-y-2', className)}>
{!hasFile ? (
<div
{...getRootProps()}
className={cn(
'border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors',
isDragActive
? 'border-primary bg-primary/5'
: 'border-muted-foreground/25 hover:border-primary hover:bg-primary/5'
)}
>
<input {...getInputProps()} />
<Upload className="mx-auto h-8 w-8 text-muted-foreground mb-2" />
<p className="text-sm text-muted-foreground">
{isDragActive ? (
'Drop the file here...'
) : (
<>
Drag & drop an invoice file here, or{' '}
<span className="text-primary font-medium">click to browse</span>
</>
)}
</p>
<p className="text-xs text-muted-foreground mt-1">
PDF, JPG, PNG up to 5MB
</p>
</div>
) : (
<div className="border rounded-lg p-4 bg-muted/50">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<FileText className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">
{selectedFile ? selectedFile.name : 'Current invoice file'}
</span>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={onFileRemove}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
)}
{error && (
<p className="text-sm text-red-600">{error}</p>
)}
</div>
);
}

View File

@ -0,0 +1,176 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
ControllerProps,
FieldPath,
FieldValues,
FormProvider,
useFormContext,
} 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-sm 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-sm font-medium text-destructive", className)}
{...props}
>
{body}
</p>
)
})
FormMessage.displayName = "FormMessage"
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View File

@ -0,0 +1,27 @@
import * as React from "react"
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
import { cn } from "@/lib/utils"
const HoverCard = HoverCardPrimitive.Root
const HoverCardTrigger = HoverCardPrimitive.Trigger
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md 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",
className
)}
{...props}
/>
))
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
export { HoverCard, HoverCardTrigger, HoverCardContent }

View File

@ -0,0 +1,69 @@
import * as React from "react"
import { OTPInput, OTPInputContext } from "input-otp"
import { Dot } from "lucide-react"
import { cn } from "@/lib/utils"
const InputOTP = React.forwardRef<
React.ElementRef<typeof OTPInput>,
React.ComponentPropsWithoutRef<typeof OTPInput>
>(({ className, containerClassName, ...props }, ref) => (
<OTPInput
ref={ref}
containerClassName={cn(
"flex items-center gap-2 has-[:disabled]:opacity-50",
containerClassName
)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
))
InputOTP.displayName = "InputOTP"
const InputOTPGroup = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center", className)} {...props} />
))
InputOTPGroup.displayName = "InputOTPGroup"
const InputOTPSlot = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div"> & { index: number }
>(({ index, className, ...props }, ref) => {
const inputOTPContext = React.useContext(OTPInputContext)
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]
return (
<div
ref={ref}
className={cn(
"relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
isActive && "z-10 ring-2 ring-ring ring-offset-background",
className
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
</div>
)}
</div>
)
})
InputOTPSlot.displayName = "InputOTPSlot"
const InputOTPSeparator = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ ...props }, ref) => (
<div ref={ref} role="separator" {...props}>
<Dot />
</div>
))
InputOTPSeparator.displayName = "InputOTPSeparator"
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }

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-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background 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-2 focus-visible:ring-ring focus-visible:ring-offset-2 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 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@ -0,0 +1,234 @@
import * as React from "react"
import * as MenubarPrimitive from "@radix-ui/react-menubar"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const MenubarMenu = MenubarPrimitive.Menu
const MenubarGroup = MenubarPrimitive.Group
const MenubarPortal = MenubarPrimitive.Portal
const MenubarSub = MenubarPrimitive.Sub
const MenubarRadioGroup = MenubarPrimitive.RadioGroup
const Menubar = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Root
ref={ref}
className={cn(
"flex h-10 items-center space-x-1 rounded-md border bg-background p-1",
className
)}
{...props}
/>
))
Menubar.displayName = MenubarPrimitive.Root.displayName
const MenubarTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Trigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-3 py-1.5 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
className
)}
{...props}
/>
))
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName
const MenubarSubTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<MenubarPrimitive.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 focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</MenubarPrimitive.SubTrigger>
))
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName
const MenubarSubContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-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 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}
/>
))
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName
const MenubarContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content>
>(
(
{ className, align = "start", alignOffset = -4, sideOffset = 8, ...props },
ref
) => (
<MenubarPrimitive.Portal>
<MenubarPrimitive.Content
ref={ref}
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in 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}
/>
</MenubarPrimitive.Portal>
)
)
MenubarContent.displayName = MenubarPrimitive.Content.displayName
const MenubarItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
MenubarItem.displayName = MenubarPrimitive.Item.displayName
const MenubarCheckboxItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<MenubarPrimitive.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 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">
<MenubarPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
))
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName
const MenubarRadioItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<MenubarPrimitive.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 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">
<MenubarPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
))
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName
const MenubarLabel = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
MenubarLabel.displayName = MenubarPrimitive.Label.displayName
const MenubarSeparator = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName
const MenubarShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
MenubarShortcut.displayname = "MenubarShortcut"
export {
Menubar,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarItem,
MenubarSeparator,
MenubarLabel,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarPortal,
MenubarSubContent,
MenubarSubTrigger,
MenubarGroup,
MenubarSub,
MenubarShortcut,
}

View File

@ -0,0 +1,128 @@
import * as React from "react"
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
import { cva } from "class-variance-authority"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
const NavigationMenu = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Root
ref={ref}
className={cn(
"relative z-10 flex max-w-max flex-1 items-center justify-center",
className
)}
{...props}
>
{children}
<NavigationMenuViewport />
</NavigationMenuPrimitive.Root>
))
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName
const NavigationMenuList = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.List>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.List
ref={ref}
className={cn(
"group flex flex-1 list-none items-center justify-center space-x-1",
className
)}
{...props}
/>
))
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName
const NavigationMenuItem = NavigationMenuPrimitive.Item
const navigationMenuTriggerStyle = cva(
"group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50"
)
const NavigationMenuTrigger = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Trigger
ref={ref}
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDown
className="relative top-[1px] ml-1 h-3 w-3 transition duration-200 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
))
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName
const NavigationMenuContent = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Content
ref={ref}
className={cn(
"left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ",
className
)}
{...props}
/>
))
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName
const NavigationMenuLink = NavigationMenuPrimitive.Link
const NavigationMenuViewport = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
>(({ className, ...props }, ref) => (
<div className={cn("absolute left-0 top-full flex justify-center")}>
<NavigationMenuPrimitive.Viewport
className={cn(
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
className
)}
ref={ref}
{...props}
/>
</div>
))
NavigationMenuViewport.displayName =
NavigationMenuPrimitive.Viewport.displayName
const NavigationMenuIndicator = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Indicator
ref={ref}
className={cn(
"top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
className
)}
{...props}
>
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
</NavigationMenuPrimitive.Indicator>
))
NavigationMenuIndicator.displayName =
NavigationMenuPrimitive.Indicator.displayName
export {
navigationMenuTriggerStyle,
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
}

View File

@ -0,0 +1,117 @@
import * as React from "react"
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
import { ButtonProps, buttonVariants } from "@/components/ui/button"
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
<nav
role="navigation"
aria-label="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
)
Pagination.displayName = "Pagination"
const PaginationContent = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
))
PaginationContent.displayName = "PaginationContent"
const PaginationItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ className, ...props }, ref) => (
<li ref={ref} className={cn("", className)} {...props} />
))
PaginationItem.displayName = "PaginationItem"
type PaginationLinkProps = {
isActive?: boolean
} & Pick<ButtonProps, "size"> &
React.ComponentProps<"a">
const PaginationLink = ({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) => (
<a
aria-current={isActive ? "page" : undefined}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className
)}
{...props}
/>
)
PaginationLink.displayName = "PaginationLink"
const PaginationPrevious = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 pl-2.5", className)}
{...props}
>
<ChevronLeft className="h-4 w-4" />
<span>Previous</span>
</PaginationLink>
)
PaginationPrevious.displayName = "PaginationPrevious"
const PaginationNext = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 pr-2.5", className)}
{...props}
>
<span>Next</span>
<ChevronRight className="h-4 w-4" />
</PaginationLink>
)
PaginationNext.displayName = "PaginationNext"
const PaginationEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
aria-hidden
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More pages</span>
</span>
)
PaginationEllipsis.displayName = "PaginationEllipsis"
export {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
}

View File

@ -0,0 +1,29 @@
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 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}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md 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",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent }

View File

@ -0,0 +1,26 @@
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }

View File

@ -0,0 +1,42 @@
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={cn("grid gap-2", className)}
{...props}
ref={ref}
/>
)
})
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
})
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
export { RadioGroup, RadioGroupItem }

View File

@ -0,0 +1,43 @@
import { GripVertical } from "lucide-react"
import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "@/lib/utils"
const ResizablePanelGroup = ({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
<ResizablePrimitive.PanelGroup
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className
)}
{...props}
/>
)
const ResizablePanel = ResizablePrimitive.Panel
const ResizableHandle = ({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean
}) => (
<ResizablePrimitive.PanelResizeHandle
className={cn(
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
<GripVertical className="h-2.5 w-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
)
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }

View File

@ -0,0 +1,46 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

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-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 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}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md 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",
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("py-1.5 pl-8 pr-2 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-8 pr-2 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 left-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,29 @@
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,131 @@
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import * as React from "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/80 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 p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
{
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}
>
{children}
<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>
</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, SheetClose,
SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetOverlay, SheetPortal, SheetTitle, SheetTrigger
}

View File

@ -0,0 +1,761 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { VariantProps, cva } 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 } 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 SidebarContext = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContext | 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<SidebarContext>(
() => ({
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}
>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
ref={ref}
className="group peer hidden md:block text-sidebar-foreground"
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(
"duration-200 relative h-svh w-[--sidebar-width] bg-transparent transition-[width] 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(
"duration-200 fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] 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", 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 min-h-svh flex-1 flex-col bg-background",
"peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] 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(
"duration-200 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,opa] 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(
"absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground select-none pointer-events-none",
"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("rounded-md h-8 flex gap-2 px-2 items-center", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 flex-1 max-w-[--skeleton-width]"
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,
useSidebar,
}

View File

@ -0,0 +1,15 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@ -0,0 +1,26 @@
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import { cn } from "@/lib/utils"
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
className
)}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background 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" />
</SliderPrimitive.Root>
))
Slider.displayName = SliderPrimitive.Root.displayName
export { Slider }

View File

@ -0,0 +1,29 @@
import { useTheme } from "next-themes"
import { Toaster as Sonner, toast } from "sonner"
type ToasterProps = React.ComponentProps<typeof Sonner>
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
)
}
export { Toaster, toast }

View File

@ -0,0 +1,27 @@
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

View File

@ -0,0 +1,117 @@
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-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", 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,53 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@ -0,0 +1,24 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background 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",
className
)}
ref={ref}
{...props}
/>
)
}
)
Textarea.displayName = "Textarea"
export { Textarea }

View File

@ -0,0 +1,127 @@
import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold", className)}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}

View File

@ -0,0 +1,33 @@
import { useToast } from "@/hooks/use-toast"
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@/components/ui/toast"
export function Toaster() {
const { toasts } = useToast()
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
)
})}
<ToastViewport />
</ToastProvider>
)
}

View File

@ -0,0 +1,59 @@
import * as React from "react"
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants>
>({
size: "default",
variant: "default",
})
const ToggleGroup = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, children, ...props }, ref) => (
<ToggleGroupPrimitive.Root
ref={ref}
className={cn("flex items-center justify-center gap-1", className)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
))
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName
const ToggleGroupItem = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>
>(({ className, children, variant, size, ...props }, ref) => {
const context = React.useContext(ToggleGroupContext)
return (
<ToggleGroupPrimitive.Item
ref={ref}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
})
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName
export { ToggleGroup, ToggleGroupItem }

View File

@ -0,0 +1,43 @@
import * as React from "react"
import * as TogglePrimitive from "@radix-ui/react-toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-10 px-3",
sm: "h-9 px-2.5",
lg: "h-11 px-5",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const Toggle = React.forwardRef<
React.ElementRef<typeof TogglePrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, ...props }, ref) => (
<TogglePrimitive.Root
ref={ref}
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
))
Toggle.displayName = TogglePrimitive.Root.displayName
export { Toggle, toggleVariants }

Some files were not shown because too many files have changed in this diff Show More