Init
This commit is contained in:
32
.cursor/rules/api-routes.mdc
Normal file
32
.cursor/rules/api-routes.mdc
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
---
|
||||||
|
globs: app/api/**/*.ts
|
||||||
|
---
|
||||||
|
|
||||||
|
# API Routes Rules
|
||||||
|
|
||||||
|
1. Routes MUST only use services for data access
|
||||||
|
2. Routes MUST handle input validation
|
||||||
|
3. Routes MUST return typed responses
|
||||||
|
4. Routes MUST use proper error handling
|
||||||
|
|
||||||
|
Example of correct API route:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { MyService } from "@/services/my-service";
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const service = new MyService(pool);
|
||||||
|
const data = await service.getData();
|
||||||
|
return Response.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
return Response.error();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
❌ FORBIDDEN:
|
||||||
|
|
||||||
|
- Direct database queries
|
||||||
|
- Business logic implementation
|
||||||
|
- Untyped responses
|
||||||
167
.cursor/rules/business-logic-separation.mdc
Normal file
167
.cursor/rules/business-logic-separation.mdc
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
---
|
||||||
|
alwaysApply: true
|
||||||
|
description: Enforce business logic separation between frontend and backend
|
||||||
|
---
|
||||||
|
|
||||||
|
# Business Logic Separation Rules
|
||||||
|
|
||||||
|
## Core Principle: NO Business Logic in Frontend
|
||||||
|
|
||||||
|
All business logic, data processing, and domain rules MUST be implemented in the backend services layer. The frontend is purely for presentation and user interaction.
|
||||||
|
|
||||||
|
## ✅ ALLOWED in Frontend ([src/components/](mdc:src/components/), [src/hooks/](mdc:src/hooks/), [src/clients/](mdc:src/clients/))
|
||||||
|
|
||||||
|
### Components
|
||||||
|
- UI rendering and presentation logic
|
||||||
|
- Form validation (UI-level only, not business rules)
|
||||||
|
- User interaction handling (clicks, inputs, navigation)
|
||||||
|
- Visual state management (loading, errors, UI states)
|
||||||
|
- Data formatting for display purposes only
|
||||||
|
|
||||||
|
### Hooks
|
||||||
|
- React state management
|
||||||
|
- API call orchestration (using clients)
|
||||||
|
- UI-specific logic (modals, forms, animations)
|
||||||
|
- Data fetching and caching coordination
|
||||||
|
|
||||||
|
### Clients
|
||||||
|
- HTTP requests to API routes
|
||||||
|
- Request/response transformation (serialization only)
|
||||||
|
- Error handling and retry logic
|
||||||
|
- Authentication token management
|
||||||
|
|
||||||
|
## ❌ FORBIDDEN in Frontend
|
||||||
|
|
||||||
|
### Business Rules
|
||||||
|
```typescript
|
||||||
|
// ❌ BAD: Business logic in component
|
||||||
|
const TaskCard = ({ task }) => {
|
||||||
|
const canEdit = task.status === 'open' && task.assignee === currentUser.id;
|
||||||
|
const priority = task.dueDate < new Date() ? 'high' : 'normal';
|
||||||
|
// This is business logic!
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ GOOD: Get computed values from backend
|
||||||
|
const TaskCard = ({ task }) => {
|
||||||
|
const { canEdit, priority } = task; // Computed by backend service
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data Processing
|
||||||
|
```typescript
|
||||||
|
// ❌ BAD: Data transformation in frontend
|
||||||
|
const processJiraTasks = (tasks) => {
|
||||||
|
return tasks.map(task => ({
|
||||||
|
...task,
|
||||||
|
normalizedStatus: mapJiraStatus(task.status),
|
||||||
|
estimatedHours: calculateEstimate(task.storyPoints)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ GOOD: Data already processed by backend service
|
||||||
|
const { processedTasks } = await tasksClient.getTasks();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Domain Logic
|
||||||
|
```typescript
|
||||||
|
// ❌ BAD: Domain calculations in frontend
|
||||||
|
const calculateTeamVelocity = (sprints) => {
|
||||||
|
// Complex business calculation
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ GOOD: Domain logic in service
|
||||||
|
// This belongs in services/team-analytics.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## ✅ REQUIRED in Backend ([src/services/](mdc:src/services/), [src/app/api/](mdc:src/app/api/))
|
||||||
|
|
||||||
|
### Services Layer
|
||||||
|
- All business rules and domain logic
|
||||||
|
- Data validation and processing
|
||||||
|
- Integration with external APIs (Jira, macOS Reminders)
|
||||||
|
- Complex calculations and algorithms
|
||||||
|
- Data aggregation and analytics
|
||||||
|
- Permission and authorization logic
|
||||||
|
|
||||||
|
### API Routes
|
||||||
|
- Input validation and sanitization
|
||||||
|
- Service orchestration
|
||||||
|
- Response formatting
|
||||||
|
- Error handling and logging
|
||||||
|
- Authentication and authorization
|
||||||
|
|
||||||
|
## Implementation Pattern
|
||||||
|
|
||||||
|
### ✅ Correct Flow
|
||||||
|
```
|
||||||
|
User Action → Component → Client → API Route → Service → Database
|
||||||
|
↑ ↓
|
||||||
|
Pure UI Logic Business Logic
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ Incorrect Flow
|
||||||
|
```
|
||||||
|
User Action → Component with Business Logic → Database
|
||||||
|
```
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Task Status Management
|
||||||
|
```typescript
|
||||||
|
// ❌ BAD: In component
|
||||||
|
const updateTaskStatus = (taskId, newStatus) => {
|
||||||
|
if (newStatus === 'done' && !task.hasAllSubtasks) {
|
||||||
|
throw new Error('Cannot complete task with pending subtasks');
|
||||||
|
}
|
||||||
|
// Business rule in frontend!
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ GOOD: In services/task-processor.ts
|
||||||
|
export const updateTaskStatus = async (taskId: string, newStatus: TaskStatus) => {
|
||||||
|
const task = await getTask(taskId);
|
||||||
|
|
||||||
|
// Business rules in service
|
||||||
|
if (newStatus === 'done' && !await hasAllSubtasksCompleted(taskId)) {
|
||||||
|
throw new BusinessError('Cannot complete task with pending subtasks');
|
||||||
|
}
|
||||||
|
|
||||||
|
return await updateTask(taskId, { status: newStatus });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Team Analytics
|
||||||
|
```typescript
|
||||||
|
// ❌ BAD: In component
|
||||||
|
const TeamDashboard = () => {
|
||||||
|
const calculateBurndown = (tasks) => {
|
||||||
|
// Complex business calculation in component
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ GOOD: In services/team-analytics.ts
|
||||||
|
export const getTeamBurndown = async (teamId: string, sprintId: string) => {
|
||||||
|
// All calculation logic in service
|
||||||
|
const tasks = await getSprintTasks(sprintId);
|
||||||
|
return calculateBurndownMetrics(tasks);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
|
||||||
|
When reviewing code:
|
||||||
|
1. **Components**: Should only contain JSX, event handlers, and UI state
|
||||||
|
2. **Hooks**: Should only orchestrate API calls and manage React state
|
||||||
|
3. **Clients**: Should only make HTTP requests and handle responses
|
||||||
|
4. **Services**: Should contain ALL business logic and data processing
|
||||||
|
5. **API Routes**: Should validate input and call appropriate services
|
||||||
|
|
||||||
|
## Red Flags
|
||||||
|
|
||||||
|
Watch for these patterns that indicate business logic in frontend:
|
||||||
|
- Complex calculations in components/hooks
|
||||||
|
- Business rule validation in forms
|
||||||
|
- Data transformation beyond display formatting
|
||||||
|
- Domain-specific constants and rules
|
||||||
|
- Integration logic with external systems
|
||||||
|
|
||||||
|
Remember: **The frontend is a thin presentation layer. All intelligence lives in the backend.**
|
||||||
31
.cursor/rules/clients.mdc
Normal file
31
.cursor/rules/clients.mdc
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
---
|
||||||
|
globs: clients/**/*.ts
|
||||||
|
---
|
||||||
|
|
||||||
|
# HTTP Clients Rules
|
||||||
|
|
||||||
|
1. All HTTP calls MUST be in clients/domains/
|
||||||
|
2. Each domain MUST have its own client
|
||||||
|
3. Clients MUST use the base HTTP client
|
||||||
|
4. Clients MUST type their responses
|
||||||
|
|
||||||
|
Example of correct client:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { HttpClient } from "@/clients/base/http-client";
|
||||||
|
import { MyData } from "@/lib/types";
|
||||||
|
|
||||||
|
export class MyClient {
|
||||||
|
constructor(private httpClient: HttpClient) {}
|
||||||
|
|
||||||
|
async getData(): Promise<MyData[]> {
|
||||||
|
return this.httpClient.get("/api/data");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
❌ FORBIDDEN:
|
||||||
|
|
||||||
|
- Direct fetch calls
|
||||||
|
- Business logic in clients
|
||||||
|
- Untyped responses
|
||||||
28
.cursor/rules/components.mdc
Normal file
28
.cursor/rules/components.mdc
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
globs: src/components/**/*.tsx
|
||||||
|
---
|
||||||
|
|
||||||
|
# Components Rules
|
||||||
|
|
||||||
|
1. UI components MUST be in src/components/ui/
|
||||||
|
2. Feature components MUST be in their feature folder
|
||||||
|
3. Components MUST use clients for data fetching
|
||||||
|
4. Components MUST be properly typed
|
||||||
|
|
||||||
|
Example of correct component:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useMyClient } from '@/hooks/use-my-client';
|
||||||
|
|
||||||
|
export const MyComponent = () => {
|
||||||
|
const { data } = useMyClient();
|
||||||
|
return <div>{data.map(item => <Item key={item.id} {...item} />)}</div>;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
❌ FORBIDDEN:
|
||||||
|
|
||||||
|
- Direct service usage
|
||||||
|
- Direct database queries
|
||||||
|
- Direct fetch calls
|
||||||
|
- Untyped props
|
||||||
167
.cursor/rules/css-variables-theme.mdc
Normal file
167
.cursor/rules/css-variables-theme.mdc
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
---
|
||||||
|
alwaysApply: true
|
||||||
|
description: CSS Variables theme system best practices
|
||||||
|
---
|
||||||
|
|
||||||
|
# CSS Variables Theme System
|
||||||
|
|
||||||
|
## Core Principle: Pure CSS Variables for Theming
|
||||||
|
|
||||||
|
This project uses **CSS Variables exclusively** for theming. No Tailwind `dark:` classes or conditional CSS classes.
|
||||||
|
|
||||||
|
## ✅ Architecture Pattern
|
||||||
|
|
||||||
|
### CSS Structure
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
/* Light theme (default values) */
|
||||||
|
--background: #f1f5f9;
|
||||||
|
--foreground: #0f172a;
|
||||||
|
--primary: #0891b2;
|
||||||
|
--success: #059669;
|
||||||
|
--destructive: #dc2626;
|
||||||
|
--accent: #d97706;
|
||||||
|
--purple: #8b5cf6;
|
||||||
|
--yellow: #eab308;
|
||||||
|
--green: #059669;
|
||||||
|
--blue: #2563eb;
|
||||||
|
--gray: #6b7280;
|
||||||
|
--gray-light: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
/* Dark theme (override values) */
|
||||||
|
--background: #1e293b;
|
||||||
|
--foreground: #f1f5f9;
|
||||||
|
--primary: #06b6d4;
|
||||||
|
--success: #10b981;
|
||||||
|
--destructive: #ef4444;
|
||||||
|
--accent: #f59e0b;
|
||||||
|
--purple: #8b5cf6;
|
||||||
|
--yellow: #eab308;
|
||||||
|
--green: #10b981;
|
||||||
|
--blue: #3b82f6;
|
||||||
|
--gray: #9ca3af;
|
||||||
|
--gray-light: #374151;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Theme Application
|
||||||
|
- **Single source of truth**: [ThemeContext.tsx](mdc:src/contexts/ThemeContext.tsx) applies theme class to `document.documentElement`
|
||||||
|
- **No duplication**: Theme is applied only once, not in multiple places
|
||||||
|
- **SSR safe**: Initial theme from server-side preferences
|
||||||
|
|
||||||
|
## ✅ Component Usage Patterns
|
||||||
|
|
||||||
|
### Correct: Using CSS Variables
|
||||||
|
```tsx
|
||||||
|
// ✅ GOOD: CSS Variables in className
|
||||||
|
<div className="bg-[var(--card)] text-[var(--foreground)] border-[var(--border)]">
|
||||||
|
|
||||||
|
// ✅ GOOD: CSS Variables in style prop
|
||||||
|
<div style={{ color: 'var(--primary)', backgroundColor: 'var(--card)' }}>
|
||||||
|
|
||||||
|
// ✅ GOOD: CSS Variables with color-mix for transparency
|
||||||
|
<div style={{
|
||||||
|
backgroundColor: 'color-mix(in srgb, var(--primary) 10%, transparent)',
|
||||||
|
borderColor: 'color-mix(in srgb, var(--primary) 20%, var(--border))'
|
||||||
|
}}>
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ Forbidden: Tailwind Dark Mode Classes
|
||||||
|
```tsx
|
||||||
|
// ❌ BAD: Tailwind dark: classes
|
||||||
|
<div className="bg-white dark:bg-gray-800 text-black dark:text-white">
|
||||||
|
|
||||||
|
// ❌ BAD: Conditional classes
|
||||||
|
<div className={theme === 'dark' ? 'bg-gray-800' : 'bg-white'}>
|
||||||
|
|
||||||
|
// ❌ BAD: Hardcoded colors
|
||||||
|
<div className="bg-red-500 text-blue-600">
|
||||||
|
```
|
||||||
|
|
||||||
|
## ✅ Color System
|
||||||
|
|
||||||
|
### Semantic Color Tokens
|
||||||
|
- `--background`: Main background color
|
||||||
|
- `--foreground`: Main text color
|
||||||
|
- `--card`: Card/panel background
|
||||||
|
- `--card-hover`: Card hover state
|
||||||
|
- `--card-column`: Column background (darker than cards)
|
||||||
|
- `--border`: Border color
|
||||||
|
- `--input`: Input field background
|
||||||
|
- `--primary`: Primary brand color
|
||||||
|
- `--primary-foreground`: Text on primary background
|
||||||
|
- `--muted`: Muted text color
|
||||||
|
- `--muted-foreground`: Secondary text color
|
||||||
|
- `--accent`: Accent color (orange/amber)
|
||||||
|
- `--destructive`: Error/danger color (red)
|
||||||
|
- `--success`: Success color (green)
|
||||||
|
- `--purple`: Purple accent
|
||||||
|
- `--yellow`: Yellow accent
|
||||||
|
- `--green`: Green accent
|
||||||
|
- `--blue`: Blue accent
|
||||||
|
- `--gray`: Gray color
|
||||||
|
- `--gray-light`: Light gray background
|
||||||
|
|
||||||
|
### Color Mixing Patterns
|
||||||
|
```css
|
||||||
|
/* Background with transparency */
|
||||||
|
background-color: color-mix(in srgb, var(--primary) 10%, transparent);
|
||||||
|
|
||||||
|
/* Border with transparency */
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 20%, var(--border));
|
||||||
|
|
||||||
|
/* Text with opacity */
|
||||||
|
color: color-mix(in srgb, var(--destructive) 80%, transparent);
|
||||||
|
```
|
||||||
|
|
||||||
|
## ✅ Theme Context Usage
|
||||||
|
|
||||||
|
### ThemeProvider Setup
|
||||||
|
```tsx
|
||||||
|
// In layout.tsx
|
||||||
|
<ThemeProvider initialTheme={initialPreferences.viewPreferences.theme}>
|
||||||
|
{children}
|
||||||
|
</ThemeProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Component Usage
|
||||||
|
```tsx
|
||||||
|
import { useTheme } from '@/contexts/ThemeContext';
|
||||||
|
|
||||||
|
function MyComponent() {
|
||||||
|
const { theme, toggleTheme, setTheme } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button onClick={toggleTheme}>
|
||||||
|
Switch to {theme === 'dark' ? 'light' : 'dark'} theme
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## ✅ Future Extensibility
|
||||||
|
|
||||||
|
This system is designed to support:
|
||||||
|
- **Custom color themes**: Easy to add new color variables
|
||||||
|
- **User preferences**: Colors can be dynamically changed
|
||||||
|
- **Theme presets**: Multiple predefined themes
|
||||||
|
- **Accessibility**: High contrast modes
|
||||||
|
|
||||||
|
## 🚨 Anti-patterns to Avoid
|
||||||
|
|
||||||
|
1. **Don't mix approaches**: Never use both CSS variables and Tailwind dark: classes
|
||||||
|
2. **Don't duplicate theme application**: Theme should be applied only in ThemeContext
|
||||||
|
3. **Don't hardcode colors**: Always use semantic color tokens
|
||||||
|
4. **Don't use conditional classes**: Use CSS variables instead
|
||||||
|
5. **Don't forget transparency**: Use `color-mix()` for semi-transparent colors
|
||||||
|
|
||||||
|
## 📁 Key Files
|
||||||
|
|
||||||
|
- [globals.css](mdc:src/app/globals.css) - CSS Variables definitions
|
||||||
|
- [ThemeContext.tsx](mdc:src/contexts/ThemeContext.tsx) - Theme management
|
||||||
|
- [UserPreferencesContext.tsx](mdc:src/contexts/UserPreferencesContext.tsx) - Preferences sync
|
||||||
|
- [layout.tsx](mdc:src/app/layout.tsx) - Theme provider setup
|
||||||
|
|
||||||
|
Remember: **CSS Variables are the single source of truth for theming. Keep it pure and consistent.**
|
||||||
30
.cursor/rules/project-structure.mdc
Normal file
30
.cursor/rules/project-structure.mdc
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
---
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Project Structure Rules
|
||||||
|
|
||||||
|
1. Backend:
|
||||||
|
- [src/services/](mdc:src/services/) - ALL database access
|
||||||
|
- [src/app/api/](mdc:src/app/api/) - API routes using services
|
||||||
|
|
||||||
|
2. Frontend:
|
||||||
|
- [src/clients/](mdc:src/clients/) - HTTP clients
|
||||||
|
- [src/components/](mdc:src/components/) - React components (organized by domain)
|
||||||
|
- [src/hooks/](mdc:src/hooks/) - React hooks
|
||||||
|
|
||||||
|
3. Shared:
|
||||||
|
- [src/lib/](mdc:src/lib/) - Types and utilities
|
||||||
|
- [scripts/](mdc:scripts/) - Utility scripts
|
||||||
|
|
||||||
|
Key Files:
|
||||||
|
|
||||||
|
- [src/services/database.ts](mdc:src/services/database.ts) - Database pool
|
||||||
|
- [src/clients/base/http-client.ts](mdc:src/clients/base/http-client.ts) - Base HTTP client
|
||||||
|
- [src/lib/types.ts](mdc:src/lib/types.ts) - Shared types
|
||||||
|
|
||||||
|
❌ FORBIDDEN:
|
||||||
|
|
||||||
|
- Database access outside src/services/
|
||||||
|
- HTTP calls outside src/clients/
|
||||||
|
- Business logic in src/components/
|
||||||
113
.cursor/rules/server-actions.mdc
Normal file
113
.cursor/rules/server-actions.mdc
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
---
|
||||||
|
alwaysApply: true
|
||||||
|
description: Guide for when to use Server Actions vs API Routes in Next.js App Router
|
||||||
|
---
|
||||||
|
|
||||||
|
# Server Actions vs API Routes - Decision Guide
|
||||||
|
|
||||||
|
## ✅ USE SERVER ACTIONS for:
|
||||||
|
|
||||||
|
### Quick Actions & Mutations
|
||||||
|
- **TaskCard actions**: `updateTaskStatus()`, `updateTaskTitle()`, `deleteTask()`
|
||||||
|
- **Daily checkboxes**: `toggleCheckbox()`, `addCheckbox()`, `updateCheckbox()`
|
||||||
|
- **User preferences**: `updateTheme()`, `updateViewPreferences()`, `updateFilters()`
|
||||||
|
- **Simple CRUD**: `createTag()`, `updateTag()`, `deleteTag()`
|
||||||
|
|
||||||
|
### Characteristics of Server Action candidates:
|
||||||
|
- Simple, frequent mutations
|
||||||
|
- No complex business logic
|
||||||
|
- Used in interactive components (forms, buttons, toggles)
|
||||||
|
- Need immediate UI feedback with `useTransition`
|
||||||
|
- Benefit from automatic cache revalidation
|
||||||
|
|
||||||
|
## ❌ KEEP API ROUTES for:
|
||||||
|
|
||||||
|
### Complex Endpoints
|
||||||
|
- **Initial data fetching**: `GET /api/tasks` with complex filters
|
||||||
|
- **External integrations**: `POST /api/jira/sync` with complex logic
|
||||||
|
- **Analytics & reports**: Complex data aggregation
|
||||||
|
- **Public API**: Endpoints that might be called from mobile/external
|
||||||
|
|
||||||
|
### Characteristics that require API Routes:
|
||||||
|
- Complex business logic or data processing
|
||||||
|
- Multiple service orchestration
|
||||||
|
- Need for HTTP monitoring/logging
|
||||||
|
- External consumption (mobile apps, webhooks)
|
||||||
|
- Real-time features (WebSockets, SSE)
|
||||||
|
- File uploads or special content types
|
||||||
|
|
||||||
|
## 🔄 Implementation Pattern
|
||||||
|
|
||||||
|
### Server Actions Structure
|
||||||
|
```typescript
|
||||||
|
// actions/tasks.ts
|
||||||
|
'use server'
|
||||||
|
|
||||||
|
import { tasksService } from '@/services/tasks';
|
||||||
|
import { revalidatePath } from 'next/cache';
|
||||||
|
|
||||||
|
export async function updateTaskStatus(taskId: string, status: TaskStatus) {
|
||||||
|
try {
|
||||||
|
const task = await tasksService.updateTask(taskId, { status });
|
||||||
|
revalidatePath('/'); // Auto cache invalidation
|
||||||
|
return { success: true, data: task };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Component Usage with useTransition
|
||||||
|
```typescript
|
||||||
|
// components/TaskCard.tsx
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { updateTaskStatus } from '@/actions/tasks';
|
||||||
|
import { useTransition } from 'react';
|
||||||
|
|
||||||
|
export function TaskCard({ task }) {
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const handleStatusChange = (status) => {
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await updateTaskStatus(task.id, status);
|
||||||
|
if (!result.success) {
|
||||||
|
// Handle error
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={isPending ? 'opacity-50' : ''}>
|
||||||
|
{/* UI with loading state */}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🏗️ Migration Strategy
|
||||||
|
|
||||||
|
When migrating from API Routes to Server Actions:
|
||||||
|
|
||||||
|
1. **Create server action** in `actions/` directory
|
||||||
|
2. **Update component** to use server action directly
|
||||||
|
3. **Remove API route** (PATCH, POST, DELETE for mutations)
|
||||||
|
4. **Simplify client** (remove mutation methods, keep GET only)
|
||||||
|
5. **Update hooks** to use server actions instead of HTTP calls
|
||||||
|
|
||||||
|
## 🎯 Benefits of Server Actions
|
||||||
|
|
||||||
|
- **🚀 Performance**: No HTTP serialization overhead
|
||||||
|
- **🔄 Cache intelligence**: Automatic revalidation with `revalidatePath()`
|
||||||
|
- **📦 Bundle reduction**: Less client-side HTTP code
|
||||||
|
- **⚡ UX**: Native loading states with `useTransition`
|
||||||
|
- **🎯 Simplicity**: Direct service calls, less boilerplate
|
||||||
|
|
||||||
|
## 🚨 Anti-patterns to Avoid
|
||||||
|
|
||||||
|
- Don't use server actions for complex data fetching
|
||||||
|
- Don't use server actions for endpoints that need HTTP monitoring
|
||||||
|
- Don't use server actions for public API endpoints
|
||||||
|
- Don't mix server actions with client-side state management for the same data
|
||||||
|
|
||||||
|
Remember: Server Actions are for **direct mutations**, API Routes are for **complex operations** and **public interfaces**.
|
||||||
42
.cursor/rules/services.mdc
Normal file
42
.cursor/rules/services.mdc
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
---
|
||||||
|
globs: src/services/*.ts
|
||||||
|
---
|
||||||
|
|
||||||
|
# Services Rules
|
||||||
|
|
||||||
|
1. Services MUST contain ALL PostgreSQL queries
|
||||||
|
2. Services are the ONLY layer allowed to communicate with the database
|
||||||
|
3. Each service MUST:
|
||||||
|
- Use the pool from [src/services/database.ts](mdc:src/services/database.ts)
|
||||||
|
- Implement proper transaction management
|
||||||
|
- Handle errors and logging
|
||||||
|
- Validate data before insertion
|
||||||
|
- Have a clear interface
|
||||||
|
|
||||||
|
Example of correct service implementation:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export class MyService {
|
||||||
|
constructor(private pool: Pool) {}
|
||||||
|
|
||||||
|
async myMethod(): Promise<Result> {
|
||||||
|
const client = await this.pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
// ... queries
|
||||||
|
await client.query("COMMIT");
|
||||||
|
} catch (error) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
❌ FORBIDDEN:
|
||||||
|
|
||||||
|
- Direct database queries outside src/services
|
||||||
|
- Raw SQL in API routes
|
||||||
|
- Database logic in components
|
||||||
54
.cursor/rules/todo-tracking.mdc
Normal file
54
.cursor/rules/todo-tracking.mdc
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
---
|
||||||
|
alwaysApply: true
|
||||||
|
description: Automatic TODO tracking and task completion management
|
||||||
|
---
|
||||||
|
|
||||||
|
# TODO Task Tracking Rules
|
||||||
|
|
||||||
|
## Automatic Task Completion
|
||||||
|
|
||||||
|
Whenever you complete a task or implement a feature mentioned in [TODO.md](mdc:TODO.md), you MUST:
|
||||||
|
|
||||||
|
1. **Immediately update the TODO.md** by changing `- [ ]` to `- [x]` for the completed task
|
||||||
|
2. **Use the exact task description** from the TODO - don't modify the text
|
||||||
|
3. **Update related sub-tasks** if completing a parent task affects them
|
||||||
|
4. **Add completion timestamp** in a comment if the task was significant
|
||||||
|
|
||||||
|
## Task Completion Examples
|
||||||
|
|
||||||
|
### ✅ Correct completion marking:
|
||||||
|
```markdown
|
||||||
|
- [x] Initialiser Next.js avec TypeScript
|
||||||
|
- [x] Configurer ESLint, Prettier
|
||||||
|
- [x] Setup structure de dossiers selon les règles du workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
### ✅ With timestamp for major milestones:
|
||||||
|
```markdown
|
||||||
|
- [x] Créer `services/database.ts` - Pool de connexion DB <!-- Completed 2025-01-15 -->
|
||||||
|
```
|
||||||
|
|
||||||
|
## When to Update TODO.md
|
||||||
|
|
||||||
|
Update the TODO immediately after:
|
||||||
|
- Creating/modifying files mentioned in tasks
|
||||||
|
- Implementing features described in tasks
|
||||||
|
- Completing configuration steps
|
||||||
|
- Finishing any work item listed in the TODO
|
||||||
|
|
||||||
|
## Task Dependencies
|
||||||
|
|
||||||
|
When completing tasks, consider:
|
||||||
|
- **Parent tasks**: Mark parent complete only when ALL sub-tasks are done
|
||||||
|
- **Blocking tasks**: Some tasks may unblock others - mention this in updates
|
||||||
|
- **Phase completion**: Note when entire phases are completed
|
||||||
|
|
||||||
|
## Progress Tracking
|
||||||
|
|
||||||
|
Always maintain visibility of:
|
||||||
|
- Current phase progress
|
||||||
|
- Next logical task to tackle
|
||||||
|
- Any blockers or issues encountered
|
||||||
|
- Completed vs remaining work ratio
|
||||||
|
|
||||||
|
This ensures the TODO.md remains an accurate reflection of project progress and helps maintain momentum.
|
||||||
4
.eslintrc.json
Normal file
4
.eslintrc.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"extends": "next/core-web-vitals"
|
||||||
|
}
|
||||||
|
|
||||||
66
.gitignore
vendored
Normal file
66
.gitignore
vendored
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env*.local
|
||||||
|
.env
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
# pnpm
|
||||||
|
.pnpm-store/
|
||||||
|
.pnpm-store
|
||||||
|
node_modules/.pnpm
|
||||||
|
.pnpm-debug.log*
|
||||||
|
# Note: pnpm-lock.yaml should be committed
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
Thumbs.db
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# temporary files
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
|
||||||
|
.pnpm-store
|
||||||
|
|
||||||
31
README.md
Normal file
31
README.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# People Randomizr
|
||||||
|
|
||||||
|
Application Next.js pour extraire aléatoirement un certain nombre de personnes à partir d'un fichier CSV.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Développement
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Ouvrez [http://localhost:3000](http://localhost:3000) dans votre navigateur.
|
||||||
|
|
||||||
|
## Fonctionnalités
|
||||||
|
|
||||||
|
- Affichage de toutes les personnes du CSV
|
||||||
|
- Extraction aléatoire d'un nombre configurable de personnes
|
||||||
|
- Affichage des résultats avec nom, description et type
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
- `app/page.tsx` - Page principale avec l'interface utilisateur
|
||||||
|
- `app/api/people/route.ts` - API route pour charger les données du CSV
|
||||||
|
- `lib/csv-parser.ts` - Parser pour le fichier CSV
|
||||||
|
- `group-dev-ad.csv` - Fichier source des données
|
||||||
|
|
||||||
18
app/api/people/route.ts
Normal file
18
app/api/people/route.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { parseCSV } from '@/lib/csv-parser';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const filePath = join(process.cwd(), 'group-dev-ad.csv');
|
||||||
|
const people = await parseCSV(filePath);
|
||||||
|
return NextResponse.json(people);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error parsing CSV:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to parse CSV file' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
83
app/globals.css
Normal file
83
app/globals.css
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
body {
|
||||||
|
@apply bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900;
|
||||||
|
background-attachment: fixed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.glass {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-strong {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow-cyan {
|
||||||
|
box-shadow: 0 0 20px rgba(6, 182, 212, 0.5), 0 0 40px rgba(6, 182, 212, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow-purple {
|
||||||
|
box-shadow: 0 0 20px rgba(168, 85, 247, 0.5), 0 0 40px rgba(168, 85, 247, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-gradient {
|
||||||
|
@apply bg-clip-text text-transparent bg-gradient-to-r from-cyan-400 via-purple-400 to-pink-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-gradient {
|
||||||
|
border-image: linear-gradient(135deg, rgba(6, 182, 212, 0.5), rgba(168, 85, 247, 0.5)) 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-track {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||||
|
background: linear-gradient(180deg, rgba(6, 182, 212, 0.5), rgba(168, 85, 247, 0.5));
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: linear-gradient(180deg, rgba(6, 182, 212, 0.7), rgba(168, 85, 247, 0.7));
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blob {
|
||||||
|
0%, 100% {
|
||||||
|
transform: translate(0, 0) scale(1);
|
||||||
|
}
|
||||||
|
33% {
|
||||||
|
transform: translate(30px, -50px) scale(1.1);
|
||||||
|
}
|
||||||
|
66% {
|
||||||
|
transform: translate(-20px, 20px) scale(0.9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-blob {
|
||||||
|
animation: blob 7s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animation-delay-2000 {
|
||||||
|
animation-delay: 2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animation-delay-4000 {
|
||||||
|
animation-delay: 4s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
20
app/layout.tsx
Normal file
20
app/layout.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "People Randomizr",
|
||||||
|
description: "Extract random people from your team",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="fr">
|
||||||
|
<body>{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
333
app/page.tsx
Normal file
333
app/page.tsx
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { parseCSV, Person } from '@/lib/csv-parser';
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
const [people, setPeople] = useState<Person[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [selectedCount, setSelectedCount] = useState<number>(5);
|
||||||
|
const [selectedPostes, setSelectedPostes] = useState<Set<string>>(new Set());
|
||||||
|
const [randomPeople, setRandomPeople] = useState<Person[]>([]);
|
||||||
|
const [showResults, setShowResults] = useState(false);
|
||||||
|
const [searchPoste, setSearchPoste] = useState<string>('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadData() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/people');
|
||||||
|
const data = await response.json();
|
||||||
|
setPeople(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading data:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Compter le nombre de personnes par poste
|
||||||
|
const posteCounts = useMemo(() => {
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
people.forEach(person => {
|
||||||
|
if (person.description) {
|
||||||
|
counts[person.description] = (counts[person.description] || 0) + 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return counts;
|
||||||
|
}, [people]);
|
||||||
|
|
||||||
|
// Extraire tous les postes uniques triés par nombre décroissant
|
||||||
|
const allPostes = useMemo(() => {
|
||||||
|
const postes = new Set<string>();
|
||||||
|
people.forEach(person => {
|
||||||
|
if (person.description) {
|
||||||
|
postes.add(person.description);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Array.from(postes).sort((a, b) => {
|
||||||
|
const countA = posteCounts[a] || 0;
|
||||||
|
const countB = posteCounts[b] || 0;
|
||||||
|
// Tri décroissant par nombre, puis alphabétique si égal
|
||||||
|
if (countA !== countB) {
|
||||||
|
return countB - countA;
|
||||||
|
}
|
||||||
|
return a.localeCompare(b);
|
||||||
|
});
|
||||||
|
}, [people, posteCounts]);
|
||||||
|
|
||||||
|
// Filtrer les postes selon la recherche
|
||||||
|
const filteredPostes = useMemo(() => {
|
||||||
|
if (!searchPoste.trim()) {
|
||||||
|
return allPostes;
|
||||||
|
}
|
||||||
|
const searchLower = searchPoste.toLowerCase();
|
||||||
|
return allPostes.filter(poste =>
|
||||||
|
poste.toLowerCase().includes(searchLower)
|
||||||
|
);
|
||||||
|
}, [allPostes, searchPoste]);
|
||||||
|
|
||||||
|
// Filtrer les personnes selon les postes sélectionnés
|
||||||
|
const filteredPeople = useMemo(() => {
|
||||||
|
if (selectedPostes.size === 0) {
|
||||||
|
return people;
|
||||||
|
}
|
||||||
|
return people.filter(person =>
|
||||||
|
person.description && selectedPostes.has(person.description)
|
||||||
|
);
|
||||||
|
}, [people, selectedPostes]);
|
||||||
|
|
||||||
|
const handlePosteToggle = (poste: string) => {
|
||||||
|
const newSelected = new Set(selectedPostes);
|
||||||
|
if (newSelected.has(poste)) {
|
||||||
|
newSelected.delete(poste);
|
||||||
|
} else {
|
||||||
|
newSelected.add(poste);
|
||||||
|
}
|
||||||
|
setSelectedPostes(newSelected);
|
||||||
|
setShowResults(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectAll = () => {
|
||||||
|
if (selectedPostes.size === allPostes.length) {
|
||||||
|
setSelectedPostes(new Set());
|
||||||
|
} else {
|
||||||
|
setSelectedPostes(new Set(allPostes));
|
||||||
|
}
|
||||||
|
setShowResults(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRandomize = () => {
|
||||||
|
if (selectedCount <= 0 || selectedCount > filteredPeople.length) {
|
||||||
|
alert(`Veuillez entrer un nombre entre 1 et ${filteredPeople.length}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shuffled = [...filteredPeople].sort(() => Math.random() - 0.5);
|
||||||
|
const selected = shuffled.slice(0, selectedCount);
|
||||||
|
setRandomPeople(selected);
|
||||||
|
setShowResults(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="relative">
|
||||||
|
<div className="w-16 h-16 border-4 border-cyan-500 border-t-transparent rounded-full animate-spin"></div>
|
||||||
|
<div className="absolute inset-0 w-16 h-16 border-4 border-purple-500 border-t-transparent rounded-full animate-spin opacity-50" style={{ animationDirection: 'reverse', animationDuration: '1.5s' }}></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen py-8 px-4 relative overflow-hidden">
|
||||||
|
{/* Effets de fond animés */}
|
||||||
|
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||||
|
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-cyan-500 rounded-full mix-blend-multiply filter blur-3xl opacity-20 animate-blob"></div>
|
||||||
|
<div className="absolute top-1/3 right-1/4 w-96 h-96 bg-purple-500 rounded-full mix-blend-multiply filter blur-3xl opacity-20 animate-blob animation-delay-2000"></div>
|
||||||
|
<div className="absolute bottom-1/4 left-1/3 w-96 h-96 bg-pink-500 rounded-full mix-blend-multiply filter blur-3xl opacity-20 animate-blob animation-delay-4000"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-6xl mx-auto relative z-10">
|
||||||
|
<div className="text-center mb-12">
|
||||||
|
<h1 className="text-6xl font-black mb-4 text-gradient tracking-tight">
|
||||||
|
People Randomizr
|
||||||
|
</h1>
|
||||||
|
<p className="text-cyan-300 text-lg font-light">Sélection intelligente • Extraction aléatoire</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="glass-strong rounded-2xl p-8 mb-8 shadow-2xl border border-cyan-500/20">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h2 className="text-3xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-cyan-400 to-purple-400">
|
||||||
|
Filtres par poste
|
||||||
|
</h2>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="px-4 py-2 glass rounded-lg border border-cyan-500/30">
|
||||||
|
<span className="text-sm text-cyan-300">
|
||||||
|
<span className="font-bold text-cyan-400 text-lg">{selectedPostes.size}</span>
|
||||||
|
<span className="text-purple-300"> / </span>
|
||||||
|
<span className="text-white">{allPostes.length}</span>
|
||||||
|
<span className="text-gray-400 ml-2">sélectionné{selectedPostes.size > 1 ? 's' : ''}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleSelectAll}
|
||||||
|
className="px-5 py-2.5 text-sm font-semibold text-white bg-gradient-to-r from-cyan-600 to-purple-600 rounded-lg hover:from-cyan-500 hover:to-purple-500 transition-all duration-300 shadow-lg hover:shadow-cyan-500/50 border border-cyan-400/30"
|
||||||
|
>
|
||||||
|
{selectedPostes.size === allPostes.length ? 'Tout désélectionner' : 'Tout sélectionner'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Barre de recherche */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="🔍 Rechercher un poste..."
|
||||||
|
value={searchPoste}
|
||||||
|
onChange={(e) => setSearchPoste(e.target.value)}
|
||||||
|
className="w-full px-5 py-3 glass rounded-xl focus:ring-2 focus:ring-cyan-500 focus:border-cyan-400/50 text-white placeholder-gray-400 border border-cyan-500/20 transition-all duration-300"
|
||||||
|
/>
|
||||||
|
{searchPoste && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSearchPoste('')}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Badges de filtres */}
|
||||||
|
<div className="max-h-64 overflow-y-auto p-3 mb-6 custom-scrollbar">
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{filteredPostes.length === 0 ? (
|
||||||
|
<div className="w-full text-center py-8">
|
||||||
|
<p className="text-gray-400 text-sm">Aucun poste trouvé</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredPostes.map((poste) => {
|
||||||
|
const isSelected = selectedPostes.has(poste);
|
||||||
|
const count = posteCounts[poste] || 0;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={poste}
|
||||||
|
onClick={() => handlePosteToggle(poste)}
|
||||||
|
className={`
|
||||||
|
px-5 py-2.5 rounded-xl text-sm font-semibold transition-all duration-300
|
||||||
|
flex items-center gap-2 border-2 relative overflow-hidden group
|
||||||
|
${isSelected
|
||||||
|
? 'bg-gradient-to-r from-cyan-600 to-purple-600 text-white border-cyan-400 shadow-lg glow-cyan transform scale-105'
|
||||||
|
: 'glass text-gray-300 border-cyan-500/20 hover:border-cyan-400/50 hover:bg-cyan-500/10 hover:text-white'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{isSelected && (
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-cyan-400/20 to-purple-400/20 animate-pulse"></div>
|
||||||
|
)}
|
||||||
|
<span className="relative z-10">{poste}</span>
|
||||||
|
<span className={`
|
||||||
|
px-2.5 py-1 rounded-lg text-xs font-bold relative z-10
|
||||||
|
${isSelected
|
||||||
|
? 'bg-white/20 text-white backdrop-blur-sm'
|
||||||
|
: 'bg-cyan-500/20 text-cyan-300 border border-cyan-500/30'
|
||||||
|
}
|
||||||
|
`}>
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row gap-6 items-end">
|
||||||
|
<div className="flex-1">
|
||||||
|
<label htmlFor="count" className="block text-sm font-semibold text-cyan-300 mb-3">
|
||||||
|
Nombre de personnes à extraire
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="count"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max={filteredPeople.length}
|
||||||
|
value={selectedCount}
|
||||||
|
onChange={(e) => setSelectedCount(parseInt(e.target.value) || 1)}
|
||||||
|
className="w-full px-5 py-3 glass rounded-xl focus:ring-2 focus:ring-cyan-500 focus:border-cyan-400/50 text-white bg-white/5 border border-cyan-500/20 transition-all duration-300"
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-gray-400 mt-2 font-medium">
|
||||||
|
{selectedPostes.size === 0 ? (
|
||||||
|
<>📊 Total disponible: <span className="text-cyan-400 font-bold">{people.length}</span> personnes</>
|
||||||
|
) : (
|
||||||
|
<>🎯 Total filtré: <span className="text-cyan-400 font-bold">{filteredPeople.length}</span> personne{filteredPeople.length > 1 ? 's' : ''} <span className="text-gray-500">(sur {people.length})</span></>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleRandomize}
|
||||||
|
disabled={filteredPeople.length === 0}
|
||||||
|
className="px-8 py-4 bg-gradient-to-r from-cyan-600 via-purple-600 to-pink-600 text-white font-bold rounded-xl hover:from-cyan-500 hover:via-purple-500 hover:to-pink-500 focus:outline-none transition-all duration-300 shadow-lg hover:shadow-cyan-500/50 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:from-cyan-600 disabled:hover:via-purple-600 disabled:hover:to-pink-600 border border-cyan-400/30 relative overflow-hidden group"
|
||||||
|
>
|
||||||
|
<span className="relative z-10 flex items-center gap-2">
|
||||||
|
<span>🎲</span>
|
||||||
|
<span>Extraire aléatoirement</span>
|
||||||
|
</span>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-cyan-400/20 to-purple-400/20 opacity-0 group-hover:opacity-100 transition-opacity"></div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showResults && randomPeople.length > 0 && (
|
||||||
|
<div className="glass-strong rounded-2xl p-8 mb-8 shadow-2xl border border-purple-500/20">
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<div className="w-1 h-8 bg-gradient-to-b from-cyan-400 to-purple-400 rounded-full"></div>
|
||||||
|
<h2 className="text-3xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-cyan-400 to-purple-400">
|
||||||
|
Résultats sélectionnés
|
||||||
|
</h2>
|
||||||
|
<span className="px-3 py-1 glass rounded-lg text-cyan-300 font-semibold border border-cyan-500/30">
|
||||||
|
{randomPeople.length} personne{randomPeople.length > 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{randomPeople.map((person, index) => (
|
||||||
|
<div
|
||||||
|
key={`${person.nom}-${index}`}
|
||||||
|
className="glass rounded-xl p-5 hover:bg-white/10 border border-cyan-500/20 hover:border-cyan-400/50 transition-all duration-300 hover:shadow-lg hover:shadow-cyan-500/20 group"
|
||||||
|
style={{ animationDelay: `${index * 50}ms` }}
|
||||||
|
>
|
||||||
|
<h3 className="font-bold text-lg text-white mb-2 group-hover:text-cyan-300 transition-colors">
|
||||||
|
{person.nom}
|
||||||
|
</h3>
|
||||||
|
{person.description && (
|
||||||
|
<p className="text-sm text-gray-300 mb-3">{person.description}</p>
|
||||||
|
)}
|
||||||
|
<span className="inline-block px-3 py-1 text-xs rounded-lg bg-gradient-to-r from-cyan-600/30 to-purple-600/30 text-cyan-300 border border-cyan-500/30 font-semibold">
|
||||||
|
{person.type}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="glass-strong rounded-2xl p-8 shadow-2xl border border-purple-500/20">
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<div className="w-1 h-8 bg-gradient-to-b from-purple-400 to-pink-400 rounded-full"></div>
|
||||||
|
<h2 className="text-3xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-purple-400 to-pink-400">
|
||||||
|
{selectedPostes.size === 0
|
||||||
|
? `Toutes les personnes`
|
||||||
|
: `Personnes filtrées`
|
||||||
|
}
|
||||||
|
</h2>
|
||||||
|
<span className="px-3 py-1 glass rounded-lg text-purple-300 font-semibold border border-purple-500/30">
|
||||||
|
{filteredPeople.length} / {people.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 max-h-96 overflow-y-auto custom-scrollbar p-2">
|
||||||
|
{filteredPeople.map((person, index) => (
|
||||||
|
<div
|
||||||
|
key={`${person.nom}-${index}`}
|
||||||
|
className={`glass rounded-xl p-4 hover:bg-white/10 border transition-all duration-300 hover:shadow-lg ${
|
||||||
|
selectedPostes.size > 0 && selectedPostes.has(person.description)
|
||||||
|
? 'border-cyan-400/50 bg-cyan-500/10 hover:border-cyan-400 hover:shadow-cyan-500/20'
|
||||||
|
: 'border-purple-500/20 hover:border-purple-400/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<h3 className="font-semibold text-white mb-1">{person.nom}</h3>
|
||||||
|
{person.description && (
|
||||||
|
<p className="text-sm text-gray-400 mt-1">{person.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
73
lib/csv-parser.ts
Normal file
73
lib/csv-parser.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
export interface Person {
|
||||||
|
nom: string;
|
||||||
|
description: string;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function parseCSV(filePath: string): Promise<Person[]> {
|
||||||
|
const fs = await import('fs');
|
||||||
|
const content = fs.readFileSync(filePath, 'utf-8');
|
||||||
|
const lines = content.split('\n');
|
||||||
|
|
||||||
|
// Skip header line
|
||||||
|
const dataLines = lines.slice(1);
|
||||||
|
|
||||||
|
const people: Person[] = [];
|
||||||
|
|
||||||
|
for (const line of dataLines) {
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
|
||||||
|
// Parse CSV line (handling quoted fields)
|
||||||
|
const fields = parseCSVLine(line);
|
||||||
|
|
||||||
|
if (fields.length >= 7) {
|
||||||
|
const nom = fields[4]?.trim() || '';
|
||||||
|
const description = fields[5]?.trim() || '';
|
||||||
|
const type = fields[6]?.trim() || '';
|
||||||
|
|
||||||
|
// Skip empty names and service accounts
|
||||||
|
if (nom && !nom.startsWith('svc.') && !nom.startsWith('!') && nom !== 'datascience') {
|
||||||
|
people.push({
|
||||||
|
nom,
|
||||||
|
description,
|
||||||
|
type,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return people;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCSVLine(line: string): string[] {
|
||||||
|
const fields: string[] = [];
|
||||||
|
let currentField = '';
|
||||||
|
let inQuotes = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < line.length; i++) {
|
||||||
|
const char = line[i];
|
||||||
|
|
||||||
|
if (char === '"') {
|
||||||
|
if (inQuotes && line[i + 1] === '"') {
|
||||||
|
// Escaped quote
|
||||||
|
currentField += '"';
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
// Toggle quote state
|
||||||
|
inQuotes = !inQuotes;
|
||||||
|
}
|
||||||
|
} else if (char === ',' && !inQuotes) {
|
||||||
|
// Field separator
|
||||||
|
fields.push(currentField);
|
||||||
|
currentField = '';
|
||||||
|
} else {
|
||||||
|
currentField += char;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add last field
|
||||||
|
fields.push(currentField);
|
||||||
|
|
||||||
|
return fields.map(field => field.trim());
|
||||||
|
}
|
||||||
|
|
||||||
5
next.config.js
Normal file
5
next.config.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {}
|
||||||
|
|
||||||
|
module.exports = nextConfig
|
||||||
|
|
||||||
27
package.json
Normal file
27
package.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "people-randomizr",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18",
|
||||||
|
"react-dom": "^18",
|
||||||
|
"next": "^14"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^18",
|
||||||
|
"@types/react-dom": "^18",
|
||||||
|
"autoprefixer": "^10.0.1",
|
||||||
|
"postcss": "^8",
|
||||||
|
"tailwindcss": "^3.3.0",
|
||||||
|
"eslint": "^8",
|
||||||
|
"eslint-config-next": "^14"
|
||||||
|
}
|
||||||
|
}
|
||||||
3724
pnpm-lock.yaml
generated
Normal file
3724
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
7
postcss.config.js
Normal file
7
postcss.config.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
15
tailwind.config.ts
Normal file
15
tailwind.config.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type { Config } from "tailwindcss";
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
content: [
|
||||||
|
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
|
export default config;
|
||||||
|
|
||||||
27
tsconfig.json
Normal file
27
tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user