Initial commit of Dealer360 UI
This commit is contained in:
commit
161423a598
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
106
README.md
Normal file
106
README.md
Normal 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
115
SETUP.md
Normal 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
23
eslint.config.js
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { 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
16
index.html
Normal 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
6648
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
81
package.json
Normal file
81
package.json
Normal 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
1
public/vite.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
0
src/App.css
Normal file
0
src/App.css
Normal file
59
src/App.tsx
Normal file
59
src/App.tsx
Normal 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
68
src/api/axiosInstance.ts
Normal 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
16
src/api/endpoints.ts
Normal 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
5
src/api/index.ts
Normal 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
|
||||||
47
src/api/services/auth.service.ts
Normal file
47
src/api/services/auth.service.ts
Normal 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
31
src/api/types.ts
Normal 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
BIN
src/assets/login-hero.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
42
src/components/CreditScoreBar.tsx
Normal file
42
src/components/CreditScoreBar.tsx
Normal 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';
|
||||||
42
src/components/KPICard.tsx
Normal file
42
src/components/KPICard.tsx
Normal 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';
|
||||||
291
src/components/SearchFilters.tsx
Normal file
291
src/components/SearchFilters.tsx
Normal 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';
|
||||||
36
src/components/charts/CreditScoreTrendChart.tsx
Normal file
36
src/components/charts/CreditScoreTrendChart.tsx
Normal 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';
|
||||||
40
src/components/charts/SalesPurchaseChart.tsx
Normal file
40
src/components/charts/SalesPurchaseChart.tsx
Normal 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';
|
||||||
52
src/components/charts/StockAgeChart.tsx
Normal file
52
src/components/charts/StockAgeChart.tsx
Normal 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';
|
||||||
3
src/components/charts/index.ts
Normal file
3
src/components/charts/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export { CreditScoreTrendChart } from './CreditScoreTrendChart';
|
||||||
|
export { SalesPurchaseChart } from './SalesPurchaseChart';
|
||||||
|
export { StockAgeChart } from './StockAgeChart';
|
||||||
44
src/components/common/ErrorBoundary.tsx
Normal file
44
src/components/common/ErrorBoundary.tsx
Normal 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;
|
||||||
68
src/components/common/ErrorBoundaryPage.tsx
Normal file
68
src/components/common/ErrorBoundaryPage.tsx
Normal 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;
|
||||||
68
src/components/common/LoadingSpinner.tsx
Normal file
68
src/components/common/LoadingSpinner.tsx
Normal 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';
|
||||||
42
src/components/common/NotFound.tsx
Normal file
42
src/components/common/NotFound.tsx
Normal 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;
|
||||||
42
src/components/common/OfflinePage.tsx
Normal file
42
src/components/common/OfflinePage.tsx
Normal 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;
|
||||||
113
src/components/common/Pagination.tsx
Normal file
113
src/components/common/Pagination.tsx
Normal 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';
|
||||||
59
src/components/dealer/ActivityTimeline.tsx
Normal file
59
src/components/dealer/ActivityTimeline.tsx
Normal 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';
|
||||||
72
src/components/dealer/CreditScoreBreakdown.tsx
Normal file
72
src/components/dealer/CreditScoreBreakdown.tsx
Normal 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';
|
||||||
100
src/components/dealer/DealerProfileHeader.tsx
Normal file
100
src/components/dealer/DealerProfileHeader.tsx
Normal 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';
|
||||||
95
src/components/dealer/DealerSnapshot.tsx
Normal file
95
src/components/dealer/DealerSnapshot.tsx
Normal 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';
|
||||||
69
src/components/dealer/DealerTable.tsx
Normal file
69
src/components/dealer/DealerTable.tsx
Normal 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';
|
||||||
48
src/components/dealer/DealerTableRow.tsx
Normal file
48
src/components/dealer/DealerTableRow.tsx
Normal 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';
|
||||||
82
src/components/dealer/KeyInsights.tsx
Normal file
82
src/components/dealer/KeyInsights.tsx
Normal 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';
|
||||||
7
src/components/dealer/index.ts
Normal file
7
src/components/dealer/index.ts
Normal 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';
|
||||||
59
src/components/layout/DashboardHeader.tsx
Normal file
59
src/components/layout/DashboardHeader.tsx
Normal 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';
|
||||||
56
src/components/ui/accordion.tsx
Normal file
56
src/components/ui/accordion.tsx
Normal 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 }
|
||||||
139
src/components/ui/alert-dialog.tsx
Normal file
139
src/components/ui/alert-dialog.tsx
Normal 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,
|
||||||
|
}
|
||||||
59
src/components/ui/alert.tsx
Normal file
59
src/components/ui/alert.tsx
Normal 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 }
|
||||||
5
src/components/ui/aspect-ratio.tsx
Normal file
5
src/components/ui/aspect-ratio.tsx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
|
||||||
|
|
||||||
|
const AspectRatio = AspectRatioPrimitive.Root
|
||||||
|
|
||||||
|
export { AspectRatio }
|
||||||
48
src/components/ui/avatar.tsx
Normal file
48
src/components/ui/avatar.tsx
Normal 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 }
|
||||||
36
src/components/ui/badge.tsx
Normal file
36
src/components/ui/badge.tsx
Normal 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 }
|
||||||
117
src/components/ui/breadcrumb.tsx
Normal file
117
src/components/ui/breadcrumb.tsx
Normal 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,
|
||||||
|
}
|
||||||
56
src/components/ui/button.tsx
Normal file
56
src/components/ui/button.tsx
Normal 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 }
|
||||||
64
src/components/ui/calendar.tsx
Normal file
64
src/components/ui/calendar.tsx
Normal 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 }
|
||||||
79
src/components/ui/card.tsx
Normal file
79
src/components/ui/card.tsx
Normal 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 }
|
||||||
260
src/components/ui/carousel.tsx
Normal file
260
src/components/ui/carousel.tsx
Normal 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
297
src/components/ui/chart.tsx
Normal 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,
|
||||||
|
}
|
||||||
28
src/components/ui/checkbox.tsx
Normal file
28
src/components/ui/checkbox.tsx
Normal 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 }
|
||||||
9
src/components/ui/collapsible.tsx
Normal file
9
src/components/ui/collapsible.tsx
Normal 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 }
|
||||||
153
src/components/ui/command.tsx
Normal file
153
src/components/ui/command.tsx
Normal 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,
|
||||||
|
}
|
||||||
198
src/components/ui/context-menu.tsx
Normal file
198
src/components/ui/context-menu.tsx
Normal 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,
|
||||||
|
}
|
||||||
120
src/components/ui/dialog.tsx
Normal file
120
src/components/ui/dialog.tsx
Normal 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,
|
||||||
|
}
|
||||||
116
src/components/ui/drawer.tsx
Normal file
116
src/components/ui/drawer.tsx
Normal 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,
|
||||||
|
}
|
||||||
198
src/components/ui/dropdown-menu.tsx
Normal file
198
src/components/ui/dropdown-menu.tsx
Normal 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
176
src/components/ui/form.tsx
Normal 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,
|
||||||
|
}
|
||||||
27
src/components/ui/hover-card.tsx
Normal file
27
src/components/ui/hover-card.tsx
Normal 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 }
|
||||||
71
src/components/ui/input-otp.tsx
Normal file
71
src/components/ui/input-otp.tsx
Normal 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 }
|
||||||
25
src/components/ui/input.tsx
Normal file
25
src/components/ui/input.tsx
Normal 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 }
|
||||||
24
src/components/ui/label.tsx
Normal file
24
src/components/ui/label.tsx
Normal 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 }
|
||||||
234
src/components/ui/menubar.tsx
Normal file
234
src/components/ui/menubar.tsx
Normal 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,
|
||||||
|
}
|
||||||
128
src/components/ui/navigation-menu.tsx
Normal file
128
src/components/ui/navigation-menu.tsx
Normal 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,
|
||||||
|
}
|
||||||
115
src/components/ui/pagination.tsx
Normal file
115
src/components/ui/pagination.tsx
Normal 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,
|
||||||
|
}
|
||||||
29
src/components/ui/popover.tsx
Normal file
29
src/components/ui/popover.tsx
Normal 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 }
|
||||||
26
src/components/ui/progress.tsx
Normal file
26
src/components/ui/progress.tsx
Normal 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 }
|
||||||
42
src/components/ui/radio-group.tsx
Normal file
42
src/components/ui/radio-group.tsx
Normal 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 }
|
||||||
59
src/components/ui/resizable.tsx
Normal file
59
src/components/ui/resizable.tsx
Normal 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 }
|
||||||
46
src/components/ui/scroll-area.tsx
Normal file
46
src/components/ui/scroll-area.tsx
Normal 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 }
|
||||||
158
src/components/ui/select.tsx
Normal file
158
src/components/ui/select.tsx
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||||
|
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Select = SelectPrimitive.Root
|
||||||
|
|
||||||
|
const SelectGroup = SelectPrimitive.Group
|
||||||
|
|
||||||
|
const SelectValue = SelectPrimitive.Value
|
||||||
|
|
||||||
|
const SelectTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-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,
|
||||||
|
}
|
||||||
29
src/components/ui/separator.tsx
Normal file
29
src/components/ui/separator.tsx
Normal 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
138
src/components/ui/sheet.tsx
Normal 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,
|
||||||
|
}
|
||||||
101
src/components/ui/sidebar.tsx
Normal file
101
src/components/ui/sidebar.tsx
Normal 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 }
|
||||||
15
src/components/ui/skeleton.tsx
Normal file
15
src/components/ui/skeleton.tsx
Normal 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 }
|
||||||
26
src/components/ui/slider.tsx
Normal file
26
src/components/ui/slider.tsx
Normal 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 }
|
||||||
29
src/components/ui/sonner.tsx
Normal file
29
src/components/ui/sonner.tsx
Normal 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 }
|
||||||
27
src/components/ui/switch.tsx
Normal file
27
src/components/ui/switch.tsx
Normal 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
117
src/components/ui/table.tsx
Normal 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,
|
||||||
|
}
|
||||||
53
src/components/ui/tabs.tsx
Normal file
53
src/components/ui/tabs.tsx
Normal 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 }
|
||||||
24
src/components/ui/textarea.tsx
Normal file
24
src/components/ui/textarea.tsx
Normal 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
127
src/components/ui/toast.tsx
Normal 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,
|
||||||
|
}
|
||||||
33
src/components/ui/toaster.tsx
Normal file
33
src/components/ui/toaster.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
36
src/components/ui/toggle-group.tsx
Normal file
36
src/components/ui/toggle-group.tsx
Normal 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 }
|
||||||
43
src/components/ui/toggle.tsx
Normal file
43
src/components/ui/toggle.tsx
Normal 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 }
|
||||||
28
src/components/ui/tooltip.tsx
Normal file
28
src/components/ui/tooltip.tsx
Normal 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 }
|
||||||
186
src/components/ui/use-toast.ts
Normal file
186
src/components/ui/use-toast.ts
Normal 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
19
src/hooks/use-mobile.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
const MOBILE_BREAKPOINT = 768;
|
||||||
|
|
||||||
|
export function useIsMobile() {
|
||||||
|
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
||||||
|
const onChange = () => {
|
||||||
|
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||||
|
};
|
||||||
|
mql.addEventListener("change", onChange);
|
||||||
|
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||||
|
return () => mql.removeEventListener("change", onChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return !!isMobile;
|
||||||
|
}
|
||||||
23
src/hooks/use-theme.ts
Normal file
23
src/hooks/use-theme.ts
Normal 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
160
src/hooks/useAuth.tsx
Normal 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;
|
||||||
|
};
|
||||||
117
src/hooks/useDealerFilters.ts
Normal file
117
src/hooks/useDealerFilters.ts
Normal 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;
|
||||||
54
src/hooks/useDealerKPIs.ts
Normal file
54
src/hooks/useDealerKPIs.ts
Normal 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
64
src/hooks/useDebounce.ts
Normal 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
276
src/index.css
Normal 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
269
src/lib/mockData.ts
Normal 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
6
src/lib/utils.ts
Normal 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
10
src/main.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
146
src/pages/Dashboard.tsx
Normal file
146
src/pages/Dashboard.tsx
Normal 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
189
src/pages/DealerProfile.tsx
Normal 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
Loading…
Reference in New Issue
Block a user