Initial commit of Dealer360 UI

This commit is contained in:
Asgarsk 2026-01-15 14:25:07 +05:30
commit 161423a598
109 changed files with 14801 additions and 0 deletions

1
.env Normal file
View File

@ -0,0 +1 @@
VITE_API_URL=http://192.168.1.12:8002

24
.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?

106
README.md Normal file
View File

@ -0,0 +1,106 @@
# Agri360
A comprehensive agricultural management platform built with modern web technologies.
## 🚀 Tech Stack
- **Framework**: React 19 with TypeScript
- **Build Tool**: Vite 7
- **Routing**: React Router DOM v7
- **Styling**: Tailwind CSS v4 (with Vite plugin)
- **UI Components**: Radix UI
- **Icons**: Lucide React
- **State Management**: Zustand
- **Data Fetching**: TanStack Query (React Query)
- **Charts**: Recharts
- **Forms**: React Hook Form with Zod validation
- **Date Utilities**: date-fns
## 📦 Installation
```bash
npm install
```
## 🏃 Development
Start the development server:
```bash
npm run dev
```
The app will be available at `http://localhost:5173`
## 🏗️ Build
Build for production:
```bash
npm run build
```
Preview production build:
```bash
npm run preview
```
## 📁 Project Structure
```
agri360/
├── src/
│ ├── pages/ # Page components
│ ├── components/ # Reusable components
│ ├── providers/ # Context providers (Query, etc.)
│ ├── lib/ # Utility functions
│ ├── hooks/ # Custom React hooks
│ ├── store/ # Zustand stores
│ ├── types/ # TypeScript type definitions
│ ├── App.tsx # Main app component
│ └── main.tsx # Entry point
├── public/ # Static assets
└── package.json
```
## 🎨 Tailwind CSS v4
This project uses Tailwind CSS v4 with the Vite plugin. Configuration is handled automatically through the `@tailwindcss/vite` plugin in `vite.config.ts`.
The main CSS file (`src/index.css`) imports Tailwind:
```css
@import "tailwindcss";
```
## 🔧 Available Scripts
- `npm run dev` - Start development server
- `npm run build` - Build for production
- `npm run preview` - Preview production build
- `npm run lint` - Run ESLint
## 📚 Key Dependencies
- **React 19**: Latest React with improved performance
- **React Router DOM**: Client-side routing
- **TanStack Query**: Powerful data synchronization for React
- **Zustand**: Lightweight state management
- **React Hook Form**: Performant forms with easy validation
- **Zod**: TypeScript-first schema validation
- **Radix UI**: Unstyled, accessible component primitives
- **Recharts**: Composable charting library built on React
## 🎯 Next Steps
1. Set up your API endpoints
2. Create your application routes
3. Build your UI components using Radix UI and Tailwind
4. Implement state management with Zustand
5. Add data fetching with TanStack Query
6. Create forms with React Hook Form and Zod validation
## 📝 License
MIT

115
SETUP.md Normal file
View File

@ -0,0 +1,115 @@
# Agri360 Setup Summary
## ✅ Completed Setup
### 1. Project Initialization
- ✅ Created Vite project with React 19 and TypeScript
- ✅ Updated to React 19.2.3
### 2. Tailwind CSS v4 Configuration
- ✅ Installed `tailwindcss@next` and `@tailwindcss/vite`
- ✅ Configured Vite plugin in `vite.config.ts`
- ✅ Updated `src/index.css` with `@import "tailwindcss"`
- ✅ Installed utility helpers: `clsx` and `tailwind-merge`
### 3. Dependencies Installed
#### Core Framework
- ✅ `react@^19.2.3`
- ✅ `react-dom@^19.2.3`
- ✅ `react-router-dom@^7.12.0`
#### Styling & UI
- ✅ `tailwindcss@^4.0.0` (v4 with Vite plugin)
- ✅ `@tailwindcss/vite@^4.1.18`
- ✅ `@radix-ui/react-slot@^1.2.4`
- ✅ `@radix-ui/react-dialog@^1.1.15`
- ✅ `@radix-ui/react-dropdown-menu@^2.1.16`
- ✅ `@radix-ui/react-label@^2.1.8`
- ✅ `@radix-ui/react-select@^2.2.6`
- ✅ `@radix-ui/react-separator@^1.1.8`
- ✅ `@radix-ui/react-tabs@^1.1.13`
- ✅ `@radix-ui/react-toast@^1.2.15`
- ✅ `lucide-react@^0.562.0`
#### State Management & Data Fetching
- ✅ `zustand@^5.0.10`
- ✅ `@tanstack/react-query@^5.90.16`
#### Forms & Validation
- ✅ `react-hook-form@^7.71.0`
- ✅ `zod@^4.3.5`
#### Charts & Utilities
- ✅ `recharts@^3.6.0`
- ✅ `date-fns@^4.1.0`
#### Utilities
- ✅ `clsx@^2.1.1`
- ✅ `tailwind-merge@^3.4.0`
### 4. Project Structure Created
```
agri360/
├── src/
│ ├── components/ # Reusable UI components
│ ├── hooks/ # Custom React hooks
│ ├── lib/ # Utility functions (utils.ts)
│ ├── pages/ # Page components (Home.tsx)
│ ├── providers/ # Context providers (QueryProvider.tsx)
│ ├── store/ # Zustand stores
│ ├── types/ # TypeScript types
│ ├── App.tsx # Main app with routing
│ └── main.tsx # Entry point
```
### 5. Configuration Files
- ✅ `vite.config.ts` - Vite config with Tailwind plugin
- ✅ `tsconfig.json` - TypeScript configuration
- ✅ `package.json` - All dependencies configured
- ✅ `README.md` - Project documentation
### 6. Basic Setup
- ✅ React Router configured
- ✅ TanStack Query provider set up
- ✅ Basic Home page created
- ✅ Tailwind CSS v4 working
- ✅ TypeScript strict mode enabled
- ✅ Build verified (no errors)
## 🚀 Next Steps
1. **Start Development Server**
```bash
cd agri360
npm run dev
```
2. **Create Your First Component**
- Use Radix UI primitives for accessible components
- Style with Tailwind CSS v4 utilities
- Add icons with Lucide React
3. **Set Up State Management**
- Create Zustand stores in `src/store/`
- Use TanStack Query for server state
4. **Build Forms**
- Use React Hook Form with Zod schemas
- Create reusable form components
5. **Add Charts**
- Use Recharts for data visualization
- Create chart components in `src/components/charts/`
## 📝 Notes
- Tailwind CSS v4 uses the new Vite plugin approach (no `tailwind.config.js` needed)
- React 19 is fully configured and working
- All TypeScript types are properly configured
- Project builds successfully without errors
## ✨ Ready to Build!
Your Agri360 project is fully set up and ready for development. All requested dependencies are installed and configured.

23
eslint.config.js Normal file
View File

@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

16
index.html Normal file
View File

@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Dealer 360 - Credit Line Platform</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

6648
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

81
package.json Normal file
View File

@ -0,0 +1,81 @@
{
"name": "agri360",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@fontsource/poppins": "^5.2.7",
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.8",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-menubar": "^1.1.16",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-query": "^5.90.17",
"axios": "^1.13.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"country-state-city": "^3.2.1",
"date-fns": "^4.1.0",
"embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.562.0",
"react": "^19.2.3",
"react-day-picker": "^9.13.0",
"react-dom": "^19.2.3",
"react-hook-form": "^7.71.0",
"react-router-dom": "^7.12.0",
"recharts": "^3.6.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.0.0",
"vaul": "^1.1.2",
"zod": "^4.3.5",
"zustand": "^5.0.10"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.8",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
}
}

1
public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

0
src/App.css Normal file
View File

59
src/App.tsx Normal file
View File

@ -0,0 +1,59 @@
import { Toaster } from "@/components/ui/toaster";
import { TooltipProvider } from "@/components/ui/tooltip";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter } from "react-router-dom";
import { useState, useEffect } from "react";
import { AuthProvider } from "./hooks/useAuth";
import { AppRoutes } from "./routes";
import ErrorBoundary from "@/components/common/ErrorBoundary";
import OfflinePage from "@/components/common/OfflinePage";
// Create QueryClient with optimized defaults
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes (formerly cacheTime)
retry: 1,
refetchOnWindowFocus: false,
},
},
});
const App = () => {
const [isOnline, setIsOnline] = useState(navigator.onLine);
useEffect(() => {
const handleOnline = () => setIsOnline(true);
const handleOffline = () => setIsOnline(false);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
if (!isOnline) {
return <OfflinePage />;
}
return (
<ErrorBoundary>
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<Toaster />
<BrowserRouter>
<AuthProvider>
<AppRoutes />
</AuthProvider>
</BrowserRouter>
</TooltipProvider>
</QueryClientProvider>
</ErrorBoundary>
);
};
export default App;

68
src/api/axiosInstance.ts Normal file
View File

@ -0,0 +1,68 @@
import axios from 'axios';
/**
* Custom Axios instance with pre-configured base URL and timeout.
* In development, this relies on Vite's proxy (see vite.config.ts) to avoid CORS issues.
*/
const axiosInstance = axios.create({
baseURL: '/api', // This matches the proxy path in vite.config.ts
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
});
// Request interceptor for adding auth tokens, etc.
axiosInstance.interceptors.request.use(
(config) => {
// You can get the token from localStorage or a store (e.g., Zustand)
const session = localStorage.getItem('dealer360_session');
if (session) {
try {
const { access_token } = JSON.parse(session);
if (access_token) {
config.headers.Authorization = `Bearer ${access_token}`;
}
} catch (error) {
console.error('Error parsing session for Authorization header', error);
}
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Response interceptor for global error handling
axiosInstance.interceptors.response.use(
(response) => response,
(error) => {
// Standard error handling
if (error.response) {
// Handle 401 Unauthorized globally - security best practice
if (error.response.status === 401) {
// Clear session and force redirect to login
localStorage.removeItem('dealer360_session');
// Prevent redirect loop if already on login page
if (!window.location.pathname.includes('/login')) {
console.warn('Session expired. Redirecting to login.');
window.location.href = '/login?expired=true';
}
}
console.error(`API Error [${error.response.status}]:`, error.response.data);
} else if (error.request) {
// The request was made but no response was received
console.error('API No Response:', error.request);
} else {
// Something happened in setting up the request
console.error('API Request Setup Error:', error.message);
}
return Promise.reject(error);
}
);
export default axiosInstance;

16
src/api/endpoints.ts Normal file
View File

@ -0,0 +1,16 @@
/**
* API Endpoint constants to keep all URLs in one place.
*/
export const API_ENDPOINTS = {
AUTH: {
LOGIN: '/v1/auth/login',
REGISTER: '/v1/auth/register',
LOGOUT: '/v1/auth/logout',
REFRESH_TOKEN: '/v1/auth/refresh-token',
},
DEALERS: {
LIST: '/dealers',
DETAIL: (id: string | number) => `/dealers/${id}`,
KPI: '/dealers/kpi',
},
};

5
src/api/index.ts Normal file
View File

@ -0,0 +1,5 @@
export { default as axiosInstance } from './axiosInstance';
export * from './endpoints';
export * from './services/auth.service';
export * from './types';
// Export other services as they are created

View File

@ -0,0 +1,47 @@
import axiosInstance from '../axiosInstance';
import { API_ENDPOINTS } from '../endpoints';
import type { ApiResponse, UserData } from '../types';
/**
* Service for handling authentication related API calls.
*/
export const authService = {
/**
* Login user and return session data.
*/
login: async (credentials: any): Promise<ApiResponse<UserData>> => {
try {
const response = await axiosInstance.post<ApiResponse<UserData>>(
API_ENDPOINTS.AUTH.LOGIN,
credentials
);
return response.data;
} catch (error) {
throw error;
}
},
/**
* Logout user.
*/
logout: async () => {
try {
const response = await axiosInstance.post(API_ENDPOINTS.AUTH.LOGOUT);
return response.data;
} catch (error) {
throw error;
}
},
/**
* Get current user profile.
*/
getProfile: async () => {
try {
const response = await axiosInstance.get('/v1/auth/profile');
return response.data;
} catch (error) {
throw error;
}
},
};

31
src/api/types.ts Normal file
View File

@ -0,0 +1,31 @@
export interface ApiResponse<T = any> {
success: boolean;
message: string;
data: T;
timestamp: string;
}
export interface UserData {
user_id: string;
username: string;
email: string;
is_active: boolean;
company_name: string;
created_at: string;
modified_at: string;
created_by: string;
modified_by: string;
roles: string[];
token: string;
token_type: string;
}
export type UserRole = 'helpdesk' | 'bank_customer' | 'manufacturing_customer' | null;
export interface AppUser {
id: string;
username: string;
email: string;
role: UserRole;
company_name: string;
}

BIN
src/assets/login-hero.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

View File

@ -0,0 +1,42 @@
import { memo, useMemo } from "react";
import { getCreditScoreColor } from "@/lib/mockData";
interface CreditScoreBarProps {
score: number;
showLabel?: boolean;
}
const colorClasses = {
success: "bg-success",
warning: "bg-warning",
danger: "bg-danger",
} as const;
const textColorClasses = {
success: "text-success",
warning: "text-warning",
danger: "text-danger",
} as const;
export const CreditScoreBar = memo(({ score, showLabel = true }: CreditScoreBarProps) => {
const color = useMemo(() => getCreditScoreColor(score), [score]);
const percentage = useMemo(() => (score / 1000) * 100, [score]);
return (
<div className="flex items-center gap-2">
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
<div
className={`h-full ${colorClasses[color]} transition-all`}
style={{ width: `${percentage}%` }}
/>
</div>
{showLabel && (
<span className={`text-sm font-medium ${textColorClasses[color]}`}>
{score}
</span>
)}
</div>
);
});
CreditScoreBar.displayName = 'CreditScoreBar';

View File

@ -0,0 +1,42 @@
import { memo } from "react";
import { Card } from "@/components/ui/card";
import type { LucideProps } from "lucide-react";
import type { ForwardRefExoticComponent, RefAttributes } from "react";
type LucideIcon = ForwardRefExoticComponent<Omit<LucideProps, "ref"> & RefAttributes<SVGSVGElement>>;
interface KPICardProps {
title: string;
value: string | number;
icon: LucideIcon;
trend?: string;
trendColor?: "success" | "danger" | "default";
}
const getTrendColorClass = (trendColor: "success" | "danger" | "default"): string => {
switch (trendColor) {
case "success":
return "text-success";
case "danger":
return "text-danger";
default:
return "text-muted-foreground";
}
};
export const KPICard = memo(({ title, value, icon: Icon, trend, trendColor = "default" }: KPICardProps) => {
return (
<Card className="p-4 hover:shadow-lg transition-shadow">
<div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-primary/10 rounded-lg">
<Icon className="h-5 w-5 text-primary" />
</div>
<h3 className="text-sm font-medium text-muted-foreground">{title}</h3>
</div>
<p className="text-2xl font-bold text-foreground mb-1">{value}</p>
{trend && <p className={`text-xs ${getTrendColorClass(trendColor)}`}>{trend}</p>}
</Card>
);
});
KPICard.displayName = 'KPICard';

View File

@ -0,0 +1,291 @@
import { memo, useState, useCallback, useMemo, useEffect } from "react";
import { Download, ChevronDown } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { Card } from "@/components/ui/card";
import { indianStates, stateDistricts } from "@/lib/mockData";
import { useDebounce } from "@/hooks/useDebounce";
interface SearchFiltersProps {
onSearch: (query: string) => void;
onFilter: (filters: FilterState) => void;
onDownload: () => void;
}
export interface FilterState {
states: string[];
districts: string[];
dealerType: string;
minCreditScore: number;
maxCreditScore: number;
}
// Separate component for state checkbox to avoid re-renders
const StateCheckbox = memo(({
state,
isChecked,
onToggle
}: {
state: string;
isChecked: boolean;
onToggle: (state: string, checked: boolean) => void;
}) => (
<div className="flex items-center space-x-2 p-2 hover:bg-muted rounded">
<Checkbox
id={`state-${state}`}
checked={isChecked}
onCheckedChange={(checked) => onToggle(state, checked === true)}
/>
<label
htmlFor={`state-${state}`}
className="text-sm flex-1 cursor-pointer"
>
{state}
</label>
</div>
));
StateCheckbox.displayName = 'StateCheckbox';
// Separate component for district checkbox
const DistrictCheckbox = memo(({
district,
isChecked,
onToggle
}: {
district: string;
isChecked: boolean;
onToggle: (district: string, checked: boolean) => void;
}) => (
<div className="flex items-center space-x-2 p-2 hover:bg-muted rounded">
<Checkbox
id={`district-${district}`}
checked={isChecked}
onCheckedChange={(checked) => onToggle(district, checked === true)}
/>
<label
htmlFor={`district-${district}`}
className="text-sm flex-1 cursor-pointer"
>
{district}
</label>
</div>
));
DistrictCheckbox.displayName = 'DistrictCheckbox';
// Credit score range component
const CreditScoreRange = memo(({
minScore,
maxScore,
onMinChange,
onMaxChange,
}: {
minScore: number;
maxScore: number;
onMinChange: (value: number) => void;
onMaxChange: (value: number) => void;
}) => (
<div className="flex-1 min-w-full sm:min-w-[280px]">
<Label className="mb-2 block">Credit Score Range</Label>
<div className="flex gap-2 items-center">
<Input
type="number"
placeholder="Min"
value={minScore}
onChange={(e) => onMinChange(Number(e.target.value))}
className="w-28"
/>
<span className="text-muted-foreground">-</span>
<Input
type="number"
placeholder="Max"
value={maxScore}
onChange={(e) => onMaxChange(Number(e.target.value))}
className="w-28"
/>
</div>
</div>
));
CreditScoreRange.displayName = 'CreditScoreRange';
export const SearchFilters = memo(({ onSearch, onFilter, onDownload }: SearchFiltersProps) => {
const [searchQuery, setSearchQuery] = useState("");
const [stateOpen, setStateOpen] = useState(false);
const [districtOpen, setDistrictOpen] = useState(false);
const [filters, setFilters] = useState<FilterState>({
states: [],
districts: [],
dealerType: "all",
minCreditScore: 0,
maxCreditScore: 1000,
});
// Debounce search query - 300ms delay
const debouncedSearchQuery = useDebounce(searchQuery, 300);
// Trigger search when debounced value changes
const handleSearchChange = useCallback((value: string) => {
setSearchQuery(value);
}, []);
// Effect to call onSearch when debounced value changes
useEffect(() => {
onSearch(debouncedSearchQuery);
}, [debouncedSearchQuery, onSearch]);
const handleFilterChange = useCallback((key: keyof FilterState, value: string | number | string[]) => {
const newFilters = { ...filters, [key]: value };
setFilters(newFilters);
onFilter(newFilters);
}, [onFilter, filters]);
const toggleState = useCallback((state: string, checked: boolean) => {
const newStates = checked
? Array.from(new Set([...filters.states, state]))
: filters.states.filter(s => s !== state);
const newFilters = {
...filters,
states: newStates,
districts: newStates.length === 0 ? [] : filters.districts,
};
setFilters(newFilters);
onFilter(newFilters);
}, [onFilter, filters]);
const toggleDistrict = useCallback((district: string, checked: boolean) => {
const newDistricts = checked
? Array.from(new Set([...filters.districts, district]))
: filters.districts.filter(d => d !== district);
const newFilters = { ...filters, districts: newDistricts };
setFilters(newFilters);
onFilter(newFilters);
}, [onFilter, filters]);
// Get available districts based on selected states
const availableDistricts = useMemo(() => {
if (filters.states.length === 0) return [];
return filters.states.flatMap(state => stateDistricts[state] || []);
}, [filters.states]);
const handleDealerTypeChange = useCallback((value: string) => {
handleFilterChange('dealerType', value);
}, [handleFilterChange]);
const handleMinScoreChange = useCallback((value: number) => {
handleFilterChange('minCreditScore', value);
}, [handleFilterChange]);
const handleMaxScoreChange = useCallback((value: number) => {
handleFilterChange('maxCreditScore', value);
}, [handleFilterChange]);
return (
<div className="space-y-4">
{/* Search Bar Row */}
<div className="w-full">
<Input
placeholder="Search by dealer name, city, state, district, credit score, mobile, aadhaar, or license..."
value={searchQuery}
onChange={(e) => handleSearchChange(e.target.value)}
className="w-full"
/>
</div>
{/* Filters in a Card */}
<Card className="p-4">
<div className="flex flex-wrap gap-4 items-end">
{/* State Filter - Multi-select */}
<div className="flex-1 min-w-[140px] sm:min-w-[220px]">
<Label className="mb-2 block">State</Label>
<Popover open={stateOpen} onOpenChange={setStateOpen}>
<PopoverTrigger asChild>
<Button variant="outline" className="w-full justify-between">
{filters.states.length > 0 ? `${filters.states.length} selected` : "Select State"}
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[250px] p-0 bg-popover z-50" align="start">
<div className="p-2">
<div className="max-h-[300px] overflow-y-auto">
{indianStates.map((state) => (
<StateCheckbox
key={state}
state={state}
isChecked={filters.states.includes(state)}
onToggle={toggleState}
/>
))}
</div>
</div>
</PopoverContent>
</Popover>
</div>
{/* District Filter - Multi-select */}
<div className="flex-1 min-w-[140px] sm:min-w-[220px]">
<Label className="mb-2 block">District</Label>
<Popover open={districtOpen} onOpenChange={setDistrictOpen}>
<PopoverTrigger asChild>
<Button variant="outline" className="w-full justify-between">
{filters.districts.length > 0 ? `${filters.districts.length} selected` : "Select District"}
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[250px] p-0 bg-popover z-50" align="start">
<div className="p-2">
<div className="max-h-[300px] overflow-y-auto">
{availableDistricts.map((district) => (
<DistrictCheckbox
key={district}
district={district}
isChecked={filters.districts.includes(district)}
onToggle={toggleDistrict}
/>
))}
</div>
</div>
</PopoverContent>
</Popover>
</div>
{/* Dealer Type Filter */}
<div className="flex-1 min-w-[140px] sm:min-w-[220px]">
<Label className="mb-2 block">Dealer Type</Label>
<Select value={filters.dealerType} onValueChange={handleDealerTypeChange}>
<SelectTrigger>
<SelectValue placeholder="Select Type" />
</SelectTrigger>
<SelectContent className="bg-popover z-50">
<SelectItem value="all">All Types</SelectItem>
<SelectItem value="Retailer">Retailer</SelectItem>
<SelectItem value="Wholesaler">Wholesaler</SelectItem>
</SelectContent>
</Select>
</div>
{/* Credit Score Range */}
<CreditScoreRange
minScore={filters.minCreditScore}
maxScore={filters.maxCreditScore}
onMinChange={handleMinScoreChange}
onMaxChange={handleMaxScoreChange}
/>
{/* Download Button */}
<Button onClick={onDownload} variant="outline" className="w-full sm:w-auto gap-2">
<Download className="h-4 w-4" />
Download Excel
</Button>
</div>
</Card>
</div>
);
});
SearchFilters.displayName = 'SearchFilters';

View File

@ -0,0 +1,36 @@
import { memo, useMemo } from 'react';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import { Card } from '@/components/ui/card';
import { getCreditScoreTrend } from '@/lib/mockData';
interface CreditScoreTrendChartProps {
creditScore: number;
compact?: boolean;
}
export const CreditScoreTrendChart = memo(({ creditScore, compact = false }: CreditScoreTrendChartProps) => {
const creditTrend = useMemo(() => getCreditScoreTrend(creditScore), [creditScore]);
return (
<Card className={`p-4 ${compact ? '' : 'h-[280px]'}`}>
<h3 className="text-lg font-semibold mb-2 text-foreground">Credit Score Trend (6M)</h3>
<ResponsiveContainer width="100%" height={200}>
<LineChart data={creditTrend}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="month" stroke="#6b7280" style={{ fontSize: '12px' }} />
<YAxis stroke="#6b7280" style={{ fontSize: '12px' }} domain={[600, 850]} />
<Tooltip />
<Line
type="monotone"
dataKey="score"
stroke="#16A34A"
strokeWidth={2}
dot={{ fill: '#16A34A', r: 4 }}
/>
</LineChart>
</ResponsiveContainer>
</Card>
);
});
CreditScoreTrendChart.displayName = 'CreditScoreTrendChart';

View File

@ -0,0 +1,40 @@
import { memo, useMemo } from 'react';
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
import { Card } from '@/components/ui/card';
import { getSalesPurchaseTrend } from '@/lib/mockData';
interface SalesPurchaseChartProps {
totalSales6M: number;
totalPurchase6M: number;
compact?: boolean;
}
export const SalesPurchaseChart = memo(({
totalSales6M,
totalPurchase6M,
compact = false
}: SalesPurchaseChartProps) => {
const salesPurchaseTrend = useMemo(
() => getSalesPurchaseTrend(totalSales6M, totalPurchase6M),
[totalSales6M, totalPurchase6M]
);
return (
<Card className={`p-4 ${compact ? '' : 'h-[280px]'}`}>
<h3 className="text-lg font-semibold mb-2 text-foreground">Sales vs Purchase (MT)</h3>
<ResponsiveContainer width="100%" height={200}>
<BarChart data={salesPurchaseTrend}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="month" stroke="#6b7280" style={{ fontSize: '12px' }} />
<YAxis stroke="#6b7280" style={{ fontSize: '12px' }} />
<Tooltip />
<Legend wrapperStyle={{ fontSize: '12px' }} />
<Bar dataKey="sales" fill="#16A34A" name="sales" />
<Bar dataKey="purchase" fill="#3B82F6" name="purchase" />
</BarChart>
</ResponsiveContainer>
</Card>
);
});
SalesPurchaseChart.displayName = 'SalesPurchaseChart';

View File

@ -0,0 +1,52 @@
import { memo, useMemo } from 'react';
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts';
import { Card } from '@/components/ui/card';
import { getStockAgeDistribution } from '@/lib/mockData';
const COLORS = ['#4ade80', '#60a5fa', '#fbbf24', '#f87171', '#a78bfa', '#38bdf8', '#fb923c'];
export const StockAgeChart = memo(() => {
const stockAgeData = useMemo(() => getStockAgeDistribution(), []);
return (
<Card className="p-4 sm:h-[280px]">
<h3 className="text-lg font-semibold mb-2 text-foreground">Stock Age Distribution</h3>
<div className="flex flex-col sm:flex-row items-center gap-4">
<ResponsiveContainer width="100%" height={180}>
<PieChart>
<Pie
data={stockAgeData}
cx="50%"
cy="50%"
labelLine={false}
outerRadius={70}
innerRadius={35}
fill="#8884d8"
dataKey="value"
>
{stockAgeData.map((_, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
<div className="w-full sm:flex-1 grid grid-cols-2 min-[450px]:grid-cols-3 sm:grid-cols-1 md:grid-cols-2 gap-2 mt-2 sm:mt-0">
{stockAgeData.map((entry, index) => (
<div key={index} className="flex items-center gap-1 text-xs">
<div
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: COLORS[index % COLORS.length] }}
/>
<span className="text-muted-foreground text-[10px] sm:text-xs">
{entry.range}: {entry.value}%
</span>
</div>
))}
</div>
</div>
</Card>
);
});
StockAgeChart.displayName = 'StockAgeChart';

View File

@ -0,0 +1,3 @@
export { CreditScoreTrendChart } from './CreditScoreTrendChart';
export { SalesPurchaseChart } from './SalesPurchaseChart';
export { StockAgeChart } from './StockAgeChart';

View File

@ -0,0 +1,44 @@
import { Component, type ErrorInfo, type ReactNode } from 'react';
import ErrorBoundaryPage from '@/components/common/ErrorBoundaryPage';
interface Props {
children?: ReactNode;
}
interface State {
hasError: boolean;
error?: Error;
}
class ErrorBoundary extends Component<Props, State> {
public state: State = {
hasError: false
};
public static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Uncaught error:', error, errorInfo);
}
private resetErrorBoundary = () => {
this.setState({ hasError: false, error: undefined });
};
public render() {
if (this.state.hasError) {
return (
<ErrorBoundaryPage
error={this.state.error}
resetErrorBoundary={this.resetErrorBoundary}
/>
);
}
return this.props.children;
}
}
export default ErrorBoundary;

View File

@ -0,0 +1,68 @@
import { memo } from 'react';
import { AlertTriangle, Home, RefreshCcw } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface ErrorBoundaryPageProps {
error?: Error;
resetErrorBoundary?: () => void;
}
export const ErrorBoundaryPage = memo(({ error, resetErrorBoundary }: ErrorBoundaryPageProps) => {
const handleGoHome = () => {
window.location.href = '/';
};
return (
<div className="min-h-screen flex items-center justify-center bg-[#fef2f2] p-4 font-poppins">
<div className="max-w-md w-full text-center space-y-6">
<div className="inline-flex items-center justify-center w-24 h-24 rounded-full bg-white shadow-sm border-2 border-red-100">
<AlertTriangle className="h-12 w-12 text-[#EF4444]" />
</div>
<div className="space-y-2">
<h1 className="text-3xl font-bold text-foreground">Something went wrong</h1>
<p className="text-muted-foreground">
We encountered an unexpected error. Our team has been notified.
</p>
</div>
{error && (
<div className="p-4 bg-white border border-red-100 rounded-lg text-left overflow-auto max-h-32">
<p className="text-xs font-mono text-red-600 font-medium">Error Details:</p>
<p className="text-[10px] font-mono text-gray-500 mt-1 break-all">
{error.message || 'Unknown application error'}
</p>
</div>
)}
<div className="flex flex-col sm:flex-row gap-3 justify-center">
{resetErrorBoundary && (
<Button
onClick={resetErrorBoundary}
className="bg-[#16A34A] hover:bg-[#15803D] text-white flex-1 h-11"
>
<RefreshCcw className="h-4 w-4 mr-2" />
Try Again
</Button>
)}
<Button
variant="outline"
onClick={handleGoHome}
className="border-gray-300 flex-1 h-11"
>
<Home className="h-4 w-4 mr-2" />
Back to Home
</Button>
</div>
<p className="text-xs text-muted-foreground">
MFMS ID / Session Ref: {Math.random().toString(36).substring(7).toUpperCase()}
</p>
</div>
</div>
);
});
ErrorBoundaryPage.displayName = 'ErrorBoundaryPage';
export default ErrorBoundaryPage;

View File

@ -0,0 +1,68 @@
import { memo } from 'react';
import { Sprout } from 'lucide-react';
interface LoadingSpinnerProps {
size?: 'sm' | 'md' | 'lg';
className?: string;
label?: string;
}
export const LoadingSpinner = memo(({
size = 'md',
className = '',
label = 'Processing...'
}: LoadingSpinnerProps) => {
const containerSizes = {
sm: 'w-8 h-8',
md: 'w-16 h-16',
lg: 'w-24 h-24',
};
const iconSizes = {
sm: 'h-4 w-4',
md: 'h-8 w-8',
lg: 'h-12 w-12',
};
const spinnerSizes = {
sm: 'border-2 h-7 w-7',
md: 'border-[3px] h-14 w-14',
lg: 'border-4 h-20 w-20',
};
return (
<div className={`flex flex-col items-center justify-center gap-4 ${className} font-poppins`}>
<div className={`relative flex items-center justify-center ${containerSizes[size]}`}>
{/* Outer Ring */}
<div
className={`absolute rounded-full border-muted-foreground/10 ${spinnerSizes[size]}`}
/>
{/* Animated Spinning Ring */}
<div
className={`absolute rounded-full border-primary border-t-transparent animate-agri-spin ${spinnerSizes[size]}`}
/>
{/* Inner Pulsing Icon */}
<div className="relative z-10 animate-agri-pulse">
<div className="animate-agri-bounce">
<Sprout className={`${iconSizes[size]} text-primary`} />
</div>
</div>
</div>
{size !== 'sm' && (
<div className="flex flex-col items-center gap-1">
<p className="text-sm font-medium text-foreground tracking-wide">{label}</p>
{size === 'lg' && (
<p className="text-xs text-muted-foreground animate-agri-pulse">
Please wait a moment
</p>
)}
</div>
)}
</div>
);
});
LoadingSpinner.displayName = 'LoadingSpinner';

View File

@ -0,0 +1,42 @@
import { useLocation } from "react-router-dom";
import { useEffect } from "react";
const NotFound = () => {
const location = useLocation();
useEffect(() => {
console.error("404 Error: User attempted to access non-existent route:", location.pathname);
}, [location.pathname]);
return (
<div className="min-h-screen flex items-center justify-center bg-[#E8F5E9] p-4 font-poppins">
<div className="max-w-md w-full text-center space-y-6">
<div className="inline-flex items-center justify-center w-24 h-24 rounded-full bg-white shadow-sm">
<h1 className="text-5xl font-bold text-[#16A34A]">404</h1>
</div>
<div className="space-y-2">
<h2 className="text-3xl font-bold text-foreground">Page Not Found</h2>
<p className="text-muted-foreground">
The screen you are looking for doesn't exist or has been moved to a new section.
</p>
</div>
<a
href="/"
className="inline-flex items-center justify-center bg-[#16A34A] hover:bg-[#15803D] text-white px-8 h-12 rounded-md text-lg font-medium transition-colors"
>
Return to Dashboard
</a>
<div className="pt-4">
<p className="text-sm text-muted-foreground">
Dealer 360 Credit Line Management
</p>
</div>
</div>
</div>
);
};
export default NotFound;

View File

@ -0,0 +1,42 @@
import { memo } from 'react';
import { WifiOff, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
export const OfflinePage = memo(() => {
const handleRetry = () => {
window.location.reload();
};
return (
<div className="min-h-screen flex items-center justify-center bg-[#E8F5E9] p-4 font-poppins">
<div className="max-w-md w-full text-center space-y-6">
<div className="inline-flex items-center justify-center w-24 h-24 rounded-full bg-white shadow-sm">
<WifiOff className="h-12 w-12 text-[#16A34A]" />
</div>
<div className="space-y-2">
<h1 className="text-3xl font-bold text-foreground">No Internet Connection</h1>
<p className="text-muted-foreground">
It looks like you're offline. Please check your internet connection and try again.
</p>
</div>
<Button
onClick={handleRetry}
className="bg-[#16A34A] hover:bg-[#15803D] text-white px-8 h-12 text-lg font-medium gap-2"
>
<RefreshCw className="h-5 w-5" />
Retry Connection
</Button>
<p className="text-sm text-muted-foreground/60 italic">
Dealer 360 requires an active connection for real-time credit data.
</p>
</div>
</div>
);
});
OfflinePage.displayName = 'OfflinePage';
export default OfflinePage;

View File

@ -0,0 +1,113 @@
import { memo, useCallback, useMemo } from 'react';
import { Button } from '@/components/ui/button';
interface PaginationProps {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
maxVisiblePages?: number;
}
export const Pagination = memo(({
currentPage,
totalPages,
onPageChange,
maxVisiblePages = 10
}: PaginationProps) => {
const isMobile = typeof window !== 'undefined' ? window.innerWidth < 640 : false;
const effectiveMaxPages = isMobile ? 3 : maxVisiblePages;
const handlePrevious = useCallback(() => {
onPageChange(Math.max(1, currentPage - 1));
}, [currentPage, onPageChange]);
const handleNext = useCallback(() => {
onPageChange(Math.min(totalPages, currentPage + 1));
}, [currentPage, totalPages, onPageChange]);
const visiblePages = useMemo(() => {
const pages: number[] = [];
const halfVisible = Math.floor(effectiveMaxPages / 2);
let startPage = Math.max(1, currentPage - halfVisible);
const effectiveEndPage = Math.min(totalPages, startPage + effectiveMaxPages - 1);
// Adjust start if we're near the end
if (effectiveEndPage - startPage + 1 < effectiveMaxPages) {
startPage = Math.max(1, effectiveEndPage - effectiveMaxPages + 1);
}
for (let i = startPage; i <= effectiveEndPage; i++) {
pages.push(i);
}
return pages;
}, [currentPage, totalPages, effectiveMaxPages]);
if (totalPages <= 1) return null;
return (
<div className="mt-6 flex flex-wrap items-center justify-center gap-1 sm:gap-2">
<Button
variant="outline"
size="sm"
onClick={handlePrevious}
disabled={currentPage === 1}
>
Previous
</Button>
{visiblePages[0] > 1 && (
<>
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(1)}
>
1
</Button>
{visiblePages[0] > 2 && (
<span className="text-muted-foreground">...</span>
)}
</>
)}
{visiblePages.map((pageNum) => (
<Button
key={pageNum}
variant={currentPage === pageNum ? "default" : "outline"}
size="sm"
onClick={() => onPageChange(pageNum)}
>
{pageNum}
</Button>
))}
{visiblePages[visiblePages.length - 1] < totalPages && (
<>
{visiblePages[visiblePages.length - 1] < totalPages - 1 && (
<span className="text-muted-foreground">...</span>
)}
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(totalPages)}
>
{totalPages}
</Button>
</>
)}
<Button
variant="outline"
size="sm"
onClick={handleNext}
disabled={currentPage === totalPages}
>
Next
</Button>
</div>
);
});
Pagination.displayName = 'Pagination';

View File

@ -0,0 +1,59 @@
import { memo, useState } from 'react';
import { MessageSquare, Phone, CheckCircle, ChevronDown, ChevronUp } from 'lucide-react';
import { Card } from '@/components/ui/card';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { useAuth } from '@/hooks/useAuth';
export const ActivityTimeline = memo(() => {
const [isOpen, setIsOpen] = useState(false);
const { userRole } = useAuth();
if (userRole !== 'helpdesk') return null;
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen} className="mb-6">
<Card className="p-4 sm:p-6">
<CollapsibleTrigger className="flex items-center justify-between w-full">
<h2 className="text-xl font-semibold text-foreground">Last Activity Timeline</h2>
{isOpen ? <ChevronUp className="h-5 w-5" /> : <ChevronDown className="h-5 w-5" />}
</CollapsibleTrigger>
<CollapsibleContent className="mt-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="flex items-start gap-3">
<MessageSquare className="h-5 w-5 text-primary mt-1" />
<div>
<p className="font-medium text-foreground">Last SMS</p>
<p className="text-sm text-muted-foreground">
Date: {new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toLocaleDateString()}
</p>
<p className="text-sm text-muted-foreground">Customer: Rahul Sharma</p>
</div>
</div>
<div className="flex items-start gap-3">
<Phone className="h-5 w-5 text-primary mt-1" />
<div>
<p className="font-medium text-foreground">Last Call</p>
<p className="text-sm text-muted-foreground">
Date: {new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toLocaleDateString()}
</p>
<p className="text-sm text-muted-foreground">Customer: Priya Patel</p>
</div>
</div>
<div className="flex items-start gap-3">
<CheckCircle className="h-5 w-5 text-primary mt-1" />
<div>
<p className="font-medium text-foreground">Last Acknowledgment</p>
<p className="text-sm text-muted-foreground">
Date: {new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toLocaleDateString()}
</p>
<p className="text-sm text-muted-foreground">Customer: Amit Kumar</p>
</div>
</div>
</div>
</CollapsibleContent>
</Card>
</Collapsible>
);
});
ActivityTimeline.displayName = 'ActivityTimeline';

View File

@ -0,0 +1,72 @@
import { memo } from 'react';
import { ClipboardList } from 'lucide-react';
import { Card } from '@/components/ui/card';
import type { ScoreParameter } from '@/lib/mockData';
interface CreditScoreBreakdownProps {
scoreBreakdown: ScoreParameter[];
compact?: boolean;
}
export const CreditScoreBreakdown = memo(({
scoreBreakdown,
compact = false
}: CreditScoreBreakdownProps) => {
if (compact) {
return (
<Card className="p-4">
<h3 className="text-lg font-semibold mb-2 text-foreground">Credit Score Breakdown</h3>
<div className="space-y-2">
{scoreBreakdown.map((param, idx) => (
<div key={idx} className="space-y-1">
<div className="flex justify-between items-center">
<span className="text-xs font-medium text-foreground">{param.parameter}</span>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">{param.weight}%</span>
<span className="text-xs font-semibold text-primary">{param.dealerScore}</span>
</div>
</div>
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all"
style={{ width: `${(param.dealerScore / 1000) * 100}%` }}
/>
</div>
</div>
))}
</div>
</Card>
);
}
return (
<Card className="p-6">
<div className="flex items-center gap-2 mb-4">
<ClipboardList className="h-5 w-5 text-primary" />
<h2 className="text-xl font-semibold text-foreground">Credit Score Breakdown</h2>
</div>
<div className="space-y-4">
{scoreBreakdown.map((param, idx) => (
<div key={idx} className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm font-medium text-foreground">{param.parameter}</span>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">{param.weight}%</span>
<span className="text-sm font-semibold text-primary">{param.dealerScore}</span>
</div>
</div>
<div className="h-2 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all"
style={{ width: `${(param.dealerScore / 1000) * 100}%` }}
/>
</div>
<p className="text-xs text-muted-foreground">{param.remarks}</p>
</div>
))}
</div>
</Card>
);
});
CreditScoreBreakdown.displayName = 'CreditScoreBreakdown';

View File

@ -0,0 +1,100 @@
import { memo } from 'react';
import { ArrowLeft } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { useAuth } from '@/hooks/useAuth';
import type { Dealer } from '@/lib/mockData';
interface DealerProfileHeaderProps {
dealer: Dealer;
creditColor: 'success' | 'warning' | 'danger';
isEligible: boolean;
isActive: boolean;
showScoreCard: boolean;
onBack: () => void;
onViewScoreCard: () => void;
}
export const DealerProfileHeader = memo(({
dealer,
creditColor,
isEligible,
isActive,
showScoreCard,
onBack,
onViewScoreCard,
}: DealerProfileHeaderProps) => {
const { userRole } = useAuth();
const lastUpdated = new Date().toLocaleString('en-GB', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
return (
<header className="bg-[#E8F5E9] border-b border-border py-4 px-4 sm:px-8">
<div className="max-w-[95%] mx-auto">
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-4">
<div className="flex items-start gap-3 sm:gap-4">
<Button variant="ghost" size="icon" onClick={onBack} className="rounded-full h-8 w-8 sm:h-10 sm:w-10">
<ArrowLeft className="h-4 w-4 sm:h-5 sm:w-5" />
</Button>
<div>
<h1 className="text-xl sm:text-2xl font-bold text-foreground m-0 leading-tight">{dealer.dealerName}</h1>
</div>
</div>
<div className="text-left sm:text-right ml-11 sm:ml-0">
<p className="text-xs sm:text-sm text-muted-foreground mb-0 sm:mb-1">Credit Score</p>
<p className={`text-3xl sm:text-4xl font-bold ${creditColor === 'success' ? 'text-success' :
creditColor === 'warning' ? 'text-warning' :
'text-danger'
}`}>
{dealer.creditScore}
</p>
<div className="mt-1 text-sm space-y-1">
<div className={`${isEligible ? "text-success" : "text-danger"} text-left sm:text-right text-xs sm:text-sm font-medium`}>
{isEligible ? "🟢 Eligible for Credit" : "🔴 Not Eligible for Credit"}
</div>
{userRole === 'helpdesk' && (
<div className="text-muted-foreground text-left sm:text-right text-[10px] sm:text-xs">
Last Updated: {lastUpdated}
</div>
)}
</div>
</div>
</div>
<div className="ml-11 sm:ml-14 mt-2 sm:mt-1 space-y-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs sm:text-sm text-muted-foreground">
<span><strong>MFMS ID:</strong> {dealer.mfmsId}</span>
<span></span>
<span>{dealer.district}, {dealer.city}, {dealer.state}</span>
<span></span>
<span>{dealer.dealerType}</span>
<span></span>
<span>
<Badge variant={isActive ? "default" : "secondary"} className={isActive ? "bg-[#16A34A]" : ""}>
{isActive ? "Active" : "Inactive"}
</Badge>
</span>
</div>
</div>
{showScoreCard && (
<div className="ml-11 sm:ml-14 mt-3 sm:mt-1">
<Button
variant="outline"
size="sm"
onClick={onViewScoreCard}
className="bg-[#16A34A] text-white hover:bg-[#15803D]"
>
View Score Card
</Button>
</div>
)}
</div>
</header>
);
});
DealerProfileHeader.displayName = 'DealerProfileHeader';

View File

@ -0,0 +1,95 @@
import { memo } from 'react';
import { User } from 'lucide-react';
import { Card } from '@/components/ui/card';
import type { Dealer } from '@/lib/mockData';
interface DealerSnapshotProps {
dealer: Dealer;
title?: string;
}
export const DealerSnapshot = memo(({ dealer, title = "Dealer Snapshot" }: DealerSnapshotProps) => {
const snapshotItems = [
{ label: "Type", value: dealer.dealerType },
{ label: "Total Companies Associated", value: dealer.noOfCompanies },
{ label: "Active Products", value: dealer.noOfProducts },
{ label: "Total Sales (6M Rolling)", value: `${dealer.totalSales6M} MT` },
{ label: "Total Purchase (6M Rolling)", value: `${dealer.totalPurchase6M} MT` },
{ label: "Avg. Liquidity Cycle (3M Weighted)", value: `${dealer.avgLiquidityCycle} Days` },
{ label: "Avg. Acknowledgment Cycle (3M Weighted)", value: `${dealer.avgAcknowledgmentCycle} Days` },
{ label: "Avg. Stock Age", value: `${dealer.stockAge} Days` },
{ label: "Aged Stock (>90 Days)", value: `${dealer.agedStock} MT` },
{ label: "Current Stock Quantity", value: `${dealer.currentStock} MT` },
];
return (
<Card className="p-4 sm:p-6">
<div className="flex items-center gap-2 mb-4">
<User className="h-5 w-5 text-primary" />
<h2 className="text-xl font-semibold text-foreground">{title}</h2>
</div>
<div className="space-y-3">
{snapshotItems.map((item, idx) => (
<div key={idx} className="flex flex-col min-[450px]:flex-row justify-between py-2 border-b border-border last:border-0 gap-1 min-[450px]:gap-4">
<span className="text-xs sm:text-sm text-muted-foreground">{item.label}</span>
<span className="text-sm font-medium text-foreground">{item.value}</span>
</div>
))}
</div>
</Card>
);
});
DealerSnapshot.displayName = 'DealerSnapshot';
// Compare My Dealer Business variant for manufacturers
interface ManufacturerData {
type: string;
totalCompanies: number;
activeProducts: number;
totalSales: number;
totalPurchase: number;
avgLiquidityCycle: number;
avgAcknowledgmentCycle: number;
avgStockAge: number;
agedStock: number;
currentStock: number;
}
interface CompareBusinessSnapshotProps {
data: ManufacturerData;
}
export const CompareBusinessSnapshot = memo(({ data }: CompareBusinessSnapshotProps) => {
const snapshotItems = [
{ label: "Type", value: data.type },
{ label: "Total Companies Associated", value: data.totalCompanies },
{ label: "Active Products", value: data.activeProducts },
{ label: "Total Sales (6M Rolling)", value: `${data.totalSales} MT` },
{ label: "Total Purchase (6M Rolling)", value: `${data.totalPurchase} MT` },
{ label: "Avg. Liquidity Cycle (3M Weighted)", value: `${data.avgLiquidityCycle} Days` },
{ label: "Avg. Acknowledgment Cycle (3M Weighted)", value: `${data.avgAcknowledgmentCycle} Days` },
{ label: "Avg. Stock Age", value: `${data.avgStockAge} Days` },
{ label: "Aged Stock (>90 Days)", value: `${data.agedStock} MT` },
{ label: "Current Stock Quantity", value: `${data.currentStock} MT` },
];
return (
<Card className="p-4 sm:p-6">
<div className="flex items-center gap-2 mb-4">
<User className="h-5 w-5 text-primary" />
<h2 className="text-xl font-semibold text-foreground">Compare My Dealer Business</h2>
</div>
<div className="space-y-3">
{snapshotItems.map((item, idx) => (
<div key={idx} className="flex flex-col min-[400px]:flex-row justify-between py-2 border-b border-border last:border-0 gap-1 min-[400px]:gap-4">
<span className="text-xs sm:text-sm text-muted-foreground">{item.label}</span>
<span className="text-sm font-medium text-foreground">{item.value}</span>
</div>
))}
</div>
</Card>
);
});
CompareBusinessSnapshot.displayName = 'CompareBusinessSnapshot';

View File

@ -0,0 +1,69 @@
import { memo, useState, useCallback } from 'react';
import { DealerTableRow } from './DealerTableRow';
import type { Dealer } from '@/lib/mockData';
interface DealerTableProps {
dealers: Dealer[];
onRowClick: (dealerId: string) => void;
}
// Table header component
const TableHeader = memo(() => (
<thead className="bg-muted sticky top-0 z-20 shadow-sm">
<tr>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">State</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">District</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">Dealer Name</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">MFMS ID</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">Mobile Number</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">Products</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">Companies</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">Sales Rating</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">Buy Rating</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">Liquidity Cycle</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">Ack. Cycle</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">Current Stock</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">Aged Stock</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground min-w-[200px]">Credit Score</th>
</tr>
</thead>
));
TableHeader.displayName = 'TableHeader';
export const DealerTable = memo(({ dealers, onRowClick }: DealerTableProps) => {
const [hoveredRow, setHoveredRow] = useState<string | null>(null);
const handleMouseEnter = useCallback((dealerId: string) => {
setHoveredRow(dealerId);
}, []);
const handleMouseLeave = useCallback(() => {
setHoveredRow(null);
}, []);
return (
<div className="bg-card rounded-lg border border-border overflow-hidden">
<div className="overflow-x-auto">
<div className="max-h-[600px] overflow-y-auto">
<table className="w-full">
<TableHeader />
<tbody>
{dealers.map((dealer) => (
<DealerTableRow
key={dealer.id}
dealer={dealer}
isHovered={hoveredRow === dealer.id}
onRowClick={onRowClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
/>
))}
</tbody>
</table>
</div>
</div>
</div>
);
});
DealerTable.displayName = 'DealerTable';

View File

@ -0,0 +1,48 @@
import { memo } from 'react';
import { CreditScoreBar } from '@/components/CreditScoreBar';
import type { Dealer } from '@/lib/mockData';
interface DealerTableRowProps {
dealer: Dealer;
isHovered: boolean;
onRowClick: (dealerId: string) => void;
onMouseEnter: (dealerId: string) => void;
onMouseLeave: () => void;
}
export const DealerTableRow = memo(({
dealer,
isHovered,
onRowClick,
onMouseEnter,
onMouseLeave
}: DealerTableRowProps) => {
return (
<tr
onClick={() => onRowClick(dealer.id)}
onMouseEnter={() => onMouseEnter(dealer.id)}
onMouseLeave={onMouseLeave}
className={`border-t border-border cursor-pointer transition-colors ${isHovered ? "bg-success/5" : "hover:bg-muted/50"
}`}
>
<td className="px-4 py-3 text-sm">{dealer.state}</td>
<td className="px-4 py-3 text-sm">{dealer.district}</td>
<td className="px-4 py-3 text-sm font-medium">{dealer.dealerName}</td>
<td className="px-4 py-3 text-sm text-muted-foreground">{dealer.mfmsId}</td>
<td className="px-4 py-3 text-sm">{dealer.mobile || "N/A"}</td>
<td className="px-4 py-3 text-sm text-center">{dealer.noOfProducts}</td>
<td className="px-4 py-3 text-sm text-center">{dealer.noOfCompanies}</td>
<td className="px-4 py-3 text-sm text-center">{dealer.salesRating}</td>
<td className="px-4 py-3 text-sm text-center">{dealer.buyRating}</td>
<td className="px-4 py-3 text-sm text-center">{dealer.avgLiquidityCycle} Days</td>
<td className="px-4 py-3 text-sm text-center">{dealer.avgAcknowledgmentCycle} Days</td>
<td className="px-4 py-3 text-sm text-center">{dealer.currentStock} MT</td>
<td className="px-4 py-3 text-sm text-center">{dealer.agedStock} MT</td>
<td className="px-4 py-3 text-sm">
<CreditScoreBar score={dealer.creditScore} />
</td>
</tr>
);
});
DealerTableRow.displayName = 'DealerTableRow';

View File

@ -0,0 +1,82 @@
import { memo } from 'react';
import { Lightbulb } from 'lucide-react';
import { Card } from '@/components/ui/card';
interface Insight {
title: string;
description: string;
type: 'success' | 'warning';
}
interface KeyInsightsProps {
compact?: boolean;
}
const DEFAULT_INSIGHTS: Insight[] = [
{ title: "Consistent Sales", description: "Strong sales performance over 6 months", type: "success" },
{ title: "High Aged Stock", description: "70 MT stock over 90 days old", type: "warning" },
{ title: "Good Liquidity", description: "24-day liquidity cycle is healthy", type: "success" },
{ title: "Fast Acknowledgment", description: "Quick acknowledgment in 3 days", type: "success" },
];
export const KeyInsights = memo(({ compact = false }: KeyInsightsProps) => {
if (compact) {
return (
<Card className="p-4">
<div className="flex items-center gap-2 mb-2">
<Lightbulb className="h-5 w-5 text-primary" />
<h3 className="text-lg font-semibold text-foreground">Key Insights</h3>
</div>
<div className="space-y-2">
{DEFAULT_INSIGHTS.map((insight, idx) => (
<div
key={idx}
className={`p-2 rounded-lg ${
insight.type === 'success'
? 'bg-success/10 border border-success/20'
: 'bg-warning/10 border border-warning/20'
}`}
>
<h4 className={`font-semibold text-xs mb-0.5 ${
insight.type === 'success' ? 'text-success' : 'text-warning'
}`}>
{insight.title}
</h4>
<p className="text-xs text-muted-foreground">{insight.description}</p>
</div>
))}
</div>
</Card>
);
}
return (
<Card className="p-6">
<div className="flex items-center gap-2 mb-4">
<Lightbulb className="h-5 w-5 text-primary" />
<h2 className="text-xl font-semibold text-foreground">Key Insights</h2>
</div>
<div className="space-y-4">
{DEFAULT_INSIGHTS.map((insight, idx) => (
<div
key={idx}
className={`p-4 rounded-lg ${
insight.type === 'success'
? 'bg-success/10 border border-success/20'
: 'bg-warning/10 border border-warning/20'
}`}
>
<h3 className={`font-semibold mb-1 ${
insight.type === 'success' ? 'text-success' : 'text-warning'
}`}>
{insight.title}
</h3>
<p className="text-sm text-muted-foreground">{insight.description}</p>
</div>
))}
</div>
</Card>
);
});
KeyInsights.displayName = 'KeyInsights';

View File

@ -0,0 +1,7 @@
export { DealerTable } from './DealerTable';
export { DealerTableRow } from './DealerTableRow';
export { DealerProfileHeader } from './DealerProfileHeader';
export { DealerSnapshot, CompareBusinessSnapshot } from './DealerSnapshot';
export { CreditScoreBreakdown } from './CreditScoreBreakdown';
export { KeyInsights } from './KeyInsights';
export { ActivityTimeline } from './ActivityTimeline';

View File

@ -0,0 +1,59 @@
import { memo } from 'react';
import { UserCircle2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
interface DashboardHeaderProps {
userEmail?: string;
onSignOut: () => void;
onChangePassword: () => void;
}
export const DashboardHeader = memo(({
userEmail,
onSignOut,
onChangePassword
}: DashboardHeaderProps) => {
const displayName = userEmail?.split('@')[0] || 'User';
return (
<header className="bg-[#E8F5E9] border-b border-border py-4 px-4 sm:px-8">
<div className="max-w-[95%] mx-auto flex items-center justify-between">
<div>
<div className="flex items-center gap-2 sm:gap-3 mb-1">
<div className="bg-[#16A34A] text-white font-bold text-lg sm:text-xl px-2 sm:px-3 py-1 sm:py-2 rounded">D3</div>
<div>
<h1 className="text-lg sm:text-xl font-bold text-foreground leading-tight">Dealer 360</h1>
<p className="text-[10px] sm:text-xs text-muted-foreground">Credit Line Platform</p>
</div>
</div>
</div>
<div className="flex items-center gap-2 sm:gap-4">
<span className="text-xs sm:text-sm font-medium text-foreground hidden min-[450px]:inline">Welcome {displayName}!</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-10 w-10 sm:h-12 sm:w-12">
<UserCircle2 className="h-7 w-7 sm:h-9 sm:w-9" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onSignOut}>
Logout
</DropdownMenuItem>
<DropdownMenuItem onClick={onChangePassword}>
Change Password
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</header>
);
});
DashboardHeader.displayName = 'DashboardHeader';

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 text-muted-foreground 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,117 @@
import * as React from "react"
import { ChevronRight } from "lucide-react"
import { cn } from "@/lib/utils"
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<"nav"> & {
separator?: React.ComponentType<{ className?: string }>
}
>(({ ...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 ? React.Fragment : "a"
if (asChild) {
return <Comp ref={ref} className={className} {...props} />
}
return (
<a
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}
>
<span className="sr-only">More</span>
</span>
)
BreadcrumbEllipsis.displayName = "BreadcrumbEllipsis"
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<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
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,
}

297
src/components/ui/chart.tsx Normal file
View File

@ -0,0 +1,297 @@
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { cn } from "@/lib/utils"
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
config: Record<string, {
label?: React.ReactNode
icon?: React.ComponentType
} & Record<string, any>>
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
<div
data-chart={chartId}
ref={ref}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line-line]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
)
})
ChartContainer.displayName = "Chart"
const ChartStyle = ({ id, config }: { id: string; config: Record<string, any> }) => {
const colorConfig = Object.entries(config).filter(
([_, config]) => config.theme || config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(config)
.filter(([_, config]) => config.theme || config.color)
.map(([key, itemConfig]) => {
const color = itemConfig.theme?.color || itemConfig.color
return `[data-chart=${id}] .color-${key} { color: ${color}; }`
})
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}
>(
(
{
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
},
ref
) => {
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item.dataKey || item.name || "value"}`
const itemConfig = item.payload?.config?.[key] || {}
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(label, payload)}
</div>
)
}
if (!labelFormatter && typeof label === "string") {
return <div className={cn("font-medium", labelClassName)}>{label}</div>
}
return null
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
ref={ref}
className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? (
<div className={cn("grid gap-1.5", hideLabel && "hidden")}>
{tooltipLabel}
</div>
) : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = item.payload?.config?.[key] || {}
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={item.dataKey}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig.icon ? (
<itemConfig.icon
className="shrink-0 text-muted-foreground"
style={
!hideIndicator
? {
color: indicatorColor,
}
: undefined
}
/>
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 items-center gap-2",
nestLabel ? "flex-col items-start" : "justify-between"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="font-mono font-medium tabular-nums text-foreground">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
)
ChartTooltipContent.displayName = "ChartTooltip"
const ChartLegend = RechartsPrimitive.Legend
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}
>(
(
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
ref
) => {
if (!payload?.length) {
return null
}
return (
<div
ref={ref}
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = item.payload?.config?.[key] || {}
return (
<div
key={item.value}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
)}
>
{itemConfig.icon && !hideIcon ? (
<itemConfig.icon
className="shrink-0"
style={{
color: item.color,
}}
/>
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
<span className="text-muted-foreground">
{itemConfig?.label || item.value}
</span>
</div>
)
})}
</div>
)
}
)
ChartLegendContent.displayName = "ChartLegend"
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
}

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 aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]: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-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}
/>
))
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 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,
}

176
src/components/ui/form.tsx Normal file
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,71 @@
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,25 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ 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-sm 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",
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,115 @@
import * as React from "react"
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button, 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
} & React.ComponentProps<"a">
const PaginationLink = ({
className,
isActive,
...props
}: PaginationLinkProps) => (
<a
aria-current={isActive ? "page" : undefined}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size: "icon",
}),
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,59 @@
import * as React from "react"
import { GripVertical } from "lucide-react"
import { cn } from "@/lib/utils"
const ResizablePanelGroup = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className
)}
{...props}
/>
)
const ResizablePanel = ({
defaultSize,
className,
...props
}: React.HTMLAttributes<HTMLDivElement> & {
defaultSize?: number
}) => {
const [size, setSize] = React.useState(defaultSize || 50)
return (
<div
className={cn("relative", className)}
style={{ flex: `0 0 ${size}%` }}
{...props}
/>
)
}
const ResizableHandle = ({
withHandle,
className,
...props
}: React.HTMLAttributes<HTMLDivElement> & {
withHandle?: boolean
}) => (
<div
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>
)}
</div>
)
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 }

138
src/components/ui/sheet.tsx Normal file
View File

@ -0,0 +1,138 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Sheet = DialogPrimitive.Root
const SheetTrigger = DialogPrimitive.Trigger
const SheetClose = DialogPrimitive.Close
const SheetPortal = DialogPrimitive.Portal
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.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 = DialogPrimitive.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 DialogPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), 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-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = DialogPrimitive.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 DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
))
SheetTitle.displayName = DialogPrimitive.Title.displayName
const SheetDescription = 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}
/>
))
SheetDescription.displayName = DialogPrimitive.Description.displayName
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@ -0,0 +1,101 @@
import * as React from "react"
import { PanelLeft } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Sheet, SheetContent } from "@/components/ui/sheet"
interface SidebarProps extends React.HTMLAttributes<HTMLDivElement> {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}
const Sidebar = React.forwardRef<HTMLDivElement, SidebarProps>(
({ side = "left", variant = "sidebar", collapsible = "offcanvas", className, ...props }, ref) => {
const [open, setOpen] = React.useState(false)
return (
<>
{collapsible === "offcanvas" && (
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent
data-sidebar="sidebar"
data-mobile="true"
data-variant={variant}
data-side={side}
className={cn(
"w-[--sidebar-width] p-0 [&>button]:hidden",
side === "right" && "sm:!right-[--sidebar-width]",
className
)}
>
<div ref={ref} {...props} />
</SheetContent>
</Sheet>
)}
<div
ref={ref}
data-sidebar="sidebar"
data-variant={variant}
data-side={side}
data-collapsible={collapsible === "offcanvas" ? "offcanvas" : collapsible === "icon" ? "icon" : undefined}
className={cn(
"group peer hidden md:block text-sidebar-foreground",
collapsible === "offcanvas" && "md:hidden",
className
)}
{...props}
/>
{collapsible === "offcanvas" && (
<Button
variant="ghost"
size="icon"
className="fixed left-2 top-2 z-40 md:hidden"
onClick={() => setOpen(true)}
>
<PanelLeft />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)}
</>
)
}
)
Sidebar.displayName = "Sidebar"
const SidebarTrigger = React.forwardRef<
React.ElementRef<typeof Button>,
React.ComponentProps<typeof Button>
>(({ className, onClick, ...props }, ref) => {
return (
<Button
ref={ref}
data-sidebar="trigger"
variant="ghost"
size="icon"
className={cn("h-7 w-7", className)}
onClick={onClick}
{...props}
>
<PanelLeft />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
})
SidebarTrigger.displayName = "SidebarTrigger"
const SidebarRail = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => {
return (
<div
data-sidebar="rail"
className={cn(
"absolute inset-y-0 z-50 hidden w-2 -translate-x-full transition-all group-data-[side=left]:-left-2 group-data-[side=right]:left-full group-data-[collapsible=offcanvas]:group-data-[state=collapsed]:translate-x-0",
className
)}
{...props}
/>
)
}
export { Sidebar, SidebarTrigger, SidebarRail }

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 "@/hooks/use-theme"
import { Toaster as Sonner } 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 }

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 }

117
src/components/ui/table.tsx Normal file
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 }

127
src/components/ui/toast.tsx Normal file
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 {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@/components/ui/toast"
import { useToast } from "@/components/ui/use-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,36 @@
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 ToggleGroup = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, ...props }, ref) => (
<ToggleGroupPrimitive.Root
ref={ref}
className={cn("flex items-center justify-center gap-1", className)}
{...props}
/>
))
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName
const ToggleGroupItem = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, ...props }, ref) => (
<ToggleGroupPrimitive.Item
ref={ref}
className={cn(toggleVariants({ variant, size }), className)}
{...props}
/>
))
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 }

View File

@ -0,0 +1,28 @@
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@ -0,0 +1,186 @@
import * as React from "react"
import type { ToastActionElement, ToastProps } from "@/components/ui/toast"
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const
let count = 0
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType["ADD_TOAST"]
toast: ToasterToast
}
| {
type: ActionType["UPDATE_TOAST"]
toast: Partial<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
}
case "DISMISS_TOAST": {
const { toastId } = action
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
}
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
type Toast = Omit<ToasterToast, "id">
function toast({ ...props }: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
}
}
export { useToast, toast }

19
src/hooks/use-mobile.tsx Normal file
View File

@ -0,0 +1,19 @@
import * as React from "react";
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange);
}, []);
return !!isMobile;
}

23
src/hooks/use-theme.ts Normal file
View File

@ -0,0 +1,23 @@
import { useState, useEffect } from "react"
export function useTheme() {
const [theme, setTheme] = useState<"light" | "dark" | "system">("system")
useEffect(() => {
const root = window.document.documentElement
root.classList.remove("light", "dark")
if (theme === "system") {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
.matches
? "dark"
: "light"
root.classList.add(systemTheme)
return
}
root.classList.add(theme)
}, [theme])
return { theme, setTheme }
}

160
src/hooks/useAuth.tsx Normal file
View File

@ -0,0 +1,160 @@
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
import type { ReactNode } from 'react';
import { useNavigate } from 'react-router-dom';
import { authService, type AppUser, type UserRole } from '@/api';
interface AuthSession {
user: AppUser;
access_token: string;
}
interface AuthContextType {
user: AppUser | null;
session: AuthSession | null;
userRole: UserRole;
loading: boolean;
signIn: (username: string, password: string) => Promise<{ error: { message: string } | null }>;
signOut: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
/**
* Maps the API user_id to the application's UserRole.
* 1 -> helpdesk
* 2 -> bank_customer
* 3 -> manufacturing_customer
*/
const mapUserIdToRole = (userId: string): UserRole => {
switch (userId) {
case '1':
return 'helpdesk';
case '2':
return 'bank_customer';
case '3':
return 'manufacturing_customer';
default:
console.warn(`Unknown user_id: ${userId}. Defaulting to null role.`);
return null;
}
};
export const AuthProvider = ({ children }: { children: ReactNode }) => {
const [user, setUser] = useState<AppUser | null>(null);
const [session, setSession] = useState<AuthSession | null>(null);
const [userRole, setUserRole] = useState<UserRole>(null);
const [loading, setLoading] = useState(true);
const navigate = useNavigate();
// Helper to sync state from localStorage
const syncStateFromStorage = useCallback(() => {
const storedSession = localStorage.getItem('dealer360_session');
if (storedSession) {
try {
const parsedSession = JSON.parse(storedSession) as AuthSession;
setSession(parsedSession);
setUser(parsedSession.user);
setUserRole(parsedSession.user.role);
} catch (e) {
localStorage.removeItem('dealer360_session');
setSession(null);
setUser(null);
setUserRole(null);
}
} else {
setSession(null);
setUser(null);
setUserRole(null);
}
setLoading(false);
}, []);
useEffect(() => {
// Initial sync
syncStateFromStorage();
// Listen for storage changes (multi-tab support)
const handleStorageChange = (e: StorageEvent) => {
if (e.key === 'dealer360_session') {
syncStateFromStorage();
// If logged out in another tab, redirect to login
if (!e.newValue && !window.location.pathname.includes('/login')) {
navigate('/login');
}
}
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [syncStateFromStorage, navigate]);
const signIn = async (username: string, password: string): Promise<{ error: { message: string } | null }> => {
try {
const response = await authService.login({ username, password });
if (response.success && response.data) {
const { user_id, email, username: apiUsername, company_name, token } = response.data;
const role = mapUserIdToRole(user_id);
const appUser: AppUser = {
id: user_id,
username: apiUsername,
email: email,
role: role,
company_name: company_name,
};
const authSession: AuthSession = {
user: appUser,
access_token: token,
};
// Store session in localStorage for persistence
localStorage.setItem('dealer360_session', JSON.stringify(authSession));
setUser(appUser);
setSession(authSession);
setUserRole(role);
return { error: null };
}
return { error: { message: response.message || 'Login failed' } };
} catch (error: any) {
const errorMessage = error.response?.data?.message || error.message || 'An unexpected error occurred during login';
return { error: { message: errorMessage } };
}
};
const signOut = async () => {
// Clear state
localStorage.removeItem('dealer360_session');
setUser(null);
setSession(null);
setUserRole(null);
// Attempt to notify backend
try {
await authService.logout();
} catch (e) {
// Silent fail
}
navigate('/login');
};
return (
<AuthContext.Provider value={{ user, session, userRole, loading, signIn, signOut }}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};

View File

@ -0,0 +1,117 @@
import { useMemo, useState, useCallback } from 'react';
import type { Dealer } from '@/lib/mockData';
import type { FilterState } from '@/components/SearchFilters';
interface UseDealerFiltersOptions {
dealers: Dealer[];
itemsPerPage?: number;
}
interface UseDealerFiltersReturn {
searchQuery: string;
setSearchQuery: (query: string) => void;
filters: FilterState;
setFilters: (filters: FilterState) => void;
currentPage: number;
setCurrentPage: (page: number) => void;
filteredDealers: Dealer[];
paginatedDealers: Dealer[];
totalPages: number;
}
const DEFAULT_FILTERS: FilterState = {
states: [],
districts: [],
dealerType: "all",
minCreditScore: 0,
maxCreditScore: 1000,
};
export function useDealerFilters({
dealers,
itemsPerPage = 100
}: UseDealerFiltersOptions): UseDealerFiltersReturn {
const [searchQuery, setSearchQuery] = useState("");
const [filters, setFilters] = useState<FilterState>(DEFAULT_FILTERS);
const [currentPage, setCurrentPage] = useState(1);
// Reset to page 1 when filters change
const handleSetFilters = useCallback((newFilters: FilterState) => {
setFilters(newFilters);
setCurrentPage(1);
}, []);
const handleSetSearchQuery = useCallback((query: string) => {
setSearchQuery(query);
setCurrentPage(1);
}, []);
// Memoized filtering logic
const filteredDealers = useMemo(() => {
return dealers.filter((dealer) => {
// Search query filter
if (searchQuery) {
const query = searchQuery.toLowerCase();
const searchableFields = [
dealer.dealerName,
dealer.city,
dealer.state,
dealer.district,
dealer.creditScore.toString(),
dealer.mobile,
dealer.aadhaar,
dealer.dealerLicense,
].filter(Boolean);
if (!searchableFields.some(field => field?.toLowerCase().includes(query))) {
return false;
}
}
// State filter
if (filters.states.length > 0 && !filters.states.includes(dealer.state)) {
return false;
}
// District filter
if (filters.districts.length > 0 && !filters.districts.includes(dealer.district)) {
return false;
}
// Dealer type filter
if (filters.dealerType && filters.dealerType !== "all" && dealer.dealerType !== filters.dealerType) {
return false;
}
// Credit score range filter
if (dealer.creditScore < filters.minCreditScore || dealer.creditScore > filters.maxCreditScore) {
return false;
}
return true;
});
}, [dealers, searchQuery, filters]);
// Memoized pagination
const paginatedDealers = useMemo(() => {
const start = (currentPage - 1) * itemsPerPage;
const end = start + itemsPerPage;
return filteredDealers.slice(start, end);
}, [filteredDealers, currentPage, itemsPerPage]);
const totalPages = Math.ceil(filteredDealers.length / itemsPerPage);
return {
searchQuery,
setSearchQuery: handleSetSearchQuery,
filters,
setFilters: handleSetFilters,
currentPage,
setCurrentPage,
filteredDealers,
paginatedDealers,
totalPages,
};
}
export default useDealerFilters;

View File

@ -0,0 +1,54 @@
import { useMemo } from 'react';
import type { Dealer } from '@/lib/mockData';
interface DealerKPIs {
totalDealers: number;
avgCreditScore: number;
highRiskPercentage: string;
avgLiquidityCycle: number;
}
/**
* Custom hook for calculating dealer KPIs
* Memoizes expensive calculations to prevent re-computation
*/
export function useDealerKPIs(dealers: Dealer[]): DealerKPIs {
return useMemo(() => {
if (dealers.length === 0) {
return {
totalDealers: 0,
avgCreditScore: 0,
highRiskPercentage: "0.0",
avgLiquidityCycle: 0,
};
}
const totalDealers = dealers.length;
// Calculate totals in a single pass for efficiency
let creditScoreSum = 0;
let liquidityCycleSum = 0;
let highRiskCount = 0;
for (const dealer of dealers) {
creditScoreSum += dealer.creditScore;
liquidityCycleSum += dealer.avgLiquidityCycle;
if (dealer.creditScore < 500) {
highRiskCount++;
}
}
const avgCreditScore = Math.round(creditScoreSum / totalDealers);
const highRiskPercentage = ((highRiskCount / totalDealers) * 100).toFixed(1);
const avgLiquidityCycle = Math.round(liquidityCycleSum / totalDealers);
return {
totalDealers,
avgCreditScore,
highRiskPercentage,
avgLiquidityCycle,
};
}, [dealers]);
}
export default useDealerKPIs;

64
src/hooks/useDebounce.ts Normal file
View File

@ -0,0 +1,64 @@
import { useState, useEffect, useCallback, useRef } from 'react';
/**
* Custom hook that returns a debounced value
* @param value - The value to debounce
* @param delay - Delay in milliseconds
*/
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(timer);
};
}, [value, delay]);
return debouncedValue;
}
/**
* Custom hook that returns a debounced callback function
* @param callback - The callback function to debounce
* @param delay - Delay in milliseconds
*/
export function useDebouncedCallback<T extends (...args: unknown[]) => unknown>(
callback: T,
delay: number
): (...args: Parameters<T>) => void {
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const callbackRef = useRef(callback);
// Keep callback ref updated
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
return useCallback(
(...args: Parameters<T>) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
callbackRef.current(...args);
}, delay);
},
[delay]
);
}
export default useDebounce;

276
src/index.css Normal file
View File

@ -0,0 +1,276 @@
@import "tailwindcss";
@import "@fontsource/poppins/400.css";
@import "@fontsource/poppins/500.css";
@import "@fontsource/poppins/600.css";
@import "@fontsource/poppins/700.css";
@theme {
--font-family-poppins: "Poppins", sans-serif;
/* Colors */
--color-border: hsl(214.3 31.8% 91.4%);
--color-input: hsl(214.3 31.8% 91.4%);
--color-ring: hsl(142.1 76.2% 36.3%);
--color-background: hsl(0 0% 100%);
--color-foreground: hsl(222.2 84% 4.9%);
--color-primary: hsl(142.1 76.2% 36.3%);
--color-primary-foreground: hsl(355.7 100% 97.3%);
--color-secondary: hsl(210 40% 96.1%);
--color-secondary-foreground: hsl(222.2 47.4% 11.2%);
--color-destructive: hsl(0 84.2% 60.2%);
--color-destructive-foreground: hsl(210 40% 98%);
--color-muted: hsl(210 40% 96.1%);
--color-muted-foreground: hsl(215.4 16.3% 46.9%);
--color-accent: hsl(210 40% 96.1%);
--color-accent-foreground: hsl(222.2 47.4% 11.2%);
--color-popover: hsl(0 0% 100%);
--color-popover-foreground: hsl(222.2 84% 4.9%);
--color-card: hsl(0 0% 100%);
--color-card-foreground: hsl(222.2 84% 4.9%);
--color-success: hsl(142.1 76.2% 36.3%);
--color-warning: hsl(45.4 93.4% 47.5%);
--color-danger: hsl(0 84.2% 60.2%);
--color-chart-1: hsl(142.1 76.2% 36.3%);
--color-chart-2: hsl(217.2 91.2% 59.8%);
--color-chart-3: hsl(45.4 93.4% 47.5%);
--color-chart-4: hsl(0 84.2% 60.2%);
--color-chart-5: hsl(262.1 83.3% 57.8%);
/* Border Radius */
--radius-sm: 0.375rem;
--radius-md: 0.5rem;
--radius-lg: 0.625rem;
}
/* Base styles */
* {
border-color: var(--color-border);
}
body {
font-family: var(--font-family-poppins);
background-color: var(--color-background);
color: var(--color-foreground);
}
/* Tailwind v4 utility aliases for semantic colors */
@layer utilities {
.bg-background {
background-color: var(--color-background);
}
.bg-foreground {
background-color: var(--color-foreground);
}
.bg-primary {
background-color: var(--color-primary);
}
.bg-primary-foreground {
background-color: var(--color-primary-foreground);
}
.bg-secondary {
background-color: var(--color-secondary);
}
.bg-secondary-foreground {
background-color: var(--color-secondary-foreground);
}
.bg-muted {
background-color: var(--color-muted);
}
.bg-muted-foreground {
background-color: var(--color-muted-foreground);
}
.bg-accent {
background-color: var(--color-accent);
}
.bg-accent-foreground {
background-color: var(--color-accent-foreground);
}
.bg-destructive {
background-color: var(--color-destructive);
}
.bg-destructive-foreground {
background-color: var(--color-destructive-foreground);
}
.bg-popover {
background-color: var(--color-popover);
}
.bg-popover-foreground {
background-color: var(--color-popover-foreground);
}
.bg-card {
background-color: var(--color-card);
}
.bg-card-foreground {
background-color: var(--color-card-foreground);
}
.bg-success {
background-color: var(--color-success);
}
.bg-warning {
background-color: var(--color-warning);
}
.bg-danger {
background-color: var(--color-danger);
}
.text-background {
color: var(--color-background);
}
.text-foreground {
color: var(--color-foreground);
}
.text-primary {
color: var(--color-primary);
}
.text-primary-foreground {
color: var(--color-primary-foreground);
}
.text-secondary {
color: var(--color-secondary);
}
.text-secondary-foreground {
color: var(--color-secondary-foreground);
}
.text-muted {
color: var(--color-muted);
}
.text-muted-foreground {
color: var(--color-muted-foreground);
}
.text-accent {
color: var(--color-accent);
}
.text-accent-foreground {
color: var(--color-accent-foreground);
}
.text-destructive {
color: var(--color-destructive);
}
.text-destructive-foreground {
color: var(--color-destructive-foreground);
}
.text-popover {
color: var(--color-popover);
}
.text-popover-foreground {
color: var(--color-popover-foreground);
}
.text-card {
color: var(--color-card);
}
.text-card-foreground {
color: var(--color-card-foreground);
}
.text-success {
color: var(--color-success);
}
.text-warning {
color: var(--color-warning);
}
.text-danger {
color: var(--color-danger);
}
.border-border {
border-color: var(--color-border);
}
.border-input {
border-color: var(--color-input);
}
.border-primary {
border-color: var(--color-primary);
}
.border-secondary {
border-color: var(--color-secondary);
}
.border-destructive {
border-color: var(--color-destructive);
}
.border-muted {
border-color: var(--color-muted);
}
.border-accent {
border-color: var(--color-accent);
}
.border-success {
border-color: var(--color-success);
}
.border-warning {
border-color: var(--color-warning);
}
.border-danger {
border-color: var(--color-danger);
}
.ring-ring {
--tw-ring-color: var(--color-ring);
}
.font-poppins {
font-family: var(--font-family-poppins);
}
}
/* Animation keyframes */
@keyframes accordion-down {
from {
height: 0;
}
to {
height: var(--radix-accordion-content-height);
}
}
@keyframes accordion-up {
from {
height: var(--radix-accordion-content-height);
}
to {
height: 0;
}
}
@layer utilities {
.animate-accordion-down {
animation: accordion-down 0.2s ease-out;
}
.animate-accordion-up {
animation: accordion-up 0.2s ease-out;
}
@keyframes agri-spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@keyframes agri-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.7; transform: scale(0.95); }
}
@keyframes agri-bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-5px); }
}
.animate-agri-spin {
animation: agri-spin 1s linear infinite;
}
.animate-agri-pulse {
animation: agri-pulse 1.5s ease-in-out infinite;
}
.animate-agri-bounce {
animation: agri-bounce 1s ease-in-out infinite;
}
}

269
src/lib/mockData.ts Normal file
View File

@ -0,0 +1,269 @@
import { State, City } from 'country-state-city';
export interface Dealer {
id: string;
state: string;
district: string;
city: string;
dealerName: string;
mfmsId: string;
noOfProducts: number;
noOfCompanies: number;
salesRating: number;
buyRating: number;
avgLiquidityCycle: number;
avgAcknowledgmentCycle: number;
currentStock: number;
agedStock: number;
creditScore: number;
dealerType: "Retailer" | "Wholesaler";
totalSales6M: number;
totalPurchase6M: number;
stockAge: number;
mobile?: string;
aadhaar?: string;
dealerLicense?: string;
}
// Get all Indian states using country-state-city
const allInStates = State.getStatesOfCountry('IN');
export const indianStates = allInStates.map(s => s.name);
// Map state names to their cities (districts)
export const stateDistricts: Record<string, string[]> = allInStates.reduce((acc, state) => {
acc[state.name] = City.getCitiesOfState('IN', state.isoCode).map(city => city.name);
return acc;
}, {} as Record<string, string[]>);
// Dealer names for generation
const DEALER_NAMES = [
"Krishna Agro Traders", "Sharma Seeds & Fertilizers", "Patel Farm Solutions",
"Reddy Agriculture Supplies", "Singh Crop Care", "Kumar Agri Inputs",
"Gupta Farm Depot", "Verma Seeds Co.", "Joshi Agricultural Store",
"Mehta Fertilizer House", "Yadav Agro Center", "Shah Farm Products",
"Desai Agro Services", "Iyer Fertilizers", "Nair Seeds & Supply",
"Pillai Farm Inputs", "Rao Agri Solutions", "Shetty Crop Care",
"Malhotra Agricultural Hub", "Chopra Seeds Depot", "Agarwal Farm Center",
"Jain Agro Supplies", "Bansal Fertilizer Store", "Khanna Agriculture Products",
"Bhatia Farm Solutions", "Kapoor Agri Depot", "Sethi Seeds Trading",
"Mittal Crop Care Center", "Arora Farm Supplies", "Sinha Agricultural Store"
] as const;
// Seeded random number generator for consistent data
const seededRandom = (seed: number): number => {
const x = Math.sin(seed) * 10000;
return x - Math.floor(x);
};
// Generate a single dealer with seeded randomness for consistency
const generateDealer = (index: number): Dealer => {
const seed = index + 1;
const stateIndex = Math.floor(seededRandom(seed * 1) * indianStates.length);
const state = indianStates[stateIndex];
const districts = stateDistricts[state] || [];
const districtIndex = districts.length > 0 ? Math.floor(seededRandom(seed * 2) * districts.length) : 0;
const district = districts[districtIndex] || `District ${state}`;
const creditScore = Math.floor(seededRandom(seed * 3) * 951) + 50;
const dealerType: "Retailer" | "Wholesaler" = seededRandom(seed * 4) > 0.3 ? "Retailer" : "Wholesaler";
return {
id: `DLR${String(index + 1).padStart(6, '0')}`,
state,
district,
city: district,
dealerName: `${DEALER_NAMES[index % DEALER_NAMES.length]} ${district}`,
mfmsId: `MFMS${String(Math.floor(seededRandom(seed * 5) * 999999)).padStart(6, '0')}`,
noOfProducts: Math.floor(seededRandom(seed * 6) * 15) + 3,
noOfCompanies: Math.floor(seededRandom(seed * 7) * 8) + 2,
salesRating: Math.floor(seededRandom(seed * 8) * 401) + 600,
buyRating: Math.floor(seededRandom(seed * 9) * 401) + 600,
avgLiquidityCycle: Math.floor(seededRandom(seed * 10) * 40) + 10,
avgAcknowledgmentCycle: Math.floor(seededRandom(seed * 11) * 7) + 1,
currentStock: Math.floor(seededRandom(seed * 12) * 800) + 100,
agedStock: Math.floor(seededRandom(seed * 13) * 200),
creditScore,
dealerType,
totalSales6M: Math.floor(seededRandom(seed * 14) * 500) + 100,
totalPurchase6M: Math.floor(seededRandom(seed * 15) * 700) + 200,
stockAge: Math.floor(seededRandom(seed * 16) * 60) + 20,
mobile: `+91 ${Math.floor(seededRandom(seed * 17) * 9000000000) + 1000000000}`,
aadhaar: `${Math.floor(seededRandom(seed * 18) * 9000) + 1000} ${Math.floor(seededRandom(seed * 19) * 9000) + 1000} ${Math.floor(seededRandom(seed * 20) * 9000) + 1000}`,
dealerLicense: `DL${String(Math.floor(seededRandom(seed * 21) * 999999)).padStart(6, '0')}`
};
};
// Lazy dealer cache
let dealersCache: Dealer[] | null = null;
const DEALER_COUNT = 2600;
// Generate dealers lazily (on first access)
export const getDealers = (): Dealer[] => {
if (dealersCache === null) {
dealersCache = Array.from({ length: DEALER_COUNT }, (_, i) => generateDealer(i));
}
return dealersCache;
};
// For backwards compatibility - but prefer getDealers() for lazy loading
export const dealers = getDealers();
// Credit score utilities
export const getCreditScoreColor = (score: number): 'success' | 'warning' | 'danger' => {
if (score >= 750) return "success";
if (score >= 500) return "warning";
return "danger";
};
export const getCreditScoreLabel = (score: number): string => {
if (score >= 750) return "Excellent";
if (score >= 650) return "Good";
if (score >= 500) return "Fair";
return "Poor";
};
// Mock score breakdown
export interface ScoreParameter {
parameter: string;
weight: number;
dealerScore: number;
remarks: string;
}
// Memoization cache for score breakdown
const scoreBreakdownCache = new Map<string, ScoreParameter[]>();
export const getScoreBreakdown = (dealerId: string, creditScore: number): ScoreParameter[] => {
const cacheKey = `${dealerId}-${creditScore}`;
if (scoreBreakdownCache.has(cacheKey)) {
return scoreBreakdownCache.get(cacheKey)!;
}
const proportion = creditScore / 1000;
const breakdown: ScoreParameter[] = [
{ parameter: "Sales Performance", weight: 25, dealerScore: Math.round(600 * proportion), remarks: "Steady movement" },
{ parameter: "Purchase Rating", weight: 15, dealerScore: Math.round(700 * proportion), remarks: "Slight inconsistency" },
{ parameter: "Liquidity Cycle", weight: 25, dealerScore: Math.round(800 * proportion), remarks: "Good cycle" },
{ parameter: "Acknowledgment Cycle", weight: 10, dealerScore: Math.round(990 * proportion), remarks: "Delay within limit" },
{ parameter: "Current Stock", weight: 10, dealerScore: Math.round(500 * proportion), remarks: "Healthy inventory" },
{ parameter: "Ageing", weight: 10, dealerScore: Math.round(150 * proportion), remarks: "Hoarding not noticed" },
{ parameter: "Regional Risk Factor", weight: 5, dealerScore: 0, remarks: "Contextual adjustment" },
];
scoreBreakdownCache.set(cacheKey, breakdown);
return breakdown;
};
// Type definitions for chart data
interface CreditTrendItem {
month: string;
score: number;
}
interface SalesPurchaseTrendItem {
month: string;
sales: number;
purchase: number;
}
// Memoization for chart data
const creditTrendCache = new Map<number, CreditTrendItem[]>();
export const getCreditScoreTrend = (creditScore: number): CreditTrendItem[] => {
if (creditTrendCache.has(creditScore)) {
return creditTrendCache.get(creditScore)!;
}
const months = ["Jul 2024", "Aug 2024", "Sep 2024", "Oct 2024", "Nov 2024", "Dec 2024"];
const variation = 20;
const trend: CreditTrendItem[] = [
{ month: months[0], score: Math.max(50, Math.min(1000, creditScore - variation)) },
{ month: months[1], score: Math.max(50, Math.min(1000, creditScore - variation / 2)) },
{ month: months[2], score: Math.max(50, Math.min(1000, creditScore - variation / 3)) },
{ month: months[3], score: Math.max(50, Math.min(1000, creditScore - variation / 4)) },
{ month: months[4], score: Math.max(50, Math.min(1000, creditScore - 2)) },
{ month: months[5], score: creditScore },
];
creditTrendCache.set(creditScore, trend);
return trend;
};
const salesPurchaseCache = new Map<string, SalesPurchaseTrendItem[]>();
export const getSalesPurchaseTrend = (totalSales6M: number, totalPurchase6M: number): SalesPurchaseTrendItem[] => {
const cacheKey = `${totalSales6M}-${totalPurchase6M}`;
if (salesPurchaseCache.has(cacheKey)) {
return salesPurchaseCache.get(cacheKey)!;
}
const months = ["Jul 2024", "Aug 2024", "Sep 2024", "Oct 2024", "Nov 2024", "Dec 2024"];
const avgSales = totalSales6M / 6;
const avgPurchase = totalPurchase6M / 6;
const trend: SalesPurchaseTrendItem[] = [
{ month: months[0], sales: Math.round(avgSales * 0.93), purchase: Math.round(avgPurchase * 0.95) },
{ month: months[1], sales: Math.round(avgSales * 1.03), purchase: Math.round(avgPurchase * 1.00) },
{ month: months[2], sales: Math.round(avgSales * 0.98), purchase: Math.round(avgPurchase * 0.97) },
{ month: months[3], sales: Math.round(avgSales * 1.07), purchase: Math.round(avgPurchase * 1.05) },
{ month: months[4], sales: Math.round(avgSales * 1.02), purchase: Math.round(avgPurchase * 1.02) },
{ month: months[5], sales: Math.round(avgSales * 0.97), purchase: Math.round(avgPurchase * 1.01) },
];
salesPurchaseCache.set(cacheKey, trend);
return trend;
};
// Static stock age distribution (doesn't need memoization)
const STOCK_AGE_DISTRIBUTION = [
{ range: "0-30 Days", value: 35 },
{ range: "31-60 Days", value: 25 },
{ range: "61-90 Days", value: 15 },
{ range: "91-120 Days", value: 10 },
{ range: "121-150 Days", value: 8 },
{ range: "151-180 Days", value: 4 },
{ range: "181+ Days", value: 3 },
] as const;
export const getStockAgeDistribution = () => [...STOCK_AGE_DISTRIBUTION];
// Monthly product scores types and memoization
interface MonthlyProductScore {
product: string;
months: string[];
m1: number;
m2: number;
m3: number;
m4: number;
m5: number;
}
const monthlyScoresCache = new Map<string, MonthlyProductScore[]>();
export const getMonthlyProductScores = (dealerId: string): MonthlyProductScore[] => {
if (monthlyScoresCache.has(dealerId)) {
return monthlyScoresCache.get(dealerId)!;
}
const products = ["Urea", "NPK", "DAP", "SSP"];
const months = ["Aug 2024", "Sep 2024", "Oct 2024", "Nov 2024", "Dec 2024"];
// Use seeded random based on dealer ID for consistency
const seed = parseInt(dealerId.replace('DLR', ''), 10) || 1;
const scores: MonthlyProductScore[] = products.map((product, i) => ({
product,
months,
m1: i === 0 ? 925 : Math.floor(seededRandom(seed * (i + 1) * 100) * 200) + 750,
m2: Math.floor(seededRandom(seed * (i + 1) * 101) * 200) + 750,
m3: Math.floor(seededRandom(seed * (i + 1) * 102) * 200) + 750,
m4: Math.floor(seededRandom(seed * (i + 1) * 103) * 200) + 750,
m5: Math.floor(seededRandom(seed * (i + 1) * 104) * 200) + 750,
}));
monthlyScoresCache.set(dealerId, scores);
return scores;
};

6
src/lib/utils.ts Normal file
View File

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

10
src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

146
src/pages/Dashboard.tsx Normal file
View File

@ -0,0 +1,146 @@
import { useCallback, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { Users, TrendingUp, AlertTriangle, Clock } from "lucide-react";
import { KPICard } from "@/components/KPICard";
import { SearchFilters } from "@/components/SearchFilters";
import { DashboardHeader } from "@/components/layout/DashboardHeader";
import { DealerTable } from "@/components/dealer/DealerTable";
import { Pagination } from "@/components/common/Pagination";
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
import { dealers } from "@/lib/mockData";
import { useToast } from "@/components/ui/use-toast";
import { useAuth } from "@/hooks/useAuth";
import { useDealerFilters } from "@/hooks/useDealerFilters";
import { useDealerKPIs } from "@/hooks/useDealerKPIs";
const Dashboard = () => {
const navigate = useNavigate();
const { toast } = useToast();
const { user, signOut, loading } = useAuth();
// Custom hooks for filtering and KPIs
const {
setSearchQuery,
setFilters,
currentPage,
setCurrentPage,
filteredDealers,
paginatedDealers,
totalPages,
} = useDealerFilters({ dealers, itemsPerPage: 100 });
const kpis = useDealerKPIs(dealers);
// Redirect to login if not authenticated
useEffect(() => {
if (!loading && !user) {
navigate('/login');
}
}, [user, loading, navigate]);
// Memoized handlers
const handleDownload = useCallback(() => {
toast({
title: "Download Started",
description: "Exporting dealer data to Excel...",
});
}, [toast]);
const handleRowClick = useCallback((dealerId: string) => {
navigate(`/dealer/${dealerId}`);
}, [navigate]);
const handleSignOut = useCallback(() => {
signOut();
}, [signOut]);
const handleChangePassword = useCallback(() => {
toast({
title: "Change Password",
description: "Password change functionality coming soon",
});
}, [toast]);
// Loading state
if (loading) {
return (
<div className="min-h-screen bg-[#E8F5E9]/30 backdrop-blur-sm flex items-center justify-center font-poppins">
<LoadingSpinner size="lg" label="Initializing Dashboard..." />
</div>
);
}
return (
<div className="min-h-screen bg-background font-poppins">
<DashboardHeader
userEmail={user?.email}
onSignOut={handleSignOut}
onChangePassword={handleChangePassword}
/>
<main className="max-w-[95%] mx-auto px-4 sm:px-8 py-4 sm:py-6">
<div className="mb-6">
<h2 className="text-xl sm:text-2xl font-bold text-foreground mb-2">Dealer Credit Line Overview</h2>
<p className="text-sm sm:text-base text-muted-foreground">Comprehensive credit analysis and dealer performance metrics</p>
</div>
{/* KPI Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
<KPICard
title="Total Dealers"
value="2,60,000"
icon={Users}
trend="Active in system"
/>
<KPICard
title="Avg Credit Score"
value={kpis.avgCreditScore}
icon={TrendingUp}
trend="↑ 2.3% from last month"
trendColor="success"
/>
<KPICard
title="High-Risk Dealers"
value={`${kpis.highRiskPercentage}%`}
icon={AlertTriangle}
trend="Score below 500"
trendColor="danger"
/>
<KPICard
title="Avg Liquidity Cycle"
value={`${kpis.avgLiquidityCycle} Days`}
icon={Clock}
trend="↓ 1.8 days improved"
trendColor="success"
/>
</div>
{/* Search & Filters */}
<div className="mb-6">
<SearchFilters
onSearch={setSearchQuery}
onFilter={setFilters}
onDownload={handleDownload}
/>
</div>
{/* Results Summary */}
<div className="mb-4 text-sm text-muted-foreground">
Showing {paginatedDealers.length} of {filteredDealers.length} dealers
</div>
{/* Dealers Table */}
<DealerTable dealers={paginatedDealers} onRowClick={handleRowClick} />
{/* Pagination */}
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/>
</main>
</div>
);
};
export default Dashboard;

189
src/pages/DealerProfile.tsx Normal file
View File

@ -0,0 +1,189 @@
import { useParams, useNavigate } from "react-router-dom";
import { useEffect, useMemo, useCallback } from "react";
import { dealers, getScoreBreakdown, getCreditScoreColor } from "@/lib/mockData";
import { useAuth } from "@/hooks/useAuth";
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
import { Button } from "@/components/ui/button";
import {
DealerProfileHeader,
DealerSnapshot,
CompareBusinessSnapshot,
CreditScoreBreakdown,
KeyInsights,
ActivityTimeline,
} from "@/components/dealer";
import {
CreditScoreTrendChart,
SalesPurchaseChart,
StockAgeChart,
} from "@/components/charts";
const DealerProfile = () => {
const { id } = useParams();
const navigate = useNavigate();
const { user, userRole, loading } = useAuth();
// Find dealer - memoized
const dealer = useMemo(() => dealers.find((d) => d.id === id), [id]);
// Memoized calculations
const scoreBreakdown = useMemo(
() => dealer ? getScoreBreakdown(dealer.id, dealer.creditScore) : [],
[dealer]
);
const creditColor = useMemo(
() => dealer ? getCreditScoreColor(dealer.creditScore) : 'danger',
[dealer]
);
// Use seeded random for consistent active status
const isActive = useMemo(() => {
if (!dealer) return false;
const seed = parseInt(dealer.id.replace('DLR', ''), 10) || 1;
return Math.sin(seed) * 10000 - Math.floor(Math.sin(seed) * 10000) > 0.3;
}, [dealer]);
const isEligible = dealer ? dealer.creditScore >= 500 : false;
// Role-based visibility checks - memoized
const rolePermissions = useMemo(() => ({
isBankCustomer: userRole === 'bank_customer',
isManufacturingCustomer: userRole === 'manufacturing_customer',
isHelpdesk: userRole === 'helpdesk',
showScoreCard: userRole !== 'bank_customer',
showDealerSnapshot: userRole !== 'bank_customer',
showStockAgeDistribution: userRole !== 'bank_customer',
showCreditScoreBreakdown: userRole !== 'manufacturing_customer',
showLastActivityTimeline: userRole === 'helpdesk',
}), [userRole]);
// Mock manufacturer data
const manufacturerData = useMemo(() => ({
type: dealer?.dealerType || 'Retailer',
totalCompanies: 1,
activeProducts: 3,
totalSales: 100,
totalPurchase: 312,
avgLiquidityCycle: 20,
avgAcknowledgmentCycle: 3,
avgStockAge: 22,
agedStock: 144,
currentStock: 344,
}), [dealer?.dealerType]);
// Navigation handlers
const handleBack = useCallback(() => navigate("/"), [navigate]);
const handleViewScoreCard = useCallback(
() => navigate(`/dealer/${dealer?.id}/scorecard`),
[navigate, dealer?.id]
);
// Auth redirect
useEffect(() => {
if (!loading && !user) {
navigate('/login');
}
}, [user, loading, navigate]);
// Loading state
if (loading) {
return (
<div className="min-h-screen bg-[#E8F5E9]/30 backdrop-blur-sm flex items-center justify-center font-poppins">
<LoadingSpinner size="lg" label="Fetching Dealer Insights..." />
</div>
);
}
// Not found state
if (!dealer) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="text-center">
<h2 className="text-2xl font-bold mb-4">Dealer Not Found</h2>
<Button onClick={handleBack}>Back to Dashboard</Button>
</div>
</div>
);
}
const {
isBankCustomer,
isManufacturingCustomer,
showScoreCard,
showDealerSnapshot,
showStockAgeDistribution,
showCreditScoreBreakdown,
showLastActivityTimeline,
} = rolePermissions;
return (
<div className="min-h-screen bg-background font-poppins">
<DealerProfileHeader
dealer={dealer}
creditColor={creditColor}
isEligible={isEligible}
isActive={isActive}
showScoreCard={showScoreCard}
onBack={handleBack}
onViewScoreCard={handleViewScoreCard}
/>
<main className="max-w-[95%] mx-auto px-4 sm:px-8 py-4 sm:py-8">
{/* Last Activity Timeline (Helpdesk only) */}
{showLastActivityTimeline && <ActivityTimeline />}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Dealer Snapshot (for all except bank customers on manufacturer view) */}
{showDealerSnapshot && !isManufacturingCustomer && (
<DealerSnapshot dealer={dealer} />
)}
{/* Dealer Snapshot (for manufacturers) */}
{isManufacturingCustomer && (
<DealerSnapshot dealer={dealer} />
)}
{/* Compare My Dealer Business (only for manufacturers) */}
{isManufacturingCustomer && (
<CompareBusinessSnapshot data={manufacturerData} />
)}
{/* Credit Score Breakdown */}
{showCreditScoreBreakdown && !isBankCustomer && (
<CreditScoreBreakdown scoreBreakdown={scoreBreakdown} />
)}
{/* Key Insights */}
{!isBankCustomer && <KeyInsights />}
</div>
{/* Charts Section */}
<div className={`grid grid-cols-1 ${isBankCustomer ? 'lg:grid-cols-2' : 'lg:grid-cols-3'} gap-6 mt-6`}>
{/* Credit Score Breakdown - For bank customers */}
{showCreditScoreBreakdown && isBankCustomer && (
<CreditScoreBreakdown scoreBreakdown={scoreBreakdown} compact />
)}
{/* Credit Score Trend */}
<CreditScoreTrendChart creditScore={dealer.creditScore} compact={isBankCustomer} />
{/* Key Insights - For bank customers */}
{isBankCustomer && <KeyInsights compact />}
{/* Sales vs Purchase Trend */}
<SalesPurchaseChart
totalSales6M={dealer.totalSales6M}
totalPurchase6M={dealer.totalPurchase6M}
compact={isBankCustomer}
/>
{/* Stock Age Distribution */}
{showStockAgeDistribution && <StockAgeChart />}
</div>
</main>
</div>
);
};
export default DealerProfile;

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