feat: uwuFinances app with themes, JWT auth and NoCodeBackend sync
This commit is contained in:
184
src/App.css
Normal file
184
src/App.css
Normal file
@@ -0,0 +1,184 @@
|
||||
.counter {
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
|
||||
.base,
|
||||
.framework,
|
||||
.vite {
|
||||
inset-inline: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.base {
|
||||
width: 170px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.framework,
|
||||
.vite {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.framework {
|
||||
z-index: 1;
|
||||
top: 34px;
|
||||
height: 28px;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||
scale(1.4);
|
||||
}
|
||||
|
||||
.vite {
|
||||
z-index: 0;
|
||||
top: 107px;
|
||||
height: 26px;
|
||||
width: auto;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||
scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
#center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding: 32px 20px 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: left;
|
||||
|
||||
& > div {
|
||||
flex: 1 1 0;
|
||||
padding: 32px;
|
||||
@media (max-width: 1024px) {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
#docs {
|
||||
border-right: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 32px 0 0;
|
||||
|
||||
.logo {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-h);
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--social-bg);
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.button-icon {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
}
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 88px;
|
||||
border-top: 1px solid var(--border);
|
||||
@media (max-width: 1024px) {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
border-left-color: var(--border);
|
||||
}
|
||||
&::after {
|
||||
right: 0;
|
||||
border-right-color: var(--border);
|
||||
}
|
||||
}
|
||||
110
src/App.tsx
Normal file
110
src/App.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import React from 'react';
|
||||
import { AppProvider, useApp } from './context/AppContext';
|
||||
import { Sidebar } from './components/common/Sidebar';
|
||||
import { TopHeader } from './components/common/TopHeader';
|
||||
import { ToastContainer } from './components/common/ToastContainer';
|
||||
import { DashboardView } from './components/dashboard/DashboardView';
|
||||
import { PurchasesView } from './components/inventory/PurchasesView';
|
||||
import { ProjectsView } from './components/projects/ProjectsView';
|
||||
import { TimeTrackerView } from './components/timetracker/TimeTrackerView';
|
||||
import { AnalyticsView } from './components/analytics/AnalyticsView';
|
||||
import { ModsLibraryView } from './components/mods/ModsLibraryView';
|
||||
import { SettingsView } from './components/settings/SettingsView';
|
||||
import { ProjectModal } from './components/projects/ProjectModal';
|
||||
import { InventoryModal } from './components/inventory/InventoryModal';
|
||||
import { AuthModal } from './components/auth/AuthModal';
|
||||
import { LoginGate } from './components/auth/LoginGate';
|
||||
|
||||
const MainContent: React.FC = () => {
|
||||
const {
|
||||
currentTab,
|
||||
showNewProjectModal,
|
||||
setShowNewProjectModal,
|
||||
showNewInventoryModal,
|
||||
setShowNewInventoryModal,
|
||||
showAuthModal,
|
||||
setShowAuthModal,
|
||||
} = useApp();
|
||||
|
||||
return (
|
||||
<main className="p-4 sm:p-6 lg:p-8 max-w-[1600px] mx-auto w-full">
|
||||
{currentTab === 'dashboard' && <DashboardView />}
|
||||
{currentTab === 'purchases' && <PurchasesView />}
|
||||
{currentTab === 'projects' && <ProjectsView />}
|
||||
{currentTab === 'mods' && <ModsLibraryView />}
|
||||
{currentTab === 'timetracker' && <TimeTrackerView />}
|
||||
{currentTab === 'analytics' && <AnalyticsView />}
|
||||
{currentTab === 'settings' && <SettingsView />}
|
||||
|
||||
{/* Global Modals: Accessible from Sidebar, TopHeader, Dashboard & all tabs */}
|
||||
<ProjectModal
|
||||
isOpen={showNewProjectModal}
|
||||
onClose={() => setShowNewProjectModal(false)}
|
||||
/>
|
||||
<InventoryModal
|
||||
isOpen={showNewInventoryModal}
|
||||
onClose={() => setShowNewInventoryModal(false)}
|
||||
/>
|
||||
<AuthModal
|
||||
isOpen={showAuthModal}
|
||||
onClose={() => setShowAuthModal(false)}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
const AppLayout: React.FC = () => {
|
||||
const { settings, authSession } = useApp();
|
||||
const activeTheme = settings?.theme || 'uwu-pink';
|
||||
|
||||
// Full site lock: If user is not logged in, display the LoginGate screen
|
||||
if (!authSession?.isAuthenticated) {
|
||||
return (
|
||||
<div className={`min-h-screen theme-root-container text-slate-100 theme-${activeTheme}`} data-theme={activeTheme}>
|
||||
<LoginGate />
|
||||
<ToastContainer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen theme-root-container text-slate-100 flex flex-col lg:flex-row theme-${activeTheme}`} data-theme={activeTheme}>
|
||||
{/* Left Sidebar Navigation */}
|
||||
<Sidebar />
|
||||
|
||||
{/* Main Content Area (Offset on desktop for fixed left sidebar) */}
|
||||
<div className="flex-1 lg:pl-72 flex flex-col min-w-0 transition-all duration-300">
|
||||
<TopHeader />
|
||||
<div className="flex-1">
|
||||
<MainContent />
|
||||
</div>
|
||||
|
||||
{/* Discreet Footer */}
|
||||
<footer className="border-t border-slate-800/80 py-5 mt-12 text-center text-xs text-slate-500 no-print px-4">
|
||||
<div className="max-w-7xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-2">
|
||||
<div>
|
||||
<span className="font-bold text-slate-400">uwuFinances</span> • Gestionnaire Financier & Atelier Console Modding
|
||||
</div>
|
||||
<div className="font-mono text-[11px] text-slate-600">
|
||||
Données sécurisées • {settings.businessName || 'Atelier Console Modding'} • v1.2 Pro
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<ToastContainer />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<AppProvider>
|
||||
<AppLayout />
|
||||
</AppProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
|
||||
BIN
src/assets/hero.png
Normal file
BIN
src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
1
src/assets/react.svg
Normal file
1
src/assets/react.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="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
1
src/assets/vite.svg
Normal file
1
src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
574
src/components/analytics/AnalyticsView.tsx
Normal file
574
src/components/analytics/AnalyticsView.tsx
Normal file
@@ -0,0 +1,574 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import {
|
||||
TrendingUp,
|
||||
Calculator,
|
||||
Clock,
|
||||
Gamepad2,
|
||||
Sliders,
|
||||
BarChart2,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
LineChart,
|
||||
Line,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ComposedChart,
|
||||
ReferenceLine,
|
||||
} from 'recharts';
|
||||
import { useApp } from '../../context/AppContext';
|
||||
|
||||
import {
|
||||
calculateProjectFinancials,
|
||||
formatCurrency,
|
||||
formatMinutes,
|
||||
getTaskTypeLabel,
|
||||
} from '../../utils/calculations';
|
||||
import type { TaskType } from '../../types';
|
||||
|
||||
|
||||
|
||||
|
||||
export const AnalyticsView: React.FC = () => {
|
||||
const { projects, settings } = useApp();
|
||||
|
||||
// 1. Simulator Interactive State
|
||||
const [simBaseConsoleCost, setSimBaseConsoleCost] = useState<number>(35);
|
||||
const [simScreenCost, setSimScreenCost] = useState<number>(45);
|
||||
const [simShellCost, setSimShellCost] = useState<number>(16);
|
||||
const [simModsCost, setSimModsCost] = useState<number>(14); // USB-C, battery, etc.
|
||||
const [simConsumablesCost, setSimConsumablesCost] = useState<number>(5);
|
||||
const [simLaborHours, setSimLaborHours] = useState<number>(2.5);
|
||||
const [simHourlyRate, setSimHourlyRate] = useState<number>(settings.defaultHourlyRate || 25);
|
||||
const [simPlatformFeePercent, setSimPlatformFeePercent] = useState<number>(settings.defaultPlatformFeePercent || 0);
|
||||
const [simSellingPrice, setSimSellingPrice] = useState<number>(185);
|
||||
|
||||
// Simulator Calculations
|
||||
const simTotalHardwareCost = simBaseConsoleCost + simScreenCost + simShellCost + simModsCost + simConsumablesCost;
|
||||
const simTotalLaborCost = simLaborHours * simHourlyRate;
|
||||
const simPlatformFee = (simSellingPrice * simPlatformFeePercent) / 100;
|
||||
const simTotalCOGS = simTotalHardwareCost + simTotalLaborCost + simPlatformFee;
|
||||
const simGrossProfit = simSellingPrice - simTotalHardwareCost;
|
||||
const simNetProfit = simSellingPrice - simTotalCOGS;
|
||||
const simMarginPercent = simSellingPrice > 0 ? (simNetProfit / simSellingPrice) * 100 : 0;
|
||||
const simEffectiveHourlyRate = simLaborHours > 0 ? (simSellingPrice - simTotalHardwareCost - simPlatformFee) / simLaborHours : 0;
|
||||
|
||||
// 2. Model Performance Statistics
|
||||
const modelStats = useMemo(() => {
|
||||
const stats: Record<string, {
|
||||
totalBuilds: number;
|
||||
soldBuilds: number;
|
||||
totalRevenue: number;
|
||||
totalHardwareCost: number;
|
||||
totalLaborMinutes: number;
|
||||
totalNetProfit: number;
|
||||
}> = {};
|
||||
|
||||
projects.forEach((p) => {
|
||||
if (!stats[p.consoleModel]) {
|
||||
stats[p.consoleModel] = {
|
||||
totalBuilds: 0,
|
||||
soldBuilds: 0,
|
||||
totalRevenue: 0,
|
||||
totalHardwareCost: 0,
|
||||
totalLaborMinutes: 0,
|
||||
totalNetProfit: 0,
|
||||
};
|
||||
}
|
||||
const fin = calculateProjectFinancials(p);
|
||||
stats[p.consoleModel].totalBuilds += 1;
|
||||
stats[p.consoleModel].totalHardwareCost += fin.hardwareDirectCost;
|
||||
stats[p.consoleModel].totalLaborMinutes += fin.laborMinutes;
|
||||
|
||||
if (p.status === 'sold') {
|
||||
stats[p.consoleModel].soldBuilds += 1;
|
||||
stats[p.consoleModel].totalRevenue += fin.revenue;
|
||||
stats[p.consoleModel].totalNetProfit += fin.netProfit;
|
||||
}
|
||||
});
|
||||
|
||||
return Object.entries(stats).map(([model, data]) => ({
|
||||
model,
|
||||
...data,
|
||||
avgLaborHours: data.totalBuilds > 0 ? (data.totalLaborMinutes / data.totalBuilds / 60) : 0,
|
||||
marginPercent: data.totalRevenue > 0 ? (data.totalNetProfit / data.totalRevenue) * 100 : 0,
|
||||
})).sort((a, b) => b.totalNetProfit - a.totalNetProfit);
|
||||
}, [projects]);
|
||||
|
||||
// 3. Task Time Distribution
|
||||
const taskDistribution = useMemo(() => {
|
||||
const map: Record<string, number> = {};
|
||||
projects.forEach((p) => {
|
||||
p.timeSessions.forEach((s) => {
|
||||
map[s.taskType] = (map[s.taskType] || 0) + s.durationMinutes;
|
||||
});
|
||||
});
|
||||
const totalMinutes = Object.values(map).reduce((sum, m) => sum + m, 0);
|
||||
|
||||
return Object.entries(map).map(([taskType, minutes]) => ({
|
||||
taskType: taskType as TaskType,
|
||||
minutes,
|
||||
percentage: totalMinutes > 0 ? (minutes / totalMinutes) * 100 : 0,
|
||||
})).sort((a, b) => b.minutes - a.minutes);
|
||||
}, [projects]);
|
||||
|
||||
return (
|
||||
<div className="space-y-8 pb-12 animate-fadeIn">
|
||||
|
||||
{/* Title */}
|
||||
<div>
|
||||
<div className="inline-flex items-center space-x-2 px-3 py-1 rounded-full text-xs font-semibold bg-emerald-500/10 text-emerald-400 border border-emerald-500/30 mb-2">
|
||||
<TrendingUp className="w-3.5 h-3.5" />
|
||||
<span>Analyses Financières & Rentabilité</span>
|
||||
</div>
|
||||
<h2 className="text-2xl sm:text-3xl font-black text-white font-display">
|
||||
Statistiques de Marge & Simulateur de Prix
|
||||
</h2>
|
||||
<p className="text-slate-400 text-xs sm:text-sm">
|
||||
Analysez la rentabilité par modèle de console et simulez vos prix de vente avant de lancer un build.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Interactive Price & Margin Simulator */}
|
||||
<div className="glass-panel p-6 sm:p-8 rounded-3xl border border-emerald-500/30 space-y-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-emerald-500/10 border border-emerald-500/30 flex items-center justify-center text-emerald-400">
|
||||
<Calculator className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-white">
|
||||
Simulateur de Prix de Vente & Marge Pro
|
||||
</h3>
|
||||
<p className="text-xs text-slate-400">
|
||||
Ajustez les composants et le temps estimé pour trouver votre prix de vente optimal
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sliders & Inputs Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 pt-2">
|
||||
|
||||
{/* Col 1: Hardware Costs Inputs */}
|
||||
<div className="space-y-4 p-4 rounded-2xl bg-slate-950/80 border border-slate-800">
|
||||
<h4 className="text-xs font-bold uppercase tracking-wider text-cyan-400 flex items-center space-x-1.5">
|
||||
<Gamepad2 className="w-4 h-4" />
|
||||
<span>Coûts Pièces Estimés</span>
|
||||
</h4>
|
||||
|
||||
<div className="space-y-3 text-xs">
|
||||
<div>
|
||||
<div className="flex justify-between text-slate-300 mb-1">
|
||||
<span>Console Donneuse :</span>
|
||||
<span className="font-mono font-bold text-white">{simBaseConsoleCost} €</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="120"
|
||||
step="5"
|
||||
value={simBaseConsoleCost}
|
||||
onChange={(e) => setSimBaseConsoleCost(Number(e.target.value))}
|
||||
className="w-full accent-cyan-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between text-slate-300 mb-1">
|
||||
<span>Écran IPS / OLED :</span>
|
||||
<span className="font-mono font-bold text-white">{simScreenCost} €</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
step="2"
|
||||
value={simScreenCost}
|
||||
onChange={(e) => setSimScreenCost(Number(e.target.value))}
|
||||
className="w-full accent-cyan-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between text-slate-300 mb-1">
|
||||
<span>Coque & Boutons :</span>
|
||||
<span className="font-mono font-bold text-white">{simShellCost} €</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="60"
|
||||
step="2"
|
||||
value={simShellCost}
|
||||
onChange={(e) => setSimShellCost(Number(e.target.value))}
|
||||
className="w-full accent-cyan-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between text-slate-300 mb-1">
|
||||
<span>Mods USB-C / LiPo / Audio :</span>
|
||||
<span className="font-mono font-bold text-white">{simModsCost} €</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="60"
|
||||
step="2"
|
||||
value={simModsCost}
|
||||
onChange={(e) => setSimModsCost(Number(e.target.value))}
|
||||
className="w-full accent-cyan-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between text-slate-300 mb-1">
|
||||
<span>Consommables & Packaging :</span>
|
||||
<span className="font-mono font-bold text-white">{simConsumablesCost} €</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="30"
|
||||
step="1"
|
||||
value={simConsumablesCost}
|
||||
onChange={(e) => setSimConsumablesCost(Number(e.target.value))}
|
||||
className="w-full accent-cyan-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t border-slate-800 flex justify-between font-bold text-slate-200">
|
||||
<span>Total Matériel :</span>
|
||||
<span className="text-cyan-300 font-mono">{formatCurrency(simTotalHardwareCost, settings.currency)}</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Col 2: Labor & Platform Settings */}
|
||||
<div className="space-y-4 p-4 rounded-2xl bg-slate-950/80 border border-slate-800">
|
||||
<h4 className="text-xs font-bold uppercase tracking-wider text-amber-400 flex items-center space-x-1.5">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>Temps & Main d'Œuvre</span>
|
||||
</h4>
|
||||
|
||||
<div className="space-y-3 text-xs">
|
||||
<div>
|
||||
<div className="flex justify-between text-slate-300 mb-1">
|
||||
<span>Temps estimé sur établi :</span>
|
||||
<span className="font-mono font-bold text-amber-300">{simLaborHours} heures ({Math.round(simLaborHours * 60)} min)</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0.5"
|
||||
max="8"
|
||||
step="0.25"
|
||||
value={simLaborHours}
|
||||
onChange={(e) => setSimLaborHours(Number(e.target.value))}
|
||||
className="w-full accent-amber-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between text-slate-300 mb-1">
|
||||
<span>Taux horaire souhaité :</span>
|
||||
<span className="font-mono font-bold text-white">{simHourlyRate} €/h</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="10"
|
||||
max="60"
|
||||
step="5"
|
||||
value={simHourlyRate}
|
||||
onChange={(e) => setSimHourlyRate(Number(e.target.value))}
|
||||
className="w-full accent-amber-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between text-slate-300 mb-1">
|
||||
<span>Commission Plateforme (% Etsy/Vinted) :</span>
|
||||
<span className="font-mono font-bold text-white">{simPlatformFeePercent}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="15"
|
||||
step="1"
|
||||
value={simPlatformFeePercent}
|
||||
onChange={(e) => setSimPlatformFeePercent(Number(e.target.value))}
|
||||
className="w-full accent-purple-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t border-slate-800 flex justify-between font-bold text-slate-200">
|
||||
<span>Coût Total de Revient (COGS) :</span>
|
||||
<span className="text-amber-300 font-mono">{formatCurrency(simTotalCOGS, settings.currency)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Col 3: Selling Price Slider & Instant Profit Verdict */}
|
||||
<div className="space-y-4 p-5 rounded-2xl bg-gradient-to-b from-slate-950 to-slate-900 border border-emerald-500/40 flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="text-xs font-bold uppercase tracking-wider text-emerald-400 flex items-center space-x-1.5">
|
||||
<Sliders className="w-4 h-4" />
|
||||
<span>Prix de Vente Simulé</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<div className="flex justify-between items-baseline mb-2">
|
||||
<span className="text-xs text-slate-400">Prix public :</span>
|
||||
<span className="font-mono font-black text-3xl text-white">
|
||||
{formatCurrency(simSellingPrice, settings.currency)}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="60"
|
||||
max="350"
|
||||
step="5"
|
||||
value={simSellingPrice}
|
||||
onChange={(e) => setSimSellingPrice(Number(e.target.value))}
|
||||
className="w-full accent-emerald-400 h-2 bg-slate-800 rounded-lg cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Verdict Box */}
|
||||
<div className="p-4 rounded-xl bg-slate-950/90 border border-emerald-500/30 space-y-2.5 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-400">Marge Brute (hors temps) :</span>
|
||||
<span className="font-mono font-bold text-white">
|
||||
{formatCurrency(simGrossProfit, settings.currency)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-400">Bénéfice Net Réel :</span>
|
||||
<span className={`font-mono font-bold text-base ${simNetProfit >= 0 ? 'text-emerald-400' : 'text-rose-400'}`}>
|
||||
{formatCurrency(simNetProfit, settings.currency)} ({simMarginPercent.toFixed(1)}%)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Real-time Dynamic Break-Even & Profit Simulation Curve Chart */}
|
||||
<div className="pt-2">
|
||||
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-1 flex items-center justify-between">
|
||||
<span>Courbe de Rentabilité Simulée</span>
|
||||
<span className="text-emerald-400">Point vert : {simSellingPrice}€</span>
|
||||
</div>
|
||||
{(() => {
|
||||
const curveData = [];
|
||||
for (let p = 70; p <= 290; p += 20) {
|
||||
const fee = (p * simPlatformFeePercent) / 100;
|
||||
const cogs = simTotalHardwareCost + simTotalLaborCost + fee;
|
||||
const net = p - cogs;
|
||||
curveData.push({
|
||||
price: `${p}€`,
|
||||
rawPrice: p,
|
||||
'Bénéfice Net': Math.round(net),
|
||||
});
|
||||
}
|
||||
return (
|
||||
<div className="h-28 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={curveData} margin={{ top: 5, right: 5, left: -25, bottom: 0 }}>
|
||||
<XAxis dataKey="price" stroke="#64748b" fontSize={9} tickLine={false} axisLine={{ stroke: '#334155' }} />
|
||||
<YAxis stroke="#64748b" fontSize={9} tickLine={false} axisLine={{ stroke: '#334155' }} />
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const d = payload[0].payload;
|
||||
return (
|
||||
<div className="bg-slate-900 border border-slate-700 p-2 rounded-xl text-[10px] font-mono shadow-xl">
|
||||
<div className="text-slate-300">Prix : {d.price}</div>
|
||||
<div className={d['Bénéfice Net'] >= 0 ? 'text-emerald-400 font-bold' : 'text-rose-400 font-bold'}>
|
||||
Gain net : {d['Bénéfice Net']} €
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
<ReferenceLine y={0} stroke="#f43f5e" strokeDasharray="3 3" />
|
||||
<Line type="monotone" dataKey="Bénéfice Net" stroke="#10b981" strokeWidth={2.5} dot={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t border-slate-800 flex justify-between font-bold">
|
||||
<span className="text-slate-300">Rémunération horaire effective :</span>
|
||||
<span className="text-amber-300 font-mono text-sm">
|
||||
{formatCurrency(simEffectiveHourlyRate, settings.currency)}/h
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Model Performance Comparison Visual Chart & Table */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Gamepad2 className="w-5 h-5 text-pink-400" />
|
||||
<h3 className="text-lg font-bold text-white">
|
||||
Performance & Rentabilité par Modèle de Console
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Visual Composed Chart: Revenue, Hardware Costs & Net Profit by Model */}
|
||||
<div className="glass-panel p-5 rounded-3xl border border-slate-800 space-y-3">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||
<div>
|
||||
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-400 flex items-center space-x-1.5">
|
||||
<BarChart2 className="w-4 h-4 text-emerald-400" />
|
||||
<span>Comparatif Chiffre d'Affaires & Bénéfices par Modèle</span>
|
||||
</h4>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3 text-xs font-mono">
|
||||
<span className="flex items-center text-pink-400">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-pink-500 mr-1.5" /> CA Total
|
||||
</span>
|
||||
<span className="flex items-center text-cyan-400">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-cyan-500 mr-1.5" /> Coût Matériel
|
||||
</span>
|
||||
<span className="flex items-center text-emerald-400">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-emerald-500 mr-1.5" /> Bénéfice Net
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-56 w-full pt-1">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart
|
||||
data={modelStats.map((s) => ({
|
||||
model: s.model,
|
||||
'Chiffre d\'Affaires': s.totalRevenue,
|
||||
'Coût Pièces': s.totalHardwareCost,
|
||||
'Bénéfice Net': s.totalNetProfit,
|
||||
marge: s.marginPercent,
|
||||
}))}
|
||||
margin={{ top: 10, right: 10, left: -20, bottom: 0 }}
|
||||
>
|
||||
<XAxis dataKey="model" stroke="#64748b" fontSize={11} tickLine={false} axisLine={{ stroke: '#334155' }} />
|
||||
<YAxis stroke="#64748b" fontSize={11} tickLine={false} axisLine={{ stroke: '#334155' }} tickFormatter={(v) => `${v}€`} />
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const d = payload[0].payload;
|
||||
return (
|
||||
<div className="bg-slate-900 border border-slate-700 p-3 rounded-2xl shadow-xl text-xs space-y-1 font-mono">
|
||||
<div className="font-bold text-white font-sans text-xs pb-1 border-b border-slate-800">{d.model}</div>
|
||||
<div className="text-pink-400">💰 CA : {formatCurrency(d['Chiffre d\'Affaires'], settings.currency)}</div>
|
||||
<div className="text-cyan-300">📦 Coût Pièces : {formatCurrency(d['Coût Pièces'], settings.currency)}</div>
|
||||
<div className="text-emerald-400 font-bold pt-1 border-t border-slate-800">
|
||||
✨ Bénéfice Net : {formatCurrency(d['Bénéfice Net'], settings.currency)} ({d.marge.toFixed(1)}%)
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="Chiffre d'Affaires" fill="#ec4899" radius={[4, 4, 0, 0]} />
|
||||
<Bar dataKey="Coût Pièces" fill="#06b6d4" radius={[4, 4, 0, 0]} />
|
||||
<Line type="monotone" dataKey="Bénéfice Net" stroke="#10b981" strokeWidth={3} dot={{ r: 4, fill: '#10b981' }} />
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="glass-panel rounded-3xl border border-slate-800 overflow-hidden">
|
||||
<table className="w-full text-left border-collapse text-xs sm:text-sm">
|
||||
|
||||
<thead>
|
||||
<tr className="border-b border-slate-800 bg-slate-950/60 text-slate-400 font-semibold uppercase text-[11px] tracking-wider">
|
||||
<th className="py-3.5 px-4">Modèle Console</th>
|
||||
<th className="py-3.5 px-3 text-center">Projets Totaux</th>
|
||||
<th className="py-3.5 px-3 text-center">Consoles Vendues</th>
|
||||
<th className="py-3.5 px-3 text-right">CA Réalisé</th>
|
||||
<th className="py-3.5 px-3 text-center">Temps Moyen / Build</th>
|
||||
<th className="py-3.5 px-3 text-right">Bénéfice Net</th>
|
||||
<th className="py-3.5 px-4 text-right">Marge Nette %</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/60">
|
||||
{modelStats.map((stat) => (
|
||||
<tr key={stat.model} className="hover:bg-slate-900/40 transition-colors">
|
||||
<td className="py-3 px-4 font-bold text-white">
|
||||
{stat.model}
|
||||
</td>
|
||||
<td className="py-3 px-3 text-center font-mono text-slate-300">
|
||||
{stat.totalBuilds}
|
||||
</td>
|
||||
<td className="py-3 px-3 text-center font-mono text-emerald-400 font-bold">
|
||||
{stat.soldBuilds}
|
||||
</td>
|
||||
<td className="py-3 px-3 text-right font-mono text-slate-200">
|
||||
{formatCurrency(stat.totalRevenue, settings.currency)}
|
||||
</td>
|
||||
<td className="py-3 px-3 text-center font-mono text-amber-300">
|
||||
{stat.avgLaborHours.toFixed(1)}h ({Math.round(stat.avgLaborHours * 60)}m)
|
||||
</td>
|
||||
<td className="py-3 px-3 text-right font-mono font-bold text-emerald-400">
|
||||
{formatCurrency(stat.totalNetProfit, settings.currency)}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right font-mono font-bold text-slate-200">
|
||||
{stat.marginPercent.toFixed(1)}%
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Task Time Distribution Breakdown */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Clock className="w-5 h-5 text-amber-400" />
|
||||
<h3 className="text-lg font-bold text-white">
|
||||
Répartition du Temps de Travail par Type d'Opération
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{taskDistribution.map((item) => (
|
||||
<div key={item.taskType} className="glass-panel p-4 rounded-2xl border border-slate-800 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold text-slate-300 truncate">
|
||||
{getTaskTypeLabel(item.taskType)}
|
||||
</span>
|
||||
<span className="text-xs font-mono font-bold text-amber-300">
|
||||
{item.percentage.toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-2 bg-slate-900 rounded-full overflow-hidden border border-slate-800">
|
||||
<div
|
||||
style={{ width: `${item.percentage}%` }}
|
||||
className="bg-amber-400 h-full rounded-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-[11px] font-mono text-slate-400">
|
||||
{formatMinutes(item.minutes)} cumulées
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
543
src/components/auth/AuthModal.tsx
Normal file
543
src/components/auth/AuthModal.tsx
Normal file
@@ -0,0 +1,543 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
X,
|
||||
User,
|
||||
KeyRound,
|
||||
Database,
|
||||
Copy,
|
||||
Check,
|
||||
RefreshCw,
|
||||
LogOut,
|
||||
Sparkles,
|
||||
Wifi,
|
||||
ShieldAlert,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Lock,
|
||||
} from 'lucide-react';
|
||||
import { useApp } from '../../context/AppContext';
|
||||
import {
|
||||
decodeJwtToken,
|
||||
testNcbConnection,
|
||||
authenticateWithDatabase,
|
||||
changeUserPassword,
|
||||
} from '../../services/authService';
|
||||
|
||||
interface AuthModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const AuthModal: React.FC<AuthModalProps> = ({ isOpen, onClose }) => {
|
||||
const {
|
||||
authSession,
|
||||
loginUser,
|
||||
logoutUser,
|
||||
addToast,
|
||||
} = useApp();
|
||||
|
||||
// Login form state
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
|
||||
// Password change state
|
||||
const [showChangePwd, setShowChangePwd] = useState(false);
|
||||
const [oldPwd, setOldPwd] = useState('');
|
||||
const [newPwd, setNewPwd] = useState('');
|
||||
const [confirmPwd, setConfirmPwd] = useState('');
|
||||
const [isChangingPwd, setIsChangingPwd] = useState(false);
|
||||
const [pwdMsg, setPwdMsg] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
|
||||
|
||||
// Active view inside modal
|
||||
const [activeTab, setActiveTab] = useState<'login' | 'jwt' | 'database'>(
|
||||
authSession.isAuthenticated ? 'login' : 'login'
|
||||
);
|
||||
|
||||
// JWT & NCB state
|
||||
const [copiedToken, setCopiedToken] = useState(false);
|
||||
const [isTestingNcb, setIsTestingNcb] = useState(false);
|
||||
const [ncbTestResult, setNcbTestResult] = useState<{ success: boolean; latencyMs: number; message: string } | null>(null);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const currentDecoded = decodeJwtToken(authSession.token);
|
||||
|
||||
const handleLoginSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoginError(null);
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const result = await authenticateWithDatabase(username, password);
|
||||
if (result.success && result.user) {
|
||||
loginUser(result.user, 720); // 30 days token
|
||||
addToast({
|
||||
type: 'success',
|
||||
title: 'Connexion réussie',
|
||||
message: `Bienvenue ${result.user.name} ! Jeton JWT émis et base NCB connectée.`,
|
||||
});
|
||||
onClose();
|
||||
} else {
|
||||
setLoginError(result.error || 'Identifiants incorrects.');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setLoginError(err.message || 'Erreur lors de la tentative de connexion.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasswordChange = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setPwdMsg(null);
|
||||
|
||||
if (!oldPwd.trim() || !newPwd.trim() || !confirmPwd.trim()) {
|
||||
setPwdMsg({ type: 'error', message: 'Veuillez remplir tous les champs.' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPwd !== confirmPwd) {
|
||||
setPwdMsg({ type: 'error', message: 'Les nouveaux mots de passe ne correspondent pas.' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPwd.length < 4) {
|
||||
setPwdMsg({ type: 'error', message: 'Le mot de passe doit comporter au moins 4 caractères.' });
|
||||
return;
|
||||
}
|
||||
|
||||
setIsChangingPwd(true);
|
||||
try {
|
||||
const usernameToUpdate = authSession.user.name || 'streaper2';
|
||||
const result = await changeUserPassword(usernameToUpdate, oldPwd, newPwd);
|
||||
if (result.success) {
|
||||
setPwdMsg({ type: 'success', message: result.message || 'Mot de passe mis à jour !' });
|
||||
setOldPwd('');
|
||||
setNewPwd('');
|
||||
setConfirmPwd('');
|
||||
addToast({
|
||||
type: 'success',
|
||||
title: 'Mot de passe mis à jour',
|
||||
message: 'Synchronisé avec la table users de NoCodeBackend.',
|
||||
});
|
||||
} else {
|
||||
setPwdMsg({ type: 'error', message: result.error || 'Échec de modification.' });
|
||||
}
|
||||
} catch (err: any) {
|
||||
setPwdMsg({ type: 'error', message: err.message || 'Erreur réseau.' });
|
||||
} finally {
|
||||
setIsChangingPwd(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyToken = () => {
|
||||
if (!authSession.token) return;
|
||||
navigator.clipboard.writeText(authSession.token);
|
||||
setCopiedToken(true);
|
||||
setTimeout(() => setCopiedToken(false), 2000);
|
||||
addToast({
|
||||
type: 'info',
|
||||
title: 'Jeton JWT Copié',
|
||||
message: 'Token copié dans le presse-papier.',
|
||||
});
|
||||
};
|
||||
|
||||
const handleTestNcb = async () => {
|
||||
setIsTestingNcb(true);
|
||||
setNcbTestResult(null);
|
||||
try {
|
||||
const result = await testNcbConnection('54770_uwu_finances');
|
||||
setNcbTestResult(result);
|
||||
if (result.success) {
|
||||
addToast({
|
||||
type: 'success',
|
||||
title: 'Base Cloud NoCodeBackend OK',
|
||||
message: result.message,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setIsTestingNcb(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-md animate-fadeIn">
|
||||
<div
|
||||
className="glass-panel w-full max-w-lg rounded-3xl border border-slate-700 bg-slate-950/95 shadow-2xl flex flex-col max-h-[90vh] overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Modal Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-800">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-2.5 rounded-2xl bg-gradient-to-tr from-pink-500/20 to-purple-500/20 border border-pink-500/30 text-pink-400">
|
||||
<Lock className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-black text-white font-display flex items-center space-x-2">
|
||||
<span>Accès Atelier & Authentification</span>
|
||||
{authSession.isAuthenticated && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold bg-emerald-500/15 text-emerald-400 border border-emerald-500/30">
|
||||
Connecté ({authSession.user.name})
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
<p className="text-xs text-slate-400">
|
||||
Compte unique atelier & vérification en base NoCodeBackend
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-xl text-slate-400 hover:text-white hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sub Navigation */}
|
||||
<div className="flex border-b border-slate-800 bg-slate-900/50 p-1.5 gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('login')}
|
||||
className={`flex-1 py-2 px-3 rounded-xl text-xs font-bold flex items-center justify-center space-x-2 transition-all ${
|
||||
activeTab === 'login'
|
||||
? 'bg-gradient-to-r from-pink-500 to-purple-600 text-white shadow-md shadow-pink-500/20'
|
||||
: 'text-slate-400 hover:text-white hover:bg-slate-800/50'
|
||||
}`}
|
||||
>
|
||||
<User className="w-3.5 h-3.5" />
|
||||
<span>{authSession.isAuthenticated ? 'Mon Compte' : 'Connexion'}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('jwt')}
|
||||
className={`flex-1 py-2 px-3 rounded-xl text-xs font-bold flex items-center justify-center space-x-2 transition-all ${
|
||||
activeTab === 'jwt'
|
||||
? 'bg-gradient-to-r from-cyan-500 to-blue-600 text-white shadow-md shadow-cyan-500/20'
|
||||
: 'text-slate-400 hover:text-white hover:bg-slate-800/50'
|
||||
}`}
|
||||
>
|
||||
<KeyRound className="w-3.5 h-3.5" />
|
||||
<span>Jeton JWT</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('database')}
|
||||
className={`flex-1 py-2 px-3 rounded-xl text-xs font-bold flex items-center justify-center space-x-2 transition-all ${
|
||||
activeTab === 'database'
|
||||
? 'bg-gradient-to-r from-purple-500 to-pink-600 text-white shadow-md shadow-purple-500/20'
|
||||
: 'text-slate-400 hover:text-white hover:bg-slate-800/50'
|
||||
}`}
|
||||
>
|
||||
<Database className="w-3.5 h-3.5" />
|
||||
<span>Base NCB</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal Body */}
|
||||
<div className="p-6 overflow-y-auto space-y-5">
|
||||
|
||||
{/* TAB 1: LOGIN / ACCOUNT VIEW */}
|
||||
{activeTab === 'login' && (
|
||||
<div className="space-y-4">
|
||||
{authSession.isAuthenticated ? (
|
||||
/* Authenticated User Card */
|
||||
<div className="p-5 rounded-2xl bg-gradient-to-br from-slate-900 to-slate-950 border border-pink-500/30 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3.5">
|
||||
<div className="relative">
|
||||
<img
|
||||
src={authSession.user.avatarUrl || 'https://api.dicebear.com/7.x/bottts/svg?seed=streaper2'}
|
||||
alt={authSession.user.name}
|
||||
className="w-12 h-12 rounded-2xl border border-pink-500/40 bg-slate-800 object-cover"
|
||||
/>
|
||||
<span className="absolute -bottom-1 -right-1 w-3.5 h-3.5 rounded-full bg-emerald-500 border-2 border-slate-950" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<h3 className="font-bold text-base text-white">{authSession.user.name}</h3>
|
||||
<span className="px-2 py-0.5 rounded-full text-[10px] font-extrabold uppercase bg-pink-500/20 text-pink-300 border border-pink-500/30">
|
||||
{authSession.user.role}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 font-mono mt-0.5">{authSession.user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={logoutUser}
|
||||
className="px-3 py-1.5 rounded-xl bg-slate-900 border border-rose-500/30 hover:bg-rose-500/10 text-rose-400 text-xs font-semibold flex items-center space-x-1.5 transition-colors"
|
||||
>
|
||||
<LogOut className="w-3.5 h-3.5" />
|
||||
<span>Se Déconnecter</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2.5 pt-2 text-xs">
|
||||
<div className="p-3 rounded-xl bg-slate-950/80 border border-slate-800">
|
||||
<div className="text-[10px] uppercase font-bold text-slate-500">Instance Base</div>
|
||||
<div className="font-mono text-cyan-300 font-bold mt-0.5">54770_uwu_finances</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-xl bg-slate-950/80 border border-slate-800">
|
||||
<div className="text-[10px] uppercase font-bold text-slate-500">Statut JWT</div>
|
||||
<div className="font-mono text-emerald-400 font-bold mt-0.5">{currentDecoded.timeLeftFormatted}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Password change toggle */}
|
||||
<div className="pt-2 border-t border-slate-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowChangePwd(!showChangePwd)}
|
||||
className="text-xs font-semibold text-pink-400 hover:text-pink-300 flex items-center space-x-1.5 transition-colors cursor-pointer"
|
||||
>
|
||||
<KeyRound className="w-3.5 h-3.5" />
|
||||
<span>{showChangePwd ? 'Masquer le changement de mot de passe' : 'Modifier le mot de passe'}</span>
|
||||
</button>
|
||||
|
||||
{showChangePwd && (
|
||||
<form onSubmit={handlePasswordChange} className="mt-3 p-3.5 rounded-xl bg-slate-950 border border-slate-800 space-y-3">
|
||||
{pwdMsg && (
|
||||
<div className={`p-2.5 rounded-lg text-xs flex items-center space-x-2 ${
|
||||
pwdMsg.type === 'success'
|
||||
? 'bg-emerald-500/10 text-emerald-300 border border-emerald-500/30'
|
||||
: 'bg-rose-500/10 text-rose-300 border border-rose-500/30'
|
||||
}`}>
|
||||
<span>{pwdMsg.message}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] font-semibold text-slate-400">Ancien Mot de Passe</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={oldPwd}
|
||||
onChange={(e) => setOldPwd(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="w-full px-3 py-1.5 rounded-lg bg-slate-900 border border-slate-700 text-white text-xs font-mono focus:outline-none focus:border-pink-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] font-semibold text-slate-400">Nouveau</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={newPwd}
|
||||
onChange={(e) => setNewPwd(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="w-full px-3 py-1.5 rounded-lg bg-slate-900 border border-slate-700 text-white text-xs font-mono focus:outline-none focus:border-pink-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] font-semibold text-slate-400">Confirmation</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={confirmPwd}
|
||||
onChange={(e) => setConfirmPwd(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="w-full px-3 py-1.5 rounded-lg bg-slate-900 border border-slate-700 text-white text-xs font-mono focus:outline-none focus:border-pink-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-1">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isChangingPwd}
|
||||
className="px-3.5 py-1.5 rounded-lg bg-gradient-to-r from-pink-500 to-purple-600 text-white text-xs font-bold shadow-md hover:scale-[1.01] transition-all disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
{isChangingPwd ? 'Enregistrement...' : 'Valider Nouveau Mot de Passe'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Login Form */
|
||||
<form onSubmit={handleLoginSubmit} className="space-y-4">
|
||||
{loginError && (
|
||||
<div className="p-3 rounded-xl bg-rose-500/10 border border-rose-500/30 text-rose-300 text-xs flex items-center space-x-2">
|
||||
<ShieldAlert className="w-4 h-4 text-rose-400 shrink-0" />
|
||||
<span>{loginError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-semibold text-slate-300 flex items-center space-x-1.5">
|
||||
<User className="w-3.5 h-3.5 text-pink-400" />
|
||||
<span>Nom d'utilisateur</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Nom d'utilisateur"
|
||||
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-900 border border-slate-700 text-white text-sm focus:outline-none focus:border-pink-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-semibold text-slate-300 flex items-center space-x-1.5">
|
||||
<Lock className="w-3.5 h-3.5 text-pink-400" />
|
||||
<span>Mot de passe</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Mot de passe"
|
||||
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-900 border border-slate-700 text-white text-sm focus:outline-none focus:border-pink-500 pr-10 font-mono"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full py-3 rounded-xl bg-gradient-to-r from-pink-500 via-purple-600 to-pink-600 text-white font-bold text-sm shadow-lg shadow-pink-500/25 hover:scale-[1.01] active:scale-[0.99] transition-all flex items-center justify-center space-x-2 disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="w-4 h-4" />
|
||||
)}
|
||||
<span>{isSubmitting ? 'Connexion en cours...' : 'Se Connecter à l\'Atelier'}</span>
|
||||
</button>
|
||||
|
||||
<div className="p-3 rounded-xl bg-slate-900/50 border border-slate-800 text-[11px] text-slate-400 flex items-center justify-between">
|
||||
<span>Synchronisé avec la BDD</span>
|
||||
<span className="font-mono text-cyan-300">NoCodeBackend</span>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 2: JWT TOKEN */}
|
||||
{activeTab === 'jwt' && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-bold uppercase tracking-wider text-cyan-400 flex items-center space-x-1.5">
|
||||
<KeyRound className="w-3.5 h-3.5" />
|
||||
<span>Jeton JWT Actif</span>
|
||||
</label>
|
||||
<span className="text-[11px] text-slate-400 font-mono">
|
||||
Validité : <span className="text-emerald-400 font-bold">{currentDecoded.timeLeftFormatted}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<textarea
|
||||
rows={4}
|
||||
readOnly
|
||||
value={authSession.token || ''}
|
||||
className="w-full px-3 py-2.5 rounded-xl bg-slate-950 border border-slate-800 text-slate-200 text-xs font-mono break-all focus:outline-none focus:border-cyan-400"
|
||||
/>
|
||||
<button
|
||||
onClick={handleCopyToken}
|
||||
className="absolute right-2.5 bottom-3.5 px-2.5 py-1.5 rounded-lg bg-slate-900 border border-slate-700 hover:border-cyan-400 text-slate-200 text-xs font-semibold flex items-center space-x-1.5 transition-all cursor-pointer"
|
||||
>
|
||||
{copiedToken ? <Check className="w-3.5 h-3.5 text-emerald-400" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
<span>{copiedToken ? 'Copié !' : 'Copier'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* JWT Payload breakdown */}
|
||||
{currentDecoded.payload && (
|
||||
<div className="p-3.5 rounded-xl bg-slate-900/60 border border-slate-800 space-y-2">
|
||||
<div className="text-[10px] uppercase font-bold text-slate-400">Claims Payload (JSON)</div>
|
||||
<pre className="text-[11px] font-mono text-slate-300 overflow-x-auto p-2 rounded-lg bg-slate-950">
|
||||
{JSON.stringify(currentDecoded.payload, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 3: NCB DATABASE */}
|
||||
{activeTab === 'database' && (
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 rounded-2xl bg-slate-900/60 border border-cyan-500/30 space-y-3">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-2.5 rounded-xl bg-cyan-500/10 border border-cyan-500/30 text-cyan-400">
|
||||
<Database className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-sm text-white">Instance 54770_uwu_finances</h3>
|
||||
<p className="text-xs text-slate-400">Table 'users' & données de l'atelier synchronisées</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" />
|
||||
<span className="text-xs font-semibold text-emerald-400">En Ligne & Connecté</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleTestNcb}
|
||||
disabled={isTestingNcb}
|
||||
className="px-3.5 py-1.5 rounded-xl bg-slate-900 border border-cyan-500/40 hover:bg-cyan-500/10 text-cyan-300 text-xs font-semibold flex items-center space-x-1.5 transition-all disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
<Wifi className={`w-3.5 h-3.5 ${isTestingNcb ? 'animate-spin' : ''}`} />
|
||||
<span>{isTestingNcb ? 'Test...' : 'Tester le Ping NCB'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{ncbTestResult && (
|
||||
<div className={`p-3 rounded-xl border text-xs flex items-center space-x-2 mt-2 ${
|
||||
ncbTestResult.success
|
||||
? 'bg-emerald-500/10 text-emerald-300 border-emerald-500/30'
|
||||
: 'bg-rose-500/10 text-rose-300 border-rose-500/30'
|
||||
}`}>
|
||||
{ncbTestResult.success ? <Check className="w-4 h-4 text-emerald-400 shrink-0" /> : <ShieldAlert className="w-4 h-4 text-rose-400 shrink-0" />}
|
||||
<span>{ncbTestResult.message}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="px-6 py-4 border-t border-slate-800 bg-slate-900/30 flex items-center justify-between">
|
||||
<span className="text-[11px] font-mono text-slate-500">
|
||||
Auth v1.2 • NoCodeBackend Auth & JWT
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-5 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-white text-xs font-bold transition-all"
|
||||
>
|
||||
Fermer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
168
src/components/auth/LoginGate.tsx
Normal file
168
src/components/auth/LoginGate.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Gamepad2,
|
||||
Lock,
|
||||
User,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Sparkles,
|
||||
RefreshCw,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
Database,
|
||||
} from 'lucide-react';
|
||||
import { useApp } from '../../context/AppContext';
|
||||
import { authenticateWithDatabase } from '../../services/authService';
|
||||
|
||||
export const LoginGate: React.FC = () => {
|
||||
const { loginUser, addToast, settings } = useApp();
|
||||
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setErrorMessage(null);
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const result = await authenticateWithDatabase(username, password);
|
||||
if (result.success && result.user) {
|
||||
loginUser(result.user, 720); // 30 days session
|
||||
addToast({
|
||||
type: 'success',
|
||||
title: 'Accès Autorisé',
|
||||
message: `Bienvenue dans votre atelier, ${result.user.name} ! Jeton JWT émis.`,
|
||||
});
|
||||
} else {
|
||||
setErrorMessage(result.error || 'Identifiant ou mot de passe incorrect.');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setErrorMessage(err.message || 'Erreur lors de la connexion au serveur.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen theme-root-container flex items-center justify-center p-4 selection:bg-pink-500 selection:text-white relative overflow-hidden">
|
||||
{/* Ambient background glows */}
|
||||
<div className="absolute top-1/4 left-1/2 -translate-x-1/2 -translate-y-1/2 w-96 h-96 bg-pink-500/10 rounded-full blur-3xl pointer-events-none" />
|
||||
<div className="absolute bottom-1/4 right-1/4 w-80 h-80 bg-purple-500/10 rounded-full blur-3xl pointer-events-none" />
|
||||
|
||||
<div className="w-full max-w-md relative z-10 animate-fadeIn">
|
||||
{/* Main Card */}
|
||||
<div className="glass-panel p-8 sm:p-10 rounded-3xl border border-slate-700/80 bg-slate-950/90 shadow-2xl shadow-pink-500/5 space-y-6">
|
||||
|
||||
{/* Logo & Header */}
|
||||
<div className="text-center space-y-3">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-3xl bg-gradient-to-tr from-pink-500 via-purple-600 to-cyan-400 p-[2px] shadow-xl shadow-pink-500/25 mb-1">
|
||||
<div className="w-full h-full bg-slate-950 rounded-[22px] flex items-center justify-center">
|
||||
<Gamepad2 className="w-8 h-8 text-pink-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="text-2xl font-black tracking-tight bg-gradient-to-r from-pink-400 via-purple-300 to-cyan-400 bg-clip-text text-transparent font-display">
|
||||
{settings.logoText || 'uwuFinances'}
|
||||
</h1>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
Atelier Console Modding • Gestion Financière & Projets
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{errorMessage && (
|
||||
<div className="p-3 rounded-2xl bg-rose-500/10 border border-rose-500/30 text-rose-300 text-xs flex items-center space-x-2 animate-shake">
|
||||
<ShieldAlert className="w-4 h-4 text-rose-400 shrink-0" />
|
||||
<span>{errorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Username Input */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-semibold text-slate-300 flex items-center space-x-1.5">
|
||||
<User className="w-3.5 h-3.5 text-pink-400" />
|
||||
<span>Identifiant Utilisateur</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
autoFocus
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Nom d'utilisateur"
|
||||
className="w-full px-4 py-3 rounded-2xl bg-slate-900/90 border border-slate-750 text-white text-sm focus:outline-none focus:border-pink-500 focus:ring-2 focus:ring-pink-500/20 transition-all font-medium"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Password Input */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-semibold text-slate-300 flex items-center space-x-1.5">
|
||||
<Lock className="w-3.5 h-3.5 text-pink-400" />
|
||||
<span>Mot de passe</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Mot de passe"
|
||||
className="w-full px-4 py-3 rounded-2xl bg-slate-900/90 border border-slate-750 text-white text-sm focus:outline-none focus:border-pink-500 focus:ring-2 focus:ring-pink-500/20 transition-all font-mono pr-11"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white transition-colors"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full py-3.5 px-4 rounded-2xl bg-gradient-to-r from-pink-500 via-purple-600 to-pink-600 hover:from-pink-400 hover:to-purple-500 text-white font-bold text-sm shadow-xl shadow-pink-500/25 hover:shadow-pink-500/40 hover:scale-[1.01] active:scale-[0.99] transition-all duration-200 flex items-center justify-center space-x-2 disabled:opacity-50 cursor-pointer mt-2"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="w-4 h-4 text-pink-200" />
|
||||
)}
|
||||
<span>{isSubmitting ? 'Vérification en BDD...' : 'Accéder à l\'Atelier'}</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Database info badge */}
|
||||
<div className="pt-2 border-t border-slate-850 flex items-center justify-between text-[11px] text-slate-400">
|
||||
<div className="flex items-center space-x-1.5">
|
||||
<Database className="w-3.5 h-3.5 text-cyan-400" />
|
||||
<span className="font-mono">Base de données Cloud NCB</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1 text-emerald-400 font-semibold">
|
||||
<ShieldCheck className="w-3.5 h-3.5" />
|
||||
<span>JWT Chiffré</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Discreet bottom notice */}
|
||||
<div className="text-center text-xs text-slate-500 mt-4">
|
||||
Session sécurisée pour l'administrateur de l'atelier
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
284
src/components/common/Header.tsx
Normal file
284
src/components/common/Header.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Gamepad2,
|
||||
LayoutDashboard,
|
||||
ShoppingCart,
|
||||
Layers,
|
||||
Clock,
|
||||
BarChart3,
|
||||
Settings as SettingsIcon,
|
||||
PackagePlus,
|
||||
Play,
|
||||
Pause,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { useApp } from '../../context/AppContext';
|
||||
|
||||
|
||||
export const Header: React.FC = () => {
|
||||
const {
|
||||
currentTab,
|
||||
setCurrentTab,
|
||||
inventory,
|
||||
projects,
|
||||
activeTimer,
|
||||
pauseTimer,
|
||||
resumeTimer,
|
||||
setShowNewProjectModal,
|
||||
setShowNewInventoryModal,
|
||||
settings,
|
||||
} = useApp();
|
||||
|
||||
// Calculate alerts
|
||||
const lowStockCount = inventory.filter(
|
||||
(i) => i.quantityInStock <= i.minStockAlert
|
||||
).length;
|
||||
|
||||
const activeProjectsCount = projects.filter(
|
||||
(p) => p.status === 'in_progress' || p.status === 'qc_testing'
|
||||
).length;
|
||||
|
||||
const readyToSellCount = projects.filter(
|
||||
(p) => p.status === 'in_stock_ready'
|
||||
).length;
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-40 w-full border-b border-slate-800/80 bg-slate-950/85 backdrop-blur-xl shadow-lg shadow-black/20 no-print">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-16 sm:h-20">
|
||||
|
||||
{/* Logo & Brand */}
|
||||
<div className="flex items-center space-x-3 cursor-pointer" onClick={() => setCurrentTab('dashboard')}>
|
||||
<div className="relative flex items-center justify-center w-11 h-11 rounded-2xl bg-gradient-to-tr from-pink-500 via-purple-600 to-cyan-400 p-[2px] shadow-lg shadow-pink-500/20 group">
|
||||
<div className="w-full h-full bg-slate-950 rounded-[14px] flex items-center justify-center transition-all group-hover:bg-slate-900">
|
||||
<Gamepad2 className="w-6 h-6 text-pink-400 group-hover:scale-110 transition-transform duration-300" />
|
||||
</div>
|
||||
<span className="absolute -top-1 -right-1 flex h-3 w-3">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-cyan-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-3 w-3 bg-cyan-500"></span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<h1 className="text-xl sm:text-2xl font-black tracking-tight bg-gradient-to-r from-pink-400 via-purple-300 to-cyan-400 bg-clip-text text-transparent font-display">
|
||||
{settings.logoText || 'uwuFinances'}
|
||||
</h1>
|
||||
<span className="hidden md:inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold bg-pink-500/10 text-pink-400 border border-pink-500/30">
|
||||
MODDING PRO
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 hidden sm:block truncate max-w-[200px]">
|
||||
{settings.businessName}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation Tabs */}
|
||||
<nav className="hidden lg:flex items-center space-x-1">
|
||||
<button
|
||||
onClick={() => setCurrentTab('dashboard')}
|
||||
className={`flex items-center space-x-2 px-3.5 py-2 rounded-xl text-sm font-semibold transition-all duration-200 ${
|
||||
currentTab === 'dashboard'
|
||||
? 'bg-gradient-to-r from-pink-500/15 to-purple-500/15 text-pink-400 border border-pink-500/30 shadow-sm'
|
||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-900/60'
|
||||
}`}
|
||||
>
|
||||
<LayoutDashboard className="w-4 h-4" />
|
||||
<span>Dashboard</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setCurrentTab('purchases')}
|
||||
className={`flex items-center space-x-2 px-3.5 py-2 rounded-xl text-sm font-semibold transition-all duration-200 relative ${
|
||||
currentTab === 'purchases'
|
||||
? 'bg-gradient-to-r from-cyan-500/15 to-blue-500/15 text-cyan-400 border border-cyan-500/30 shadow-sm'
|
||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-900/60'
|
||||
}`}
|
||||
>
|
||||
<ShoppingCart className="w-4 h-4" />
|
||||
<span>Achats & Stock</span>
|
||||
{lowStockCount > 0 && (
|
||||
<span className="ml-1.5 px-1.5 py-0.5 text-[10px] font-extrabold rounded-full bg-amber-500/20 text-amber-300 border border-amber-500/40 animate-pulse">
|
||||
{lowStockCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setCurrentTab('projects')}
|
||||
className={`flex items-center space-x-2 px-3.5 py-2 rounded-xl text-sm font-semibold transition-all duration-200 relative ${
|
||||
currentTab === 'projects'
|
||||
? 'bg-gradient-to-r from-purple-500/15 to-pink-500/15 text-purple-400 border border-purple-500/30 shadow-sm'
|
||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-900/60'
|
||||
}`}
|
||||
>
|
||||
<Layers className="w-4 h-4" />
|
||||
<span>Projets & Dépenses</span>
|
||||
{readyToSellCount > 0 ? (
|
||||
<span className="ml-1.5 px-1.5 py-0.5 text-[10px] font-extrabold rounded-full bg-emerald-500/20 text-emerald-300 border border-emerald-500/40">
|
||||
{readyToSellCount} en vente
|
||||
</span>
|
||||
) : activeProjectsCount > 0 ? (
|
||||
<span className="ml-1.5 px-1.5 py-0.5 text-[10px] font-extrabold rounded-full bg-blue-500/20 text-blue-300 border border-blue-500/40">
|
||||
{activeProjectsCount} en cours
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
|
||||
|
||||
<button
|
||||
onClick={() => setCurrentTab('timetracker')}
|
||||
className={`flex items-center space-x-2 px-3.5 py-2 rounded-xl text-sm font-semibold transition-all duration-200 relative ${
|
||||
currentTab === 'timetracker'
|
||||
? 'bg-gradient-to-r from-amber-500/15 to-orange-500/15 text-amber-400 border border-amber-500/30 shadow-sm'
|
||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-900/60'
|
||||
}`}
|
||||
>
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>Chronomètre Établi</span>
|
||||
{activeTimer.isRunning && (
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-ping ml-1" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setCurrentTab('analytics')}
|
||||
className={`flex items-center space-x-2 px-3.5 py-2 rounded-xl text-sm font-semibold transition-all duration-200 ${
|
||||
currentTab === 'analytics'
|
||||
? 'bg-gradient-to-r from-emerald-500/15 to-teal-500/15 text-emerald-400 border border-emerald-500/30 shadow-sm'
|
||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-900/60'
|
||||
}`}
|
||||
>
|
||||
<BarChart3 className="w-4 h-4" />
|
||||
<span>Rentabilité</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setCurrentTab('settings')}
|
||||
className={`p-2 rounded-xl text-slate-400 hover:text-slate-200 hover:bg-slate-900/60 transition-all ${
|
||||
currentTab === 'settings' ? 'text-pink-400 bg-pink-500/10 border border-pink-500/30' : ''
|
||||
}`}
|
||||
title="Paramètres & Sauvegardes"
|
||||
>
|
||||
<SettingsIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{/* Right Action Buttons */}
|
||||
<div className="flex items-center space-x-2 sm:space-x-3">
|
||||
{/* Live Stopwatch Mini Widget (if running or has elapsed time) */}
|
||||
{activeTimer.elapsedSeconds > 0 && (
|
||||
<div
|
||||
onClick={() => setCurrentTab('timetracker')}
|
||||
className="cursor-pointer hidden sm:flex items-center space-x-2 px-3 py-1.5 rounded-xl bg-slate-900 border border-amber-500/40 text-amber-300 text-xs font-mono shadow-md hover:border-amber-400 transition-all"
|
||||
>
|
||||
<div className={`w-2 h-2 rounded-full ${activeTimer.isRunning ? 'bg-amber-400 animate-pulse' : 'bg-slate-500'}`} />
|
||||
<span>
|
||||
{Math.floor(activeTimer.elapsedSeconds / 3600) > 0
|
||||
? `${Math.floor(activeTimer.elapsedSeconds / 3600)}:`
|
||||
: ''}
|
||||
{Math.floor((activeTimer.elapsedSeconds % 3600) / 60).toString().padStart(2, '0')}:
|
||||
{(activeTimer.elapsedSeconds % 60).toString().padStart(2, '0')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
activeTimer.isRunning ? pauseTimer() : resumeTimer();
|
||||
}}
|
||||
className="p-1 hover:text-white"
|
||||
>
|
||||
{activeTimer.isRunning ? <Pause className="w-3 h-3" /> : <Play className="w-3 h-3" />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Add Part */}
|
||||
<button
|
||||
onClick={() => setShowNewInventoryModal(true)}
|
||||
className="flex items-center space-x-1.5 px-3 py-1.5 sm:px-3.5 sm:py-2 rounded-xl text-xs sm:text-sm font-semibold bg-slate-900 text-cyan-300 border border-cyan-500/30 hover:bg-cyan-500/10 hover:border-cyan-500/60 shadow-sm transition-all duration-200"
|
||||
title="Ajouter un achat de pièces au stock"
|
||||
>
|
||||
<PackagePlus className="w-4 h-4 text-cyan-400" />
|
||||
<span className="hidden sm:inline">Achat Pièce</span>
|
||||
</button>
|
||||
|
||||
{/* Quick New Project */}
|
||||
<button
|
||||
onClick={() => setShowNewProjectModal(true)}
|
||||
className="flex items-center space-x-1.5 px-3 py-1.5 sm:px-4 sm:py-2 rounded-xl text-xs sm:text-sm font-bold bg-gradient-to-r from-pink-500 via-purple-600 to-pink-600 text-white shadow-lg shadow-pink-500/25 hover:shadow-pink-500/40 hover:scale-[1.02] active:scale-[0.98] transition-all duration-200"
|
||||
title="Créer une nouvelle console custom"
|
||||
>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
<span>Nouveau Projet</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Mobile Navigation Tabs */}
|
||||
<div className="lg:hidden flex items-center justify-around py-2 border-t border-slate-900 overflow-x-auto space-x-1">
|
||||
<button
|
||||
onClick={() => setCurrentTab('dashboard')}
|
||||
className={`flex flex-col items-center p-1.5 rounded-lg text-xs ${
|
||||
currentTab === 'dashboard' ? 'text-pink-400 font-bold' : 'text-slate-400'
|
||||
}`}
|
||||
>
|
||||
<LayoutDashboard className="w-4 h-4" />
|
||||
<span className="text-[10px] mt-0.5">Dashboard</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentTab('purchases')}
|
||||
className={`flex flex-col items-center p-1.5 rounded-lg text-xs relative ${
|
||||
currentTab === 'purchases' ? 'text-cyan-400 font-bold' : 'text-slate-400'
|
||||
}`}
|
||||
>
|
||||
<ShoppingCart className="w-4 h-4" />
|
||||
<span className="text-[10px] mt-0.5">Stock</span>
|
||||
{lowStockCount > 0 && (
|
||||
<span className="absolute top-0 right-1 w-2 h-2 rounded-full bg-amber-400" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentTab('projects')}
|
||||
className={`flex flex-col items-center p-1.5 rounded-lg text-xs relative ${
|
||||
currentTab === 'projects' ? 'text-purple-400 font-bold' : 'text-slate-400'
|
||||
}`}
|
||||
>
|
||||
<Layers className="w-4 h-4" />
|
||||
<span className="text-[10px] mt-0.5">Projets</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentTab('timetracker')}
|
||||
className={`flex flex-col items-center p-1.5 rounded-lg text-xs ${
|
||||
currentTab === 'timetracker' ? 'text-amber-400 font-bold' : 'text-slate-400'
|
||||
}`}
|
||||
>
|
||||
<Clock className="w-4 h-4" />
|
||||
<span className="text-[10px] mt-0.5">Chrono</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentTab('analytics')}
|
||||
className={`flex flex-col items-center p-1.5 rounded-lg text-xs ${
|
||||
currentTab === 'analytics' ? 'text-emerald-400 font-bold' : 'text-slate-400'
|
||||
}`}
|
||||
>
|
||||
<BarChart3 className="w-4 h-4" />
|
||||
<span className="text-[10px] mt-0.5">Marge</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentTab('settings')}
|
||||
className={`flex flex-col items-center p-1.5 rounded-lg text-xs ${
|
||||
currentTab === 'settings' ? 'text-pink-400 font-bold' : 'text-slate-400'
|
||||
}`}
|
||||
>
|
||||
<SettingsIcon className="w-4 h-4" />
|
||||
<span className="text-[10px] mt-0.5">Options</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
470
src/components/common/Sidebar.tsx
Normal file
470
src/components/common/Sidebar.tsx
Normal file
@@ -0,0 +1,470 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Gamepad2,
|
||||
LayoutDashboard,
|
||||
ShoppingCart,
|
||||
Layers,
|
||||
Clock,
|
||||
BarChart3,
|
||||
Settings as SettingsIcon,
|
||||
PackagePlus,
|
||||
Play,
|
||||
Pause,
|
||||
Sparkles,
|
||||
ChevronRight,
|
||||
Menu,
|
||||
X,
|
||||
ShieldCheck,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
import { useApp } from '../../context/AppContext';
|
||||
|
||||
interface NavItem {
|
||||
id: 'dashboard' | 'purchases' | 'projects' | 'mods' | 'timetracker' | 'analytics' | 'settings';
|
||||
label: string;
|
||||
subtitle: string;
|
||||
icon: React.ElementType;
|
||||
colorClass: {
|
||||
active: string;
|
||||
text: string;
|
||||
border: string;
|
||||
iconColor: string;
|
||||
bgHover: string;
|
||||
};
|
||||
}
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{
|
||||
id: 'dashboard',
|
||||
label: 'Tableau de Bord',
|
||||
subtitle: 'Vue globale & graphiques',
|
||||
icon: LayoutDashboard,
|
||||
colorClass: {
|
||||
active: 'bg-gradient-to-r from-pink-500/15 via-purple-500/10 to-transparent text-pink-400 border-pink-500/40 shadow-sm shadow-pink-500/10',
|
||||
text: 'text-pink-400',
|
||||
border: 'border-pink-500/40',
|
||||
iconColor: 'text-pink-400',
|
||||
bgHover: 'hover:bg-pink-500/5 hover:text-pink-300',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'purchases',
|
||||
label: 'Achats & Stocks',
|
||||
subtitle: 'Composants & consoles',
|
||||
icon: ShoppingCart,
|
||||
colorClass: {
|
||||
active: 'bg-gradient-to-r from-cyan-500/15 via-blue-500/10 to-transparent text-cyan-400 border-cyan-500/40 shadow-sm shadow-cyan-500/10',
|
||||
text: 'text-cyan-400',
|
||||
border: 'border-cyan-500/40',
|
||||
iconColor: 'text-cyan-400',
|
||||
bgHover: 'hover:bg-cyan-500/5 hover:text-cyan-300',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'projects',
|
||||
label: 'Projets & Dépenses',
|
||||
subtitle: 'Customisations & coûts',
|
||||
icon: Layers,
|
||||
colorClass: {
|
||||
active: 'bg-gradient-to-r from-purple-500/15 via-pink-500/10 to-transparent text-purple-400 border-purple-500/40 shadow-sm shadow-purple-500/10',
|
||||
text: 'text-purple-400',
|
||||
border: 'border-purple-500/40',
|
||||
iconColor: 'text-purple-400',
|
||||
bgHover: 'hover:bg-purple-500/5 hover:text-purple-300',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'mods',
|
||||
label: 'Mods & Recettes',
|
||||
subtitle: 'Kits & catalogue pièces',
|
||||
icon: Sparkles,
|
||||
colorClass: {
|
||||
active: 'bg-gradient-to-r from-rose-500/15 via-pink-500/10 to-transparent text-rose-400 border-rose-500/40 shadow-sm shadow-rose-500/10',
|
||||
text: 'text-rose-400',
|
||||
border: 'border-rose-500/40',
|
||||
iconColor: 'text-rose-400',
|
||||
bgHover: 'hover:bg-rose-500/5 hover:text-rose-300',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'timetracker',
|
||||
label: 'Chronomètre Établi',
|
||||
subtitle: 'Main d\'œuvre & sessions',
|
||||
icon: Clock,
|
||||
colorClass: {
|
||||
active: 'bg-gradient-to-r from-amber-500/15 via-orange-500/10 to-transparent text-amber-400 border-amber-500/40 shadow-sm shadow-amber-500/10',
|
||||
text: 'text-amber-400',
|
||||
border: 'border-amber-500/40',
|
||||
iconColor: 'text-amber-400',
|
||||
bgHover: 'hover:bg-amber-500/5 hover:text-amber-300',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'analytics',
|
||||
label: 'Rentabilité & Marge',
|
||||
subtitle: 'Simulateur & ROI',
|
||||
icon: BarChart3,
|
||||
colorClass: {
|
||||
active: 'bg-gradient-to-r from-emerald-500/15 via-teal-500/10 to-transparent text-emerald-400 border-emerald-500/40 shadow-sm shadow-emerald-500/10',
|
||||
text: 'text-emerald-400',
|
||||
border: 'border-emerald-500/40',
|
||||
iconColor: 'text-emerald-400',
|
||||
bgHover: 'hover:bg-emerald-500/5 hover:text-emerald-300',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
label: 'Paramètres & Données',
|
||||
subtitle: 'Taux horaire & exports',
|
||||
icon: SettingsIcon,
|
||||
colorClass: {
|
||||
active: 'bg-gradient-to-r from-slate-700/30 via-slate-800/20 to-transparent text-slate-200 border-slate-600 shadow-sm',
|
||||
text: 'text-slate-300',
|
||||
border: 'border-slate-600',
|
||||
iconColor: 'text-slate-300',
|
||||
bgHover: 'hover:bg-slate-800/40 hover:text-slate-200',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const Sidebar: React.FC = () => {
|
||||
const {
|
||||
currentTab,
|
||||
setCurrentTab,
|
||||
inventory,
|
||||
projects,
|
||||
modsLibrary,
|
||||
activeTimer,
|
||||
pauseTimer,
|
||||
resumeTimer,
|
||||
setShowNewProjectModal,
|
||||
setShowNewInventoryModal,
|
||||
settings,
|
||||
authSession,
|
||||
setShowAuthModal,
|
||||
} = useApp();
|
||||
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
|
||||
// Alerts
|
||||
const lowStockCount = inventory.filter(
|
||||
(i) => i.quantityInStock <= i.minStockAlert
|
||||
).length;
|
||||
|
||||
const activeProjectsCount = projects.filter(
|
||||
(p) => p.status === 'in_progress' || p.status === 'qc_testing'
|
||||
).length;
|
||||
|
||||
const readyToSellCount = projects.filter(
|
||||
(p) => p.status === 'in_stock_ready'
|
||||
).length;
|
||||
|
||||
// Active project title if stopwatch is running
|
||||
const activeTimerProject = projects.find((p) => p.id === activeTimer.projectId);
|
||||
|
||||
const formatTimerDigits = (totalSec: number) => {
|
||||
const hours = Math.floor(totalSec / 3600);
|
||||
const minutes = Math.floor((totalSec % 3600) / 60);
|
||||
const seconds = totalSec % 60;
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes.toString().padStart(2, '0')}m ${seconds.toString().padStart(2, '0')}s`;
|
||||
}
|
||||
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const handleNavClick = (tabId: NavItem['id']) => {
|
||||
setCurrentTab(tabId);
|
||||
setMobileOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile Top Bar (Visible only on small screens < lg) */}
|
||||
<div className="lg:hidden sticky top-0 z-40 flex items-center justify-between px-4 py-3 theme-header border-b border-slate-800/80 backdrop-blur-xl no-print">
|
||||
<div
|
||||
className="flex items-center space-x-3 cursor-pointer"
|
||||
onClick={() => handleNavClick('dashboard')}
|
||||
>
|
||||
<div className="flex items-center justify-center w-9 h-9 rounded-xl bg-gradient-to-tr from-pink-500 to-purple-600 p-[2px]">
|
||||
<div className="w-full h-full bg-slate-950 rounded-[10px] flex items-center justify-center">
|
||||
<Gamepad2 className="w-5 h-5 text-pink-400" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-black tracking-tight bg-gradient-to-r from-pink-400 to-purple-400 bg-clip-text text-transparent">
|
||||
{settings.logoText || 'uwuFinances'}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
{activeTimer.elapsedSeconds > 0 && (
|
||||
<button
|
||||
onClick={() => handleNavClick('timetracker')}
|
||||
className="flex items-center space-x-1 px-2.5 py-1 rounded-lg bg-slate-900 border border-amber-500/40 text-amber-300 text-xs font-mono"
|
||||
>
|
||||
<div className={`w-2 h-2 rounded-full ${activeTimer.isRunning ? 'bg-amber-400 animate-pulse' : 'bg-slate-500'}`} />
|
||||
<span>{Math.floor(activeTimer.elapsedSeconds / 60)}m</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setMobileOpen(!mobileOpen)}
|
||||
className="p-2 rounded-xl bg-slate-900 border border-slate-800 text-slate-300 hover:text-white"
|
||||
aria-label="Toggle Menu"
|
||||
>
|
||||
{mobileOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Backdrop for Mobile Overlay */}
|
||||
{mobileOpen && (
|
||||
<div
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-40 lg:hidden"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar Container */}
|
||||
<aside
|
||||
className={`fixed top-0 left-0 bottom-0 z-50 w-72 lg:w-72 theme-sidebar border-r border-slate-800 flex flex-col justify-between transition-transform duration-300 ease-in-out no-print ${
|
||||
mobileOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
|
||||
}`}
|
||||
>
|
||||
{/* Top Header & Branding */}
|
||||
<div className="p-5 border-b border-slate-850">
|
||||
<div
|
||||
className="flex items-center space-x-3.5 cursor-pointer group"
|
||||
onClick={() => handleNavClick('dashboard')}
|
||||
>
|
||||
<div className="relative flex items-center justify-center w-12 h-12 rounded-2xl bg-gradient-to-tr from-pink-500 via-purple-600 to-cyan-400 p-[2px] shadow-lg shadow-pink-500/20 group-hover:scale-105 transition-transform duration-300">
|
||||
<div className="w-full h-full bg-slate-950 rounded-[14px] flex items-center justify-center">
|
||||
<Gamepad2 className="w-6 h-6 text-pink-400 group-hover:rotate-12 transition-transform duration-300" />
|
||||
</div>
|
||||
<span className="absolute -top-1 -right-1 flex h-3.5 w-3.5">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-cyan-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-3.5 w-3.5 bg-cyan-500 border-2 border-slate-950"></span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center space-x-2">
|
||||
<h1 className="text-xl font-black tracking-tight bg-gradient-to-r from-pink-400 via-purple-300 to-cyan-400 bg-clip-text text-transparent font-display truncate">
|
||||
{settings.logoText || 'uwuFinances'}
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 truncate mt-0.5">
|
||||
{settings.businessName || 'Atelier Console Modding'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Action Buttons (Full width, spacious & clear) */}
|
||||
<div className="mt-5 space-y-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowNewProjectModal(true);
|
||||
setMobileOpen(false);
|
||||
}}
|
||||
className="w-full flex items-center justify-center space-x-2 px-3.5 py-2.5 rounded-xl font-bold text-sm bg-gradient-to-r from-pink-500 via-purple-600 to-pink-600 text-white shadow-lg shadow-pink-500/20 hover:shadow-pink-500/35 hover:scale-[1.02] active:scale-[0.98] transition-all duration-200"
|
||||
>
|
||||
<Sparkles className="w-4 h-4 text-pink-200" />
|
||||
<span>Nouveau Projet</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowNewInventoryModal(true);
|
||||
setMobileOpen(false);
|
||||
}}
|
||||
className="w-full flex items-center justify-center space-x-2 px-3.5 py-2 rounded-xl font-semibold text-xs bg-slate-900/90 text-cyan-300 border border-cyan-500/30 hover:bg-cyan-500/10 hover:border-cyan-500/50 transition-all duration-200"
|
||||
>
|
||||
<PackagePlus className="w-4 h-4 text-cyan-400" />
|
||||
<span>Achat Pièce / Stock</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation Items (Main List) */}
|
||||
<div className="flex-1 overflow-y-auto px-3.5 py-4 space-y-1.5 custom-scrollbar">
|
||||
<div className="px-3 pb-2 text-[10px] font-bold uppercase tracking-wider text-slate-500">
|
||||
Menu Principal
|
||||
</div>
|
||||
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = currentTab === item.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => handleNavClick(item.id)}
|
||||
className={`w-full flex items-center justify-between px-3.5 py-3 rounded-2xl text-left transition-all duration-200 group border ${
|
||||
isActive
|
||||
? `${item.colorClass.active} font-bold`
|
||||
: `text-slate-400 border-transparent hover:border-slate-800/80 hover:bg-slate-900/50 ${item.colorClass.bgHover}`
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center space-x-3.5 min-w-0">
|
||||
<div
|
||||
className={`p-2 rounded-xl transition-all ${
|
||||
isActive
|
||||
? 'bg-slate-900/80 shadow-inner'
|
||||
: 'bg-slate-900/40 group-hover:bg-slate-800/60'
|
||||
}`}
|
||||
>
|
||||
<Icon
|
||||
className={`w-5 h-5 transition-transform duration-200 group-hover:scale-110 ${
|
||||
isActive ? item.colorClass.iconColor : 'text-slate-400 group-hover:text-slate-200'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="truncate">
|
||||
<div
|
||||
className={`text-sm tracking-tight ${
|
||||
isActive ? 'text-white font-bold' : 'text-slate-300 font-medium group-hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</div>
|
||||
<div className="text-[11px] text-slate-500 truncate">
|
||||
{item.subtitle}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Badges / Indicators */}
|
||||
<div className="flex items-center space-x-1.5 ml-2 shrink-0">
|
||||
{item.id === 'mods' && (
|
||||
<span className="px-2 py-0.5 text-[10px] font-extrabold rounded-full bg-rose-500/20 text-rose-300 border border-rose-500/40">
|
||||
{modsLibrary.length}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{item.id === 'purchases' && lowStockCount > 0 && (
|
||||
<span className="px-2 py-0.5 text-[10px] font-extrabold rounded-full bg-amber-500/20 text-amber-300 border border-amber-500/40 flex items-center space-x-1">
|
||||
<AlertTriangle className="w-2.5 h-2.5" />
|
||||
<span>{lowStockCount}</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{item.id === 'projects' && (
|
||||
<>
|
||||
{readyToSellCount > 0 ? (
|
||||
<span className="px-2 py-0.5 text-[10px] font-extrabold rounded-full bg-emerald-500/20 text-emerald-300 border border-emerald-500/40">
|
||||
{readyToSellCount} prêt
|
||||
</span>
|
||||
) : activeProjectsCount > 0 ? (
|
||||
<span className="px-2 py-0.5 text-[10px] font-extrabold rounded-full bg-purple-500/20 text-purple-300 border border-purple-500/40">
|
||||
{activeProjectsCount} actif
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
{item.id === 'timetracker' && activeTimer.isRunning && (
|
||||
<span className="flex h-2.5 w-2.5 relative">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-emerald-500"></span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
<ChevronRight
|
||||
className={`w-4 h-4 transition-transform ${
|
||||
isActive ? 'text-white translate-x-0.5 opacity-100' : 'text-slate-600 opacity-0 group-hover:opacity-100'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Bottom Section: Live Stopwatch Card (if timer active) + Status */}
|
||||
<div className="p-3.5 border-t border-slate-850 space-y-2.5 bg-slate-950/60">
|
||||
{/* Live Stopwatch Card */}
|
||||
{activeTimer.elapsedSeconds > 0 && (
|
||||
<div
|
||||
onClick={() => handleNavClick('timetracker')}
|
||||
className="cursor-pointer p-3 rounded-2xl bg-gradient-to-br from-slate-900 to-slate-950 border border-amber-500/40 shadow-lg shadow-amber-500/5 hover:border-amber-400 transition-all group"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<div className="flex items-center space-x-1.5">
|
||||
<span className={`w-2 h-2 rounded-full ${activeTimer.isRunning ? 'bg-amber-400 animate-pulse' : 'bg-slate-500'}`} />
|
||||
<span className="text-[10px] uppercase font-bold tracking-wider text-amber-400">
|
||||
{activeTimer.isRunning ? 'Chrono en cours' : 'Chrono en pause'}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
activeTimer.isRunning ? pauseTimer() : resumeTimer();
|
||||
}}
|
||||
className="p-1 rounded-lg bg-amber-500/20 text-amber-300 hover:bg-amber-500/30 transition-all"
|
||||
title={activeTimer.isRunning ? 'Mettre en pause' : 'Reprendre'}
|
||||
>
|
||||
{activeTimer.isRunning ? <Pause className="w-3.5 h-3.5" /> : <Play className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="font-mono text-lg font-black text-amber-300 tracking-tight">
|
||||
{formatTimerDigits(activeTimer.elapsedSeconds)}
|
||||
</div>
|
||||
|
||||
<div className="text-[11px] text-slate-400 truncate mt-0.5">
|
||||
{activeTimerProject ? activeTimerProject.title : 'Session libre'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User Profile & JWT Session Card */}
|
||||
<div
|
||||
onClick={() => {
|
||||
setShowAuthModal(true);
|
||||
setMobileOpen(false);
|
||||
}}
|
||||
className="p-2.5 rounded-2xl bg-slate-900/60 border border-slate-800 hover:border-pink-500/40 cursor-pointer flex items-center justify-between transition-all group"
|
||||
>
|
||||
<div className="flex items-center space-x-2.5 min-w-0">
|
||||
<div className="relative shrink-0">
|
||||
<img
|
||||
src={authSession.user?.avatarUrl || 'https://api.dicebear.com/7.x/bottts/svg?seed=uwu'}
|
||||
alt={authSession.user?.name}
|
||||
className="w-8 h-8 rounded-xl bg-slate-800 object-cover border border-slate-700"
|
||||
/>
|
||||
{authSession.isAuthenticated && (
|
||||
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full bg-emerald-400 animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-bold text-xs text-white truncate group-hover:text-pink-300 transition-colors">
|
||||
{authSession.user?.name}
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-400 uppercase font-mono flex items-center space-x-1 truncate">
|
||||
<span>{authSession.user?.role}</span>
|
||||
<span>•</span>
|
||||
<span className="text-cyan-400">JWT Actif</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className="text-[10px] font-mono font-bold px-1.5 py-0.5 rounded bg-pink-500/10 text-pink-300 border border-pink-500/20 shrink-0">
|
||||
Session
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Local Security & Version Badge */}
|
||||
<div className="px-2 py-1 rounded-xl bg-slate-900/40 border border-slate-800 flex items-center justify-between text-[11px] text-slate-400">
|
||||
<div className="flex items-center space-x-1.5">
|
||||
<ShieldCheck className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>Base NCB EU</span>
|
||||
</div>
|
||||
<span className="text-[10px] font-mono text-slate-500">v1.2 JWT</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
};
|
||||
52
src/components/common/ToastContainer.tsx
Normal file
52
src/components/common/ToastContainer.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
import { CheckCircle2, AlertCircle, Info, AlertTriangle, X } from 'lucide-react';
|
||||
import { useApp } from '../../context/AppContext';
|
||||
|
||||
export const ToastContainer: React.FC = () => {
|
||||
const { toasts, removeToast } = useApp();
|
||||
|
||||
if (toasts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-5 right-5 z-50 flex flex-col space-y-3 max-w-sm w-full pointer-events-none no-print">
|
||||
{toasts.map((toast) => {
|
||||
let icon = <Info className="w-5 h-5 text-cyan-400 shrink-0" />;
|
||||
let borderColor = 'border-cyan-500/40 bg-slate-900/95';
|
||||
|
||||
if (toast.type === 'success') {
|
||||
icon = <CheckCircle2 className="w-5 h-5 text-emerald-400 shrink-0" />;
|
||||
borderColor = 'border-emerald-500/40 bg-slate-900/95';
|
||||
} else if (toast.type === 'error') {
|
||||
icon = <AlertCircle className="w-5 h-5 text-rose-400 shrink-0" />;
|
||||
borderColor = 'border-rose-500/40 bg-slate-900/95';
|
||||
} else if (toast.type === 'warning') {
|
||||
icon = <AlertTriangle className="w-5 h-5 text-amber-400 shrink-0" />;
|
||||
borderColor = 'border-amber-500/40 bg-slate-900/95';
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`pointer-events-auto flex items-start p-4 rounded-2xl border shadow-xl backdrop-blur-xl transition-all duration-300 transform translate-y-0 opacity-100 ${borderColor}`}
|
||||
>
|
||||
<div className="mr-3 mt-0.5">{icon}</div>
|
||||
<div className="flex-1 mr-2">
|
||||
<h4 className="text-sm font-bold text-white leading-tight">
|
||||
{toast.title}
|
||||
</h4>
|
||||
<p className="text-xs text-slate-300 mt-1 leading-relaxed">
|
||||
{toast.message}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeToast(toast.id)}
|
||||
className="text-slate-400 hover:text-white p-1 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
212
src/components/common/TopHeader.tsx
Normal file
212
src/components/common/TopHeader.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
ShoppingCart,
|
||||
Layers,
|
||||
Clock,
|
||||
BarChart3,
|
||||
Settings as SettingsIcon,
|
||||
Sparkles,
|
||||
PackagePlus,
|
||||
Coins,
|
||||
Cpu,
|
||||
Database,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import { useApp } from '../../context/AppContext';
|
||||
import { formatCurrency } from '../../utils/calculations';
|
||||
|
||||
export const TopHeader: React.FC = () => {
|
||||
const {
|
||||
currentTab,
|
||||
inventory,
|
||||
projects,
|
||||
dbSyncStatus,
|
||||
setShowNewProjectModal,
|
||||
setShowNewInventoryModal,
|
||||
authSession,
|
||||
setShowAuthModal,
|
||||
} = useApp();
|
||||
|
||||
// Quick topbar calculations
|
||||
const totalStockValuation = inventory.reduce(
|
||||
(acc, item) =>
|
||||
acc +
|
||||
item.quantityInStock *
|
||||
(item.unitPurchasePrice + (item.shippingCostShare || 0) + (item.taxImportShare || 0)),
|
||||
0
|
||||
);
|
||||
|
||||
const activeProjectsCount = projects.filter(
|
||||
(p) => p.status === 'in_progress' || p.status === 'qc_testing'
|
||||
).length;
|
||||
|
||||
const getPageInfo = () => {
|
||||
switch (currentTab) {
|
||||
case 'dashboard':
|
||||
return {
|
||||
title: 'Tableau de Bord & Finances',
|
||||
subtitle: 'Vue d\'ensemble des performances, graphiques et pipeline d\'atelier',
|
||||
icon: LayoutDashboard,
|
||||
iconColor: 'text-pink-400',
|
||||
};
|
||||
case 'purchases':
|
||||
return {
|
||||
title: 'Achats & Gestion des Stocks',
|
||||
subtitle: 'Consoles donneuses, écrans IPS, coques, composants et alertes de stock',
|
||||
icon: ShoppingCart,
|
||||
iconColor: 'text-cyan-400',
|
||||
};
|
||||
case 'projects':
|
||||
return {
|
||||
title: 'Centre de Dépenses Projets',
|
||||
subtitle: 'Nomenclature pièces (BOM), temps d\'atelier, coût de revient et certificats',
|
||||
icon: Layers,
|
||||
iconColor: 'text-purple-400',
|
||||
};
|
||||
case 'timetracker':
|
||||
return {
|
||||
title: 'Chronomètre d\'Atelier & Main d\'Œuvre',
|
||||
subtitle: 'Chronométrage des sessions de modding et valorisation horaire en direct',
|
||||
icon: Clock,
|
||||
iconColor: 'text-amber-400',
|
||||
};
|
||||
case 'analytics':
|
||||
return {
|
||||
title: 'Rentabilité & Simulateur de Prix',
|
||||
subtitle: 'Calculateur de marge nette, point mort (break-even) et analyse par console',
|
||||
icon: BarChart3,
|
||||
iconColor: 'text-emerald-400',
|
||||
};
|
||||
case 'settings':
|
||||
return {
|
||||
title: 'Paramètres & Sauvegardes',
|
||||
subtitle: 'Identité de l\'atelier, taux horaire par défaut, thèmes et connexion NoCodeBackend',
|
||||
icon: SettingsIcon,
|
||||
iconColor: 'text-slate-300',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
title: 'uwuFinances',
|
||||
subtitle: 'Console Modding Studio Hub',
|
||||
icon: LayoutDashboard,
|
||||
iconColor: 'text-pink-400',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const pageInfo = getPageInfo();
|
||||
const Icon = pageInfo.icon;
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-30 w-full theme-header backdrop-blur-xl border-b border-slate-800/80 px-4 sm:px-8 py-3.5 flex items-center justify-between no-print">
|
||||
{/* Page Title & Breadcrumb */}
|
||||
<div className="flex items-center space-x-3.5">
|
||||
<div className="p-2 rounded-xl bg-slate-900 border border-slate-800 hidden sm:flex items-center justify-center shadow-inner">
|
||||
<Icon className={`w-5 h-5 ${pageInfo.iconColor}`} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base sm:text-lg font-bold text-white tracking-tight flex items-center space-x-2">
|
||||
<span>{pageInfo.title}</span>
|
||||
</h2>
|
||||
<p className="text-xs text-slate-400 hidden md:block">
|
||||
{pageInfo.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Stats & Quick Actions */}
|
||||
<div className="flex items-center space-x-2 sm:space-x-3">
|
||||
{/* User Profile & JWT Button */}
|
||||
<button
|
||||
onClick={() => setShowAuthModal(true)}
|
||||
className={`flex items-center space-x-2 px-2.5 py-1.5 rounded-xl text-xs border transition-all cursor-pointer ${
|
||||
authSession.isAuthenticated
|
||||
? 'bg-slate-900 border-pink-500/30 hover:border-pink-500/60 text-slate-200'
|
||||
: 'bg-slate-900/60 border-slate-800 hover:border-slate-700 text-slate-400'
|
||||
}`}
|
||||
title="Gérer la session utilisateur, le jeton JWT et la liaison NCB"
|
||||
>
|
||||
<div className="relative">
|
||||
<img
|
||||
src={authSession.user?.avatarUrl || 'https://api.dicebear.com/7.x/bottts/svg?seed=uwu'}
|
||||
alt={authSession.user?.name}
|
||||
className="w-5 h-5 rounded-full bg-slate-800 object-cover border border-slate-700"
|
||||
/>
|
||||
{authSession.isAuthenticated && (
|
||||
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full bg-emerald-400 animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
<span className="font-semibold text-xs text-slate-200 hidden md:inline truncate max-w-[120px]">
|
||||
{authSession.user?.name?.split(' ')[0] || 'Utilisateur'}
|
||||
</span>
|
||||
<span className="px-1.5 py-0.2 rounded text-[9px] font-mono font-bold uppercase bg-pink-500/15 text-pink-300 hidden lg:inline border border-pink-500/30">
|
||||
JWT
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Cloud Database Status indicator */}
|
||||
<button
|
||||
onClick={() => setShowAuthModal(true)}
|
||||
className={`hidden md:flex items-center space-x-1.5 px-2.5 py-1.5 rounded-xl text-xs border transition-all cursor-pointer ${
|
||||
dbSyncStatus === 'connected'
|
||||
? 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30 hover:bg-emerald-500/20'
|
||||
: dbSyncStatus === 'syncing'
|
||||
? 'bg-amber-500/10 text-amber-400 border-amber-500/30'
|
||||
: 'bg-rose-500/10 text-rose-400 border-rose-500/30 hover:bg-rose-500/20'
|
||||
}`}
|
||||
title="Statut Cloud Database NoCodeBackend (54770_uwu_finances) - Cliquez pour gérer"
|
||||
>
|
||||
{dbSyncStatus === 'syncing' ? (
|
||||
<RefreshCw className="w-3 h-3 animate-spin text-amber-400" />
|
||||
) : (
|
||||
<span className={`w-2 h-2 rounded-full ${
|
||||
dbSyncStatus === 'connected' ? 'bg-emerald-400 animate-pulse' : 'bg-rose-400'
|
||||
}`} />
|
||||
)}
|
||||
<Database className="w-3 h-3 text-slate-400" />
|
||||
<span className="font-mono text-[11px] font-semibold">NCB EU</span>
|
||||
</button>
|
||||
|
||||
{/* Quick Inventory Stock Valuation badge */}
|
||||
<div className="hidden xl:flex items-center space-x-2 px-3 py-1.5 rounded-xl bg-slate-900/80 border border-slate-800 text-xs">
|
||||
<Coins className="w-3.5 h-3.5 text-cyan-400" />
|
||||
<span className="text-slate-400">Stock valorisé:</span>
|
||||
<span className="font-mono font-bold text-cyan-300">
|
||||
{formatCurrency(totalStockValuation)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Active Projects badge */}
|
||||
<div className="hidden xl:flex items-center space-x-2 px-3 py-1.5 rounded-xl bg-slate-900/80 border border-slate-800 text-xs">
|
||||
<Cpu className="w-3.5 h-3.5 text-purple-400" />
|
||||
<span className="text-slate-400">En cours:</span>
|
||||
<span className="font-mono font-bold text-purple-300">
|
||||
{activeProjectsCount} projet{activeProjectsCount > 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Quick Add Part */}
|
||||
<button
|
||||
onClick={() => setShowNewInventoryModal(true)}
|
||||
className="flex items-center space-x-1.5 px-3 py-1.5 sm:px-3.5 sm:py-2 rounded-xl text-xs sm:text-sm font-semibold bg-slate-900 text-cyan-300 border border-cyan-500/30 hover:bg-cyan-500/10 hover:border-cyan-500/60 shadow-sm transition-all duration-200"
|
||||
title="Ajouter un achat de pièces au stock"
|
||||
>
|
||||
<PackagePlus className="w-4 h-4 text-cyan-400" />
|
||||
<span className="hidden sm:inline">Achat Pièce</span>
|
||||
</button>
|
||||
|
||||
{/* Quick New Project */}
|
||||
<button
|
||||
onClick={() => setShowNewProjectModal(true)}
|
||||
className="flex items-center space-x-1.5 px-3 py-1.5 sm:px-3.5 sm:py-2 rounded-xl text-xs sm:text-sm font-bold bg-gradient-to-r from-pink-500 via-purple-600 to-pink-600 text-white shadow-lg shadow-pink-500/20 hover:shadow-pink-500/35 hover:scale-[1.02] active:scale-[0.98] transition-all duration-200"
|
||||
title="Créer une nouvelle console custom"
|
||||
>
|
||||
<Sparkles className="w-4 h-4 text-pink-200" />
|
||||
<span>Nouveau Projet</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
863
src/components/dashboard/DashboardView.tsx
Normal file
863
src/components/dashboard/DashboardView.tsx
Normal file
@@ -0,0 +1,863 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
TrendingUp,
|
||||
DollarSign,
|
||||
Package,
|
||||
Clock,
|
||||
AlertTriangle,
|
||||
Sparkles,
|
||||
Gamepad2,
|
||||
ArrowUpRight,
|
||||
ArrowRight,
|
||||
Plus,
|
||||
Play,
|
||||
CheckCircle2,
|
||||
Box,
|
||||
Layers,
|
||||
Wrench,
|
||||
Percent,
|
||||
PieChart as PieIcon,
|
||||
BarChart2,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
BarChart,
|
||||
Bar,
|
||||
Legend,
|
||||
} from 'recharts';
|
||||
import { useApp } from '../../context/AppContext';
|
||||
import {
|
||||
calculateProjectFinancials,
|
||||
calculateTotalStockValue,
|
||||
formatCurrency,
|
||||
formatMinutes,
|
||||
getProjectStatusInfo,
|
||||
} from '../../utils/calculations';
|
||||
|
||||
|
||||
|
||||
|
||||
export const DashboardView: React.FC = () => {
|
||||
const {
|
||||
projects,
|
||||
inventory,
|
||||
settings,
|
||||
setCurrentTab,
|
||||
setSelectedProjectId,
|
||||
setShowNewProjectModal,
|
||||
setShowNewInventoryModal,
|
||||
startTimer,
|
||||
} = useApp();
|
||||
|
||||
// 1. Calculate All Financial Metrics
|
||||
const soldProjects = projects.filter((p) => p.status === 'sold');
|
||||
const inStockReadyProjects = projects.filter((p) => p.status === 'in_stock_ready');
|
||||
const inProgressProjects = projects.filter(
|
||||
(p) => p.status === 'in_progress' || p.status === 'qc_testing'
|
||||
);
|
||||
|
||||
// Total Revenue from sold builds
|
||||
const totalRevenue = soldProjects.reduce((sum, p) => {
|
||||
return sum + (p.salePriceActual !== undefined ? p.salePriceActual : p.salePriceTarget || 0);
|
||||
}, 0);
|
||||
|
||||
// Total Hardware/Direct Costs for sold builds
|
||||
const totalSoldDirectCosts = soldProjects.reduce((sum, p) => {
|
||||
const fin = calculateProjectFinancials(p);
|
||||
return sum + fin.hardwareDirectCost + fin.shippingAndPlatformCost;
|
||||
}, 0);
|
||||
|
||||
// Total Net Profit for sold builds (CA - COGS)
|
||||
const totalNetProfit = soldProjects.reduce((sum, p) => {
|
||||
const fin = calculateProjectFinancials(p);
|
||||
return sum + fin.netProfit;
|
||||
}, 0);
|
||||
|
||||
// Total Gross Profit for sold builds (CA - Hardware)
|
||||
const totalGrossProfit = totalRevenue - totalSoldDirectCosts;
|
||||
|
||||
// Average Margin %
|
||||
const averageMarginPercent =
|
||||
soldProjects.length > 0
|
||||
? soldProjects.reduce((sum, p) => sum + calculateProjectFinancials(p).marginPercent, 0) /
|
||||
soldProjects.length
|
||||
: 0;
|
||||
|
||||
// Total Labor time logged across all projects
|
||||
const totalLaborMinutesAll = projects.reduce((sum, p) => {
|
||||
return sum + p.timeSessions.reduce((s, t) => s + t.durationMinutes, 0);
|
||||
}, 0);
|
||||
|
||||
// Total Labor time for sold builds only
|
||||
const totalLaborMinutesSold = soldProjects.reduce((sum, p) => {
|
||||
return sum + p.timeSessions.reduce((s, t) => s + t.durationMinutes, 0);
|
||||
}, 0);
|
||||
|
||||
// Effective Hourly Rate on sold builds: Total Net Before Labor / Hours
|
||||
const effectiveHourlyRateAverage =
|
||||
totalLaborMinutesSold > 0
|
||||
? totalGrossProfit / (totalLaborMinutesSold / 60)
|
||||
: 0;
|
||||
|
||||
// Total Inventory Value (Parts & Donor Consoles in stock)
|
||||
const totalStockValue = calculateTotalStockValue(inventory);
|
||||
|
||||
// Total potential value of finished consoles currently in stock ready to sell
|
||||
const potentialShowcaseRevenue = inStockReadyProjects.reduce(
|
||||
(sum, p) => sum + (p.salePriceTarget || 0),
|
||||
0
|
||||
);
|
||||
|
||||
// Count donor consoles in stock (category === 'console_base')
|
||||
const donorConsolesInStock = inventory
|
||||
.filter((i) => i.category === 'console_base')
|
||||
.reduce((sum, i) => sum + i.quantityInStock, 0);
|
||||
|
||||
// Low stock alerts
|
||||
const lowStockItems = inventory.filter(
|
||||
(item) => item.quantityInStock <= item.minStockAlert
|
||||
);
|
||||
|
||||
// Group projects by Console Model for breakdown
|
||||
const modelStats: Record<string, { count: number; soldCount: number; profit: number }> = {};
|
||||
projects.forEach((proj) => {
|
||||
if (!modelStats[proj.consoleModel]) {
|
||||
modelStats[proj.consoleModel] = { count: 0, soldCount: 0, profit: 0 };
|
||||
}
|
||||
modelStats[proj.consoleModel].count += 1;
|
||||
if (proj.status === 'sold') {
|
||||
modelStats[proj.consoleModel].soldCount += 1;
|
||||
modelStats[proj.consoleModel].profit += calculateProjectFinancials(proj).netProfit;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-8 pb-12 animate-fadeIn">
|
||||
|
||||
{/* Welcome Banner */}
|
||||
<div className="relative overflow-hidden rounded-3xl bg-gradient-to-r from-slate-900 via-purple-950/40 to-slate-900 border border-pink-500/20 p-6 sm:p-8 shadow-2xl">
|
||||
<div className="absolute top-0 right-0 -mr-16 -mt-16 w-64 h-64 bg-pink-500/10 rounded-full blur-3xl pointer-events-none" />
|
||||
<div className="absolute bottom-0 right-32 -mb-16 w-64 h-64 bg-cyan-500/10 rounded-full blur-3xl pointer-events-none" />
|
||||
|
||||
<div className="relative z-10 flex flex-col md:flex-row md:items-center justify-between gap-6">
|
||||
<div className="space-y-2">
|
||||
<div className="inline-flex items-center space-x-2 px-3 py-1 rounded-full text-xs font-semibold bg-pink-500/10 text-pink-400 border border-pink-500/30">
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
<span>Studio & Atelier de Customisation</span>
|
||||
</div>
|
||||
<h2 className="text-2xl sm:text-3xl font-black text-white font-display">
|
||||
Tableau de bord financier & Atelier
|
||||
</h2>
|
||||
<p className="text-slate-400 text-sm max-w-2xl leading-relaxed">
|
||||
Supervisez en direct vos achats de pièces, vos coûts de fabrication par console,
|
||||
le temps passé sur chaque mod et la rentabilité nette de votre activité.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 shrink-0">
|
||||
<button
|
||||
onClick={() => {
|
||||
startTimer();
|
||||
setCurrentTab('timetracker');
|
||||
}}
|
||||
className="flex items-center space-x-2 px-4 py-2.5 rounded-2xl bg-amber-500/15 text-amber-300 border border-amber-500/30 hover:bg-amber-500/25 hover:border-amber-500/50 transition-all font-semibold text-sm shadow-md"
|
||||
>
|
||||
<Play className="w-4 h-4 fill-amber-300 text-amber-300" />
|
||||
<span>Lancer Chrono Établi</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowNewProjectModal(true)}
|
||||
className="flex items-center space-x-2 px-4 py-2.5 rounded-2xl bg-gradient-to-r from-pink-500 to-purple-600 text-white font-bold text-sm shadow-lg shadow-pink-500/20 hover:scale-[1.02] active:scale-[0.98] transition-all"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>Nouvelle Console</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main KPI Cards Grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
|
||||
|
||||
{/* CA & Ventes */}
|
||||
<div className="glass-panel p-5 rounded-2xl border border-slate-800 hover:border-pink-500/40 transition-all group">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-slate-400">
|
||||
Chiffre d'Affaires Réalisé
|
||||
</span>
|
||||
<div className="w-10 h-10 rounded-xl bg-pink-500/10 flex items-center justify-center text-pink-400 group-hover:scale-110 transition-transform">
|
||||
<DollarSign className="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<div className="text-2xl sm:text-3xl font-black text-white font-mono">
|
||||
{formatCurrency(totalRevenue, settings.currency)}
|
||||
</div>
|
||||
<div className="flex items-center mt-2 text-xs text-slate-400">
|
||||
<span className="text-emerald-400 font-bold mr-1.5 flex items-center">
|
||||
<ArrowUpRight className="w-3.5 h-3.5 mr-0.5" />
|
||||
{soldProjects.length} vendue{soldProjects.length > 1 ? 's' : ''}
|
||||
</span>
|
||||
<span>sur {projects.length} projets totaux</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bénéfice Net Réalisé */}
|
||||
<div className="glass-panel p-5 rounded-2xl border border-slate-800 hover:border-emerald-500/40 transition-all group">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-slate-400">
|
||||
Bénéfice Net Réalisé
|
||||
</span>
|
||||
<div className="w-10 h-10 rounded-xl bg-emerald-500/10 flex items-center justify-center text-emerald-400 group-hover:scale-110 transition-transform">
|
||||
<TrendingUp className="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<div className="text-2xl sm:text-3xl font-black text-emerald-400 font-mono">
|
||||
{formatCurrency(totalNetProfit, settings.currency)}
|
||||
</div>
|
||||
<div className="flex items-center mt-2 text-xs text-slate-400">
|
||||
<span className="text-emerald-300 font-semibold mr-1">
|
||||
Marge moy. : {averageMarginPercent.toFixed(1)}%
|
||||
</span>
|
||||
<span>(après main d'œuvre)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Valeur Stock Pièces & Consoles */}
|
||||
<div className="glass-panel p-5 rounded-2xl border border-slate-800 hover:border-cyan-500/40 transition-all group cursor-pointer" onClick={() => setCurrentTab('purchases')}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-slate-400">
|
||||
Valeur Stock Pièces
|
||||
</span>
|
||||
<div className="w-10 h-10 rounded-xl bg-cyan-500/10 flex items-center justify-center text-cyan-400 group-hover:scale-110 transition-transform">
|
||||
<Package className="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<div className="text-2xl sm:text-3xl font-black text-cyan-300 font-mono">
|
||||
{formatCurrency(totalStockValue, settings.currency)}
|
||||
</div>
|
||||
<div className="flex items-center mt-2 text-xs text-slate-400">
|
||||
<span className="text-cyan-400 font-bold mr-1">
|
||||
{donorConsolesInStock} consoles brutes
|
||||
</span>
|
||||
<span>+ {inventory.length} réf. pièces</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Temps d'Atelier & Rémunération Réelle */}
|
||||
<div className="glass-panel p-5 rounded-2xl border border-slate-800 hover:border-amber-500/40 transition-all group cursor-pointer" onClick={() => setCurrentTab('timetracker')}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-slate-400">
|
||||
Temps Total d'Atelier
|
||||
</span>
|
||||
<div className="w-10 h-10 rounded-xl bg-amber-500/10 flex items-center justify-center text-amber-400 group-hover:scale-110 transition-transform">
|
||||
<Clock className="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<div className="text-2xl sm:text-3xl font-black text-amber-300 font-mono">
|
||||
{formatMinutes(totalLaborMinutesAll)}
|
||||
</div>
|
||||
<div className="flex items-center mt-2 text-xs text-slate-400">
|
||||
<span className="text-amber-400 font-bold mr-1">
|
||||
{effectiveHourlyRateAverage > 0 ? `${formatCurrency(effectiveHourlyRateAverage, settings.currency)}/h` : 'Taux configuré : ' + settings.defaultHourlyRate + '€/h'}
|
||||
</span>
|
||||
<span>taux effectif</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Consoles Status Tracker Bar */}
|
||||
<div className="glass-panel rounded-3xl p-6 border border-slate-800">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-white flex items-center space-x-2">
|
||||
<Gamepad2 className="w-5 h-5 text-pink-400" />
|
||||
<span>Parc & Pipeline de Consoles</span>
|
||||
</h3>
|
||||
<p className="text-xs text-slate-400 mt-0.5">
|
||||
Suivi du cycle de vie complet : des consoles brutes au stock prêt à vendre
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-xs text-slate-400 font-mono">
|
||||
Valeur vitrine en stock : <span className="text-emerald-400 font-bold">{formatCurrency(potentialShowcaseRevenue, settings.currency)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
<div
|
||||
onClick={() => setCurrentTab('purchases')}
|
||||
className="bg-slate-900/80 rounded-2xl p-4 border border-amber-500/20 hover:border-amber-500/60 hover:bg-slate-900 cursor-pointer transition-all group"
|
||||
title="Voir le stock de consoles brutes"
|
||||
>
|
||||
<div className="text-xs text-amber-400 font-semibold flex items-center group-hover:translate-x-0.5 transition-transform">
|
||||
<Box className="w-3.5 h-3.5 mr-1.5" /> Consoles Brutes
|
||||
</div>
|
||||
<div className="text-2xl font-black text-white font-mono mt-1">
|
||||
{donorConsolesInStock} <span className="text-xs font-normal text-slate-400">en stock</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-slate-400 mt-1">Prêtes à être moddées →</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
onClick={() => {
|
||||
setSelectedProjectId(null);
|
||||
setCurrentTab('projects');
|
||||
}}
|
||||
className="bg-slate-900/80 rounded-2xl p-4 border border-blue-500/20 hover:border-blue-500/60 hover:bg-slate-900 cursor-pointer transition-all group"
|
||||
title="Gérer les builds en cours d'atelier"
|
||||
>
|
||||
<div className="text-xs text-blue-400 font-semibold flex items-center group-hover:translate-x-0.5 transition-transform">
|
||||
<Wrench className="w-3.5 h-3.5 mr-1.5" /> En cours d'Atelier
|
||||
</div>
|
||||
<div className="text-2xl font-black text-white font-mono mt-1">
|
||||
{inProgressProjects.length} <span className="text-xs font-normal text-slate-400">builds</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-slate-400 mt-1">Soudure & Assemblage →</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
onClick={() => {
|
||||
setSelectedProjectId(null);
|
||||
setCurrentTab('projects');
|
||||
}}
|
||||
className="bg-slate-900/80 rounded-2xl p-4 border border-emerald-500/20 hover:border-emerald-500/60 hover:bg-slate-900 cursor-pointer transition-all group"
|
||||
title="Voir les consoles finies en stock prêtes à vendre"
|
||||
>
|
||||
<div className="text-xs text-emerald-400 font-semibold flex items-center group-hover:translate-x-0.5 transition-transform">
|
||||
<Sparkles className="w-3.5 h-3.5 mr-1.5" /> En Vitrine / Prêtes
|
||||
</div>
|
||||
<div className="text-2xl font-black text-emerald-400 font-mono mt-1">
|
||||
{inStockReadyProjects.length} <span className="text-xs font-normal text-slate-400">consoles</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-slate-400 mt-1">Prêtes pour la vente →</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
onClick={() => {
|
||||
setSelectedProjectId(null);
|
||||
setCurrentTab('projects');
|
||||
}}
|
||||
className="bg-slate-900/80 rounded-2xl p-4 border border-pink-500/20 hover:border-pink-500/60 hover:bg-slate-900 cursor-pointer transition-all group"
|
||||
title="Voir l'historique des consoles vendues"
|
||||
>
|
||||
<div className="text-xs text-pink-400 font-semibold flex items-center group-hover:translate-x-0.5 transition-transform">
|
||||
<CheckCircle2 className="w-3.5 h-3.5 mr-1.5" /> Consoles Vendues
|
||||
</div>
|
||||
<div className="text-2xl font-black text-pink-400 font-mono mt-1">
|
||||
{soldProjects.length} <span className="text-xs font-normal text-slate-400">livrées</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-slate-400 mt-1">Clients comblés 🎉 →</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Interactive Charts Section 1: Financial Area Trend & Cost Donut */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
|
||||
{/* Left: Financial Performance Area Chart */}
|
||||
<div className="lg:col-span-2 glass-panel p-6 rounded-3xl border border-slate-800 space-y-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-white flex items-center space-x-2">
|
||||
<BarChart2 className="w-5 h-5 text-pink-400" />
|
||||
<span>Performance Financière par Console</span>
|
||||
</h3>
|
||||
<p className="text-xs text-slate-400">
|
||||
Comparaison Chiffre d'Affaires, Coût Total de Revient et Bénéfice Net
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3 text-xs font-mono">
|
||||
<span className="flex items-center text-pink-400">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-pink-500 mr-1.5" /> Prix Vente
|
||||
</span>
|
||||
<span className="flex items-center text-cyan-400">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-cyan-500 mr-1.5" /> Coûts (COGS)
|
||||
</span>
|
||||
<span className="flex items-center text-emerald-400">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-emerald-500 mr-1.5" /> Bénéfice
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-72 w-full pt-2">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart
|
||||
data={projects.map((p) => {
|
||||
const f = calculateProjectFinancials(p);
|
||||
return {
|
||||
name: p.serialNumber,
|
||||
title: p.title,
|
||||
revenue: f.revenue,
|
||||
cogs: f.totalCostOfGoodsSold,
|
||||
netProfit: f.netProfit,
|
||||
hardwareCost: f.hardwareDirectCost,
|
||||
laborCost: f.laborCost,
|
||||
};
|
||||
})}
|
||||
margin={{ top: 10, right: 10, left: -20, bottom: 0 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="colorRevenue" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#ec4899" stopOpacity={0.4} />
|
||||
<stop offset="95%" stopColor="#ec4899" stopOpacity={0.0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="colorCogs" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#06b6d4" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#06b6d4" stopOpacity={0.0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="colorProfit" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#10b981" stopOpacity={0.4} />
|
||||
<stop offset="95%" stopColor="#10b981" stopOpacity={0.0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
stroke="#64748b"
|
||||
fontSize={11}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: '#334155' }}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="#64748b"
|
||||
fontSize={11}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: '#334155' }}
|
||||
tickFormatter={(val) => `${val}€`}
|
||||
/>
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload;
|
||||
return (
|
||||
<div className="bg-slate-900 border border-slate-700 p-3 rounded-2xl shadow-xl text-xs space-y-1.5 font-mono">
|
||||
<div className="font-bold text-white font-sans text-xs pb-1 border-b border-slate-800">
|
||||
{data.title} ({data.name})
|
||||
</div>
|
||||
<div className="flex justify-between space-x-4 text-pink-400">
|
||||
<span>Prix Vente :</span>
|
||||
<span className="font-bold">{formatCurrency(data.revenue, settings.currency)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between space-x-4 text-cyan-300">
|
||||
<span>Coût Revient :</span>
|
||||
<span>{formatCurrency(data.cogs, settings.currency)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between space-x-4 text-slate-400 text-[10px]">
|
||||
<span>↳ Matériel : {formatCurrency(data.hardwareCost, settings.currency)}</span>
|
||||
<span>↳ Mo : {formatCurrency(data.laborCost, settings.currency)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between space-x-4 text-emerald-400 font-bold pt-1 border-t border-slate-800">
|
||||
<span>Bénéfice Net :</span>
|
||||
<span>{formatCurrency(data.netProfit, settings.currency)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="revenue"
|
||||
stroke="#ec4899"
|
||||
strokeWidth={2.5}
|
||||
fillOpacity={1}
|
||||
fill="url(#colorRevenue)"
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="cogs"
|
||||
stroke="#06b6d4"
|
||||
strokeWidth={2}
|
||||
fillOpacity={1}
|
||||
fill="url(#colorCogs)"
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="netProfit"
|
||||
stroke="#10b981"
|
||||
strokeWidth={2.5}
|
||||
fillOpacity={1}
|
||||
fill="url(#colorProfit)"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Cost Structure Donut Chart */}
|
||||
<div className="glass-panel p-6 rounded-3xl border border-slate-800 space-y-4 flex flex-col justify-between">
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-white flex items-center space-x-2">
|
||||
<PieIcon className="w-5 h-5 text-purple-400" />
|
||||
<span>Répartition des Dépenses</span>
|
||||
</h3>
|
||||
<p className="text-xs text-slate-400">
|
||||
Coûts engagés sur l'ensemble de vos builds
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
let screens = 0;
|
||||
let consoles = 0;
|
||||
let shells = 0;
|
||||
let power = 0;
|
||||
let audio = 0;
|
||||
let labor = 0;
|
||||
let fees = 0;
|
||||
|
||||
projects.forEach((p) => {
|
||||
const fin = calculateProjectFinancials(p);
|
||||
labor += fin.laborCost;
|
||||
fees += fin.consumablesAndPackagingCost + fin.shippingAndPlatformCost;
|
||||
p.parts.forEach((pt) => {
|
||||
if (pt.category === 'screen') screens += pt.totalCost;
|
||||
else if (pt.category === 'console_base') consoles += pt.totalCost;
|
||||
else if (pt.category === 'shell' || pt.category === 'buttons_pads') shells += pt.totalCost;
|
||||
else if (pt.category === 'battery_power') power += pt.totalCost;
|
||||
else if (pt.category === 'sound_amp') audio += pt.totalCost;
|
||||
else fees += pt.totalCost;
|
||||
});
|
||||
});
|
||||
|
||||
const pieData = [
|
||||
{ name: 'Écrans IPS/OLED', value: Math.round(screens), color: '#06b6d4' },
|
||||
{ name: 'Consoles Donneuses', value: Math.round(consoles), color: '#f59e0b' },
|
||||
{ name: 'Coques & Boutons', value: Math.round(shells), color: '#a855f7' },
|
||||
{ name: 'Main d\'œuvre', value: Math.round(labor), color: '#ec4899' },
|
||||
{ name: 'Batteries & USB-C', value: Math.round(power), color: '#10b981' },
|
||||
{ name: 'Audio & Amplis', value: Math.round(audio), color: '#3b82f6' },
|
||||
{ name: 'Frais & Consommables', value: Math.round(fees), color: '#64748b' },
|
||||
].filter((d) => d.value > 0);
|
||||
|
||||
const totalPieCost = pieData.reduce((s, d) => s + d.value, 0);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="h-48 w-full relative flex items-center justify-center">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={pieData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={55}
|
||||
outerRadius={80}
|
||||
paddingAngle={3}
|
||||
dataKey="value"
|
||||
>
|
||||
{pieData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0];
|
||||
const percent = totalPieCost > 0 ? ((Number(data.value) / totalPieCost) * 100).toFixed(1) : 0;
|
||||
return (
|
||||
<div className="bg-slate-900 border border-slate-700 p-2.5 rounded-xl shadow-xl text-xs font-mono">
|
||||
<div className="font-bold text-white">{data.name}</div>
|
||||
<div className="text-cyan-400">{formatCurrency(Number(data.value), settings.currency)} ({percent}%)</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
|
||||
<span className="text-[10px] text-slate-400 uppercase font-bold tracking-wider">Total</span>
|
||||
<span className="text-base font-black text-white font-mono">{formatCurrency(totalPieCost, settings.currency)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-1.5 text-[11px] pt-2 border-t border-slate-800">
|
||||
{pieData.slice(0, 6).map((item) => (
|
||||
<div key={item.name} className="flex items-center space-x-1.5 truncate">
|
||||
<span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: item.color }} />
|
||||
<span className="text-slate-300 truncate">{item.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Interactive Charts Section 2: Bar Chart Breakdown by Console Model */}
|
||||
<div className="glass-panel p-6 rounded-3xl border border-slate-800 space-y-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-white flex items-center space-x-2">
|
||||
<Gamepad2 className="w-5 h-5 text-cyan-400" />
|
||||
<span>Volume & Statut de Production par Modèle</span>
|
||||
</h3>
|
||||
<p className="text-xs text-slate-400">
|
||||
Répartition des consoles en cours, en vitrine et déjà vendues
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-60 w-full pt-2">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={Object.entries(modelStats).map(([model, data]) => {
|
||||
const inProgress = projects.filter((p) => p.consoleModel === model && (p.status === 'in_progress' || p.status === 'qc_testing')).length;
|
||||
const ready = projects.filter((p) => p.consoleModel === model && p.status === 'in_stock_ready').length;
|
||||
const sold = projects.filter((p) => p.consoleModel === model && p.status === 'sold').length;
|
||||
return {
|
||||
model,
|
||||
'En cours': inProgress,
|
||||
'En Vitrine': ready,
|
||||
'Vendues': sold,
|
||||
totalProfit: data.profit,
|
||||
};
|
||||
})}
|
||||
margin={{ top: 10, right: 10, left: -20, bottom: 0 }}
|
||||
>
|
||||
<XAxis dataKey="model" stroke="#64748b" fontSize={11} tickLine={false} axisLine={{ stroke: '#334155' }} />
|
||||
<YAxis stroke="#64748b" fontSize={11} tickLine={false} axisLine={{ stroke: '#334155' }} allowDecimals={false} />
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const d = payload[0].payload;
|
||||
return (
|
||||
<div className="bg-slate-900 border border-slate-700 p-3 rounded-2xl shadow-xl text-xs space-y-1 font-mono">
|
||||
<div className="font-bold text-white font-sans text-xs pb-1 border-b border-slate-800">{d.model}</div>
|
||||
<div className="text-blue-400">⚡ En cours : {d['En cours']} build(s)</div>
|
||||
<div className="text-emerald-400">✨ En vitrine : {d['En Vitrine']} console(s)</div>
|
||||
<div className="text-pink-400">🎉 Vendues : {d['Vendues']} console(s)</div>
|
||||
{d.totalProfit > 0 && (
|
||||
<div className="text-amber-300 font-bold pt-1 border-t border-slate-800">
|
||||
Gain Net Total : {formatCurrency(d.totalProfit, settings.currency)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: '11px', paddingTop: '8px' }} />
|
||||
<Bar dataKey="En cours" fill="#3b82f6" radius={[4, 4, 0, 0]} />
|
||||
<Bar dataKey="En Vitrine" fill="#10b981" radius={[4, 4, 0, 0]} />
|
||||
<Bar dataKey="Vendues" fill="#ec4899" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{/* Two Columns: Recent/Active Projects & Stock Alerts */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
|
||||
{/* Left 2 Cols: Projects & Custom Builds Showcase */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-white flex items-center space-x-2">
|
||||
<Layers className="w-5 h-5 text-purple-400" />
|
||||
<span>Consoles Récents & Projets Actifs</span>
|
||||
</h3>
|
||||
<p className="text-xs text-slate-400">
|
||||
Accédez aux fiches de dépenses, temps et rentabilité par console
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setCurrentTab('projects')}
|
||||
className="text-xs font-semibold text-pink-400 hover:text-pink-300 flex items-center space-x-1"
|
||||
>
|
||||
<span>Voir tout ({projects.length})</span>
|
||||
<ArrowRight className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3.5">
|
||||
{projects.slice(0, 4).map((project) => {
|
||||
const fin = calculateProjectFinancials(project);
|
||||
const statusInfo = getProjectStatusInfo(project.status);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={project.id}
|
||||
onClick={() => {
|
||||
setSelectedProjectId(project.id);
|
||||
setCurrentTab('projects');
|
||||
}}
|
||||
className="glass-panel p-4 sm:p-5 rounded-2xl border border-slate-800/80 hover:border-purple-500/40 cursor-pointer transition-all duration-200 group"
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div className="flex items-start space-x-3.5">
|
||||
<div className="w-12 h-12 rounded-xl bg-slate-900 border border-slate-800 flex items-center justify-center shrink-0 text-pink-400 group-hover:scale-105 transition-transform">
|
||||
<Gamepad2 className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<h4 className="text-sm font-bold text-white group-hover:text-pink-300 transition-colors">
|
||||
{project.title}
|
||||
</h4>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 mt-1">
|
||||
<span className="text-[11px] font-mono text-slate-400">
|
||||
{project.serialNumber}
|
||||
</span>
|
||||
<span className="text-slate-600">•</span>
|
||||
<span className="text-[11px] text-cyan-400 font-semibold">
|
||||
{project.consoleModel}
|
||||
</span>
|
||||
<span className="text-slate-600">•</span>
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold ${statusInfo.bg} ${statusInfo.text}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${statusInfo.dot} mr-1`} />
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between sm:justify-end sm:space-x-6 border-t sm:border-t-0 pt-2 sm:pt-0 border-slate-800/80">
|
||||
<div className="text-left sm:text-right">
|
||||
<div className="text-xs text-slate-400">Prix Vente</div>
|
||||
<div className="text-sm font-bold text-white font-mono">
|
||||
{formatCurrency(fin.revenue, settings.currency)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-left sm:text-right">
|
||||
<div className="text-xs text-slate-400">Bénéfice Net</div>
|
||||
<div className={`text-sm font-bold font-mono ${fin.netProfit >= 0 ? 'text-emerald-400' : 'text-rose-400'}`}>
|
||||
{formatCurrency(fin.netProfit, settings.currency)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-left sm:text-right hidden md:block">
|
||||
<div className="text-xs text-slate-400">Temps</div>
|
||||
<div className="text-xs font-semibold text-amber-300 font-mono">
|
||||
{formatMinutes(fin.laborMinutes)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ArrowRight className="w-4 h-4 text-slate-500 group-hover:text-white group-hover:translate-x-1 transition-all" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right 1 Col: Stock Alerts & Inventory Quick-Look */}
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-bold text-white flex items-center space-x-2">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-400" />
|
||||
<span>Alertes & Stock Pièces</span>
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setCurrentTab('purchases')}
|
||||
className="text-xs font-semibold text-cyan-400 hover:text-cyan-300"
|
||||
>
|
||||
Gérer le stock
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="glass-panel p-5 rounded-3xl border border-slate-800 space-y-4">
|
||||
{lowStockItems.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<div className="p-3 rounded-2xl bg-amber-500/10 border border-amber-500/30 text-amber-300 text-xs flex items-start space-x-2">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5 text-amber-400" />
|
||||
<div>
|
||||
<span className="font-bold">{lowStockItems.length} article(s)</span> en stock critique. Pensez à réapprovisionner pour ne pas bloquer vos futurs builds.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-slate-800/80">
|
||||
{lowStockItems.slice(0, 4).map((item) => (
|
||||
<div key={item.id} className="py-2.5 flex items-center justify-between text-xs">
|
||||
<div className="pr-2">
|
||||
<div className="font-semibold text-slate-200 truncate max-w-[170px]">
|
||||
{item.name}
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-400">
|
||||
{item.consoleModel} • Fournisseur: {item.supplier}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<span className="px-2 py-0.5 rounded-full font-bold bg-rose-500/15 text-rose-300 border border-rose-500/30 font-mono">
|
||||
{item.quantityInStock} dispo
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-6 text-center text-slate-400 space-y-2">
|
||||
<CheckCircle2 className="w-8 h-8 text-emerald-400 mx-auto" />
|
||||
<div className="text-xs font-semibold text-slate-300">
|
||||
Tous les stocks sont au vert !
|
||||
</div>
|
||||
<div className="text-[11px]">
|
||||
Aucun composant en dessous du seuil d'alerte.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setShowNewInventoryModal(true)}
|
||||
className="w-full py-2.5 rounded-xl bg-slate-900 border border-cyan-500/30 text-cyan-300 hover:bg-cyan-500/10 transition-all text-xs font-bold flex items-center justify-center space-x-1.5"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Enregistrer un nouvel achat de pièces</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Console Breakdown Summary */}
|
||||
<div className="glass-panel p-5 rounded-3xl border border-slate-800 space-y-3">
|
||||
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-400 flex items-center space-x-1.5">
|
||||
<Percent className="w-4 h-4 text-pink-400" />
|
||||
<span>Répartition par Modèle</span>
|
||||
</h4>
|
||||
<div className="space-y-2 text-xs">
|
||||
{Object.entries(modelStats).map(([model, data]) => (
|
||||
<div key={model} className="flex items-center justify-between py-1 border-b border-slate-900/60 last:border-0">
|
||||
<span className="text-slate-300 font-medium">{model}</span>
|
||||
<div className="flex items-center space-x-2 font-mono">
|
||||
<span className="text-slate-400">{data.count} projets</span>
|
||||
{data.profit > 0 && (
|
||||
<span className="text-emerald-400 font-bold">
|
||||
+{formatCurrency(data.profit, settings.currency)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
383
src/components/inventory/InventoryModal.tsx
Normal file
383
src/components/inventory/InventoryModal.tsx
Normal file
@@ -0,0 +1,383 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, Package, Calculator, MapPin, Tag, Building2, Calendar, FileText } from 'lucide-react';
|
||||
import { useApp } from '../../context/AppContext';
|
||||
import type { InventoryItem, ConsoleModel, PartCategory } from '../../types';
|
||||
|
||||
import { formatCurrency } from '../../utils/calculations';
|
||||
|
||||
interface InventoryModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
editingItem?: InventoryItem | null;
|
||||
}
|
||||
|
||||
export const InventoryModal: React.FC<InventoryModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
editingItem,
|
||||
}) => {
|
||||
const { addInventoryItem, updateInventoryItem, settings, consoleModels, partCategories } = useApp();
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [sku, setSku] = useState('');
|
||||
const [category, setCategory] = useState<PartCategory>('screen');
|
||||
const [consoleModel, setConsoleModel] = useState<ConsoleModel>('GBA SP');
|
||||
const [supplier, setSupplier] = useState('FunnyPlaying');
|
||||
const [orderDate, setOrderDate] = useState(new Date().toISOString().split('T')[0]);
|
||||
const [unitPurchasePrice, setUnitPurchasePrice] = useState<number>(35);
|
||||
const [shippingCostShare, setShippingCostShare] = useState<number>(2);
|
||||
const [taxImportShare, setTaxImportShare] = useState<number>(0);
|
||||
const [quantityInStock, setQuantityInStock] = useState<number>(3);
|
||||
const [minStockAlert, setMinStockAlert] = useState<number>(1);
|
||||
const [locationInWorkshop, setLocationInWorkshop] = useState('Tiroir B1');
|
||||
const [notes, setNotes] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (editingItem) {
|
||||
setName(editingItem.name);
|
||||
setSku(editingItem.sku);
|
||||
setCategory(editingItem.category);
|
||||
setConsoleModel(editingItem.consoleModel);
|
||||
setSupplier(editingItem.supplier);
|
||||
setOrderDate(editingItem.orderDate);
|
||||
setUnitPurchasePrice(editingItem.unitPurchasePrice);
|
||||
setShippingCostShare(editingItem.shippingCostShare || 0);
|
||||
setTaxImportShare(editingItem.taxImportShare || 0);
|
||||
setQuantityInStock(editingItem.quantityInStock);
|
||||
setMinStockAlert(editingItem.minStockAlert || 1);
|
||||
setLocationInWorkshop(editingItem.locationInWorkshop || '');
|
||||
setNotes(editingItem.notes || '');
|
||||
} else {
|
||||
// Reset form
|
||||
setName('');
|
||||
setSku('SKU-' + Math.floor(1000 + Math.random() * 9000));
|
||||
setCategory('screen');
|
||||
setConsoleModel('GBA SP');
|
||||
setSupplier('AliExpress / Fabricant');
|
||||
setOrderDate(new Date().toISOString().split('T')[0]);
|
||||
setUnitPurchasePrice(25);
|
||||
setShippingCostShare(1.5);
|
||||
setTaxImportShare(0);
|
||||
setQuantityInStock(2);
|
||||
setMinStockAlert(1);
|
||||
setLocationInWorkshop('');
|
||||
setNotes('');
|
||||
}
|
||||
}, [editingItem, isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
// Calcul du coût unitaire réel avec frais
|
||||
const calculatedRealUnitCost =
|
||||
Number(unitPurchasePrice || 0) +
|
||||
Number(shippingCostShare || 0) +
|
||||
Number(taxImportShare || 0);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) return;
|
||||
|
||||
const itemData = {
|
||||
name: name.trim(),
|
||||
sku: sku.trim() || 'SKU-' + Date.now().toString().slice(-4),
|
||||
category,
|
||||
consoleModel,
|
||||
supplier: supplier.trim() || 'Inconnu',
|
||||
orderDate,
|
||||
unitPurchasePrice: Number(unitPurchasePrice) || 0,
|
||||
shippingCostShare: Number(shippingCostShare) || 0,
|
||||
taxImportShare: Number(taxImportShare) || 0,
|
||||
unitCostWithFees: calculatedRealUnitCost,
|
||||
quantityInStock: Number(quantityInStock) || 0,
|
||||
quantityReserved: editingItem?.quantityReserved || 0,
|
||||
minStockAlert: Number(minStockAlert) || 1,
|
||||
status: (Number(quantityInStock) <= 0
|
||||
? 'out_of_stock'
|
||||
: Number(quantityInStock) <= Number(minStockAlert)
|
||||
? 'low_stock'
|
||||
: 'in_stock') as 'in_stock' | 'low_stock' | 'out_of_stock',
|
||||
locationInWorkshop: locationInWorkshop.trim(),
|
||||
notes: notes.trim(),
|
||||
};
|
||||
|
||||
if (editingItem) {
|
||||
updateInventoryItem(editingItem.id, itemData);
|
||||
} else {
|
||||
addInventoryItem(itemData);
|
||||
}
|
||||
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md overflow-y-auto no-print">
|
||||
<div className="relative w-full max-w-2xl bg-slate-900 border border-slate-700/80 rounded-3xl shadow-2xl overflow-hidden my-8">
|
||||
|
||||
{/* Modal Header */}
|
||||
<div className="flex items-center justify-between px-6 py-5 border-b border-slate-800 bg-slate-950/50">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-cyan-500/10 border border-cyan-500/30 flex items-center justify-center text-cyan-400">
|
||||
<Package className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-white">
|
||||
{editingItem ? 'Modifier la pièce / stock' : 'Enregistrer un Achat de Pièces'}
|
||||
</h3>
|
||||
<p className="text-xs text-slate-400">
|
||||
Centre d'achat & approvisionnement pour l'atelier
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-slate-400 hover:text-white p-2 rounded-xl hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form Body */}
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
||||
|
||||
{/* Row 1: Designation & SKU */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="md:col-span-2 space-y-1.5">
|
||||
<label className="text-xs font-semibold text-slate-300">
|
||||
Nom / Désignation de la pièce *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Ex: Écran IPS V3 Laminé OSD, Coque Atomic Purple..."
|
||||
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-700 text-white placeholder-slate-500 focus:outline-none focus:border-cyan-500 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-semibold text-slate-300 flex items-center space-x-1">
|
||||
<Tag className="w-3.5 h-3.5 text-cyan-400" />
|
||||
<span>Référence / SKU</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={sku}
|
||||
onChange={(e) => setSku(e.target.value)}
|
||||
placeholder="Ex: SCR-GBA-01"
|
||||
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-700 text-white placeholder-slate-500 focus:outline-none focus:border-cyan-500 text-sm font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Console Model & Category */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-semibold text-slate-300">
|
||||
Modèle de Console compatible *
|
||||
</label>
|
||||
<select
|
||||
value={consoleModel}
|
||||
onChange={(e) => setConsoleModel(e.target.value as ConsoleModel)}
|
||||
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-700 text-white focus:outline-none focus:border-cyan-500 text-sm"
|
||||
>
|
||||
{consoleModels.map((m) => (
|
||||
<option key={m.id} value={m.slug}>
|
||||
{m.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-semibold text-slate-300">
|
||||
Catégorie de Composant *
|
||||
</label>
|
||||
<select
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value as PartCategory)}
|
||||
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-700 text-white focus:outline-none focus:border-cyan-500 text-sm"
|
||||
>
|
||||
{partCategories.map((c) => (
|
||||
<option key={c.id} value={c.slug}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 3: Supplier & Date */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-semibold text-slate-300 flex items-center space-x-1">
|
||||
<Building2 className="w-3.5 h-3.5 text-cyan-400" />
|
||||
<span>Fournisseur / Provenance</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={supplier}
|
||||
onChange={(e) => setSupplier(e.target.value)}
|
||||
placeholder="Ex: FunnyPlaying, AliExpress, Vinted, Brocante..."
|
||||
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-700 text-white placeholder-slate-500 focus:outline-none focus:border-cyan-500 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-semibold text-slate-300 flex items-center space-x-1">
|
||||
<Calendar className="w-3.5 h-3.5 text-cyan-400" />
|
||||
<span>Date d'achat / Commande</span>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={orderDate}
|
||||
onChange={(e) => setOrderDate(e.target.value)}
|
||||
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-700 text-white focus:outline-none focus:border-cyan-500 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pricing & Fees Breakdown Box */}
|
||||
<div className="p-4 rounded-2xl bg-slate-950/70 border border-cyan-500/30 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-cyan-400 flex items-center space-x-1.5">
|
||||
<Calculator className="w-4 h-4" />
|
||||
<span>Calcul du Coût d'Achat Réel Unitaire</span>
|
||||
</span>
|
||||
<span className="text-xs text-slate-400">
|
||||
Coût pondéré avec frais
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] text-slate-300">
|
||||
Prix d'achat unitaire ({settings.currency})
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={unitPurchasePrice}
|
||||
onChange={(e) => setUnitPurchasePrice(parseFloat(e.target.value) || 0)}
|
||||
className="w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 text-white text-sm font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] text-slate-300">
|
||||
Part Frais de Port ({settings.currency})
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={shippingCostShare}
|
||||
onChange={(e) => setShippingCostShare(parseFloat(e.target.value) || 0)}
|
||||
className="w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 text-white text-sm font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] text-slate-300">
|
||||
Part Douane / Taxes ({settings.currency})
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={taxImportShare}
|
||||
onChange={(e) => setTaxImportShare(parseFloat(e.target.value) || 0)}
|
||||
className="w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 text-white text-sm font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-2 border-t border-slate-800 text-xs">
|
||||
<span className="text-slate-300">Coût unitaire effectif à imputer aux projets :</span>
|
||||
<span className="text-base font-black text-cyan-300 font-mono">
|
||||
{formatCurrency(calculatedRealUnitCost, settings.currency)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 4: Quantity & Workshop Location */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-semibold text-slate-300">
|
||||
Quantité en stock dispo
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={quantityInStock}
|
||||
onChange={(e) => setQuantityInStock(parseInt(e.target.value) || 0)}
|
||||
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-700 text-white text-sm font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-semibold text-slate-300">
|
||||
Seuil alerte stock bas
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={minStockAlert}
|
||||
onChange={(e) => setMinStockAlert(parseInt(e.target.value) || 1)}
|
||||
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-700 text-white text-sm font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-semibold text-slate-300 flex items-center space-x-1">
|
||||
<MapPin className="w-3.5 h-3.5 text-cyan-400" />
|
||||
<span>Emplacement Atelier</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={locationInWorkshop}
|
||||
onChange={(e) => setLocationInWorkshop(e.target.value)}
|
||||
placeholder="Ex: Tiroir B1, Bac A2..."
|
||||
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-700 text-white placeholder-slate-500 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-semibold text-slate-300 flex items-center space-x-1">
|
||||
<FileText className="w-3.5 h-3.5 text-cyan-400" />
|
||||
<span>Notes & Remarques techniques</span>
|
||||
</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Ex: Révision de la nappe flex v3, réglage luminosité par capteur tactile..."
|
||||
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-700 text-white placeholder-slate-500 text-sm focus:outline-none focus:border-cyan-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="flex items-center justify-end space-x-3 pt-4 border-t border-slate-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2.5 rounded-xl bg-slate-800 text-slate-300 hover:text-white hover:bg-slate-700 text-sm font-semibold transition-colors"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-5 py-2.5 rounded-xl bg-gradient-to-r from-cyan-500 to-blue-600 text-white font-bold text-sm shadow-lg shadow-cyan-500/20 hover:scale-[1.02] active:scale-[0.98] transition-all"
|
||||
>
|
||||
{editingItem ? 'Enregistrer les modifications' : 'Ajouter au stock'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
830
src/components/inventory/PurchasesView.tsx
Normal file
830
src/components/inventory/PurchasesView.tsx
Normal file
@@ -0,0 +1,830 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import {
|
||||
Search,
|
||||
Plus,
|
||||
|
||||
Package,
|
||||
AlertTriangle,
|
||||
Download,
|
||||
Trash2,
|
||||
Edit2,
|
||||
MapPin,
|
||||
PieChart as PieIcon,
|
||||
BarChart2,
|
||||
QrCode,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
Tooltip,
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import { useApp } from '../../context/AppContext';
|
||||
import type { InventoryItem, ConsoleModel, PartCategory } from '../../types';
|
||||
|
||||
import {
|
||||
formatCurrency,
|
||||
getPartCategoryLabel,
|
||||
getCategoryBadgeColor,
|
||||
calculateTotalStockValue,
|
||||
} from '../../utils/calculations';
|
||||
|
||||
import { InventoryModal } from './InventoryModal';
|
||||
import { QrLabelModal } from '../labels/QrLabelModal';
|
||||
|
||||
export const PurchasesView: React.FC = () => {
|
||||
const {
|
||||
inventory,
|
||||
deleteInventoryItem,
|
||||
updateInventoryItem,
|
||||
receivePlannedItem,
|
||||
adjustStockQuantity,
|
||||
exportInventoryCSV,
|
||||
showNewInventoryModal,
|
||||
setShowNewInventoryModal,
|
||||
settings,
|
||||
consoleModels,
|
||||
partCategories,
|
||||
} = useApp();
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedConsole, setSelectedConsole] = useState<ConsoleModel | 'ALL'>('ALL');
|
||||
const [selectedCategory, setSelectedCategory] = useState<PartCategory | 'ALL'>('ALL');
|
||||
const [stockStatusFilter, setStockStatusFilter] = useState<'ALL' | 'in_stock' | 'planned_order' | 'ordered' | 'low_stock' | 'out_of_stock'>('ALL');
|
||||
const [editingItem, setEditingItem] = useState<InventoryItem | null>(null);
|
||||
const [itemToDelete, setItemToDelete] = useState<InventoryItem | null>(null);
|
||||
const [labelModalItem, setLabelModalItem] = useState<InventoryItem | null>(null);
|
||||
const [showBatchLabels, setShowBatchLabels] = useState(false);
|
||||
|
||||
// Filter items
|
||||
const filteredItems = useMemo(() => {
|
||||
return inventory.filter((item) => {
|
||||
// Search
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
const matchesName = item.name.toLowerCase().includes(query);
|
||||
const matchesSku = item.sku.toLowerCase().includes(query);
|
||||
const matchesSupplier = item.supplier.toLowerCase().includes(query);
|
||||
const matchesNotes = (item.notes || '').toLowerCase().includes(query);
|
||||
const matchesLocation = (item.locationInWorkshop || '').toLowerCase().includes(query);
|
||||
const matchesProject = (item.linkedProjectTitle || '').toLowerCase().includes(query);
|
||||
if (!matchesName && !matchesSku && !matchesSupplier && !matchesNotes && !matchesLocation && !matchesProject) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Console Model
|
||||
if (selectedConsole !== 'ALL' && item.consoleModel !== selectedConsole) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Category
|
||||
if (selectedCategory !== 'ALL' && item.category !== selectedCategory) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Stock Status
|
||||
if (stockStatusFilter === 'planned_order' && item.status !== 'planned_order') {
|
||||
return false;
|
||||
}
|
||||
if (stockStatusFilter === 'ordered' && item.status !== 'ordered') {
|
||||
return false;
|
||||
}
|
||||
if (stockStatusFilter === 'low_stock' && (item.quantityInStock > item.minStockAlert || item.status === 'planned_order')) {
|
||||
return false;
|
||||
}
|
||||
if (stockStatusFilter === 'out_of_stock' && (item.quantityInStock > 0 || item.status === 'planned_order')) {
|
||||
return false;
|
||||
}
|
||||
if (stockStatusFilter === 'in_stock' && (item.quantityInStock === 0 || item.status === 'planned_order')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [inventory, searchQuery, selectedConsole, selectedCategory, stockStatusFilter]);
|
||||
|
||||
// Overall Inventory Stats
|
||||
const totalStockValuation = calculateTotalStockValue(inventory);
|
||||
const inStockItems = inventory.filter((i) => i.status !== 'planned_order');
|
||||
const totalUnitsInStock = inStockItems.reduce((sum, i) => sum + i.quantityInStock, 0);
|
||||
const lowStockCount = inStockItems.filter((i) => i.quantityInStock <= i.minStockAlert).length;
|
||||
const donorConsolesCount = inStockItems
|
||||
.filter((i) => i.category === 'console_base')
|
||||
.reduce((sum, i) => sum + i.quantityInStock, 0);
|
||||
|
||||
const plannedItems = inventory.filter((i) => i.status === 'planned_order');
|
||||
const plannedProcurementTotalCost = plannedItems.reduce(
|
||||
(sum, i) => sum + ((i.quantityReserved || 1) * (i.unitCostWithFees || i.unitPurchasePrice)),
|
||||
0
|
||||
);
|
||||
const orderedItems = inventory.filter((i) => i.status === 'ordered');
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-12 animate-fadeIn">
|
||||
|
||||
{/* Top Banner & Title */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="inline-flex items-center space-x-2 px-3 py-1 rounded-full text-xs font-semibold bg-cyan-500/10 text-cyan-400 border border-cyan-500/30 mb-2">
|
||||
<Package className="w-3.5 h-3.5" />
|
||||
<span>Centre d'Achat & Logistique</span>
|
||||
</div>
|
||||
<h2 className="text-2xl sm:text-3xl font-black text-white font-display">
|
||||
Gestion du Stock & Pièces Détachées
|
||||
</h2>
|
||||
<p className="text-slate-400 text-xs sm:text-sm">
|
||||
Enregistrez vos commandes fournisseurs, suivez vos coûts unitaires réels et gérez l'état de votre inventaire.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2.5 shrink-0">
|
||||
<button
|
||||
onClick={() => setShowBatchLabels(true)}
|
||||
className="flex items-center space-x-1.5 px-3.5 py-2 rounded-xl bg-purple-500/15 border border-purple-500/30 text-purple-300 hover:bg-purple-500/25 text-xs font-bold transition-all shadow-sm"
|
||||
title="Générer une planche d'étiquettes QR pour tout le stock sélectionné"
|
||||
>
|
||||
<QrCode className="w-4 h-4" />
|
||||
<span>Planche Étiquettes QR</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={exportInventoryCSV}
|
||||
className="flex items-center space-x-1.5 px-3.5 py-2 rounded-xl bg-slate-900 border border-slate-700 text-slate-300 hover:text-white hover:border-slate-500 text-xs font-semibold transition-all shadow-sm"
|
||||
title="Exporter l'inventaire au format CSV (Excel)"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
<span>Export CSV</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingItem(null);
|
||||
setShowNewInventoryModal(true);
|
||||
}}
|
||||
className="flex items-center space-x-2 px-4 py-2 rounded-xl bg-gradient-to-r from-cyan-500 to-blue-600 text-white font-bold text-sm shadow-lg shadow-cyan-500/20 hover:scale-[1.02] active:scale-[0.98] transition-all"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>Nouvel Achat / Pièce</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI Stats Strip */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-5 gap-3">
|
||||
<div className="glass-panel p-4 rounded-2xl border border-slate-800">
|
||||
<div className="text-xs text-slate-400 font-medium">Valeur Stock Immobilisé</div>
|
||||
<div className="text-xl sm:text-2xl font-black text-cyan-300 font-mono mt-1">
|
||||
{formatCurrency(totalStockValuation, settings.currency)}
|
||||
</div>
|
||||
<div className="text-[11px] text-slate-400 mt-0.5">Calculé au coût réel</div>
|
||||
</div>
|
||||
|
||||
<div className="glass-panel p-4 rounded-2xl border border-slate-800">
|
||||
<div className="text-xs text-slate-400 font-medium">Références & Pièces</div>
|
||||
<div className="text-xl sm:text-2xl font-black text-white font-mono mt-1">
|
||||
{inStockItems.length} <span className="text-xs font-normal text-slate-400">réf. ({totalUnitsInStock} pcs)</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-slate-400 mt-0.5">Composants en stock</div>
|
||||
</div>
|
||||
|
||||
<div className="glass-panel p-4 rounded-2xl border border-slate-800">
|
||||
<div className="text-xs text-amber-400 font-medium">Consoles Brutes en Stock</div>
|
||||
<div className="text-xl sm:text-2xl font-black text-amber-300 font-mono mt-1">
|
||||
{donorConsolesCount} <span className="text-xs font-normal text-slate-400">consoles</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-slate-400 mt-0.5">Donneuses prêtes</div>
|
||||
</div>
|
||||
|
||||
<div className="glass-panel p-4 rounded-2xl border border-amber-500/30 bg-amber-500/5">
|
||||
<div className="text-xs text-amber-300 font-semibold flex items-center justify-between">
|
||||
<span>À Approvisionner</span>
|
||||
<span className="px-1.5 py-0.5 rounded bg-amber-500/20 text-[10px] font-mono font-bold">{plannedItems.length} réf.</span>
|
||||
</div>
|
||||
<div className="text-xl sm:text-2xl font-black text-amber-300 font-mono mt-1">
|
||||
{formatCurrency(plannedProcurementTotalCost, settings.currency)}
|
||||
</div>
|
||||
<div className="text-[11px] text-amber-400/80 mt-0.5">Budget pièces à prévoir</div>
|
||||
</div>
|
||||
|
||||
<div className="glass-panel p-4 rounded-2xl border border-slate-800">
|
||||
<div className="text-xs text-slate-400 font-medium">Alertes Stock Critique</div>
|
||||
<div className={`text-xl sm:text-2xl font-black font-mono mt-1 ${lowStockCount > 0 ? 'text-rose-400' : 'text-emerald-400'}`}>
|
||||
{lowStockCount} <span className="text-xs font-normal text-slate-400">articles</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-slate-400 mt-0.5">
|
||||
{lowStockCount > 0 ? 'À recommander sous peu' : 'Aucune rupture'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Visual Stock Charts: Valuation by Category & Health Levels */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
|
||||
{/* Left 1 Col: Donut Chart Valuation by Category */}
|
||||
<div className="glass-panel p-5 rounded-3xl border border-slate-800 space-y-3 flex flex-col justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-white flex items-center space-x-2">
|
||||
<PieIcon className="w-4 h-4 text-cyan-400" />
|
||||
<span>Répartition de la Valeur Stock</span>
|
||||
</h3>
|
||||
<p className="text-[11px] text-slate-400">
|
||||
Immobilisation financière par type de pièce
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
const catTotals: Record<string, number> = {};
|
||||
inventory.forEach((i) => {
|
||||
const val = i.quantityInStock * (i.unitCostWithFees || i.unitPurchasePrice || 0);
|
||||
catTotals[i.category] = (catTotals[i.category] || 0) + val;
|
||||
});
|
||||
|
||||
const catColors: Record<string, string> = {
|
||||
screen: '#06b6d4',
|
||||
console_base: '#f59e0b',
|
||||
shell: '#a855f7',
|
||||
buttons_pads: '#3b82f6',
|
||||
battery_power: '#10b981',
|
||||
sound_amp: '#ec4899',
|
||||
cartridge_games: '#f43f5e',
|
||||
packaging_accessories: '#8b5cf6',
|
||||
consumables: '#64748b',
|
||||
other: '#94a3b8',
|
||||
};
|
||||
|
||||
const chartData = Object.entries(catTotals)
|
||||
.map(([cat, val]) => ({
|
||||
name: getPartCategoryLabel(cat),
|
||||
value: Math.round(val),
|
||||
color: catColors[cat] || '#06b6d4',
|
||||
}))
|
||||
.filter((d) => d.value > 0)
|
||||
.sort((a, b) => b.value - a.value);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="h-44 w-full relative flex items-center justify-center">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={chartData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={50}
|
||||
outerRadius={72}
|
||||
paddingAngle={3}
|
||||
dataKey="value"
|
||||
>
|
||||
{chartData.map((entry, index) => (
|
||||
<Cell key={`cell-stock-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const d = payload[0];
|
||||
const percent = totalStockValuation > 0 ? ((Number(d.value) / totalStockValuation) * 100).toFixed(1) : 0;
|
||||
return (
|
||||
<div className="bg-slate-900 border border-slate-700 p-2.5 rounded-xl shadow-xl text-xs font-mono">
|
||||
<div className="font-bold text-white">{d.name}</div>
|
||||
<div className="text-cyan-400">{formatCurrency(Number(d.value), settings.currency)} ({percent}%)</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
|
||||
<span className="text-[10px] text-slate-400 uppercase font-bold">Total</span>
|
||||
<span className="text-sm font-black text-cyan-300 font-mono">{formatCurrency(totalStockValuation, settings.currency)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-1 text-[10px] pt-1 border-t border-slate-800">
|
||||
{chartData.slice(0, 4).map((item) => (
|
||||
<div key={item.name} className="flex items-center space-x-1.5 truncate">
|
||||
<span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: item.color }} />
|
||||
<span className="text-slate-300 truncate">{item.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Right 2 Cols: Bar Chart of Stock Levels vs Min Thresholds */}
|
||||
<div className="lg:col-span-2 glass-panel p-5 rounded-3xl border border-slate-800 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-white flex items-center space-x-2">
|
||||
<BarChart2 className="w-4 h-4 text-emerald-400" />
|
||||
<span>Niveaux de Disponibilité des Pièces</span>
|
||||
</h3>
|
||||
<p className="text-[11px] text-slate-400">
|
||||
Quantité en stock physique vs seuil de réapprovisionnement
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3 text-xs font-mono">
|
||||
<span className="flex items-center text-cyan-400">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-cyan-500 mr-1.5" /> Stock Actuel
|
||||
</span>
|
||||
<span className="flex items-center text-rose-400">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-rose-500 mr-1.5" /> Seuil Alerte
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-44 w-full pt-1">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={inventory.slice(0, 8).map((item) => ({
|
||||
name: item.name.length > 18 ? item.name.substring(0, 16) + '...' : item.name,
|
||||
fullName: item.name,
|
||||
'Stock Dispo': item.quantityInStock,
|
||||
'Seuil Alerte': item.minStockAlert,
|
||||
unitCost: item.unitCostWithFees,
|
||||
}))}
|
||||
margin={{ top: 10, right: 10, left: -20, bottom: 0 }}
|
||||
>
|
||||
<XAxis dataKey="name" stroke="#64748b" fontSize={10} tickLine={false} axisLine={{ stroke: '#334155' }} />
|
||||
<YAxis stroke="#64748b" fontSize={10} tickLine={false} axisLine={{ stroke: '#334155' }} allowDecimals={false} />
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const d = payload[0].payload;
|
||||
return (
|
||||
<div className="bg-slate-900 border border-slate-700 p-2.5 rounded-xl shadow-xl text-xs space-y-1 font-mono">
|
||||
<div className="font-bold text-white font-sans text-xs pb-1 border-b border-slate-800">{d.fullName}</div>
|
||||
<div className="text-cyan-400">📦 Stock physique : {d['Stock Dispo']} pcs</div>
|
||||
<div className="text-rose-400">⚠️ Seuil alerte : {d['Seuil Alerte']} pcs</div>
|
||||
<div className="text-slate-400 text-[10px]">Coût : {formatCurrency(d.unitCost, settings.currency)}/pc</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="Stock Dispo" fill="#06b6d4" radius={[3, 3, 0, 0]} />
|
||||
<Bar dataKey="Seuil Alerte" fill="#f43f5e" radius={[3, 3, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
{/* Filters & Search Toolbar */}
|
||||
<div className="glass-panel p-4 rounded-2xl border border-slate-800 space-y-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-12 gap-3">
|
||||
|
||||
{/* Search Input */}
|
||||
<div className="sm:col-span-5 relative">
|
||||
<Search className="w-4 h-4 text-slate-400 absolute left-3.5 top-3" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Rechercher par nom, SKU, fournisseur, emplacement..."
|
||||
className="w-full pl-9 pr-3.5 py-2 rounded-xl bg-slate-950 border border-slate-700 text-white placeholder-slate-500 focus:outline-none focus:border-cyan-500 text-xs sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Console Filter */}
|
||||
<div className="sm:col-span-3">
|
||||
<select
|
||||
value={selectedConsole}
|
||||
onChange={(e) => setSelectedConsole(e.target.value as ConsoleModel | 'ALL')}
|
||||
className="w-full px-3 py-2 rounded-xl bg-slate-950 border border-slate-700 text-white focus:outline-none focus:border-cyan-500 text-xs sm:text-sm"
|
||||
>
|
||||
<option value="ALL">Toutes les consoles</option>
|
||||
{consoleModels.map((c) => (
|
||||
<option key={c.id} value={c.slug}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Category Filter */}
|
||||
<div className="sm:col-span-2">
|
||||
<select
|
||||
value={selectedCategory}
|
||||
onChange={(e) => setSelectedCategory(e.target.value as PartCategory | 'ALL')}
|
||||
className="w-full px-3 py-2 rounded-xl bg-slate-950 border border-slate-700 text-white focus:outline-none focus:border-cyan-500 text-xs sm:text-sm"
|
||||
>
|
||||
<option value="ALL">Toutes les catégories</option>
|
||||
{partCategories.map((cat) => (
|
||||
<option key={cat.id} value={cat.slug}>
|
||||
{cat.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Status Filter */}
|
||||
<div className="sm:col-span-2">
|
||||
<select
|
||||
value={stockStatusFilter}
|
||||
onChange={(e) => setStockStatusFilter(e.target.value as any)}
|
||||
className="w-full px-3 py-2 rounded-xl bg-slate-950 border border-slate-700 text-white focus:outline-none focus:border-cyan-500 text-xs sm:text-sm"
|
||||
>
|
||||
<option value="ALL">Tous les statuts ({inventory.length})</option>
|
||||
<option value="in_stock">🟢 En stock physique ({inStockItems.length})</option>
|
||||
<option value="planned_order">📦 À Approvisionner (En Prévision) ({plannedItems.length})</option>
|
||||
<option value="ordered">🚚 En Commande ({orderedItems.length})</option>
|
||||
<option value="low_stock">⚠️ Stock Bas ({lowStockCount})</option>
|
||||
<option value="out_of_stock">🚫 En Rupture (0)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Status Chips */}
|
||||
<div className="flex flex-wrap gap-2 pt-1 border-t border-slate-850">
|
||||
<button
|
||||
onClick={() => setStockStatusFilter('ALL')}
|
||||
className={`px-3 py-1 rounded-lg text-xs font-semibold transition-all ${
|
||||
stockStatusFilter === 'ALL'
|
||||
? 'bg-cyan-500 text-white shadow-sm shadow-cyan-500/30'
|
||||
: 'bg-slate-950 text-slate-400 hover:text-white border border-slate-800'
|
||||
}`}
|
||||
>
|
||||
Tous ({inventory.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStockStatusFilter('in_stock')}
|
||||
className={`px-3 py-1 rounded-lg text-xs font-semibold transition-all ${
|
||||
stockStatusFilter === 'in_stock'
|
||||
? 'bg-emerald-500 text-white shadow-sm shadow-emerald-500/30'
|
||||
: 'bg-slate-950 text-slate-400 hover:text-emerald-300 border border-slate-800'
|
||||
}`}
|
||||
>
|
||||
🟢 En Stock ({inStockItems.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStockStatusFilter('planned_order')}
|
||||
className={`px-3 py-1 rounded-lg text-xs font-semibold transition-all flex items-center gap-1.5 ${
|
||||
stockStatusFilter === 'planned_order'
|
||||
? 'bg-amber-500 text-slate-950 shadow-sm shadow-amber-500/30 font-bold'
|
||||
: 'bg-amber-500/10 text-amber-300 hover:bg-amber-500/20 border border-amber-500/30'
|
||||
}`}
|
||||
>
|
||||
<span>📦 À Approvisionner (En Prévision)</span>
|
||||
<span className="px-1.5 py-0.2 rounded-full bg-amber-500/30 text-[10px] font-mono font-bold">
|
||||
{plannedItems.length}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStockStatusFilter('ordered')}
|
||||
className={`px-3 py-1 rounded-lg text-xs font-semibold transition-all ${
|
||||
stockStatusFilter === 'ordered'
|
||||
? 'bg-cyan-500 text-white shadow-sm shadow-cyan-500/30'
|
||||
: 'bg-slate-950 text-slate-400 hover:text-cyan-300 border border-slate-800'
|
||||
}`}
|
||||
>
|
||||
🚚 En Commande ({orderedItems.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStockStatusFilter('low_stock')}
|
||||
className={`px-3 py-1 rounded-lg text-xs font-semibold transition-all ${
|
||||
stockStatusFilter === 'low_stock'
|
||||
? 'bg-rose-500 text-white shadow-sm shadow-rose-500/30'
|
||||
: 'bg-slate-950 text-slate-400 hover:text-rose-300 border border-slate-800'
|
||||
}`}
|
||||
>
|
||||
⚠️ Stock Critique ({lowStockCount})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inventory Items List */}
|
||||
<div className="glass-panel rounded-3xl border border-slate-800 overflow-hidden">
|
||||
{filteredItems.length === 0 ? (
|
||||
<div className="p-12 text-center space-y-3">
|
||||
<Package className="w-12 h-12 text-slate-600 mx-auto" />
|
||||
<h3 className="text-base font-bold text-slate-300">Aucune pièce trouvée</h3>
|
||||
<p className="text-xs text-slate-400 max-w-sm mx-auto">
|
||||
Aucun composant ne correspond à vos critères de recherche ou de filtre.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearchQuery('');
|
||||
setSelectedConsole('ALL');
|
||||
setSelectedCategory('ALL');
|
||||
setStockStatusFilter('ALL');
|
||||
}}
|
||||
className="text-xs font-semibold text-cyan-400 hover:underline"
|
||||
>
|
||||
Réinitialiser les filtres
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse text-xs sm:text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-800 bg-slate-950/60 text-slate-400 font-semibold uppercase text-[11px] tracking-wider">
|
||||
<th className="py-3.5 px-4">Composant / Désignation</th>
|
||||
<th className="py-3.5 px-3">Console & Catégorie</th>
|
||||
<th className="py-3.5 px-3">Fournisseur & Date</th>
|
||||
<th className="py-3.5 px-3 text-right">Prix Achat</th>
|
||||
<th className="py-3.5 px-3 text-right">Coût Réel Unitaire</th>
|
||||
<th className="py-3.5 px-4 text-center">Stock & Statut</th>
|
||||
<th className="py-3.5 px-3 text-right">Valeur / Budget</th>
|
||||
<th className="py-3.5 px-4 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/60">
|
||||
{filteredItems.map((item) => {
|
||||
const badgeColor = getCategoryBadgeColor(item.category);
|
||||
const isPlanned = item.status === 'planned_order';
|
||||
const isOrdered = item.status === 'ordered';
|
||||
const isLow = !isPlanned && item.quantityInStock <= item.minStockAlert && item.quantityInStock > 0;
|
||||
const isOut = !isPlanned && item.quantityInStock <= 0;
|
||||
const totalLineValuation = isPlanned
|
||||
? (item.quantityReserved || 1) * item.unitCostWithFees
|
||||
: item.quantityInStock * item.unitCostWithFees;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={item.id}
|
||||
className={`transition-colors duration-150 group ${
|
||||
isPlanned
|
||||
? 'bg-amber-500/5 hover:bg-amber-500/10'
|
||||
: isOrdered
|
||||
? 'bg-cyan-500/5 hover:bg-cyan-500/10'
|
||||
: 'hover:bg-slate-900/50'
|
||||
}`}
|
||||
>
|
||||
{/* Name & SKU */}
|
||||
<td className="py-3 px-4">
|
||||
<div className="font-bold text-white group-hover:text-cyan-300 transition-colors flex items-center gap-1.5 flex-wrap">
|
||||
<span>{item.name}</span>
|
||||
{isPlanned && (
|
||||
<span className="px-2 py-0.5 rounded-md text-[10px] font-extrabold bg-amber-500/20 text-amber-300 border border-amber-500/40">
|
||||
📦 À APPROVISIONNER (EN PRÉVISION)
|
||||
</span>
|
||||
)}
|
||||
{isOrdered && (
|
||||
<span className="px-2 py-0.5 rounded-md text-[10px] font-extrabold bg-cyan-500/20 text-cyan-300 border border-cyan-500/40">
|
||||
🚚 EN COMMANDE
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 mt-1 flex-wrap">
|
||||
<span className="font-mono text-[11px] text-slate-400">
|
||||
{item.sku}
|
||||
</span>
|
||||
{item.linkedProjectTitle && (
|
||||
<span className="inline-flex items-center text-[10px] text-amber-300 bg-amber-500/10 px-2 py-0.5 rounded border border-amber-500/30">
|
||||
🎯 Pour : {item.linkedProjectTitle}
|
||||
</span>
|
||||
)}
|
||||
{item.locationInWorkshop && !isPlanned && (
|
||||
<span className="inline-flex items-center text-[10px] text-slate-400 bg-slate-900 px-1.5 py-0.5 rounded border border-slate-800">
|
||||
<MapPin className="w-2.5 h-2.5 mr-1 text-cyan-400" />
|
||||
{item.locationInWorkshop}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Console & Category */}
|
||||
<td className="py-3 px-3">
|
||||
<div className="font-semibold text-slate-200">
|
||||
{item.consoleModel}
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded-md text-[10px] font-bold border ${badgeColor.bg} ${badgeColor.text} ${badgeColor.border}`}
|
||||
>
|
||||
{getPartCategoryLabel(item.category)}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Supplier & Date */}
|
||||
<td className="py-3 px-3">
|
||||
<div className="text-slate-300 font-medium">
|
||||
{item.supplier}
|
||||
</div>
|
||||
<div className="text-[11px] text-slate-400 font-mono">
|
||||
{item.orderDate}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Unit Base Purchase Price */}
|
||||
<td className="py-3 px-3 text-right font-mono text-slate-300">
|
||||
{formatCurrency(item.unitPurchasePrice, settings.currency)}
|
||||
</td>
|
||||
|
||||
{/* Real Unit Cost */}
|
||||
<td className="py-3 px-3 text-right">
|
||||
<div className="font-mono font-bold text-cyan-300">
|
||||
{formatCurrency(item.unitCostWithFees, settings.currency)}
|
||||
</div>
|
||||
{(item.shippingCostShare > 0 || item.taxImportShare > 0) && (
|
||||
<div className="text-[10px] text-slate-400">
|
||||
(+{(item.shippingCostShare + item.taxImportShare).toFixed(2)}€ frais)
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Stock / Procurement Column */}
|
||||
<td className="py-3 px-4">
|
||||
{isPlanned ? (
|
||||
<div className="text-center space-y-1">
|
||||
<span className="inline-block font-mono font-bold text-xs px-2.5 py-0.5 rounded-full bg-amber-500/20 text-amber-300 border border-amber-500/40">
|
||||
Qté requise : {item.quantityReserved || 1}
|
||||
</span>
|
||||
<div className="text-[10px] text-amber-400/80">
|
||||
Stock actuel : 0
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<button
|
||||
onClick={() => adjustStockQuantity(item.id, -1)}
|
||||
disabled={item.quantityInStock <= 0}
|
||||
className="w-6 h-6 rounded-lg bg-slate-800 hover:bg-slate-700 disabled:opacity-30 disabled:cursor-not-allowed flex items-center justify-center text-white font-bold text-xs"
|
||||
title="Retirer 1 unité du stock"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
|
||||
<div className="min-w-[48px] text-center">
|
||||
<span
|
||||
className={`inline-block font-mono font-black text-sm px-2.5 py-0.5 rounded-full ${
|
||||
isOut
|
||||
? 'bg-rose-500/20 text-rose-400 border border-rose-500/40'
|
||||
: isLow
|
||||
? 'bg-amber-500/20 text-amber-300 border border-amber-500/40'
|
||||
: 'bg-slate-900 text-white border border-slate-700'
|
||||
}`}
|
||||
>
|
||||
{item.quantityInStock}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => adjustStockQuantity(item.id, 1)}
|
||||
className="w-6 h-6 rounded-lg bg-slate-800 hover:bg-slate-700 flex items-center justify-center text-white font-bold text-xs"
|
||||
title="Ajouter 1 unité au stock"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
{isLow && (
|
||||
<div className="text-center text-[10px] text-amber-400 font-semibold mt-1">
|
||||
Alerte seuil ({item.minStockAlert})
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Total Line Valuation / Budget */}
|
||||
<td className="py-3 px-3 text-right font-mono font-bold">
|
||||
<div className={isPlanned ? 'text-amber-300' : 'text-slate-200'}>
|
||||
{formatCurrency(totalLineValuation, settings.currency)}
|
||||
</div>
|
||||
{isPlanned && (
|
||||
<div className="text-[10px] text-amber-400/70 font-sans font-normal">
|
||||
À prévoir
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Actions */}
|
||||
<td className="py-3 px-4 text-right">
|
||||
<div className="flex items-center justify-end space-x-1.5 flex-wrap">
|
||||
{isPlanned && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => receivePlannedItem(item.id)}
|
||||
className="px-2.5 py-1 text-[11px] font-bold bg-emerald-600 hover:bg-emerald-500 text-white rounded-lg shadow-sm flex items-center gap-1 transition-colors"
|
||||
title="Réceptionner cette pièce en stock et l'allouer au projet"
|
||||
>
|
||||
📥 Réceptionner
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateInventoryItem(item.id, { status: 'ordered' })}
|
||||
className="px-2.5 py-1 text-[11px] font-semibold bg-cyan-600 hover:bg-cyan-500 text-white rounded-lg transition-colors"
|
||||
title="Passer en commande"
|
||||
>
|
||||
🚚 Commander
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isOrdered && (
|
||||
<button
|
||||
onClick={() => receivePlannedItem(item.id)}
|
||||
className="px-2.5 py-1 text-[11px] font-bold bg-emerald-600 hover:bg-emerald-500 text-white rounded-lg shadow-sm flex items-center gap-1 transition-colors"
|
||||
title="Réceptionner la commande"
|
||||
>
|
||||
📥 Réceptionner
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setLabelModalItem(item)}
|
||||
className="p-1.5 text-slate-400 hover:text-purple-400 hover:bg-slate-800 rounded-lg transition-colors"
|
||||
title="Imprimer l'étiquette QR pour ce composant"
|
||||
>
|
||||
<QrCode className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingItem(item);
|
||||
setShowNewInventoryModal(true);
|
||||
}}
|
||||
className="p-1.5 text-slate-400 hover:text-cyan-400 hover:bg-slate-800 rounded-lg transition-colors"
|
||||
title="Modifier cette pièce"
|
||||
>
|
||||
<Edit2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setItemToDelete(item)}
|
||||
className="p-1.5 text-slate-400 hover:text-rose-400 hover:bg-slate-800 rounded-lg transition-colors"
|
||||
title="Supprimer du stock"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Inventory Modal for Create / Edit */}
|
||||
<InventoryModal
|
||||
isOpen={showNewInventoryModal}
|
||||
onClose={() => {
|
||||
setShowNewInventoryModal(false);
|
||||
setEditingItem(null);
|
||||
}}
|
||||
editingItem={editingItem}
|
||||
/>
|
||||
|
||||
{/* Confirmation Modal for Delete */}
|
||||
{itemToDelete && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md">
|
||||
<div className="bg-slate-900 border border-slate-700 rounded-3xl p-6 max-w-md w-full space-y-4 shadow-2xl">
|
||||
<div className="w-12 h-12 rounded-2xl bg-rose-500/10 border border-rose-500/30 flex items-center justify-center text-rose-400">
|
||||
<AlertTriangle className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-lg font-bold text-white">Supprimer cette référence ?</h4>
|
||||
<p className="text-xs text-slate-300 mt-1">
|
||||
Êtes-vous certain de vouloir supprimer <strong>{itemToDelete.name}</strong> ({itemToDelete.quantityInStock} pcs en stock) ?
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-end space-x-3 pt-2">
|
||||
<button
|
||||
onClick={() => setItemToDelete(null)}
|
||||
className="px-4 py-2 rounded-xl bg-slate-800 text-slate-300 hover:text-white text-xs font-semibold"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
deleteInventoryItem(itemToDelete.id);
|
||||
setItemToDelete(null);
|
||||
}}
|
||||
className="px-4 py-2 rounded-xl bg-rose-600 hover:bg-rose-500 text-white text-xs font-bold shadow-lg shadow-rose-600/20"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* QrLabelModal for single part or batch sheet */}
|
||||
<QrLabelModal
|
||||
isOpen={!!labelModalItem || showBatchLabels}
|
||||
onClose={() => {
|
||||
setLabelModalItem(null);
|
||||
setShowBatchLabels(false);
|
||||
}}
|
||||
inventoryItem={labelModalItem}
|
||||
batchItems={showBatchLabels ? filteredItems : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
521
src/components/labels/QrLabelModal.tsx
Normal file
521
src/components/labels/QrLabelModal.tsx
Normal file
@@ -0,0 +1,521 @@
|
||||
import React, { useState } from 'react';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import {
|
||||
X,
|
||||
Printer,
|
||||
QrCode,
|
||||
Sliders,
|
||||
Check,
|
||||
Tag,
|
||||
} from 'lucide-react';
|
||||
import { useApp } from '../../context/AppContext';
|
||||
import type { Project, InventoryItem } from '../../types';
|
||||
import { formatCurrency, getPartCategoryLabel } from '../../utils/calculations';
|
||||
|
||||
export type LabelType = 'project_box' | 'project_back_shell' | 'inventory_drawer' | 'inventory_pouch';
|
||||
export type PageLayout = 'single_thermal' | 'a4_grid_2x4' | 'a4_grid_3x7';
|
||||
|
||||
interface QrLabelModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
project?: Project | null;
|
||||
inventoryItem?: InventoryItem | null;
|
||||
batchItems?: InventoryItem[];
|
||||
}
|
||||
|
||||
export const QrLabelModal: React.FC<QrLabelModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
project,
|
||||
inventoryItem,
|
||||
batchItems,
|
||||
}) => {
|
||||
const { settings } = useApp();
|
||||
|
||||
const isProject = !!project;
|
||||
const [labelType, setLabelType] = useState<LabelType>(
|
||||
isProject ? 'project_box' : 'inventory_drawer'
|
||||
);
|
||||
const [pageLayout, setPageLayout] = useState<PageLayout>('single_thermal');
|
||||
const [showPrice, setShowPrice] = useState(true);
|
||||
const [showLocation, setShowLocation] = useState(true);
|
||||
const [showMods, setShowMods] = useState(true);
|
||||
const [showDate, setShowDate] = useState(true);
|
||||
const [copiesCount, setCopiesCount] = useState(1);
|
||||
const [themeInverted, setThemeInverted] = useState(false); // Dark sticker vs Standard White thermal paper
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
// Generate QR payload
|
||||
const getQrValue = (p?: Project | null, item?: InventoryItem | null) => {
|
||||
if (p) {
|
||||
return JSON.stringify({
|
||||
app: 'uwuFinances',
|
||||
type: 'PROJECT',
|
||||
serial: p.serialNumber,
|
||||
model: p.consoleModel,
|
||||
title: p.title,
|
||||
date: p.completedDate || p.startDate,
|
||||
warranty: `${p.warrantyMonths || 6}M`,
|
||||
});
|
||||
}
|
||||
if (item) {
|
||||
return JSON.stringify({
|
||||
app: 'uwuFinances',
|
||||
type: 'INVENTORY_PART',
|
||||
sku: item.sku,
|
||||
name: item.name,
|
||||
category: item.category,
|
||||
model: item.consoleModel,
|
||||
loc: item.locationInWorkshop || 'Atelier',
|
||||
});
|
||||
}
|
||||
return 'uwuFinances:ITEM';
|
||||
};
|
||||
|
||||
const handlePrint = () => {
|
||||
window.print();
|
||||
};
|
||||
|
||||
// Render a single sticker label
|
||||
const renderLabelCard = (
|
||||
currentProject?: Project | null,
|
||||
currentItem?: InventoryItem | null,
|
||||
keyIndex = 0
|
||||
) => {
|
||||
const qrData = getQrValue(currentProject, currentItem);
|
||||
|
||||
// 1. BOX PACKAGING STICKER (Large format: ~80x50mm)
|
||||
if (labelType === 'project_box' && currentProject) {
|
||||
return (
|
||||
<div
|
||||
key={keyIndex}
|
||||
className={`label-item relative w-[320px] h-[200px] p-3.5 rounded-xl border-2 transition-all flex flex-col justify-between overflow-hidden ${
|
||||
themeInverted
|
||||
? 'bg-slate-950 text-white border-pink-500'
|
||||
: 'bg-white text-black border-black shadow-md'
|
||||
}`}
|
||||
style={{ width: '82mm', height: '52mm' }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b pb-1.5 border-current">
|
||||
<div className="flex items-center space-x-1.5">
|
||||
<span className="font-extrabold text-xs tracking-tight uppercase font-display">
|
||||
{settings.businessName || 'uwu Custom Studio'}
|
||||
</span>
|
||||
<span className="text-[9px] px-1 py-0.2 rounded font-bold border border-current">
|
||||
PRO MOD
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-mono text-[11px] font-black tracking-wider">
|
||||
{currentProject.serialNumber}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Body: Info + QR Code */}
|
||||
<div className="flex items-center gap-2.5 my-auto">
|
||||
<div className="p-1 rounded bg-white border border-slate-300 shrink-0">
|
||||
<QRCodeSVG
|
||||
value={qrData}
|
||||
size={68}
|
||||
level="M"
|
||||
includeMargin={false}
|
||||
fgColor="#000000"
|
||||
bgColor="#ffffff"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="font-black text-xs leading-snug line-clamp-1">
|
||||
{currentProject.title}
|
||||
</div>
|
||||
<div className="text-[10px] font-bold flex items-center space-x-1 opacity-90">
|
||||
<span>🎮 {currentProject.consoleModel}</span>
|
||||
</div>
|
||||
|
||||
{showMods && currentProject.modsList && currentProject.modsList.length > 0 && (
|
||||
<div className="text-[8.5px] leading-tight line-clamp-2 opacity-80">
|
||||
{currentProject.modsList.slice(0, 3).join(' • ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between text-[9px] font-mono border-t pt-1 border-current">
|
||||
{showDate && (
|
||||
<span>Date: {currentProject.completedDate || currentProject.startDate}</span>
|
||||
)}
|
||||
<span>QC: Validé ✓</span>
|
||||
{showPrice && (
|
||||
<span className="font-black font-sans text-[10px]">
|
||||
{formatCurrency(currentProject.salePriceActual || currentProject.salePriceTarget, settings.currency)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 2. BACK SHELL / BATTERY COMPARTMENT STICKER (Compact: ~50x25mm)
|
||||
if (labelType === 'project_back_shell' && currentProject) {
|
||||
return (
|
||||
<div
|
||||
key={keyIndex}
|
||||
className={`label-item relative w-[220px] h-[110px] p-2 rounded-lg border-2 flex items-center gap-2 overflow-hidden ${
|
||||
themeInverted
|
||||
? 'bg-slate-950 text-white border-pink-500'
|
||||
: 'bg-white text-black border-black shadow-md'
|
||||
}`}
|
||||
style={{ width: '52mm', height: '28mm' }}
|
||||
>
|
||||
<div className="p-0.5 rounded bg-white border border-slate-300 shrink-0">
|
||||
<QRCodeSVG
|
||||
value={qrData}
|
||||
size={48}
|
||||
level="L"
|
||||
includeMargin={false}
|
||||
fgColor="#000000"
|
||||
bgColor="#ffffff"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1 text-[9px] space-y-0.5 font-mono">
|
||||
<div className="font-extrabold uppercase text-[9.5px] truncate font-display">
|
||||
{settings.logoText || 'uwuFinances'}
|
||||
</div>
|
||||
<div className="font-black text-[10px] text-pink-600 truncate">
|
||||
{currentProject.serialNumber}
|
||||
</div>
|
||||
<div className="truncate opacity-80">{currentProject.consoleModel}</div>
|
||||
<div className="text-[8px] opacity-70">Garantie {currentProject.warrantyMonths || 6}M</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 3. INVENTORY DRAWER / STORAGE BOX STICKER (Medium: ~60x30mm)
|
||||
if (currentItem) {
|
||||
return (
|
||||
<div
|
||||
key={keyIndex}
|
||||
className={`label-item relative w-[250px] h-[130px] p-2.5 rounded-xl border-2 flex flex-col justify-between overflow-hidden ${
|
||||
themeInverted
|
||||
? 'bg-slate-950 text-white border-cyan-500'
|
||||
: 'bg-white text-black border-black shadow-md'
|
||||
}`}
|
||||
style={{ width: '60mm', height: '32mm' }}
|
||||
>
|
||||
{/* Top: SKU & Category */}
|
||||
<div className="flex items-center justify-between border-b pb-1 border-current">
|
||||
<span className="font-mono text-[10px] font-black">
|
||||
{currentItem.sku}
|
||||
</span>
|
||||
<span className="text-[8.5px] font-bold uppercase truncate max-w-[120px]">
|
||||
{getPartCategoryLabel(currentItem.category)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Middle: QR & Title */}
|
||||
<div className="flex items-center gap-2 my-1">
|
||||
<div className="p-0.5 rounded bg-white border border-slate-300 shrink-0">
|
||||
<QRCodeSVG
|
||||
value={qrData}
|
||||
size={46}
|
||||
level="M"
|
||||
includeMargin={false}
|
||||
fgColor="#000000"
|
||||
bgColor="#ffffff"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-bold text-[10px] leading-tight line-clamp-2">
|
||||
{currentItem.name}
|
||||
</div>
|
||||
<div className="text-[9px] font-semibold opacity-80 mt-0.5">
|
||||
🎮 {currentItem.consoleModel}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom: Location & Unit Cost */}
|
||||
<div className="flex items-center justify-between text-[8.5px] font-mono border-t pt-1 border-current">
|
||||
{showLocation && (
|
||||
<span className="truncate max-w-[130px]">
|
||||
📍 {currentItem.locationInWorkshop || 'Bac Stock'}
|
||||
</span>
|
||||
)}
|
||||
{showPrice && (
|
||||
<span className="font-bold font-sans text-[9.5px]">
|
||||
Coût: {formatCurrency(currentItem.unitCostWithFees || currentItem.unitPurchasePrice, settings.currency)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Compile list of labels to render (support duplicates and batch items)
|
||||
const itemsToRender: { project?: Project | null; item?: InventoryItem | null }[] = [];
|
||||
if (batchItems && batchItems.length > 0) {
|
||||
batchItems.forEach((it) => {
|
||||
for (let c = 0; c < copiesCount; c++) {
|
||||
itemsToRender.push({ item: it });
|
||||
}
|
||||
});
|
||||
} else if (project) {
|
||||
for (let c = 0; c < copiesCount; c++) {
|
||||
itemsToRender.push({ project });
|
||||
}
|
||||
} else if (inventoryItem) {
|
||||
for (let c = 0; c < copiesCount; c++) {
|
||||
itemsToRender.push({ item: inventoryItem });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/85 backdrop-blur-md overflow-y-auto">
|
||||
<div className="relative w-full max-w-4xl bg-slate-900 border border-purple-500/40 rounded-3xl shadow-2xl overflow-hidden my-6 animate-scaleUp">
|
||||
{/* Modal Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-800 bg-gradient-to-r from-purple-950/40 via-slate-900 to-pink-950/40 no-print">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-2.5 rounded-2xl bg-purple-500/15 border border-purple-500/30 text-purple-400">
|
||||
<QrCode className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-white flex items-center space-x-2">
|
||||
<span>Studio d'Impression d'Étiquettes & QR Codes</span>
|
||||
<span className="px-2 py-0.5 rounded-full text-[10px] font-bold bg-pink-500/20 text-pink-300 border border-pink-500/40">
|
||||
STICKERS PRO
|
||||
</span>
|
||||
</h3>
|
||||
<p className="text-xs text-slate-400">
|
||||
Générez et imprimez des étiquettes autocollantes pour vos boîtes kraft, tiroirs de pièces et coques
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-xl text-slate-400 hover:text-white hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal Body: Controls on Left, Live Preview on Right */}
|
||||
<div className="p-6 grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
|
||||
{/* Controls Column */}
|
||||
<div className="space-y-4 no-print text-xs">
|
||||
{/* 1. Label Format Choice */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="font-bold text-slate-300 flex items-center space-x-1.5">
|
||||
<Tag className="w-3.5 h-3.5 text-pink-400" />
|
||||
<span>Format & Type d'Étiquette :</span>
|
||||
</label>
|
||||
|
||||
{isProject ? (
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLabelType('project_box')}
|
||||
className={`p-2.5 rounded-xl border text-left flex items-center justify-between transition-all ${
|
||||
labelType === 'project_box'
|
||||
? 'bg-pink-500/15 border-pink-500 text-white font-bold'
|
||||
: 'bg-slate-950 border-slate-800 text-slate-400 hover:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
<div className="text-xs">📦 Étiquette Boîte Kraft Packaging</div>
|
||||
<div className="text-[10px] text-slate-500 font-mono">Format 80x50 mm (Boîtes rigides)</div>
|
||||
</div>
|
||||
{labelType === 'project_box' && <Check className="w-4 h-4 text-pink-400" />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLabelType('project_back_shell')}
|
||||
className={`p-2.5 rounded-xl border text-left flex items-center justify-between transition-all ${
|
||||
labelType === 'project_back_shell'
|
||||
? 'bg-pink-500/15 border-pink-500 text-white font-bold'
|
||||
: 'bg-slate-950 border-slate-800 text-slate-400 hover:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
<div className="text-xs">🎮 Sticker Arrière Console / Batterie</div>
|
||||
<div className="text-[10px] text-slate-500 font-mono">Format Compact 50x28 mm</div>
|
||||
</div>
|
||||
{labelType === 'project_back_shell' && <Check className="w-4 h-4 text-pink-400" />}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLabelType('inventory_drawer')}
|
||||
className={`p-2.5 rounded-xl border text-left flex items-center justify-between transition-all ${
|
||||
labelType === 'inventory_drawer'
|
||||
? 'bg-cyan-500/15 border-cyan-500 text-white font-bold'
|
||||
: 'bg-slate-950 border-slate-800 text-slate-400 hover:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
<div className="text-xs">🗄️ Étiquette Tiroir & Bac de Stock</div>
|
||||
<div className="text-[10px] text-slate-500 font-mono">Format 60x32 mm (Casier atelier)</div>
|
||||
</div>
|
||||
{labelType === 'inventory_drawer' && <Check className="w-4 h-4 text-cyan-400" />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 2. Printer / Page Layout Format */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="font-bold text-slate-300 flex items-center space-x-1.5">
|
||||
<Printer className="w-3.5 h-3.5 text-cyan-400" />
|
||||
<span>Support d'Impression :</span>
|
||||
</label>
|
||||
|
||||
<select
|
||||
value={pageLayout}
|
||||
onChange={(e) => setPageLayout(e.target.value as PageLayout)}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white"
|
||||
>
|
||||
<option value="single_thermal">🖨️ Rouleau Thermique (Étiquette Unique)</option>
|
||||
<option value="a4_grid_2x4">📄 Planche A4 (Grille 2x4 - 8 étiquettes/page)</option>
|
||||
<option value="a4_grid_3x7">📄 Planche A4 (Grille 3x7 - 21 étiquettes/page)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 3. Number of Copies */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="font-bold text-slate-300">Nombre d'exemplaires :</label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="50"
|
||||
value={copiesCount}
|
||||
onChange={(e) => setCopiesCount(Math.max(1, parseInt(e.target.value) || 1))}
|
||||
className="w-20 bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white font-mono font-bold text-center"
|
||||
/>
|
||||
<span className="text-slate-400 text-xs">autocollant(s) à imprimer</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 4. Display Toggles */}
|
||||
<div className="p-3.5 rounded-2xl bg-slate-950/70 border border-slate-850 space-y-2.5">
|
||||
<span className="text-[11px] font-bold text-slate-400 uppercase tracking-wider block">
|
||||
Champs Affichés sur l'Étiquette :
|
||||
</span>
|
||||
|
||||
<label className="flex items-center space-x-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showPrice}
|
||||
onChange={(e) => setShowPrice(e.target.checked)}
|
||||
className="rounded border-slate-700 text-pink-500 focus:ring-pink-500 bg-slate-900"
|
||||
/>
|
||||
<span className="text-slate-300">Afficher le prix / coût de revient</span>
|
||||
</label>
|
||||
|
||||
{isProject && (
|
||||
<label className="flex items-center space-x-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showMods}
|
||||
onChange={(e) => setShowMods(e.target.checked)}
|
||||
className="rounded border-slate-700 text-pink-500 focus:ring-pink-500 bg-slate-900"
|
||||
/>
|
||||
<span className="text-slate-300">Afficher la liste des mods installés</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{!isProject && (
|
||||
<label className="flex items-center space-x-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showLocation}
|
||||
onChange={(e) => setShowLocation(e.target.checked)}
|
||||
className="rounded border-slate-700 text-cyan-500 focus:ring-cyan-500 bg-slate-900"
|
||||
/>
|
||||
<span className="text-slate-300">Afficher l'emplacement dans l'atelier</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<label className="flex items-center space-x-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showDate}
|
||||
onChange={(e) => setShowDate(e.target.checked)}
|
||||
className="rounded border-slate-700 text-pink-500 focus:ring-pink-500 bg-slate-900"
|
||||
/>
|
||||
<span className="text-slate-300">Afficher la date</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center space-x-2 cursor-pointer pt-1 border-t border-slate-850">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={themeInverted}
|
||||
onChange={(e) => setThemeInverted(e.target.checked)}
|
||||
className="rounded border-slate-700 text-purple-500 focus:ring-purple-500 bg-slate-900"
|
||||
/>
|
||||
<span className="text-slate-300">Style sombre / Négatif (Cyberpunk sticker)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Print Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePrint}
|
||||
className="w-full flex items-center justify-center space-x-2 px-5 py-3 rounded-2xl font-black text-sm bg-gradient-to-r from-pink-500 via-purple-600 to-pink-600 hover:from-pink-400 hover:to-purple-500 text-white shadow-xl shadow-pink-500/25 hover:scale-[1.02] active:scale-[0.98] transition-all"
|
||||
>
|
||||
<Printer className="w-5 h-5" />
|
||||
<span>Imprimer {itemsToRender.length} Étiquette(s)</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Live Preview Column (2 cols wide on LG) */}
|
||||
<div className="lg:col-span-2 flex flex-col space-y-3">
|
||||
<div className="flex items-center justify-between no-print">
|
||||
<span className="text-xs font-bold text-slate-300 uppercase tracking-wider flex items-center space-x-1.5">
|
||||
<Sliders className="w-3.5 h-3.5 text-purple-400" />
|
||||
<span>Aperçu Réel Avant Impression :</span>
|
||||
</span>
|
||||
<span className="text-[11px] font-mono text-slate-500">
|
||||
{pageLayout === 'single_thermal' ? 'Format Rouleau 1x1' : 'Planche Plan A4'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Printable Container */}
|
||||
<div className="flex-1 p-6 rounded-3xl bg-slate-950 border border-slate-800 flex items-center justify-center overflow-auto max-h-[500px] custom-scrollbar printable-label-sheet">
|
||||
<div
|
||||
className={`print-sheet flex flex-wrap gap-4 items-center justify-center ${
|
||||
pageLayout === 'a4_grid_2x4'
|
||||
? 'grid grid-cols-2 gap-3 max-w-[650px]'
|
||||
: pageLayout === 'a4_grid_3x7'
|
||||
? 'grid grid-cols-3 gap-2 max-w-[750px]'
|
||||
: 'flex flex-col'
|
||||
}`}
|
||||
>
|
||||
{itemsToRender.map((elem, idx) =>
|
||||
renderLabelCard(elem.project, elem.item, idx)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center text-[11px] text-slate-500 no-print">
|
||||
💡 Le QR code encodé est scannable avec n'importe quel smartphone ou douchette de code-barres.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
274
src/components/mods/ApplyModModal.tsx
Normal file
274
src/components/mods/ApplyModModal.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
X,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Wrench,
|
||||
AlertTriangle,
|
||||
Boxes,
|
||||
ListTodo,
|
||||
} from 'lucide-react';
|
||||
import { useApp } from '../../context/AppContext';
|
||||
import type { ModRecipe } from '../../types';
|
||||
|
||||
interface ApplyModModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
modToApply: ModRecipe | null;
|
||||
defaultProjectId?: string | null;
|
||||
}
|
||||
|
||||
export const ApplyModModal: React.FC<ApplyModModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
modToApply,
|
||||
defaultProjectId,
|
||||
}) => {
|
||||
const { projects, inventory, applyModToProject, setSelectedProjectId, setCurrentTab } = useApp();
|
||||
|
||||
// Active / eligible projects
|
||||
const activeProjects = projects.filter(
|
||||
(p) => p.status !== 'sold'
|
||||
);
|
||||
|
||||
const [selectedProjectId, setLocalSelectedProjectId] = useState<string>(
|
||||
defaultProjectId || (activeProjects[0]?.id || '')
|
||||
);
|
||||
const [deductStock, setDeductStock] = useState(true);
|
||||
const [addTasks, setAddTasks] = useState(true);
|
||||
|
||||
if (!isOpen || !modToApply) return null;
|
||||
|
||||
const targetProject = projects.find((p) => p.id === selectedProjectId);
|
||||
|
||||
const totalPartsCost = (modToApply.requiredParts || []).reduce(
|
||||
(s, p) => s + p.estimatedCost * p.quantity,
|
||||
0
|
||||
);
|
||||
|
||||
const handleApply = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!selectedProjectId) return;
|
||||
|
||||
applyModToProject(selectedProjectId, modToApply.id, {
|
||||
deductStock,
|
||||
addTasks,
|
||||
});
|
||||
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleApplyAndOpenProject = () => {
|
||||
if (!selectedProjectId) return;
|
||||
applyModToProject(selectedProjectId, modToApply.id, {
|
||||
deductStock,
|
||||
addTasks,
|
||||
});
|
||||
setSelectedProjectId(selectedProjectId);
|
||||
setCurrentTab('projects');
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-4 bg-slate-950/80 backdrop-blur-md overflow-y-auto">
|
||||
<div className="bg-slate-900 border border-slate-700/80 rounded-2xl w-full max-w-xl shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-200">
|
||||
|
||||
{/* Header */}
|
||||
<div className="p-5 border-b border-slate-800 flex items-center justify-between bg-gradient-to-r from-cyan-500/10 via-purple-500/10 to-transparent">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2.5 bg-cyan-500/20 text-cyan-400 border border-cyan-500/30 rounded-xl">
|
||||
<Wrench className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-white">
|
||||
Appliquer le Mod au Projet
|
||||
</h2>
|
||||
<p className="text-xs text-slate-400">
|
||||
Transférer les composants, la checklist et valoriser votre projet
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-slate-400 hover:text-white bg-slate-800 hover:bg-slate-700 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form Body */}
|
||||
<form onSubmit={handleApply} className="p-5 space-y-5">
|
||||
|
||||
{/* Mod Summary Card */}
|
||||
<div className="p-4 bg-slate-950 border border-slate-800 rounded-xl space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-pink-400 uppercase tracking-wider">
|
||||
Recette Sélectionnée
|
||||
</span>
|
||||
<span className="text-xs font-bold text-emerald-400 font-mono">
|
||||
+{modToApply.suggestedAddedValue} € valeur
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="font-bold text-white text-base">{modToApply.name}</h3>
|
||||
{modToApply.description && (
|
||||
<p className="text-xs text-slate-400 line-clamp-2">{modToApply.description}</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2 pt-1 text-[11px]">
|
||||
<span className="px-2 py-0.5 bg-slate-800 text-slate-300 rounded-md font-mono flex items-center gap-1">
|
||||
<Clock className="w-3 h-3 text-purple-400" /> ~{modToApply.estimatedMinutes} min
|
||||
</span>
|
||||
<span className="px-2 py-0.5 bg-slate-800 text-cyan-300 rounded-md font-mono flex items-center gap-1">
|
||||
<Boxes className="w-3 h-3 text-cyan-400" /> {modToApply.requiredParts?.length || 0} pièces (~{totalPartsCost.toFixed(2)} €)
|
||||
</span>
|
||||
<span className="px-2 py-0.5 bg-slate-800 text-amber-300 rounded-md font-mono flex items-center gap-1">
|
||||
<ListTodo className="w-3 h-3 text-amber-400" /> {modToApply.checklistTasks?.length || 0} tâches
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Project Target Picker */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1.5">
|
||||
Choisir le Projet Cible *
|
||||
</label>
|
||||
{activeProjects.length === 0 ? (
|
||||
<div className="p-3 bg-amber-500/10 border border-amber-500/20 rounded-xl text-xs text-amber-300 flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0" />
|
||||
<span>Aucun projet en cours disponible. Veuillez d'abord créer un projet.</span>
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
required
|
||||
value={selectedProjectId}
|
||||
onChange={(e) => setLocalSelectedProjectId(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-700 rounded-xl px-3.5 py-2.5 text-white text-sm font-medium focus:outline-none focus:border-cyan-500"
|
||||
>
|
||||
{activeProjects.map((proj) => (
|
||||
<option key={proj.id} value={proj.id}>
|
||||
[{proj.serialNumber}] {proj.title} • {proj.consoleModel} ({proj.status})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{targetProject && (
|
||||
<div className="mt-2 p-2.5 bg-slate-950/60 border border-slate-800/80 rounded-lg text-xs text-slate-400 flex items-center justify-between">
|
||||
<span>Modèle : <strong className="text-white">{targetProject.consoleModel}</strong></span>
|
||||
<span>Prix de vente cible actuel : <strong className="text-emerald-400 font-mono">{targetProject.salePriceTarget} €</strong></span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Parts preview & Stock availability */}
|
||||
{modToApply.requiredParts && modToApply.requiredParts.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1.5">
|
||||
Pièces qui seront ajoutées au projet ({modToApply.requiredParts.length}) :
|
||||
</label>
|
||||
<div className="space-y-1.5 max-h-36 overflow-y-auto pr-1">
|
||||
{modToApply.requiredParts.map((part, idx) => {
|
||||
const invItem = part.partId ? inventory.find((i) => i.id === part.partId) : null;
|
||||
const hasStock = invItem ? invItem.quantityInStock >= part.quantity : true;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="p-2 bg-slate-950/80 border border-slate-800 rounded-lg flex items-center justify-between text-xs"
|
||||
>
|
||||
<div className="flex-1 min-w-0 pr-2">
|
||||
<div className="text-white font-medium truncate">{part.partName}</div>
|
||||
<div className="text-[10px] text-slate-400">
|
||||
Qté : {part.quantity} • Coût estimé : {part.estimatedCost.toFixed(2)} €
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0">
|
||||
{invItem ? (
|
||||
hasStock ? (
|
||||
<span className="px-2 py-0.5 bg-emerald-500/10 text-emerald-400 border border-emerald-500/30 rounded text-[10px] font-mono">
|
||||
En Stock ({invItem.quantityInStock})
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-2 py-0.5 bg-amber-500/15 text-amber-300 border border-amber-500/30 rounded text-[10px] font-mono">
|
||||
À Prévoir (Stock : {invItem.quantityInStock})
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
<span className="px-2 py-0.5 bg-amber-500/15 text-amber-300 border border-amber-500/30 rounded text-[10px] font-mono">
|
||||
À Prévoir (En Prévision)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Planned Items Info Banner */}
|
||||
<div className="p-3 bg-slate-950/90 border border-amber-500/30 rounded-xl text-xs text-amber-300 flex items-start gap-2">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5 text-amber-400" />
|
||||
<span className="text-[11px] leading-relaxed text-amber-200/90">
|
||||
Les pièces non disponibles en stock seront ajoutées au projet en mode <strong>« À approvisionner »</strong> (coût à prévoir) et enregistrées dans votre stock sous le statut <strong>« En Prévision »</strong>.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Options Toggles */}
|
||||
<div className="space-y-2 pt-2 border-t border-slate-800">
|
||||
<label className="flex items-center gap-2 cursor-pointer text-xs text-slate-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={deductStock}
|
||||
onChange={(e) => setDeductStock(e.target.checked)}
|
||||
className="rounded bg-slate-950 border-slate-700 text-cyan-500 focus:ring-cyan-500 w-4 h-4"
|
||||
/>
|
||||
<span>📦 Déduire automatiquement les pièces disponibles de l'inventaire</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 cursor-pointer text-xs text-slate-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={addTasks}
|
||||
onChange={(e) => setAddTasks(e.target.checked)}
|
||||
className="rounded bg-slate-950 border-slate-700 text-purple-500 focus:ring-purple-500 w-4 h-4"
|
||||
/>
|
||||
<span>📋 Importer les {modToApply.checklistTasks?.length || 0} étapes du mod dans la checklist du projet</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="pt-3 border-t border-slate-800 flex flex-col sm:flex-row items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="w-full sm:w-auto px-4 py-2.5 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-xl text-xs font-semibold transition-colors"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!selectedProjectId}
|
||||
onClick={handleApplyAndOpenProject}
|
||||
className="flex-1 sm:flex-initial px-4 py-2.5 bg-slate-800 hover:bg-slate-700 text-cyan-300 border border-cyan-500/30 rounded-xl text-xs font-semibold flex items-center justify-center gap-1.5 transition-colors"
|
||||
>
|
||||
Appliquer & Ouvrir
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!selectedProjectId}
|
||||
className="flex-1 sm:flex-initial px-5 py-2.5 bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 disabled:opacity-50 text-white rounded-xl text-xs font-bold shadow-lg shadow-cyan-500/20 flex items-center justify-center gap-1.5 transition-all"
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4" /> Appliquer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
591
src/components/mods/ModRecipeModal.tsx
Normal file
591
src/components/mods/ModRecipeModal.tsx
Normal file
@@ -0,0 +1,591 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
X,
|
||||
Sparkles,
|
||||
Layers,
|
||||
Clock,
|
||||
Euro,
|
||||
Plus,
|
||||
Trash2,
|
||||
CheckCircle2,
|
||||
Wrench,
|
||||
} from 'lucide-react';
|
||||
import { useApp } from '../../context/AppContext';
|
||||
import type {
|
||||
ModRecipe,
|
||||
ModCategory,
|
||||
ConsoleModel,
|
||||
ModRequiredPart,
|
||||
} from '../../types';
|
||||
|
||||
interface ModRecipeModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
editingMod?: ModRecipe | null;
|
||||
}
|
||||
|
||||
export const ModRecipeModal: React.FC<ModRecipeModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
editingMod,
|
||||
}) => {
|
||||
const { inventory, addModRecipe, updateModRecipe, modCategories, consoleModels } = useApp();
|
||||
|
||||
// Form State
|
||||
const [name, setName] = useState('');
|
||||
const [category, setCategory] = useState<ModCategory>('screen');
|
||||
const [selectedConsoles, setSelectedConsoles] = useState<ConsoleModel[]>(['Game Boy Advance']);
|
||||
const [description, setDescription] = useState('');
|
||||
const [installationNotes, setInstallationNotes] = useState('');
|
||||
const [difficulty, setDifficulty] = useState<'easy' | 'medium' | 'hard' | 'expert'>('medium');
|
||||
const [estimatedMinutes, setEstimatedMinutes] = useState(45);
|
||||
const [suggestedAddedValue, setSuggestedAddedValue] = useState(40);
|
||||
const [isPopular, setIsPopular] = useState(false);
|
||||
const [tagsInput, setTagsInput] = useState('');
|
||||
|
||||
// Required Parts
|
||||
const [requiredParts, setRequiredParts] = useState<ModRequiredPart[]>([]);
|
||||
const [selectedInventoryPartId, setSelectedInventoryPartId] = useState('');
|
||||
|
||||
// Checklist Tasks
|
||||
const [checklistTasks, setChecklistTasks] = useState<string[]>([]);
|
||||
const [newTaskInput, setNewTaskInput] = useState('');
|
||||
|
||||
// Initialize or Reset
|
||||
useEffect(() => {
|
||||
if (editingMod) {
|
||||
setName(editingMod.name);
|
||||
setCategory(editingMod.category);
|
||||
setSelectedConsoles(editingMod.compatibleModels || []);
|
||||
setDescription(editingMod.description || '');
|
||||
setInstallationNotes(editingMod.installationNotes || '');
|
||||
setDifficulty(editingMod.difficulty || 'medium');
|
||||
setEstimatedMinutes(editingMod.estimatedMinutes || 45);
|
||||
setSuggestedAddedValue(editingMod.suggestedAddedValue || 40);
|
||||
setIsPopular(editingMod.isPopular || false);
|
||||
setTagsInput(editingMod.tags ? editingMod.tags.join(', ') : '');
|
||||
setRequiredParts(editingMod.requiredParts || []);
|
||||
setChecklistTasks(editingMod.checklistTasks || []);
|
||||
} else {
|
||||
setName('');
|
||||
setCategory('screen');
|
||||
setSelectedConsoles(['Game Boy Advance']);
|
||||
setDescription('');
|
||||
setInstallationNotes('');
|
||||
setDifficulty('medium');
|
||||
setEstimatedMinutes(45);
|
||||
setSuggestedAddedValue(40);
|
||||
setIsPopular(false);
|
||||
setTagsInput('');
|
||||
setRequiredParts([]);
|
||||
setChecklistTasks([
|
||||
'Démonter la console et nettoyer la zone d\'intervention',
|
||||
'Réaliser les soudures nécessaires avec étain de précision',
|
||||
'Contrôler le bon fonctionnement avant remontage complet',
|
||||
]);
|
||||
}
|
||||
}, [editingMod, isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const toggleConsole = (consoleName: ConsoleModel) => {
|
||||
if (selectedConsoles.includes(consoleName)) {
|
||||
if (selectedConsoles.length > 1) {
|
||||
setSelectedConsoles(selectedConsoles.filter((c) => c !== consoleName));
|
||||
}
|
||||
} else {
|
||||
setSelectedConsoles([...selectedConsoles, consoleName]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddInventoryPart = () => {
|
||||
if (!selectedInventoryPartId) return;
|
||||
const invItem = inventory.find((i) => i.id === selectedInventoryPartId);
|
||||
if (!invItem) return;
|
||||
|
||||
setRequiredParts([
|
||||
...requiredParts,
|
||||
{
|
||||
partId: invItem.id,
|
||||
partName: invItem.name,
|
||||
category: invItem.category,
|
||||
quantity: 1,
|
||||
estimatedCost: invItem.unitCostWithFees,
|
||||
},
|
||||
]);
|
||||
setSelectedInventoryPartId('');
|
||||
};
|
||||
|
||||
const handleAddCustomPart = () => {
|
||||
setRequiredParts([
|
||||
...requiredParts,
|
||||
{
|
||||
partName: 'Nouvelle pièce requise',
|
||||
category: 'other',
|
||||
quantity: 1,
|
||||
estimatedCost: 10,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const handleUpdatePart = (index: number, updates: Partial<ModRequiredPart>) => {
|
||||
const updated = [...requiredParts];
|
||||
updated[index] = { ...updated[index], ...updates };
|
||||
setRequiredParts(updated);
|
||||
};
|
||||
|
||||
const handleRemovePart = (index: number) => {
|
||||
setRequiredParts(requiredParts.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleAddTask = () => {
|
||||
if (!newTaskInput.trim()) return;
|
||||
setChecklistTasks([...checklistTasks, newTaskInput.trim()]);
|
||||
setNewTaskInput('');
|
||||
};
|
||||
|
||||
const handleRemoveTask = (index: number) => {
|
||||
setChecklistTasks(checklistTasks.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const calculateTotalPartsCost = () => {
|
||||
return requiredParts.reduce((acc, p) => acc + (p.estimatedCost * p.quantity), 0);
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) return;
|
||||
|
||||
const parsedTags = tagsInput
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const modData = {
|
||||
name: name.trim(),
|
||||
category,
|
||||
compatibleModels: selectedConsoles,
|
||||
description: description.trim(),
|
||||
installationNotes: installationNotes.trim(),
|
||||
difficulty,
|
||||
estimatedMinutes: Number(estimatedMinutes) || 30,
|
||||
suggestedAddedValue: Number(suggestedAddedValue) || 0,
|
||||
requiredParts,
|
||||
checklistTasks,
|
||||
tags: parsedTags,
|
||||
isPopular,
|
||||
};
|
||||
|
||||
if (editingMod) {
|
||||
updateModRecipe(editingMod.id, modData);
|
||||
} else {
|
||||
addModRecipe(modData);
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-4 md:p-6 bg-slate-950/80 backdrop-blur-md overflow-y-auto">
|
||||
<div className="bg-slate-900 border border-slate-700/80 rounded-2xl w-full max-w-4xl max-h-[92vh] flex flex-col shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-200">
|
||||
|
||||
{/* Modal Header */}
|
||||
<div className="p-5 sm:p-6 border-b border-slate-800 flex items-center justify-between bg-gradient-to-r from-pink-500/10 via-purple-500/10 to-transparent">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-3 bg-pink-500/20 text-pink-400 border border-pink-500/30 rounded-xl">
|
||||
<Sparkles className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white flex items-center gap-2">
|
||||
{editingMod ? 'Modifier la Recette de Mod' : 'Créer une Nouvelle Recette de Mod'}
|
||||
</h2>
|
||||
<p className="text-xs text-slate-400">
|
||||
Définissez les pièces requises, le temps d'atelier et la valeur ajoutée pour réutiliser ce mod dans vos projets.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-slate-400 hover:text-white bg-slate-800/80 hover:bg-slate-700 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal Body / Form */}
|
||||
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto p-5 sm:p-6 space-y-6">
|
||||
|
||||
{/* Section 1: Informations Générales */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-pink-400 flex items-center gap-2">
|
||||
<Wrench className="w-4 h-4" /> 1. Informations Générales
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-xs font-medium text-slate-300 mb-1">
|
||||
Nom du Mod / Titre de la Recette *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="Ex: Écran IPS V5 Laminé Grand Angle avec Menu OSD"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-700 rounded-xl px-4 py-2.5 text-white placeholder-slate-500 focus:outline-none focus:border-pink-500 text-sm font-medium"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-300 mb-1">
|
||||
Catégorie de Modification
|
||||
</label>
|
||||
<select
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value as ModCategory)}
|
||||
className="w-full bg-slate-950 border border-slate-700 rounded-xl px-3 py-2.5 text-white focus:outline-none focus:border-pink-500 text-sm"
|
||||
>
|
||||
{modCategories.map((cat) => (
|
||||
<option key={cat.id} value={cat.slug}>
|
||||
{cat.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-300 mb-1">
|
||||
Niveau de Difficulté
|
||||
</label>
|
||||
<select
|
||||
value={difficulty}
|
||||
onChange={(e) => setDifficulty(e.target.value as any)}
|
||||
className="w-full bg-slate-950 border border-slate-700 rounded-xl px-3 py-2.5 text-white focus:outline-none focus:border-pink-500 text-sm font-medium"
|
||||
>
|
||||
<option value="easy">🟢 Facile (Sans soudure / Plug & Play)</option>
|
||||
<option value="medium">🟡 Intermédiaire (3-4 points de soudure)</option>
|
||||
<option value="hard">🟠 Avancé (Découpe coque & soudures de précision)</option>
|
||||
<option value="expert">🔴 Expert (Full Recap CMS & Micro-soudure)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Consoles Compatibles (Multi-select Chips) */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-300 mb-2">
|
||||
Consoles Compatibles (cliquez pour activer/désactiver)
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto p-2 bg-slate-950 border border-slate-800 rounded-xl">
|
||||
{consoleModels.map((cm) => {
|
||||
const isSelected = selectedConsoles.includes(cm.slug);
|
||||
return (
|
||||
<button
|
||||
key={cm.id}
|
||||
type="button"
|
||||
onClick={() => toggleConsole(cm.slug)}
|
||||
className={`px-3 py-1 rounded-lg text-xs font-medium transition-all ${
|
||||
isSelected
|
||||
? 'bg-pink-500 text-white shadow-sm shadow-pink-500/30'
|
||||
: 'bg-slate-800 text-slate-400 hover:bg-slate-700 hover:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
{isSelected ? '✓ ' : '+ '}
|
||||
{cm.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description & Notes */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-300 mb-1">
|
||||
Description Commerciale & Caractéristiques
|
||||
</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
placeholder="Explication des fonctionnalités pour la fiche client..."
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-700 rounded-xl px-3 py-2 text-white placeholder-slate-500 focus:outline-none focus:border-pink-500 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-300 mb-1">
|
||||
Notes Techniques d'Installation & Précautions
|
||||
</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
placeholder="Ex: Points de test TP2 / TP3, isolation thermique du module..."
|
||||
value={installationNotes}
|
||||
onChange={(e) => setInstallationNotes(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-700 rounded-xl px-3 py-2 text-white placeholder-slate-500 focus:outline-none focus:border-pink-500 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 2: Pièces & Matériel Requis (Lié à l'inventaire) */}
|
||||
<div className="space-y-4 pt-4 border-t border-slate-800">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-cyan-400 flex items-center gap-2">
|
||||
<Layers className="w-4 h-4" /> 2. Pièces & Composants Requis
|
||||
</h3>
|
||||
<p className="text-xs text-slate-400">
|
||||
Total matériel estimé : <strong className="text-cyan-300 font-mono">{calculateTotalPartsCost().toFixed(2)} €</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddCustomPart}
|
||||
className="px-2.5 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-200 rounded-lg text-xs font-medium flex items-center gap-1 transition-colors"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" /> Pièce Libre
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Link from Inventory Dropdown */}
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={selectedInventoryPartId}
|
||||
onChange={(e) => setSelectedInventoryPartId(e.target.value)}
|
||||
className="flex-1 bg-slate-950 border border-slate-700 rounded-xl px-3 py-2 text-white text-xs focus:outline-none focus:border-cyan-500"
|
||||
>
|
||||
<option value="">Sélectionner une pièce depuis votre stock actuel...</option>
|
||||
{inventory.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
[{item.sku}] {item.name} ({item.unitCostWithFees.toFixed(2)} € - Dispo: {item.quantityInStock})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!selectedInventoryPartId}
|
||||
onClick={handleAddInventoryPart}
|
||||
className="px-4 py-2 bg-cyan-600 hover:bg-cyan-500 disabled:opacity-50 text-white rounded-xl text-xs font-semibold flex items-center gap-1 transition-colors shrink-0"
|
||||
>
|
||||
<Plus className="w-4 h-4" /> Ajouter du Stock
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* List of Added Parts */}
|
||||
{requiredParts.length === 0 ? (
|
||||
<div className="p-4 rounded-xl border border-dashed border-slate-800 text-center text-xs text-slate-500">
|
||||
Aucune pièce liée à cette recette. Vous pouvez sélectionner des pièces de votre stock ou en créer librement.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-48 overflow-y-auto pr-1">
|
||||
{requiredParts.map((part, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="p-2.5 bg-slate-950/80 border border-slate-800 rounded-xl flex flex-col sm:flex-row sm:items-center justify-between gap-2 text-xs"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<input
|
||||
type="text"
|
||||
value={part.partName}
|
||||
onChange={(e) => handleUpdatePart(index, { partName: e.target.value })}
|
||||
className="w-full bg-transparent border-b border-transparent hover:border-slate-700 focus:border-cyan-500 text-white font-medium focus:outline-none"
|
||||
/>
|
||||
<div className="text-[10px] text-slate-500">
|
||||
{part.partId ? '🔗 Pièce liée au stock' : '📝 Pièce générique libre'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-slate-400">Qté:</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={part.quantity}
|
||||
onChange={(e) => handleUpdatePart(index, { quantity: Math.max(1, parseInt(e.target.value) || 1) })}
|
||||
className="w-12 bg-slate-900 border border-slate-700 rounded px-1.5 py-0.5 text-center text-white text-xs font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-slate-400">Coût:</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
value={part.estimatedCost}
|
||||
onChange={(e) => handleUpdatePart(index, { estimatedCost: parseFloat(e.target.value) || 0 })}
|
||||
className="w-16 bg-slate-900 border border-slate-700 rounded px-1.5 py-0.5 text-right text-cyan-400 font-mono text-xs"
|
||||
/>
|
||||
<span className="text-slate-400">€</span>
|
||||
</div>
|
||||
|
||||
<div className="font-mono text-cyan-300 font-semibold min-w-[50px] text-right">
|
||||
{(part.estimatedCost * part.quantity).toFixed(2)} €
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemovePart(index)}
|
||||
className="p-1 text-slate-500 hover:text-red-400 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Section 3: Temps Atelier, Valeur Marchande & Checklist */}
|
||||
<div className="space-y-4 pt-4 border-t border-slate-800">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-purple-400 flex items-center gap-2">
|
||||
<Clock className="w-4 h-4" /> 3. Temps d'Atelier, Prix & Checklist d'Étapes
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-300 mb-1">
|
||||
Temps d'Installation Estimé (Minutes)
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Clock className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
|
||||
<input
|
||||
type="number"
|
||||
min="5"
|
||||
step="5"
|
||||
value={estimatedMinutes}
|
||||
onChange={(e) => setEstimatedMinutes(parseInt(e.target.value) || 0)}
|
||||
className="w-full bg-slate-950 border border-slate-700 rounded-xl pl-9 pr-4 py-2.5 text-white font-mono text-sm focus:outline-none focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-300 mb-1">
|
||||
Valeur Ajoutée Suggérée sur le Prix de Vente (€)
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Euro className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="5"
|
||||
value={suggestedAddedValue}
|
||||
onChange={(e) => setSuggestedAddedValue(parseFloat(e.target.value) || 0)}
|
||||
className="w-full bg-slate-950 border border-slate-700 rounded-xl pl-9 pr-4 py-2.5 text-emerald-400 font-mono font-bold text-sm focus:outline-none focus:border-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Checklist Tasks Builder */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-300 mb-2">
|
||||
Étapes de Travail / Checklist Recommandée pour ce Mod
|
||||
</label>
|
||||
|
||||
<div className="flex gap-2 mb-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Ex: Découpe interne de la coque, Soudure des fils OSD..."
|
||||
value={newTaskInput}
|
||||
onChange={(e) => setNewTaskInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleAddTask();
|
||||
}
|
||||
}}
|
||||
className="flex-1 bg-slate-950 border border-slate-700 rounded-xl px-3 py-2 text-white text-xs focus:outline-none focus:border-purple-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddTask}
|
||||
className="px-3 py-2 bg-purple-600 hover:bg-purple-500 text-white rounded-xl text-xs font-semibold flex items-center gap-1 transition-colors"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" /> Ajouter Étape
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 max-h-36 overflow-y-auto">
|
||||
{checklistTasks.map((task, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-center justify-between p-2 bg-slate-950/60 border border-slate-800/80 rounded-lg text-xs"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-5 h-5 rounded-full bg-purple-500/20 text-purple-300 flex items-center justify-center text-[10px] font-bold">
|
||||
{idx + 1}
|
||||
</span>
|
||||
<span className="text-slate-200">{task}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveTask(idx)}
|
||||
className="text-slate-500 hover:text-red-400 p-1"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags & Badges */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 items-center">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-300 mb-1">
|
||||
Tags & Mots-clés (séparés par des virgules)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Écran, IPS, OSD, Rétroéclairage..."
|
||||
value={tagsInput}
|
||||
onChange={(e) => setTagsInput(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-700 rounded-xl px-3 py-2 text-white text-xs focus:outline-none focus:border-pink-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 flex items-center gap-2">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isPopular}
|
||||
onChange={(e) => setIsPopular(e.target.checked)}
|
||||
className="rounded bg-slate-950 border-slate-700 text-pink-500 focus:ring-pink-500 w-4 h-4"
|
||||
/>
|
||||
<span className="text-xs text-slate-300 font-medium flex items-center gap-1">
|
||||
<Sparkles className="w-3.5 h-3.5 text-pink-400" /> Marquer comme "Mod Populaire / Best Seller"
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="pt-4 border-t border-slate-800 flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2.5 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-xl text-xs font-semibold transition-colors"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="px-6 py-2.5 bg-gradient-to-r from-pink-500 to-purple-600 hover:from-pink-400 hover:to-purple-500 text-white rounded-xl text-xs font-bold shadow-lg shadow-pink-500/20 flex items-center gap-2 transition-all transform hover:-translate-y-0.5"
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
{editingMod ? 'Enregistrer les Modifications' : 'Créer la Recette de Mod'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
615
src/components/mods/ModsLibraryView.tsx
Normal file
615
src/components/mods/ModsLibraryView.tsx
Normal file
@@ -0,0 +1,615 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Sparkles,
|
||||
Plus,
|
||||
Search,
|
||||
Monitor,
|
||||
BatteryCharging,
|
||||
Volume2,
|
||||
Palette,
|
||||
Cpu,
|
||||
Layers,
|
||||
Wrench,
|
||||
Clock,
|
||||
Euro,
|
||||
Copy,
|
||||
Edit2,
|
||||
Trash2,
|
||||
Boxes,
|
||||
ListTodo,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Play,
|
||||
} from 'lucide-react';
|
||||
import { useApp } from '../../context/AppContext';
|
||||
import { ModRecipeModal } from './ModRecipeModal';
|
||||
import { ApplyModModal } from './ApplyModModal';
|
||||
import type { ModRecipe, ModCategory } from '../../types';
|
||||
|
||||
const CATEGORY_META: Record<ModCategory, { label: string; icon: React.ElementType; color: string; border: string; bg: string }> = {
|
||||
screen: {
|
||||
label: 'Écran & Affichage',
|
||||
icon: Monitor,
|
||||
color: 'text-cyan-400',
|
||||
border: 'border-cyan-500/30',
|
||||
bg: 'bg-cyan-500/10',
|
||||
},
|
||||
battery_power: {
|
||||
label: 'Batterie & USB-C',
|
||||
icon: BatteryCharging,
|
||||
color: 'text-emerald-400',
|
||||
border: 'border-emerald-500/30',
|
||||
bg: 'bg-emerald-500/10',
|
||||
},
|
||||
audio_sound: {
|
||||
label: 'Audio & Ampli',
|
||||
icon: Volume2,
|
||||
color: 'text-pink-400',
|
||||
border: 'border-pink-500/30',
|
||||
bg: 'bg-pink-500/10',
|
||||
},
|
||||
shell_aesthetics: {
|
||||
label: 'Coque & Esthétique',
|
||||
icon: Palette,
|
||||
color: 'text-blue-400',
|
||||
border: 'border-blue-500/30',
|
||||
bg: 'bg-blue-500/10',
|
||||
},
|
||||
buttons_led: {
|
||||
label: 'Boutons & LED',
|
||||
icon: Sparkles,
|
||||
color: 'text-purple-400',
|
||||
border: 'border-purple-500/30',
|
||||
bg: 'bg-purple-500/10',
|
||||
},
|
||||
motherboard_recap: {
|
||||
label: 'Recap & Carte Mère',
|
||||
icon: Cpu,
|
||||
color: 'text-amber-400',
|
||||
border: 'border-amber-500/30',
|
||||
bg: 'bg-amber-500/10',
|
||||
},
|
||||
complete_kit: {
|
||||
label: 'Pack Ultime All-in-One',
|
||||
icon: Layers,
|
||||
color: 'text-rose-400',
|
||||
border: 'border-rose-500/30',
|
||||
bg: 'bg-rose-500/10',
|
||||
},
|
||||
storage_expansion: {
|
||||
label: 'Stockage & Mémoire',
|
||||
icon: Boxes,
|
||||
color: 'text-teal-400',
|
||||
border: 'border-teal-500/30',
|
||||
bg: 'bg-teal-500/10',
|
||||
},
|
||||
other: {
|
||||
label: 'Autre Mod',
|
||||
icon: Wrench,
|
||||
color: 'text-slate-300',
|
||||
border: 'border-slate-700',
|
||||
bg: 'bg-slate-800',
|
||||
},
|
||||
};
|
||||
|
||||
const DIFFICULTY_META = {
|
||||
easy: { label: 'Facile (Plug & Play)', color: 'text-emerald-400 bg-emerald-500/10 border-emerald-500/20' },
|
||||
medium: { label: 'Intermédiaire', color: 'text-amber-400 bg-amber-500/10 border-amber-500/20' },
|
||||
hard: { label: 'Avancé', color: 'text-orange-400 bg-orange-500/10 border-orange-500/20' },
|
||||
expert: { label: 'Expert (Recap/CMS)', color: 'text-red-400 bg-red-500/10 border-red-500/20' },
|
||||
};
|
||||
|
||||
export const ModsLibraryView: React.FC = () => {
|
||||
const { modsLibrary, deleteModRecipe, addModRecipe, modCategories, consoleModels } = useApp();
|
||||
|
||||
// Search & Filter State
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>('all');
|
||||
const [selectedConsole, setSelectedConsole] = useState<string>('all');
|
||||
const [selectedDifficulty, setSelectedDifficulty] = useState<string>('all');
|
||||
|
||||
// Modals state
|
||||
const [showRecipeModal, setShowRecipeModal] = useState(false);
|
||||
const [editingMod, setEditingMod] = useState<ModRecipe | null>(null);
|
||||
const [modToApply, setModToApply] = useState<ModRecipe | null>(null);
|
||||
const [expandedCards, setExpandedCards] = useState<Record<string, boolean>>({});
|
||||
|
||||
// Toggle card checklist expansion
|
||||
const toggleCardExpand = (id: string) => {
|
||||
setExpandedCards((prev) => ({ ...prev, [id]: !prev[id] }));
|
||||
};
|
||||
|
||||
// Duplicate recipe
|
||||
const handleDuplicate = (mod: ModRecipe) => {
|
||||
const copyData = {
|
||||
...mod,
|
||||
name: `${mod.name} (Copie)`,
|
||||
isPopular: false,
|
||||
};
|
||||
addModRecipe(copyData);
|
||||
};
|
||||
|
||||
// Stats
|
||||
const totalMods = modsLibrary.length;
|
||||
const popularModsCount = modsLibrary.filter((m) => m.isPopular).length;
|
||||
const avgAddedValue = totalMods > 0
|
||||
? modsLibrary.reduce((s, m) => s + (m.suggestedAddedValue || 0), 0) / totalMods
|
||||
: 0;
|
||||
const avgMinutes = totalMods > 0
|
||||
? modsLibrary.reduce((s, m) => s + (m.estimatedMinutes || 0), 0) / totalMods
|
||||
: 0;
|
||||
|
||||
// Filtered mods
|
||||
const filteredMods = modsLibrary.filter((mod) => {
|
||||
// Search query
|
||||
if (searchQuery.trim()) {
|
||||
const q = searchQuery.toLowerCase();
|
||||
const matchName = mod.name.toLowerCase().includes(q);
|
||||
const matchDesc = (mod.description || '').toLowerCase().includes(q);
|
||||
const matchTags = (mod.tags || []).some((t) => t.toLowerCase().includes(q));
|
||||
const matchParts = (mod.requiredParts || []).some((p) => p.partName.toLowerCase().includes(q));
|
||||
if (!matchName && !matchDesc && !matchTags && !matchParts) return false;
|
||||
}
|
||||
|
||||
// Category filter
|
||||
if (selectedCategory !== 'all' && mod.category !== selectedCategory) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Console model filter
|
||||
if (selectedConsole !== 'all') {
|
||||
const isCompatible = (mod.compatibleModels || []).some(
|
||||
(c) => c === selectedConsole || c === 'Autre / Custom'
|
||||
);
|
||||
if (!isCompatible) return false;
|
||||
}
|
||||
|
||||
// Difficulty filter
|
||||
if (selectedDifficulty !== 'all' && mod.difficulty !== selectedDifficulty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-16">
|
||||
|
||||
{/* Top Banner & Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 p-6 bg-gradient-to-r from-purple-900/30 via-slate-900 to-pink-900/20 border border-slate-800 rounded-3xl shadow-xl">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-2.5 py-0.5 bg-pink-500/20 text-pink-400 border border-pink-500/30 rounded-full text-xs font-semibold uppercase tracking-wider flex items-center gap-1">
|
||||
<Sparkles className="w-3 h-3" /> Catalogue & Recettes de Modding
|
||||
</span>
|
||||
<span className="text-xs text-slate-400 font-mono">
|
||||
{totalMods} recettes prêtes à l'emploi
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="text-2xl sm:text-3xl font-black text-white tracking-tight">
|
||||
Bibliothèque de Mods & Kits Prédéfinis
|
||||
</h1>
|
||||
<p className="text-xs sm:text-sm text-slate-300 max-w-2xl">
|
||||
Créez vos recettes de modding réutilisables (écrans IPS, USB-C, CleanAmp, coques UV, recaps), associez les pièces requises et appliquez-les à vos projets en un clic.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingMod(null);
|
||||
setShowRecipeModal(true);
|
||||
}}
|
||||
className="px-5 py-3 bg-gradient-to-r from-pink-500 to-purple-600 hover:from-pink-400 hover:to-purple-500 text-white rounded-2xl text-xs sm:text-sm font-bold shadow-lg shadow-pink-500/25 flex items-center gap-2 transition-all transform hover:-translate-y-0.5 shrink-0"
|
||||
>
|
||||
<Plus className="w-4 h-4" /> Nouvelle Recette de Mod
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI Cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="p-4 bg-slate-900/80 border border-slate-800/80 rounded-2xl flex items-center gap-3">
|
||||
<div className="p-3 bg-purple-500/10 text-purple-400 border border-purple-500/20 rounded-xl shrink-0">
|
||||
<Layers className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[11px] text-slate-400 uppercase tracking-wider font-semibold">Total Recettes</div>
|
||||
<div className="text-xl font-bold text-white font-mono">{totalMods}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-slate-900/80 border border-slate-800/80 rounded-2xl flex items-center gap-3">
|
||||
<div className="p-3 bg-pink-500/10 text-pink-400 border border-pink-500/20 rounded-xl shrink-0">
|
||||
<Sparkles className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[11px] text-slate-400 uppercase tracking-wider font-semibold">Best Sellers</div>
|
||||
<div className="text-xl font-bold text-pink-400 font-mono">{popularModsCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-slate-900/80 border border-slate-800/80 rounded-2xl flex items-center gap-3">
|
||||
<div className="p-3 bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 rounded-xl shrink-0">
|
||||
<Euro className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[11px] text-slate-400 uppercase tracking-wider font-semibold">Valeur Ajoutée Moy.</div>
|
||||
<div className="text-xl font-bold text-emerald-400 font-mono">+{avgAddedValue.toFixed(0)} €</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-slate-900/80 border border-slate-800/80 rounded-2xl flex items-center gap-3">
|
||||
<div className="p-3 bg-cyan-500/10 text-cyan-400 border border-cyan-500/20 rounded-xl shrink-0">
|
||||
<Clock className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[11px] text-slate-400 uppercase tracking-wider font-semibold">Temps Atelier Moyen</div>
|
||||
<div className="text-xl font-bold text-cyan-400 font-mono">{avgMinutes.toFixed(0)} min</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter & Search Toolbar */}
|
||||
<div className="p-4 bg-slate-900/90 border border-slate-800 rounded-2xl space-y-3">
|
||||
<div className="flex flex-col md:flex-row gap-3 items-stretch md:items-center">
|
||||
|
||||
{/* Search Input */}
|
||||
<div className="relative flex-1">
|
||||
<Search className="w-4 h-4 absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Rechercher un mod par nom, pièce requise, tag (ex: IPS, CleanAmp, USB-C, Recap)..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800 focus:border-pink-500 rounded-xl pl-10 pr-4 py-2 text-xs sm:text-sm text-white placeholder-slate-500 focus:outline-none"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white text-xs"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Console filter dropdown */}
|
||||
<div className="shrink-0">
|
||||
<select
|
||||
value={selectedConsole}
|
||||
onChange={(e) => setSelectedConsole(e.target.value)}
|
||||
className="w-full md:w-auto bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-xs text-slate-200 focus:outline-none focus:border-pink-500"
|
||||
>
|
||||
<option value="all">🎮 Toutes les Consoles</option>
|
||||
{consoleModels.map((cm) => (
|
||||
<option key={cm.id} value={cm.slug}>
|
||||
{cm.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Category filter dropdown */}
|
||||
<div className="shrink-0">
|
||||
<select
|
||||
value={selectedCategory}
|
||||
onChange={(e) => setSelectedCategory(e.target.value)}
|
||||
className="w-full md:w-auto bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-xs text-slate-200 focus:outline-none focus:border-pink-500"
|
||||
>
|
||||
<option value="all">📁 Toutes Catégories</option>
|
||||
{modCategories.map((mc) => (
|
||||
<option key={mc.id} value={mc.slug}>
|
||||
{mc.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Difficulty filter dropdown */}
|
||||
<div className="shrink-0">
|
||||
<select
|
||||
value={selectedDifficulty}
|
||||
onChange={(e) => setSelectedDifficulty(e.target.value)}
|
||||
className="w-full md:w-auto bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-xs text-slate-200 focus:outline-none focus:border-pink-500"
|
||||
>
|
||||
<option value="all">⚡ Toute Difficulté</option>
|
||||
<option value="easy">🟢 Facile</option>
|
||||
<option value="medium">🟡 Intermédiaire</option>
|
||||
<option value="hard">🟠 Avancé</option>
|
||||
<option value="expert">🔴 Expert</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Category Chips */}
|
||||
<div className="flex flex-wrap gap-1.5 pt-1 overflow-x-auto pb-1">
|
||||
<button
|
||||
onClick={() => setSelectedCategory('all')}
|
||||
className={`px-3 py-1 rounded-lg text-xs font-medium transition-all ${
|
||||
selectedCategory === 'all'
|
||||
? 'bg-pink-500 text-white shadow-sm shadow-pink-500/30'
|
||||
: 'bg-slate-950 text-slate-400 hover:text-white border border-slate-800'
|
||||
}`}
|
||||
>
|
||||
Tous ({modsLibrary.length})
|
||||
</button>
|
||||
{Object.entries(CATEGORY_META).map(([catKey, meta]) => {
|
||||
const count = modsLibrary.filter((m) => m.category === catKey).length;
|
||||
if (count === 0 && selectedCategory !== catKey) return null;
|
||||
const Icon = meta.icon;
|
||||
const isActive = selectedCategory === catKey;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={catKey}
|
||||
onClick={() => setSelectedCategory(catKey)}
|
||||
className={`px-2.5 py-1 rounded-lg text-xs font-medium flex items-center gap-1.5 transition-all border ${
|
||||
isActive
|
||||
? 'bg-slate-800 text-white border-pink-500 shadow-sm'
|
||||
: 'bg-slate-950 text-slate-400 hover:text-slate-200 border-slate-800'
|
||||
}`}
|
||||
>
|
||||
<Icon className={`w-3.5 h-3.5 ${meta.color}`} />
|
||||
<span>{meta.label}</span>
|
||||
<span className="text-[10px] font-mono opacity-70">({count})</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid of Mod Recipe Cards */}
|
||||
{filteredMods.length === 0 ? (
|
||||
<div className="p-12 text-center bg-slate-900/60 border border-slate-800 rounded-3xl space-y-4">
|
||||
<div className="p-4 bg-slate-800/80 text-slate-400 rounded-2xl w-fit mx-auto">
|
||||
<Search className="w-8 h-8" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-base font-bold text-white">Aucune recette trouvée</h3>
|
||||
<p className="text-xs text-slate-400 max-w-md mx-auto">
|
||||
Aucun mod ne correspond à vos filtres actuels. Réinitialisez la recherche ou créez un nouveau mod personnalisé.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearchQuery('');
|
||||
setSelectedCategory('all');
|
||||
setSelectedConsole('all');
|
||||
setSelectedDifficulty('all');
|
||||
}}
|
||||
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-200 rounded-xl text-xs font-semibold transition-colors"
|
||||
>
|
||||
Réinitialiser les filtres
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-5">
|
||||
{filteredMods.map((mod) => {
|
||||
const catMeta = CATEGORY_META[mod.category] || CATEGORY_META.other;
|
||||
const CatIcon = catMeta.icon;
|
||||
const diffMeta = DIFFICULTY_META[mod.difficulty] || DIFFICULTY_META.medium;
|
||||
const totalPartsCost = (mod.requiredParts || []).reduce(
|
||||
(acc, p) => acc + p.estimatedCost * p.quantity,
|
||||
0
|
||||
);
|
||||
const isExpanded = expandedCards[mod.id] || false;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={mod.id}
|
||||
className="bg-slate-900 border border-slate-800 hover:border-slate-700/80 rounded-2xl p-5 flex flex-col justify-between space-y-4 shadow-lg hover:shadow-xl transition-all group relative overflow-hidden"
|
||||
>
|
||||
{/* Glow accent */}
|
||||
<div
|
||||
className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-bl from-pink-500/5 to-transparent rounded-bl-full pointer-events-none"
|
||||
/>
|
||||
|
||||
{/* Card Top: Badges & Actions */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className={`px-2.5 py-1 rounded-lg text-[11px] font-semibold flex items-center gap-1 border ${catMeta.bg} ${catMeta.color} ${catMeta.border}`}>
|
||||
<CatIcon className="w-3.5 h-3.5" />
|
||||
{catMeta.label}
|
||||
</span>
|
||||
|
||||
<span className={`px-2 py-0.5 rounded-md text-[10px] font-medium border ${diffMeta.color}`}>
|
||||
{diffMeta.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{mod.isPopular && (
|
||||
<span className="px-2 py-0.5 bg-gradient-to-r from-amber-500/20 to-pink-500/20 text-amber-300 border border-amber-500/30 rounded-full text-[10px] font-bold flex items-center gap-1 shrink-0">
|
||||
⭐ Best Seller
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title & Description */}
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-white group-hover:text-pink-300 transition-colors">
|
||||
{mod.name}
|
||||
</h3>
|
||||
{mod.description && (
|
||||
<p className="text-xs text-slate-400 mt-1 line-clamp-2">
|
||||
{mod.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Compatible Consoles Chips */}
|
||||
<div className="flex flex-wrap gap-1 pt-1">
|
||||
{(mod.compatibleModels || []).map((cons) => (
|
||||
<span
|
||||
key={cons}
|
||||
className="px-2 py-0.5 bg-slate-950 border border-slate-800 rounded text-[10px] text-slate-300 font-medium"
|
||||
>
|
||||
🎮 {cons}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Key Metrics: Time & Value */}
|
||||
<div className="grid grid-cols-2 gap-2 p-2.5 bg-slate-950/80 border border-slate-800/80 rounded-xl text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 bg-purple-500/10 text-purple-400 rounded-lg">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] text-slate-400">Atelier</div>
|
||||
<div className="font-bold text-white font-mono">~{mod.estimatedMinutes} min</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 bg-emerald-500/10 text-emerald-400 rounded-lg">
|
||||
<Euro className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] text-slate-400">Valeur Vente</div>
|
||||
<div className="font-bold text-emerald-400 font-mono">+{mod.suggestedAddedValue} €</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Required Parts Preview */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-[11px] text-slate-400 font-medium">
|
||||
<span className="flex items-center gap-1">
|
||||
<Boxes className="w-3.5 h-3.5 text-cyan-400" />
|
||||
{mod.requiredParts?.length || 0} composant(s) requis :
|
||||
</span>
|
||||
<span className="font-mono text-cyan-300">
|
||||
Total ~{totalPartsCost.toFixed(2)} €
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="p-2 bg-slate-950/50 border border-slate-800/60 rounded-lg text-[11px] space-y-1">
|
||||
{mod.requiredParts && mod.requiredParts.length > 0 ? (
|
||||
mod.requiredParts.map((rp, i) => (
|
||||
<div key={i} className="flex items-center justify-between text-slate-300">
|
||||
<span className="truncate pr-2">• {rp.quantity}x {rp.partName}</span>
|
||||
<span className="font-mono text-slate-400 shrink-0">{(rp.estimatedCost * rp.quantity).toFixed(2)} €</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<span className="text-slate-500 italic">Aucune pièce matérielle liée</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expandable Checklist Steps */}
|
||||
{mod.checklistTasks && mod.checklistTasks.length > 0 && (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleCardExpand(mod.id)}
|
||||
className="w-full flex items-center justify-between py-1 text-[11px] text-slate-400 hover:text-slate-200 transition-colors"
|
||||
>
|
||||
<span className="flex items-center gap-1">
|
||||
<ListTodo className="w-3.5 h-3.5 text-amber-400" />
|
||||
{mod.checklistTasks.length} étapes d'atelier recommandées
|
||||
</span>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<ChevronDown className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-1 p-2 bg-slate-950/60 border border-slate-800/60 rounded-lg text-[11px] space-y-1 animate-in fade-in duration-150">
|
||||
{mod.checklistTasks.map((task, idx) => (
|
||||
<div key={idx} className="flex items-start gap-1.5 text-slate-300">
|
||||
<span className="text-purple-400 font-bold">{idx + 1}.</span>
|
||||
<span>{task}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{mod.tags && mod.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{mod.tags.map((tag) => (
|
||||
<span key={tag} className="text-[10px] text-slate-500 flex items-center gap-0.5">
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Card Footer Actions */}
|
||||
<div className="pt-3 border-t border-slate-800 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
title="Modifier cette recette"
|
||||
onClick={() => {
|
||||
setEditingMod(mod);
|
||||
setShowRecipeModal(true);
|
||||
}}
|
||||
className="p-2 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition-colors"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
title="Dupliquer cette recette"
|
||||
onClick={() => handleDuplicate(mod)}
|
||||
className="p-2 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition-colors"
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
title="Supprimer cette recette"
|
||||
onClick={() => {
|
||||
if (window.confirm(`Supprimer définitivement la recette "${mod.name}" ?`)) {
|
||||
deleteModRecipe(mod.id);
|
||||
}
|
||||
}}
|
||||
className="p-2 text-slate-400 hover:text-red-400 hover:bg-slate-800 rounded-lg transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setModToApply(mod)}
|
||||
className="px-3.5 py-2 bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-white rounded-xl text-xs font-bold shadow-md shadow-cyan-500/20 flex items-center gap-1.5 transition-all transform hover:scale-105"
|
||||
>
|
||||
<Play className="w-3.5 h-3.5 fill-current" /> Appliquer au Projet
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modals */}
|
||||
<ModRecipeModal
|
||||
isOpen={showRecipeModal}
|
||||
onClose={() => {
|
||||
setShowRecipeModal(false);
|
||||
setEditingMod(null);
|
||||
}}
|
||||
editingMod={editingMod}
|
||||
/>
|
||||
|
||||
<ApplyModModal
|
||||
isOpen={!!modToApply}
|
||||
onClose={() => setModToApply(null)}
|
||||
modToApply={modToApply}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
180
src/components/projects/MarkAsSoldModal.tsx
Normal file
180
src/components/projects/MarkAsSoldModal.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, Sparkles, DollarSign, Store, User, Calendar } from 'lucide-react';
|
||||
import { useApp } from '../../context/AppContext';
|
||||
import type { Project, SalesChannel } from '../../types';
|
||||
import { formatCurrency } from '../../utils/calculations';
|
||||
|
||||
|
||||
interface MarkAsSoldModalProps {
|
||||
project: Project | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const SALES_CHANNELS: { id: SalesChannel; label: string }[] = [
|
||||
{ id: 'vinted', label: 'Vinted' },
|
||||
{ id: 'leboncoin', label: 'Leboncoin' },
|
||||
{ id: 'etsy', label: 'Etsy' },
|
||||
{ id: 'ebay', label: 'eBay' },
|
||||
{ id: 'direct_shop', label: 'Vente Directe / Boutique' },
|
||||
{ id: 'convention', label: 'Convention / Événement' },
|
||||
{ id: 'autre', label: 'Autre canal' },
|
||||
];
|
||||
|
||||
export const MarkAsSoldModal: React.FC<MarkAsSoldModalProps> = ({
|
||||
project,
|
||||
isOpen,
|
||||
onClose,
|
||||
}) => {
|
||||
const { markProjectAsSold, settings } = useApp();
|
||||
|
||||
const [soldPrice, setSoldPrice] = useState<number>(180);
|
||||
const [salesChannel, setSalesChannel] = useState<SalesChannel>('vinted');
|
||||
const [soldDate, setSoldDate] = useState<string>(
|
||||
new Date().toISOString().split('T')[0]
|
||||
);
|
||||
const [customerName, setCustomerName] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
if (project) {
|
||||
setSoldPrice(project.salePriceActual || project.salePriceTarget || 150);
|
||||
setSalesChannel(project.salesChannel || 'vinted');
|
||||
setSoldDate(project.soldDate || new Date().toISOString().split('T')[0]);
|
||||
setCustomerName(project.customerName || '');
|
||||
}
|
||||
}, [project, isOpen]);
|
||||
|
||||
if (!isOpen || !project) return null;
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
markProjectAsSold(
|
||||
project.id,
|
||||
Number(soldPrice) || 0,
|
||||
salesChannel,
|
||||
soldDate,
|
||||
customerName.trim()
|
||||
);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md no-print">
|
||||
<div className="relative w-full max-w-md bg-slate-900 border border-pink-500/40 rounded-3xl shadow-2xl overflow-hidden animate-scaleUp">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-5 border-b border-slate-800 bg-gradient-to-r from-pink-950/40 to-purple-950/40">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-pink-500/20 border border-pink-500/40 flex items-center justify-center text-pink-400">
|
||||
<Sparkles className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-white">
|
||||
Enregistrer la Vente 🎉
|
||||
</h3>
|
||||
<p className="text-xs text-slate-400 truncate max-w-[240px]">
|
||||
{project.title}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-slate-400 hover:text-white p-2 rounded-xl hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
|
||||
{/* Price */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-semibold text-slate-300 flex items-center space-x-1">
|
||||
<DollarSign className="w-3.5 h-3.5 text-pink-400" />
|
||||
<span>Prix de vente final ({settings.currency}) *</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
required
|
||||
value={soldPrice}
|
||||
onChange={(e) => setSoldPrice(parseFloat(e.target.value) || 0)}
|
||||
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-700 text-white font-mono font-bold text-lg focus:outline-none focus:border-pink-500"
|
||||
/>
|
||||
<div className="text-[11px] text-slate-400">
|
||||
Prix affiché initialement : {formatCurrency(project.salePriceTarget, settings.currency)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sales Channel */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-semibold text-slate-300 flex items-center space-x-1">
|
||||
<Store className="w-3.5 h-3.5 text-pink-400" />
|
||||
<span>Canal / Plateforme de Vente *</span>
|
||||
</label>
|
||||
<select
|
||||
value={salesChannel}
|
||||
onChange={(e) => setSalesChannel(e.target.value as SalesChannel)}
|
||||
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-700 text-white text-sm focus:outline-none focus:border-pink-500"
|
||||
>
|
||||
{SALES_CHANNELS.map((ch) => (
|
||||
<option key={ch.id} value={ch.id}>
|
||||
{ch.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Customer Name */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-semibold text-slate-300 flex items-center space-x-1">
|
||||
<User className="w-3.5 h-3.5 text-pink-400" />
|
||||
<span>Nom du Client / Pseudo</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={customerName}
|
||||
onChange={(e) => setCustomerName(e.target.value)}
|
||||
placeholder="Ex: Antoine M. (@retro_gamer)"
|
||||
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-700 text-white text-sm placeholder-slate-500 focus:outline-none focus:border-pink-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sold Date */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-semibold text-slate-300 flex items-center space-x-1">
|
||||
<Calendar className="w-3.5 h-3.5 text-pink-400" />
|
||||
<span>Date de la vente</span>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={soldDate}
|
||||
onChange={(e) => setSoldDate(e.target.value)}
|
||||
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-700 text-white text-sm focus:outline-none focus:border-pink-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<div className="flex items-center justify-end space-x-3 pt-4 border-t border-slate-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 rounded-xl bg-slate-800 text-slate-300 hover:text-white text-xs font-semibold"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-5 py-2.5 rounded-xl bg-gradient-to-r from-pink-500 to-purple-600 text-white font-bold text-sm shadow-lg shadow-pink-500/30 hover:scale-[1.02] active:scale-[0.98] transition-all"
|
||||
>
|
||||
Valider la Vente 🎉
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
1952
src/components/projects/ProjectDetailView.tsx
Normal file
1952
src/components/projects/ProjectDetailView.tsx
Normal file
File diff suppressed because it is too large
Load Diff
577
src/components/projects/ProjectModal.tsx
Normal file
577
src/components/projects/ProjectModal.tsx
Normal file
@@ -0,0 +1,577 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, Gamepad2, Tag, Calendar, User, DollarSign, Wrench, Sparkles, FileText } from 'lucide-react';
|
||||
import { useApp } from '../../context/AppContext';
|
||||
import type { Project, ConsoleModel, ProjectType, ProjectStatus } from '../../types';
|
||||
|
||||
|
||||
interface ProjectModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
editingProject?: Project | null;
|
||||
}
|
||||
|
||||
export const ProjectModal: React.FC<ProjectModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
editingProject,
|
||||
}) => {
|
||||
const { addProject, updateProject, modsLibrary, settings, consoleModels } = useApp();
|
||||
|
||||
const [title, setTitle] = useState('');
|
||||
const [serialNumber, setSerialNumber] = useState('');
|
||||
const [consoleModel, setConsoleModel] = useState<ConsoleModel>('GBA SP');
|
||||
const [type, setType] = useState<ProjectType>('showcase_sale');
|
||||
const [status, setStatus] = useState<ProjectStatus>('in_progress');
|
||||
const [customerName, setCustomerName] = useState('');
|
||||
const [customerContact, setCustomerContact] = useState('');
|
||||
const [startDate, setStartDate] = useState(new Date().toISOString().split('T')[0]);
|
||||
const [salePriceTarget, setSalePriceTarget] = useState<number>(180);
|
||||
const [hourlyRateDefault, setHourlyRateDefault] = useState<number>(settings.defaultHourlyRate || 25);
|
||||
const [consumablesEst, setConsumablesEst] = useState<number>(3.5);
|
||||
const [packagingCost, setPackagingCost] = useState<number>(4.0);
|
||||
const [shippingCost, setShippingCost] = useState<number>(0);
|
||||
const [platformFeePercent, setPlatformFeePercent] = useState<number>(settings.defaultPlatformFeePercent || 0);
|
||||
const [warrantyMonths, setWarrantyMonths] = useState<number>(6);
|
||||
const [notes, setNotes] = useState('');
|
||||
const [modsText, setModsText] = useState('');
|
||||
const [targetDeadlineDate, setTargetDeadlineDate] = useState('');
|
||||
const [targetProfitMin, setTargetProfitMin] = useState<string>('60');
|
||||
const [targetLaborHoursMax, setTargetLaborHoursMax] = useState<string>('3.5');
|
||||
const [seedPresetTasks, setSeedPresetTasks] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (editingProject) {
|
||||
setTitle(editingProject.title);
|
||||
setSerialNumber(editingProject.serialNumber);
|
||||
setConsoleModel(editingProject.consoleModel);
|
||||
setType(editingProject.type);
|
||||
setStatus(editingProject.status);
|
||||
setCustomerName(editingProject.customerName || '');
|
||||
setCustomerContact(editingProject.customerContact || '');
|
||||
setStartDate(editingProject.startDate);
|
||||
setSalePriceTarget(editingProject.salePriceTarget || 0);
|
||||
setHourlyRateDefault(editingProject.hourlyRateDefault || settings.defaultHourlyRate || 25);
|
||||
setConsumablesEst(editingProject.additionalFees?.consumablesEst || 0);
|
||||
setPackagingCost(editingProject.additionalFees?.packagingCost || 0);
|
||||
setShippingCost(editingProject.additionalFees?.shippingCost || 0);
|
||||
setPlatformFeePercent(editingProject.additionalFees?.platformFeePercent || 0);
|
||||
setWarrantyMonths(editingProject.warrantyMonths || 6);
|
||||
setNotes(editingProject.notes || '');
|
||||
setModsText(editingProject.modsList ? editingProject.modsList.join('\n') : '');
|
||||
setTargetDeadlineDate(editingProject.objectives?.targetDeadlineDate || '');
|
||||
setTargetProfitMin(editingProject.objectives?.targetProfitMin?.toString() || '');
|
||||
setTargetLaborHoursMax(editingProject.objectives?.targetLaborHoursMax?.toString() || '');
|
||||
setSeedPresetTasks(false);
|
||||
} else {
|
||||
const year = new Date().getFullYear();
|
||||
const randomId = Math.floor(100 + Math.random() * 900);
|
||||
setTitle('');
|
||||
setSerialNumber(`UWU-${year}-${randomId}`);
|
||||
setConsoleModel('GBA SP');
|
||||
setType('showcase_sale');
|
||||
setStatus('in_progress');
|
||||
setCustomerName('');
|
||||
setCustomerContact('');
|
||||
setStartDate(new Date().toISOString().split('T')[0]);
|
||||
setSalePriceTarget(179);
|
||||
setHourlyRateDefault(settings.defaultHourlyRate || 25);
|
||||
setConsumablesEst(3.5);
|
||||
setPackagingCost(4.0);
|
||||
setShippingCost(0);
|
||||
setPlatformFeePercent(settings.defaultPlatformFeePercent || 0);
|
||||
setWarrantyMonths(6);
|
||||
setNotes('');
|
||||
setModsText('Écran IPS laminé rétroéclairé haute définition\nPort de charge USB-C\nBatterie LiPo haute autonomie\nCoque neuve de qualité supérieure');
|
||||
|
||||
// Default target date in 14 days
|
||||
const targetDate = new Date();
|
||||
targetDate.setDate(targetDate.getDate() + 14);
|
||||
setTargetDeadlineDate(targetDate.toISOString().split('T')[0]);
|
||||
setTargetProfitMin('65');
|
||||
setTargetLaborHoursMax('3.5');
|
||||
setSeedPresetTasks(true);
|
||||
}
|
||||
}, [editingProject, isOpen, settings]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!title.trim()) return;
|
||||
|
||||
const parsedMods = modsText
|
||||
.split('\n')
|
||||
.map((m) => m.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const initialPresetTasks = seedPresetTasks && !editingProject ? [
|
||||
{ id: 't1', title: 'Diagnostic donneuse & nettoyage ultrasons', isCompleted: false, category: 'diagnostic' as const },
|
||||
{ id: 't2', title: 'Pose & soudure écran IPS + câblage OSD', isCompleted: false, category: 'soudure' as const },
|
||||
{ id: 't3', title: 'Installation module USB-C & filtre audio', isCompleted: false, category: 'soudure' as const },
|
||||
{ id: 't4', title: 'Assemblage coque neuve & boutons', isCompleted: false, category: 'assemblage' as const },
|
||||
{ id: 't5', title: 'Contrôle qualité QC & test autonomie LiPo', isCompleted: false, category: 'test_qc' as const },
|
||||
{ id: 't6', title: 'Nettoyage microfibre & mise en boîte', isCompleted: false, category: 'packaging' as const },
|
||||
] : (editingProject?.tasks || []);
|
||||
|
||||
const projectData = {
|
||||
title: title.trim(),
|
||||
serialNumber: serialNumber.trim() || `UWU-${Date.now().toString().slice(-4)}`,
|
||||
consoleModel,
|
||||
type,
|
||||
status,
|
||||
customerName: customerName.trim() || undefined,
|
||||
customerContact: customerContact.trim() || undefined,
|
||||
startDate,
|
||||
salePriceTarget: Number(salePriceTarget) || 0,
|
||||
hourlyRateDefault: Number(hourlyRateDefault) || 25,
|
||||
additionalFees: {
|
||||
consumablesEst: Number(consumablesEst) || 0,
|
||||
packagingCost: Number(packagingCost) || 0,
|
||||
shippingCost: Number(shippingCost) || 0,
|
||||
platformFeePercent: Number(platformFeePercent) || 0,
|
||||
platformFeeFixed: editingProject?.additionalFees?.platformFeeFixed || 0,
|
||||
otherFees: editingProject?.additionalFees?.otherFees || 0,
|
||||
},
|
||||
warrantyMonths: Number(warrantyMonths) || 6,
|
||||
notes: notes.trim(),
|
||||
modsList: parsedMods,
|
||||
parts: editingProject?.parts || [],
|
||||
timeSessions: editingProject?.timeSessions || [],
|
||||
tasks: initialPresetTasks,
|
||||
objectives: {
|
||||
targetDeadlineDate: targetDeadlineDate || undefined,
|
||||
targetProfitMin: targetProfitMin ? parseFloat(targetProfitMin) : undefined,
|
||||
targetLaborHoursMax: targetLaborHoursMax ? parseFloat(targetLaborHoursMax) : undefined,
|
||||
},
|
||||
};
|
||||
|
||||
if (editingProject) {
|
||||
updateProject(editingProject.id, projectData);
|
||||
} else {
|
||||
addProject(projectData);
|
||||
}
|
||||
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md overflow-y-auto no-print">
|
||||
<div className="relative w-full max-w-2xl bg-slate-900 border border-purple-500/40 rounded-3xl shadow-2xl overflow-hidden my-8 animate-scaleUp">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-5 border-b border-slate-800 bg-gradient-to-r from-purple-950/50 via-slate-900 to-pink-950/50">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-purple-500/20 border border-purple-500/40 flex items-center justify-center text-purple-300">
|
||||
<Gamepad2 className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-white">
|
||||
{editingProject ? 'Modifier la Console / Projet' : 'Créer une Nouvelle Console Custom'}
|
||||
</h3>
|
||||
<p className="text-xs text-slate-400">
|
||||
Centre de dépenses, nomenclature et rentabilité de build
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-slate-400 hover:text-white p-2 rounded-xl hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
||||
|
||||
{/* Row 1: Title & Serial */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="md:col-span-2 space-y-1.5">
|
||||
<label className="text-xs font-semibold text-slate-300">
|
||||
Titre / Nom du Projet *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Ex: GBA SP Cyberpunk Neon IPS V3 & USB-C"
|
||||
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-700 text-white text-sm focus:outline-none focus:border-purple-500 placeholder-slate-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-semibold text-slate-300 flex items-center space-x-1">
|
||||
<Tag className="w-3.5 h-3.5 text-purple-400" />
|
||||
<span>N° de Série Unique</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={serialNumber}
|
||||
onChange={(e) => setSerialNumber(e.target.value)}
|
||||
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-700 text-white font-mono text-sm focus:outline-none focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Console Model, Type & Status */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-semibold text-slate-300">
|
||||
Modèle de Console *
|
||||
</label>
|
||||
<select
|
||||
value={consoleModel}
|
||||
onChange={(e) => setConsoleModel(e.target.value as ConsoleModel)}
|
||||
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-700 text-white text-sm focus:outline-none focus:border-purple-500"
|
||||
>
|
||||
{consoleModels.map((m) => (
|
||||
<option key={m.id} value={m.slug}>
|
||||
{m.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-semibold text-slate-300">
|
||||
Type de Projet
|
||||
</label>
|
||||
<select
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value as ProjectType)}
|
||||
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-700 text-white text-sm focus:outline-none focus:border-purple-500"
|
||||
>
|
||||
<option value="showcase_sale">Création pour Vente / Vitrine</option>
|
||||
<option value="client_custom">Commande sur mesure Client</option>
|
||||
<option value="repair_mod_only">Modding console fournie client</option>
|
||||
<option value="personal_collection">Collection Personnelle</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-semibold text-slate-300">
|
||||
Statut initial
|
||||
</label>
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value as ProjectStatus)}
|
||||
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-700 text-white text-sm focus:outline-none focus:border-purple-500"
|
||||
>
|
||||
<option value="draft">Brouillon / En projet</option>
|
||||
<option value="in_progress">En cours de modding</option>
|
||||
<option value="qc_testing">Tests & Contrôle Qualité</option>
|
||||
<option value="in_stock_ready">En Stock / Prête à vendre</option>
|
||||
<option value="reserved">Réservée</option>
|
||||
<option value="sold">Déjà Vendue</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 3: Target Sale Price, Hourly Rate & Date */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-semibold text-slate-300 flex items-center space-x-1">
|
||||
<DollarSign className="w-3.5 h-3.5 text-pink-400" />
|
||||
<span>Prix de Vente Cible ({settings.currency}) *</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={salePriceTarget}
|
||||
onChange={(e) => setSalePriceTarget(parseFloat(e.target.value) || 0)}
|
||||
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-700 text-white font-mono font-bold text-sm focus:outline-none focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-semibold text-slate-300 flex items-center space-x-1">
|
||||
<Wrench className="w-3.5 h-3.5 text-amber-400" />
|
||||
<span>Taux Horaire Main d'Œuvre ({settings.currency}/h)</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.5"
|
||||
value={hourlyRateDefault}
|
||||
onChange={(e) => setHourlyRateDefault(parseFloat(e.target.value) || 25)}
|
||||
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-700 text-white font-mono text-sm focus:outline-none focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-semibold text-slate-300 flex items-center space-x-1">
|
||||
<Calendar className="w-3.5 h-3.5 text-purple-400" />
|
||||
<span>Date de début</span>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-700 text-white text-sm focus:outline-none focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section: Objectifs & Délais Cibles */}
|
||||
<div className="p-4 rounded-2xl bg-gradient-to-br from-pink-950/20 to-purple-950/20 border border-pink-500/30 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-pink-300 flex items-center space-x-1.5">
|
||||
<Sparkles className="w-3.5 h-3.5 text-pink-400" />
|
||||
<span>Objectifs de Rentabilité & Échéancier</span>
|
||||
</span>
|
||||
<span className="text-[11px] text-slate-400 font-mono">Suivi automatique</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-slate-300">
|
||||
📅 Échéance Cible (Date)
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={targetDeadlineDate}
|
||||
onChange={(e) => setTargetDeadlineDate(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl bg-slate-950 border border-slate-800 text-white text-xs font-mono focus:border-pink-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-slate-300">
|
||||
💰 Objectif Gain Net (€)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="1"
|
||||
value={targetProfitMin}
|
||||
onChange={(e) => setTargetProfitMin(e.target.value)}
|
||||
placeholder="ex: 65"
|
||||
className="w-full px-3 py-2 rounded-xl bg-slate-950 border border-slate-800 text-white text-xs font-mono focus:border-pink-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-slate-300">
|
||||
⏱️ Temps Atelier Max (Heures)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.5"
|
||||
value={targetLaborHoursMax}
|
||||
onChange={(e) => setTargetLaborHoursMax(e.target.value)}
|
||||
placeholder="ex: 3.5"
|
||||
className="w-full px-3 py-2 rounded-xl bg-slate-950 border border-slate-800 text-white text-xs font-mono focus:border-pink-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!editingProject && (
|
||||
<label className="flex items-center space-x-2 pt-1 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={seedPresetTasks}
|
||||
onChange={(e) => setSeedPresetTasks(e.target.checked)}
|
||||
className="rounded border-slate-700 text-pink-500 focus:ring-pink-500 bg-slate-950"
|
||||
/>
|
||||
<span className="text-xs text-slate-300">
|
||||
Générer automatiquement la checklist standard (Diagnostic, Soudure IPS/USB-C, QC, Packaging)
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Row 4: Customer Details (if applicable) */}
|
||||
{(type === 'client_custom' || type === 'repair_mod_only') && (
|
||||
<div className="p-4 rounded-2xl bg-purple-500/10 border border-purple-500/30 grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-semibold text-purple-300 flex items-center space-x-1">
|
||||
<User className="w-3.5 h-3.5" />
|
||||
<span>Nom du Client</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={customerName}
|
||||
onChange={(e) => setCustomerName(e.target.value)}
|
||||
placeholder="Ex: Thomas L."
|
||||
className="w-full px-3.5 py-2 rounded-xl bg-slate-950 border border-purple-500/30 text-white text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-semibold text-purple-300">
|
||||
Contact / Email / Instagram
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={customerContact}
|
||||
onChange={(e) => setCustomerContact(e.target.value)}
|
||||
placeholder="Ex: @thomas.gaming / contact@email.com"
|
||||
className="w-full px-3.5 py-2 rounded-xl bg-slate-950 border border-purple-500/30 text-white text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Additional Fees Estimator Box */}
|
||||
<div className="p-4 rounded-2xl bg-slate-950 border border-slate-800 space-y-3">
|
||||
<div className="text-xs font-bold uppercase tracking-wider text-slate-400">
|
||||
Frais Annexes & Consommables Estimés
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] text-slate-300">
|
||||
Consommables (Étain, IPA)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={consumablesEst}
|
||||
onChange={(e) => setConsumablesEst(parseFloat(e.target.value) || 0)}
|
||||
className="w-full px-2.5 py-1.5 rounded-lg bg-slate-900 border border-slate-700 text-white text-xs font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] text-slate-300">
|
||||
Packaging / Boîte
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={packagingCost}
|
||||
onChange={(e) => setPackagingCost(parseFloat(e.target.value) || 0)}
|
||||
className="w-full px-2.5 py-1.5 rounded-lg bg-slate-900 border border-slate-700 text-white text-xs font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] text-slate-300">
|
||||
Frais d'envoi client
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={shippingCost}
|
||||
onChange={(e) => setShippingCost(parseFloat(e.target.value) || 0)}
|
||||
className="w-full px-2.5 py-1.5 rounded-lg bg-slate-900 border border-slate-700 text-white text-xs font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] text-slate-300">
|
||||
% Plateforme (Etsy/Vinted)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.5"
|
||||
value={platformFeePercent}
|
||||
onChange={(e) => setPlatformFeePercent(parseFloat(e.target.value) || 0)}
|
||||
className="w-full px-2.5 py-1.5 rounded-lg bg-slate-900 border border-slate-700 text-white text-xs font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Installed Mods list (with quick pick from Mod Catalogue) */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-semibold text-slate-300 flex items-center space-x-1">
|
||||
<Sparkles className="w-3.5 h-3.5 text-pink-400" />
|
||||
<span>Liste des Mods Installés (pour certificat & étiquettes)</span>
|
||||
</label>
|
||||
<span className="text-[11px] text-slate-400">
|
||||
Cliquez sur un mod ci-dessous pour l'ajouter/retirer
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Compatible mods quick chips */}
|
||||
{modsLibrary.length > 0 && (
|
||||
<div className="p-2.5 bg-slate-950/80 border border-slate-800 rounded-xl space-y-1.5">
|
||||
<div className="text-[10px] font-bold text-pink-400 uppercase tracking-wider flex items-center gap-1">
|
||||
<Sparkles className="w-3 h-3" /> Mods Disponibles dans le Catalogue pour {consoleModel} :
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5 max-h-28 overflow-y-auto">
|
||||
{modsLibrary
|
||||
.filter(
|
||||
(m) =>
|
||||
m.compatibleModels?.includes(consoleModel) ||
|
||||
m.compatibleModels?.includes('Autre / Custom') ||
|
||||
!m.compatibleModels ||
|
||||
m.compatibleModels.length === 0
|
||||
)
|
||||
.map((mod) => {
|
||||
const isIncluded = modsText.includes(mod.name);
|
||||
return (
|
||||
<button
|
||||
key={mod.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isIncluded) {
|
||||
// remove
|
||||
const lines = modsText.split('\n').filter((l) => l.trim() !== mod.name);
|
||||
setModsText(lines.join('\n'));
|
||||
} else {
|
||||
// add
|
||||
const lines = modsText ? modsText.split('\n').filter(Boolean) : [];
|
||||
setModsText([...lines, mod.name].join('\n'));
|
||||
}
|
||||
}}
|
||||
className={`px-2.5 py-1 rounded-lg text-xs font-medium transition-all flex items-center gap-1 ${
|
||||
isIncluded
|
||||
? 'bg-pink-500 text-white shadow-sm shadow-pink-500/30'
|
||||
: 'bg-slate-900 text-slate-300 hover:bg-slate-800 border border-slate-700'
|
||||
}`}
|
||||
>
|
||||
<span>{isIncluded ? '✓' : '+'}</span>
|
||||
<span className="truncate max-w-[200px]">{mod.name}</span>
|
||||
<span className="text-[10px] opacity-70 font-mono">+{mod.suggestedAddedValue}€</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
rows={3}
|
||||
value={modsText}
|
||||
onChange={(e) => setModsText(e.target.value)}
|
||||
placeholder="Écran IPS V3 Drop-in Laminé Port de charge USB-C Pro Batterie LiPo 950mAh Makho"
|
||||
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-700 text-white placeholder-slate-500 text-xs focus:outline-none focus:border-pink-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-semibold text-slate-300 flex items-center space-x-1">
|
||||
<FileText className="w-3.5 h-3.5 text-purple-400" />
|
||||
<span>Notes de fabrication & Spécificités</span>
|
||||
</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Remarques particulières, soudure délicate, calibrage écran..."
|
||||
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-700 text-white placeholder-slate-500 text-xs focus:outline-none focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end space-x-3 pt-4 border-t border-slate-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2.5 rounded-xl bg-slate-800 text-slate-300 hover:text-white text-xs font-semibold"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-5 py-2.5 rounded-xl bg-gradient-to-r from-purple-500 to-pink-600 text-white font-bold text-sm shadow-lg shadow-purple-500/20 hover:scale-[1.02] active:scale-[0.98] transition-all"
|
||||
>
|
||||
{editingProject ? 'Enregistrer les modifications' : 'Créer le projet'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
278
src/components/projects/ProjectObjectivesCard.tsx
Normal file
278
src/components/projects/ProjectObjectivesCard.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Target,
|
||||
Calendar,
|
||||
DollarSign,
|
||||
Clock,
|
||||
Percent,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Edit2,
|
||||
Save,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useApp } from '../../context/AppContext';
|
||||
import type { Project, ProjectObjectives } from '../../types';
|
||||
import { calculateProjectFinancials, formatCurrency } from '../../utils/calculations';
|
||||
|
||||
interface ProjectObjectivesCardProps {
|
||||
project: Project;
|
||||
}
|
||||
|
||||
export const ProjectObjectivesCard: React.FC<ProjectObjectivesCardProps> = ({ project }) => {
|
||||
const { updateProjectObjectives } = useApp();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
const objectives = project.objectives || {};
|
||||
const financials = calculateProjectFinancials(project);
|
||||
|
||||
const [formDeadline, setFormDeadline] = useState(objectives.targetDeadlineDate || '');
|
||||
const [formProfit, setFormProfit] = useState(objectives.targetProfitMin?.toString() || '');
|
||||
const [formLaborHours, setFormLaborHours] = useState(objectives.targetLaborHoursMax?.toString() || '');
|
||||
const [formMargin, setFormMargin] = useState(objectives.targetMarginPercentMin?.toString() || '');
|
||||
|
||||
const handleSave = () => {
|
||||
const updated: Partial<ProjectObjectives> = {
|
||||
targetDeadlineDate: formDeadline || undefined,
|
||||
targetProfitMin: formProfit ? parseFloat(formProfit) : undefined,
|
||||
targetLaborHoursMax: formLaborHours ? parseFloat(formLaborHours) : undefined,
|
||||
targetMarginPercentMin: formMargin ? parseFloat(formMargin) : undefined,
|
||||
};
|
||||
updateProjectObjectives(project.id, updated);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
// Calculations for objectives attainment
|
||||
const actualLaborHours = financials.laborMinutes / 60;
|
||||
const isProfitAchieved = objectives.targetProfitMin !== undefined
|
||||
? financials.netProfit >= objectives.targetProfitMin
|
||||
: true;
|
||||
|
||||
const isLaborOnTrack = objectives.targetLaborHoursMax !== undefined
|
||||
? actualLaborHours <= objectives.targetLaborHoursMax
|
||||
: true;
|
||||
|
||||
const isMarginAchieved = objectives.targetMarginPercentMin !== undefined
|
||||
? financials.marginPercent >= objectives.targetMarginPercentMin
|
||||
: true;
|
||||
|
||||
// Deadline countdown
|
||||
let deadlineStatusText = '';
|
||||
let deadlineBadgeColor = 'text-slate-400 bg-slate-800/60';
|
||||
if (objectives.targetDeadlineDate) {
|
||||
const targetDate = new Date(objectives.targetDeadlineDate);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const diffDays = Math.ceil((targetDate.getTime() - today.getTime()) / (1000 * 3600 * 24));
|
||||
|
||||
if (project.status === 'sold' || project.status === 'in_stock_ready') {
|
||||
deadlineStatusText = 'Terminé dans les temps ✅';
|
||||
deadlineBadgeColor = 'text-emerald-300 bg-emerald-500/20 border-emerald-500/40';
|
||||
} else if (diffDays < 0) {
|
||||
deadlineStatusText = `En retard de ${Math.abs(diffDays)} j ⚠️`;
|
||||
deadlineBadgeColor = 'text-rose-300 bg-rose-500/20 border-rose-500/40';
|
||||
} else if (diffDays === 0) {
|
||||
deadlineStatusText = "Aujourd'hui ! 🚨";
|
||||
deadlineBadgeColor = 'text-amber-300 bg-amber-500/20 border-amber-500/40';
|
||||
} else {
|
||||
deadlineStatusText = `J-${diffDays} (dans ${diffDays} j)`;
|
||||
deadlineBadgeColor = 'text-cyan-300 bg-cyan-500/20 border-cyan-500/40';
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-5 rounded-2xl bg-gradient-to-br from-slate-900/90 to-slate-950 border border-slate-800 shadow-xl space-y-4">
|
||||
<div className="flex items-center justify-between pb-3 border-b border-slate-800">
|
||||
<div className="flex items-center space-x-2.5">
|
||||
<div className="p-2 rounded-xl bg-pink-500/10 border border-pink-500/30 text-pink-400">
|
||||
<Target className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-white text-base">Objectifs & Jalons du Projet</h3>
|
||||
<p className="text-xs text-slate-400">
|
||||
Cibles financières, délais et temps de travail
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{!isEditing ? (
|
||||
<button
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="flex items-center space-x-1.5 px-3 py-1.5 rounded-xl text-xs font-semibold bg-slate-900 text-slate-300 border border-slate-800 hover:text-white hover:border-pink-500/40 transition-all"
|
||||
>
|
||||
<Edit2 className="w-3.5 h-3.5 text-pink-400" />
|
||||
<span>Ajuster Objectifs</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="flex items-center space-x-1 px-3 py-1.5 rounded-xl text-xs font-bold bg-pink-600 hover:bg-pink-500 text-white transition-all shadow-md"
|
||||
>
|
||||
<Save className="w-3.5 h-3.5" />
|
||||
<span>Enregistrer</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsEditing(false)}
|
||||
className="p-1.5 rounded-xl bg-slate-800 text-slate-400 hover:text-white"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Edit Form Mode */}
|
||||
{isEditing ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 pt-1">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-400 mb-1">
|
||||
📅 Échéance Cible (Date)
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formDeadline}
|
||||
onChange={(e) => setFormDeadline(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-pink-500 font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-400 mb-1">
|
||||
💰 Objectif Gain Net (€)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="1"
|
||||
value={formProfit}
|
||||
onChange={(e) => setFormProfit(e.target.value)}
|
||||
placeholder="ex: 60"
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-pink-500 font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-400 mb-1">
|
||||
⏱️ Temps Atelier Max (Heures)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.5"
|
||||
value={formLaborHours}
|
||||
onChange={(e) => setFormLaborHours(e.target.value)}
|
||||
placeholder="ex: 3.5"
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-pink-500 font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-400 mb-1">
|
||||
📊 Objectif Marge Min (%)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="5"
|
||||
value={formMargin}
|
||||
onChange={(e) => setFormMargin(e.target.value)}
|
||||
placeholder="ex: 35"
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-pink-500 font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Objectives Overview Cards */
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3.5">
|
||||
{/* 1. Target Deadline */}
|
||||
<div className="p-3.5 rounded-xl bg-slate-950/70 border border-slate-800/80 flex flex-col justify-between">
|
||||
<div className="flex items-center justify-between text-xs text-slate-400 mb-1.5">
|
||||
<span className="flex items-center space-x-1.5">
|
||||
<Calendar className="w-3.5 h-3.5 text-cyan-400" />
|
||||
<span>Date d'Échéance</span>
|
||||
</span>
|
||||
{objectives.targetDeadlineDate && (
|
||||
<span className={`px-2 py-0.5 rounded-full text-[10px] font-bold border ${deadlineBadgeColor}`}>
|
||||
{deadlineStatusText}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="font-mono text-base font-black text-white">
|
||||
{objectives.targetDeadlineDate || 'Non définie'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. Target Profit */}
|
||||
<div className="p-3.5 rounded-xl bg-slate-950/70 border border-slate-800/80 flex flex-col justify-between">
|
||||
<div className="flex items-center justify-between text-xs text-slate-400 mb-1.5">
|
||||
<span className="flex items-center space-x-1.5">
|
||||
<DollarSign className="w-3.5 h-3.5 text-pink-400" />
|
||||
<span>Objectif Gain Net</span>
|
||||
</span>
|
||||
{objectives.targetProfitMin && (
|
||||
<span className={`flex items-center space-x-1 text-[11px] font-bold ${isProfitAchieved ? 'text-emerald-400' : 'text-amber-400'}`}>
|
||||
{isProfitAchieved ? <CheckCircle className="w-3 h-3" /> : <AlertCircle className="w-3 h-3" />}
|
||||
<span>{isProfitAchieved ? 'Atteint' : 'En cours'}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-baseline space-x-2">
|
||||
<span className="font-mono text-base font-black text-pink-400">
|
||||
{objectives.targetProfitMin ? formatCurrency(objectives.targetProfitMin) : '—'}
|
||||
</span>
|
||||
<span className="text-xs text-slate-500 font-mono">
|
||||
(Actuel: {formatCurrency(financials.netProfit)})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3. Target Labor Hours */}
|
||||
<div className="p-3.5 rounded-xl bg-slate-950/70 border border-slate-800/80 flex flex-col justify-between">
|
||||
<div className="flex items-center justify-between text-xs text-slate-400 mb-1.5">
|
||||
<span className="flex items-center space-x-1.5">
|
||||
<Clock className="w-3.5 h-3.5 text-amber-400" />
|
||||
<span>Plafond Temps Atelier</span>
|
||||
</span>
|
||||
{objectives.targetLaborHoursMax && (
|
||||
<span className={`text-[11px] font-bold ${isLaborOnTrack ? 'text-emerald-400' : 'text-rose-400'}`}>
|
||||
{isLaborOnTrack ? 'Dans les temps' : 'Dépassement'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-baseline space-x-2">
|
||||
<span className="font-mono text-base font-black text-amber-400">
|
||||
{objectives.targetLaborHoursMax ? `${objectives.targetLaborHoursMax}h max` : '—'}
|
||||
</span>
|
||||
<span className="text-xs text-slate-500 font-mono">
|
||||
(Passé: {actualLaborHours.toFixed(1)}h)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 4. Target Margin % */}
|
||||
<div className="p-3.5 rounded-xl bg-slate-950/70 border border-slate-800/80 flex flex-col justify-between">
|
||||
<div className="flex items-center justify-between text-xs text-slate-400 mb-1.5">
|
||||
<span className="flex items-center space-x-1.5">
|
||||
<Percent className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>Objectif Marge Nette</span>
|
||||
</span>
|
||||
{objectives.targetMarginPercentMin && (
|
||||
<span className={`text-[11px] font-bold ${isMarginAchieved ? 'text-emerald-400' : 'text-amber-400'}`}>
|
||||
{isMarginAchieved ? 'Atteint' : 'En dessous'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-baseline space-x-2">
|
||||
<span className="font-mono text-base font-black text-emerald-400">
|
||||
{objectives.targetMarginPercentMin ? `${objectives.targetMarginPercentMin}%` : '—'}
|
||||
</span>
|
||||
<span className="text-xs text-slate-500 font-mono">
|
||||
(Actuel: {financials.marginPercent.toFixed(1)}%)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
313
src/components/projects/ProjectTasksChecklist.tsx
Normal file
313
src/components/projects/ProjectTasksChecklist.tsx
Normal file
@@ -0,0 +1,313 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
Plus,
|
||||
Trash2,
|
||||
ListTodo,
|
||||
Calendar,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { useApp } from '../../context/AppContext';
|
||||
import type { Project, ProjectTask } from '../../types';
|
||||
|
||||
interface ProjectTasksChecklistProps {
|
||||
project: Project;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export const ProjectTasksChecklist: React.FC<ProjectTasksChecklistProps> = ({
|
||||
project,
|
||||
compact = false,
|
||||
}) => {
|
||||
const { toggleProjectTask, addProjectTask, deleteProjectTask } = useApp();
|
||||
const [newTitle, setNewTitle] = useState('');
|
||||
const [newCategory, setNewCategory] = useState<ProjectTask['category']>('soudure');
|
||||
const [newDueDate, setNewDueDate] = useState('');
|
||||
|
||||
const tasks = project.tasks || [];
|
||||
const completedCount = tasks.filter((t) => t.isCompleted).length;
|
||||
const totalCount = tasks.length;
|
||||
const progressPercent = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0;
|
||||
|
||||
const handleAddTask = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newTitle.trim()) return;
|
||||
|
||||
addProjectTask(project.id, {
|
||||
title: newTitle.trim(),
|
||||
isCompleted: false,
|
||||
category: newCategory,
|
||||
dueDate: newDueDate || undefined,
|
||||
});
|
||||
|
||||
setNewTitle('');
|
||||
setNewDueDate('');
|
||||
};
|
||||
|
||||
// Quick preset tasks templates
|
||||
const addPresetTasks = (presetType: 'ips_mod' | 'cleaning' | 'audio_mod') => {
|
||||
const presets: Record<string, Omit<ProjectTask, 'id'>[]> = {
|
||||
ips_mod: [
|
||||
{ title: 'Diagnostic initial & test boot donneuse', isCompleted: false, category: 'diagnostic' },
|
||||
{ title: 'Démontage & nettoyage ultrasons carte mère', isCompleted: false, category: 'nettoyage' },
|
||||
{ title: 'Découpe & ajustement coque pour écran IPS', isCompleted: false, category: 'assemblage' },
|
||||
{ title: 'Soudure des fils OSD (L, R, Select)', isCompleted: false, category: 'soudure' },
|
||||
{ title: 'Test d\'allumage & centrage de la vitre', isCompleted: false, category: 'test_qc' },
|
||||
{ title: 'Test batterie & calibration charge', isCompleted: false, category: 'test_qc' },
|
||||
{ title: 'Nettoyage final & mise en boîte kraft', isCompleted: false, category: 'packaging' },
|
||||
],
|
||||
cleaning: [
|
||||
{ title: 'Démontage complet vis tri-wing', isCompleted: false, category: 'diagnostic' },
|
||||
{ title: 'Bain IPA ultrasons contacts boutons et switch', isCompleted: false, category: 'nettoyage' },
|
||||
{ title: 'Remplacement des condensateurs (Cap Kit)', isCompleted: false, category: 'soudure' },
|
||||
{ title: 'Remontage & test audio / vidéo', isCompleted: false, category: 'test_qc' },
|
||||
],
|
||||
audio_mod: [
|
||||
{ title: 'Dessoudage de l\'ancien haut-parleur', isCompleted: false, category: 'soudure' },
|
||||
{ title: 'Pose de l\'amplificateur audio CleanAmp Pro', isCompleted: false, category: 'soudure' },
|
||||
{ title: 'Câblage haut-parleur 2W néodyme', isCompleted: false, category: 'assemblage' },
|
||||
{ title: 'Test absence de parasites et souffle', isCompleted: false, category: 'test_qc' },
|
||||
],
|
||||
};
|
||||
|
||||
const toAdd = presets[presetType];
|
||||
if (toAdd) {
|
||||
toAdd.forEach((t) => addProjectTask(project.id, t));
|
||||
}
|
||||
};
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<div className="flex items-center space-x-1.5 text-slate-300 font-medium">
|
||||
<ListTodo className="w-3.5 h-3.5 text-purple-400" />
|
||||
<span>Checklist Tâches :</span>
|
||||
</div>
|
||||
<span className="font-mono text-xs font-bold text-purple-300">
|
||||
{completedCount}/{totalCount} ({progressPercent}%)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Mini progress bar */}
|
||||
<div className="w-full h-1.5 bg-slate-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-pink-500 to-purple-500 transition-all duration-300"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Task list compact */}
|
||||
<div className="space-y-1.5 max-h-40 overflow-y-auto pr-1 custom-scrollbar">
|
||||
{tasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleProjectTask(project.id, task.id);
|
||||
}}
|
||||
className="flex items-center justify-between p-1.5 rounded-lg bg-slate-900/60 hover:bg-slate-800/80 cursor-pointer text-xs group transition-colors"
|
||||
>
|
||||
<div className="flex items-center space-x-2 min-w-0">
|
||||
{task.isCompleted ? (
|
||||
<CheckCircle2 className="w-3.5 h-3.5 text-emerald-400 shrink-0" />
|
||||
) : (
|
||||
<Circle className="w-3.5 h-3.5 text-slate-500 shrink-0 group-hover:text-purple-400" />
|
||||
)}
|
||||
<span
|
||||
className={`truncate ${
|
||||
task.isCompleted
|
||||
? 'line-through text-slate-500'
|
||||
: 'text-slate-300'
|
||||
}`}
|
||||
>
|
||||
{task.title}
|
||||
</span>
|
||||
</div>
|
||||
{task.dueDate && (
|
||||
<span className="text-[10px] text-slate-500 shrink-0 font-mono">
|
||||
{task.dueDate}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{tasks.length === 0 && (
|
||||
<div className="text-[11px] text-slate-500 italic py-1">
|
||||
Aucune tâche définie.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-5 rounded-2xl bg-slate-900/60 border border-slate-800 backdrop-blur-md space-y-4">
|
||||
{/* Header with progress */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 pb-3 border-b border-slate-800">
|
||||
<div className="flex items-center space-x-2.5">
|
||||
<div className="p-2 rounded-xl bg-purple-500/10 border border-purple-500/30 text-purple-400">
|
||||
<ListTodo className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-white text-base">Checklist & Tâches Atelier</h3>
|
||||
<p className="text-xs text-slate-400">
|
||||
Suivi étape par étape de la customisation
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="text-right">
|
||||
<div className="text-xs text-slate-400">Progression</div>
|
||||
<div className="text-sm font-black font-mono text-purple-300">
|
||||
{completedCount} / {totalCount} terminées ({progressPercent}%)
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-24 h-2 bg-slate-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-pink-500 to-purple-500 transition-all duration-500"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preset Generators if empty */}
|
||||
{tasks.length === 0 && (
|
||||
<div className="p-4 rounded-xl bg-slate-950/70 border border-purple-500/20 text-center space-y-2.5">
|
||||
<p className="text-xs text-slate-300">
|
||||
Gagnez du temps avec un modèle de tâches prédéfini :
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addPresetTasks('ips_mod')}
|
||||
className="flex items-center space-x-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold bg-purple-500/20 text-purple-300 border border-purple-500/40 hover:bg-purple-500/30 transition-all"
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5 text-purple-400" />
|
||||
<span>+ Pack Mod IPS & USB-C</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addPresetTasks('cleaning')}
|
||||
className="flex items-center space-x-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold bg-cyan-500/20 text-cyan-300 border border-cyan-500/40 hover:bg-cyan-500/30 transition-all"
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5 text-cyan-400" />
|
||||
<span>+ Pack Restauration & CapKit</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addPresetTasks('audio_mod')}
|
||||
className="flex items-center space-x-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold bg-pink-500/20 text-pink-300 border border-pink-500/40 hover:bg-pink-500/30 transition-all"
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5 text-pink-400" />
|
||||
<span>+ Pack Mod Audio Amp</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Task List */}
|
||||
<div className="space-y-2">
|
||||
{tasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className={`flex items-center justify-between p-3 rounded-xl border transition-all duration-200 group ${
|
||||
task.isCompleted
|
||||
? 'bg-slate-950/40 border-slate-850/60 opacity-80'
|
||||
: 'bg-slate-950/80 border-slate-800 hover:border-purple-500/40'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
onClick={() => toggleProjectTask(project.id, task.id)}
|
||||
className="flex items-center space-x-3 cursor-pointer flex-1 min-w-0"
|
||||
>
|
||||
{task.isCompleted ? (
|
||||
<CheckCircle2 className="w-5 h-5 text-emerald-400 shrink-0 transition-transform group-hover:scale-110" />
|
||||
) : (
|
||||
<Circle className="w-5 h-5 text-slate-500 shrink-0 group-hover:text-purple-400 transition-colors" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<span
|
||||
className={`text-sm tracking-tight block truncate ${
|
||||
task.isCompleted
|
||||
? 'line-through text-slate-500 font-normal'
|
||||
: 'text-slate-100 font-medium'
|
||||
}`}
|
||||
>
|
||||
{task.title}
|
||||
</span>
|
||||
{task.category && (
|
||||
<span className="text-[10px] text-purple-400 uppercase tracking-wider font-bold">
|
||||
{task.category}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 ml-3 shrink-0">
|
||||
{task.dueDate && (
|
||||
<div className="flex items-center space-x-1 px-2 py-1 rounded-lg bg-slate-900 border border-slate-800 text-[11px] font-mono text-slate-400">
|
||||
<Calendar className="w-3 h-3 text-slate-500" />
|
||||
<span>{task.dueDate}</span>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteProjectTask(project.id, task.id)}
|
||||
className="p-1.5 rounded-lg text-slate-500 hover:text-rose-400 hover:bg-rose-500/10 opacity-0 group-hover:opacity-100 transition-all"
|
||||
title="Supprimer la tâche"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add New Task Form */}
|
||||
<form onSubmit={handleAddTask} className="flex flex-col sm:flex-row items-center gap-2 pt-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newTitle}
|
||||
onChange={(e) => setNewTitle(e.target.value)}
|
||||
placeholder="Ajouter une tâche (ex: Pose dissipateur thermique, Test charge...)"
|
||||
className="flex-1 w-full bg-slate-950 border border-slate-800 rounded-xl px-3.5 py-2 text-sm text-white placeholder-slate-500 focus:outline-none focus:border-purple-500"
|
||||
/>
|
||||
|
||||
<div className="flex items-center space-x-2 w-full sm:w-auto">
|
||||
<select
|
||||
value={newCategory}
|
||||
onChange={(e) => setNewCategory(e.target.value as ProjectTask['category'])}
|
||||
className="bg-slate-950 border border-slate-800 rounded-xl px-2.5 py-2 text-xs text-purple-300 focus:outline-none focus:border-purple-500"
|
||||
>
|
||||
<option value="diagnostic">Diagnostic</option>
|
||||
<option value="nettoyage">Nettoyage</option>
|
||||
<option value="soudure">Soudure</option>
|
||||
<option value="assemblage">Assemblage</option>
|
||||
<option value="test_qc">Test QC</option>
|
||||
<option value="packaging">Packaging</option>
|
||||
</select>
|
||||
|
||||
<input
|
||||
type="date"
|
||||
value={newDueDate}
|
||||
onChange={(e) => setNewDueDate(e.target.value)}
|
||||
className="bg-slate-950 border border-slate-800 rounded-xl px-2.5 py-2 text-xs text-slate-300 focus:outline-none focus:border-purple-500 font-mono"
|
||||
title="Date d'échéance optionnelle"
|
||||
/>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="flex items-center space-x-1.5 px-3.5 py-2 rounded-xl text-xs font-bold bg-purple-600 hover:bg-purple-500 text-white transition-all shadow-md shrink-0"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>Ajouter</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
327
src/components/projects/ProjectsView.tsx
Normal file
327
src/components/projects/ProjectsView.tsx
Normal file
@@ -0,0 +1,327 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import {
|
||||
Layers,
|
||||
Search,
|
||||
Plus,
|
||||
LayoutGrid,
|
||||
List as ListIcon,
|
||||
Columns3,
|
||||
Calendar as CalendarIcon,
|
||||
Download,
|
||||
Filter,
|
||||
} from 'lucide-react';
|
||||
import { useApp } from '../../context/AppContext';
|
||||
import type { Project, ConsoleModel, ProjectStatus } from '../../types';
|
||||
import { ProjectModal } from './ProjectModal';
|
||||
import { ProjectDetailView } from './ProjectDetailView';
|
||||
import { MarkAsSoldModal } from './MarkAsSoldModal';
|
||||
import { ProjectsTileView } from './views/ProjectsTileView';
|
||||
import { ProjectsListView } from './views/ProjectsListView';
|
||||
import { ProjectsKanbanView } from './views/ProjectsKanbanView';
|
||||
import { ProjectsCalendarView } from './views/ProjectsCalendarView';
|
||||
|
||||
export type ProjectsViewMode = 'tile' | 'list' | 'kanban' | 'calendar';
|
||||
|
||||
const STATUS_FILTERS: { id: ProjectStatus | 'ALL'; label: string }[] = [
|
||||
{ id: 'ALL', label: 'Tous les statuts' },
|
||||
{ id: 'in_progress', label: '⚡ En cours d\'atelier' },
|
||||
{ id: 'in_stock_ready', label: '✨ En stock / Prêtes' },
|
||||
{ id: 'sold', label: '🎉 Vendues' },
|
||||
{ id: 'qc_testing', label: '🔍 Tests & QC' },
|
||||
{ id: 'draft', label: '📝 Brouillons' },
|
||||
];
|
||||
|
||||
const CONSOLE_MODELS: (ConsoleModel | 'ALL')[] = [
|
||||
'ALL',
|
||||
'Game Boy DMG',
|
||||
'Game Boy Color',
|
||||
'Game Boy Advance',
|
||||
'GBA SP',
|
||||
'Game Boy Pocket',
|
||||
'Game Boy Micro',
|
||||
'Nintendo DS Lite',
|
||||
'PSP 1000 / 2000 / 3000',
|
||||
'PS Vita',
|
||||
'Nintendo Switch / Lite / OLED',
|
||||
'Autre / Custom',
|
||||
];
|
||||
|
||||
export const ProjectsView: React.FC = () => {
|
||||
const {
|
||||
projects,
|
||||
selectedProjectId,
|
||||
setSelectedProjectId,
|
||||
showNewProjectModal,
|
||||
setShowNewProjectModal,
|
||||
exportProjectsCSV,
|
||||
} = useApp();
|
||||
|
||||
const [viewMode, setViewMode] = useState<ProjectsViewMode>('tile');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedStatus, setSelectedStatus] = useState<ProjectStatus | 'ALL'>('ALL');
|
||||
const [selectedConsole, setSelectedConsole] = useState<ConsoleModel | 'ALL'>('ALL');
|
||||
const [soldModalProject, setSoldModalProject] = useState<Project | null>(null);
|
||||
|
||||
// Filtered projects
|
||||
const filteredProjects = useMemo(() => {
|
||||
return projects.filter((p) => {
|
||||
// Search
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
const matchTitle = p.title.toLowerCase().includes(query);
|
||||
const matchSerial = p.serialNumber.toLowerCase().includes(query);
|
||||
const matchCustomer = (p.customerName || '').toLowerCase().includes(query);
|
||||
const matchNotes = (p.notes || '').toLowerCase().includes(query);
|
||||
if (!matchTitle && !matchSerial && !matchCustomer && !matchNotes) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Status
|
||||
if (selectedStatus !== 'ALL' && p.status !== selectedStatus) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Console
|
||||
if (selectedConsole !== 'ALL' && p.consoleModel !== selectedConsole) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [projects, searchQuery, selectedStatus, selectedConsole]);
|
||||
|
||||
// If a specific project is selected, render its detailed view!
|
||||
if (selectedProjectId) {
|
||||
return (
|
||||
<ProjectDetailView
|
||||
projectId={selectedProjectId}
|
||||
onBack={() => setSelectedProjectId(null)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Summary counts
|
||||
const inStockCount = projects.filter((p) => p.status === 'in_stock_ready').length;
|
||||
const inProgressCount = projects.filter(
|
||||
(p) => p.status === 'in_progress' || p.status === 'qc_testing'
|
||||
).length;
|
||||
const soldCount = projects.filter((p) => p.status === 'sold').length;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-12 animate-fadeIn">
|
||||
{/* Header & Quick stats */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="inline-flex items-center space-x-2 px-3 py-1 rounded-full text-xs font-semibold bg-purple-500/10 text-purple-400 border border-purple-500/30 mb-2">
|
||||
<Layers className="w-3.5 h-3.5" />
|
||||
<span>Centre de Dépenses & Modding</span>
|
||||
</div>
|
||||
<h2 className="text-2xl sm:text-3xl font-black text-white font-display">
|
||||
Gestion des Projets & Objectifs
|
||||
</h2>
|
||||
<p className="text-slate-400 text-sm mt-1">
|
||||
{projects.length} projets au total • {inProgressCount} en atelier • {inStockCount} en vitrine • {soldCount} vendus
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* View Mode Switcher & Export */}
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
{/* Display View Mode Switcher */}
|
||||
<div className="flex items-center bg-slate-900/90 rounded-2xl p-1 border border-slate-800 shadow-inner">
|
||||
<button
|
||||
onClick={() => setViewMode('tile')}
|
||||
className={`flex items-center space-x-1.5 px-3 py-1.5 rounded-xl text-xs font-bold transition-all ${
|
||||
viewMode === 'tile'
|
||||
? 'bg-gradient-to-r from-pink-500 to-purple-600 text-white shadow-md'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
title="Affichage en Tuiles / Cartes"
|
||||
>
|
||||
<LayoutGrid className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Tuiles</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setViewMode('list')}
|
||||
className={`flex items-center space-x-1.5 px-3 py-1.5 rounded-xl text-xs font-bold transition-all ${
|
||||
viewMode === 'list'
|
||||
? 'bg-gradient-to-r from-pink-500 to-purple-600 text-white shadow-md'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
title="Affichage en Lignes / Tableau"
|
||||
>
|
||||
<ListIcon className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Lignes</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setViewMode('kanban')}
|
||||
className={`flex items-center space-x-1.5 px-3 py-1.5 rounded-xl text-xs font-bold transition-all ${
|
||||
viewMode === 'kanban'
|
||||
? 'bg-gradient-to-r from-pink-500 to-purple-600 text-white shadow-md'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
title="Tableau Kanban"
|
||||
>
|
||||
<Columns3 className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Kanban</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setViewMode('calendar')}
|
||||
className={`flex items-center space-x-1.5 px-3 py-1.5 rounded-xl text-xs font-bold transition-all ${
|
||||
viewMode === 'calendar'
|
||||
? 'bg-gradient-to-r from-pink-500 to-purple-600 text-white shadow-md'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
title="Vue Calendrier & Échéances"
|
||||
>
|
||||
<CalendarIcon className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Calendrier</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={exportProjectsCSV}
|
||||
className="p-2 sm:px-3 sm:py-2 rounded-xl text-xs font-semibold bg-slate-900 border border-slate-800 text-slate-300 hover:text-white hover:border-slate-700 transition-all flex items-center space-x-1.5"
|
||||
title="Exporter la liste en CSV"
|
||||
>
|
||||
<Download className="w-4 h-4 text-purple-400" />
|
||||
<span className="hidden md:inline">CSV</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setShowNewProjectModal(true)}
|
||||
className="flex items-center space-x-2 px-4 py-2 rounded-xl text-xs sm:text-sm font-bold bg-gradient-to-r from-pink-500 via-purple-600 to-pink-600 text-white shadow-lg shadow-pink-500/25 hover:shadow-pink-500/40 hover:scale-[1.02] active:scale-[0.98] transition-all"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>Nouveau Projet</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter & Search Toolbar (shown for Tile, List, Kanban) */}
|
||||
{viewMode !== 'calendar' && (
|
||||
<div className="flex flex-col md:flex-row items-center gap-3 p-3 rounded-2xl bg-slate-900/60 border border-slate-800 backdrop-blur-md">
|
||||
{/* Search bar */}
|
||||
<div className="relative flex-1 w-full">
|
||||
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Rechercher par titre, n° série, client..."
|
||||
className="w-full pl-10 pr-4 py-2 bg-slate-950 border border-slate-800 rounded-xl text-xs sm:text-sm text-white placeholder-slate-500 focus:outline-none focus:border-purple-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status filter */}
|
||||
<div className="flex items-center space-x-2 w-full md:w-auto">
|
||||
<Filter className="w-4 h-4 text-slate-400 shrink-0 hidden sm:block" />
|
||||
<select
|
||||
value={selectedStatus}
|
||||
onChange={(e) => setSelectedStatus(e.target.value as ProjectStatus | 'ALL')}
|
||||
className="w-full md:w-auto bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-xs sm:text-sm text-slate-200 focus:outline-none focus:border-purple-500"
|
||||
>
|
||||
{STATUS_FILTERS.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Console filter */}
|
||||
<select
|
||||
value={selectedConsole}
|
||||
onChange={(e) => setSelectedConsole(e.target.value as ConsoleModel | 'ALL')}
|
||||
className="w-full md:w-auto bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-xs sm:text-sm text-slate-200 focus:outline-none focus:border-purple-500"
|
||||
>
|
||||
<option value="ALL">Toutes les consoles</option>
|
||||
{CONSOLE_MODELS.filter((c) => c !== 'ALL').map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dynamic Views Rendering */}
|
||||
{viewMode === 'tile' && (
|
||||
<ProjectsTileView
|
||||
projects={filteredProjects}
|
||||
onSelectProject={(id) => setSelectedProjectId(id)}
|
||||
onOpenSoldModal={(p) => setSoldModalProject(p)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{viewMode === 'list' && (
|
||||
<ProjectsListView
|
||||
projects={filteredProjects}
|
||||
onSelectProject={(id) => setSelectedProjectId(id)}
|
||||
onOpenSoldModal={(p) => setSoldModalProject(p)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{viewMode === 'kanban' && (
|
||||
<ProjectsKanbanView
|
||||
projects={filteredProjects}
|
||||
onSelectProject={(id) => setSelectedProjectId(id)}
|
||||
onOpenSoldModal={(p) => setSoldModalProject(p)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{viewMode === 'calendar' && (
|
||||
<ProjectsCalendarView
|
||||
projects={projects}
|
||||
onSelectProject={(id) => setSelectedProjectId(id)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{viewMode !== 'calendar' && filteredProjects.length === 0 && (
|
||||
<div className="p-12 rounded-3xl bg-slate-900/40 border border-slate-800 text-center space-y-4">
|
||||
<div className="w-16 h-16 mx-auto rounded-2xl bg-purple-500/10 border border-purple-500/30 flex items-center justify-center text-purple-400">
|
||||
<Layers className="w-8 h-8" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-white">Aucun projet trouvé</h3>
|
||||
<p className="text-sm text-slate-400 mt-1 max-w-md mx-auto">
|
||||
{searchQuery || selectedStatus !== 'ALL' || selectedConsole !== 'ALL'
|
||||
? 'Aucun projet ne correspond à vos filtres actuels. Essayez de réinitialiser la recherche.'
|
||||
: 'Commencez dès maintenant en créant votre premier projet de customisation de console !'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearchQuery('');
|
||||
setSelectedStatus('ALL');
|
||||
setSelectedConsole('ALL');
|
||||
setShowNewProjectModal(true);
|
||||
}}
|
||||
className="inline-flex items-center space-x-2 px-5 py-2.5 rounded-xl font-bold text-sm bg-gradient-to-r from-pink-500 to-purple-600 text-white shadow-lg shadow-pink-500/25"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>Créer un Projet</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modals */}
|
||||
<ProjectModal
|
||||
isOpen={showNewProjectModal}
|
||||
onClose={() => setShowNewProjectModal(false)}
|
||||
/>
|
||||
|
||||
{soldModalProject && (
|
||||
<MarkAsSoldModal
|
||||
project={soldModalProject}
|
||||
isOpen={true}
|
||||
onClose={() => setSoldModalProject(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
373
src/components/projects/views/ProjectsCalendarView.tsx
Normal file
373
src/components/projects/views/ProjectsCalendarView.tsx
Normal file
@@ -0,0 +1,373 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import {
|
||||
Calendar as CalendarIcon,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Target,
|
||||
} from 'lucide-react';
|
||||
import type { Project } from '../../../types';
|
||||
import {
|
||||
calculateProjectFinancials,
|
||||
formatCurrency,
|
||||
getProjectStatusInfo,
|
||||
} from '../../../utils/calculations';
|
||||
|
||||
interface ProjectsCalendarViewProps {
|
||||
projects: Project[];
|
||||
onSelectProject: (id: string) => void;
|
||||
}
|
||||
|
||||
interface CalendarDayEvent {
|
||||
project: Project;
|
||||
type: 'start' | 'deadline' | 'completed' | 'sold';
|
||||
label: string;
|
||||
colorClass: string;
|
||||
}
|
||||
|
||||
export const ProjectsCalendarView: React.FC<ProjectsCalendarViewProps> = ({
|
||||
projects,
|
||||
onSelectProject,
|
||||
}) => {
|
||||
// Current calendar viewing month
|
||||
const [currentDate, setCurrentDate] = useState(() => new Date());
|
||||
|
||||
const year = currentDate.getFullYear();
|
||||
const month = currentDate.getMonth(); // 0-indexed
|
||||
|
||||
// Month navigation
|
||||
const prevMonth = () => {
|
||||
setCurrentDate(new Date(year, month - 1, 1));
|
||||
};
|
||||
|
||||
const nextMonth = () => {
|
||||
setCurrentDate(new Date(year, month + 1, 1));
|
||||
};
|
||||
|
||||
const setToday = () => {
|
||||
setCurrentDate(new Date());
|
||||
};
|
||||
|
||||
// Month name in French
|
||||
const monthName = new Intl.DateTimeFormat('fr-FR', {
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
}).format(currentDate);
|
||||
|
||||
// Generate calendar grid days
|
||||
const calendarDays = useMemo(() => {
|
||||
const firstDayOfMonth = new Date(year, month, 1);
|
||||
// JS getDay(): 0 is Sunday, 1 is Monday... convert to Monday=0
|
||||
let startDayOfWeek = firstDayOfMonth.getDay() - 1;
|
||||
if (startDayOfWeek === -1) startDayOfWeek = 6;
|
||||
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||
const daysInPrevMonth = new Date(year, month, 0).getDate();
|
||||
|
||||
const days: {
|
||||
dayNumber: number;
|
||||
dateString: string;
|
||||
isCurrentMonth: boolean;
|
||||
isToday: boolean;
|
||||
}[] = [];
|
||||
|
||||
const todayStr = new Date().toISOString().split('T')[0];
|
||||
|
||||
// Previous month padding days
|
||||
for (let i = startDayOfWeek - 1; i >= 0; i--) {
|
||||
const d = daysInPrevMonth - i;
|
||||
const prevMonthIdx = month === 0 ? 11 : month - 1;
|
||||
const prevYear = month === 0 ? year - 1 : year;
|
||||
const dateStr = `${prevYear}-${(prevMonthIdx + 1).toString().padStart(2, '0')}-${d.toString().padStart(2, '0')}`;
|
||||
days.push({
|
||||
dayNumber: d,
|
||||
dateString: dateStr,
|
||||
isCurrentMonth: false,
|
||||
isToday: dateStr === todayStr,
|
||||
});
|
||||
}
|
||||
|
||||
// Current month days
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
const dateStr = `${year}-${(month + 1).toString().padStart(2, '0')}-${d.toString().padStart(2, '0')}`;
|
||||
days.push({
|
||||
dayNumber: d,
|
||||
dateString: dateStr,
|
||||
isCurrentMonth: true,
|
||||
isToday: dateStr === todayStr,
|
||||
});
|
||||
}
|
||||
|
||||
// Next month padding days to complete grid (multiples of 7)
|
||||
const remainingDays = (7 - (days.length % 7)) % 7;
|
||||
for (let d = 1; d <= remainingDays; d++) {
|
||||
const nextMonthIdx = month === 11 ? 0 : month + 1;
|
||||
const nextYear = month === 11 ? year + 1 : year;
|
||||
const dateStr = `${nextYear}-${(nextMonthIdx + 1).toString().padStart(2, '0')}-${d.toString().padStart(2, '0')}`;
|
||||
days.push({
|
||||
dayNumber: d,
|
||||
dateString: dateStr,
|
||||
isCurrentMonth: false,
|
||||
isToday: dateStr === todayStr,
|
||||
});
|
||||
}
|
||||
|
||||
return days;
|
||||
}, [year, month]);
|
||||
|
||||
// Map events to date strings
|
||||
const eventsByDate = useMemo(() => {
|
||||
const map = new Map<string, CalendarDayEvent[]>();
|
||||
|
||||
projects.forEach((proj) => {
|
||||
// 1. Target Deadline
|
||||
if (proj.objectives?.targetDeadlineDate) {
|
||||
const d = proj.objectives.targetDeadlineDate;
|
||||
const list = map.get(d) || [];
|
||||
list.push({
|
||||
project: proj,
|
||||
type: 'deadline',
|
||||
label: `🎯 Échéance: ${proj.serialNumber}`,
|
||||
colorClass: 'bg-amber-500/20 text-amber-300 border-amber-500/40 hover:bg-amber-500/30',
|
||||
});
|
||||
map.set(d, list);
|
||||
}
|
||||
|
||||
// 2. Start Date
|
||||
if (proj.startDate) {
|
||||
const d = proj.startDate;
|
||||
const list = map.get(d) || [];
|
||||
list.push({
|
||||
project: proj,
|
||||
type: 'start',
|
||||
label: `⚡ Lancement: ${proj.serialNumber}`,
|
||||
colorClass: 'bg-purple-500/20 text-purple-300 border-purple-500/40 hover:bg-purple-500/30',
|
||||
});
|
||||
map.set(d, list);
|
||||
}
|
||||
|
||||
// 3. Completed Date
|
||||
if (proj.completedDate && proj.status !== 'sold') {
|
||||
const d = proj.completedDate;
|
||||
const list = map.get(d) || [];
|
||||
list.push({
|
||||
project: proj,
|
||||
type: 'completed',
|
||||
label: `✨ Prête: ${proj.serialNumber}`,
|
||||
colorClass: 'bg-cyan-500/20 text-cyan-300 border-cyan-500/40 hover:bg-cyan-500/30',
|
||||
});
|
||||
map.set(d, list);
|
||||
}
|
||||
|
||||
// 4. Sold Date
|
||||
if (proj.soldDate) {
|
||||
const d = proj.soldDate;
|
||||
const list = map.get(d) || [];
|
||||
list.push({
|
||||
project: proj,
|
||||
type: 'sold',
|
||||
label: `🎉 Vendu: ${proj.serialNumber}`,
|
||||
colorClass: 'bg-emerald-500/20 text-emerald-300 border-emerald-500/40 hover:bg-emerald-500/30',
|
||||
});
|
||||
map.set(d, list);
|
||||
}
|
||||
});
|
||||
|
||||
return map;
|
||||
}, [projects]);
|
||||
|
||||
// Upcoming deadlines summary
|
||||
const upcomingDeadlines = useMemo(() => {
|
||||
return projects
|
||||
.filter((p) => p.objectives?.targetDeadlineDate && p.status !== 'sold')
|
||||
.sort((a, b) => (a.objectives?.targetDeadlineDate || '').localeCompare(b.objectives?.targetDeadlineDate || ''));
|
||||
}, [projects]);
|
||||
|
||||
const WEEK_DAYS = ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim'];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Calendar Header Toolbar */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 p-4 rounded-2xl bg-slate-900/80 border border-slate-800 backdrop-blur-md shadow-lg">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-2.5 rounded-xl bg-pink-500/10 border border-pink-500/30 text-pink-400">
|
||||
<CalendarIcon className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-extrabold text-lg sm:text-xl text-white capitalize">
|
||||
{monthName}
|
||||
</h3>
|
||||
<p className="text-xs text-slate-400">
|
||||
Planning des jalons, lancements et échéances de modding
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Month switcher */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={setToday}
|
||||
className="px-3 py-1.5 rounded-xl text-xs font-bold bg-slate-950 hover:bg-slate-800 text-slate-300 border border-slate-800 transition-all"
|
||||
>
|
||||
Aujourd'hui
|
||||
</button>
|
||||
|
||||
<div className="flex items-center bg-slate-950 rounded-xl border border-slate-800 p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={prevMonth}
|
||||
className="p-1.5 rounded-lg hover:bg-slate-800 text-slate-400 hover:text-white transition-all"
|
||||
title="Mois précédent"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={nextMonth}
|
||||
className="p-1.5 rounded-lg hover:bg-slate-800 text-slate-400 hover:text-white transition-all"
|
||||
title="Mois suivant"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Grid: Calendar on Left, Upcoming Deadlines Card on Right */}
|
||||
<div className="grid grid-cols-1 xl:grid-cols-4 gap-6">
|
||||
{/* Calendar Board (3 Cols on XL) */}
|
||||
<div className="xl:col-span-3 rounded-2xl bg-slate-900/60 border border-slate-800 overflow-hidden shadow-xl">
|
||||
{/* Day of Week Headers */}
|
||||
<div className="grid grid-cols-7 border-b border-slate-800 bg-slate-950/80 text-center py-2.5 text-xs font-bold text-slate-400 uppercase tracking-wider">
|
||||
{WEEK_DAYS.map((d) => (
|
||||
<div key={d}>{d}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Day Cells Grid */}
|
||||
<div className="grid grid-cols-7 divide-x divide-y divide-slate-850 bg-slate-950/40">
|
||||
{calendarDays.map((day) => {
|
||||
const events = eventsByDate.get(day.dateString) || [];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={day.dateString}
|
||||
className={`min-h-[105px] sm:min-h-[120px] p-1.5 sm:p-2 transition-all flex flex-col justify-between ${
|
||||
day.isCurrentMonth
|
||||
? 'bg-slate-900/20 hover:bg-slate-800/30'
|
||||
: 'bg-slate-950/80 text-slate-600 opacity-60'
|
||||
} ${day.isToday ? 'ring-2 ring-pink-500/40 bg-pink-500/5' : ''}`}
|
||||
>
|
||||
{/* Day Header */}
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span
|
||||
className={`font-mono text-xs font-bold px-1.5 py-0.5 rounded ${
|
||||
day.isToday
|
||||
? 'bg-pink-500 text-white font-extrabold shadow-sm'
|
||||
: day.isCurrentMonth
|
||||
? 'text-slate-300'
|
||||
: 'text-slate-600'
|
||||
}`}
|
||||
>
|
||||
{day.dayNumber}
|
||||
</span>
|
||||
{events.length > 0 && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-pink-400" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Events list inside day */}
|
||||
<div className="space-y-1 overflow-y-auto max-h-20 custom-scrollbar">
|
||||
{events.map((ev, i) => (
|
||||
<div
|
||||
key={i}
|
||||
onClick={() => onSelectProject(ev.project.id)}
|
||||
className={`text-[10px] font-mono px-1.5 py-0.5 rounded border truncate cursor-pointer transition-all ${ev.colorClass}`}
|
||||
title={`${ev.label} - ${ev.project.title}`}
|
||||
>
|
||||
{ev.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Sidebar: Upcoming Deadlines & Milestones */}
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 rounded-2xl bg-slate-900/60 border border-slate-800 backdrop-blur-md space-y-3">
|
||||
<div className="flex items-center space-x-2 pb-2 border-b border-slate-800">
|
||||
<Target className="w-4 h-4 text-amber-400" />
|
||||
<h4 className="font-bold text-sm text-white">Échéances & Objectifs Cibles</h4>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2.5">
|
||||
{upcomingDeadlines.map((proj) => {
|
||||
const fin = calculateProjectFinancials(proj);
|
||||
const statusInfo = getProjectStatusInfo(proj.status);
|
||||
const deadline = proj.objectives?.targetDeadlineDate;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={proj.id}
|
||||
onClick={() => onSelectProject(proj.id)}
|
||||
className="p-3 rounded-xl bg-slate-950/80 hover:bg-slate-950 border border-slate-800 hover:border-amber-500/40 cursor-pointer transition-all group"
|
||||
>
|
||||
<div className="flex items-center justify-between text-xs mb-1">
|
||||
<span className="font-mono text-[10px] font-bold text-amber-300 bg-amber-500/10 px-1.5 py-0.5 rounded border border-amber-500/20">
|
||||
📅 {deadline}
|
||||
</span>
|
||||
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded-full border ${statusInfo.color}`}>
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="font-bold text-xs text-slate-200 group-hover:text-pink-300 transition-colors truncate">
|
||||
{proj.title}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mt-2 pt-2 border-t border-slate-850 text-[10px] font-mono text-slate-400">
|
||||
<span>{proj.serialNumber}</span>
|
||||
<span className="font-bold text-emerald-400">
|
||||
{formatCurrency(fin.revenue)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{upcomingDeadlines.length === 0 && (
|
||||
<div className="py-6 text-center text-xs text-slate-500 italic">
|
||||
Aucune échéance fixée sur les projets en cours.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend Card */}
|
||||
<div className="p-4 rounded-2xl bg-slate-900/60 border border-slate-800 text-xs space-y-2">
|
||||
<div className="font-bold text-slate-300 mb-1">Légende du Calendrier</div>
|
||||
<div className="flex items-center space-x-2 text-slate-400">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-amber-400" />
|
||||
<span>🎯 Échéances cibles (Objectifs)</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 text-slate-400">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-purple-400" />
|
||||
<span>⚡ Date de lancement projet</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 text-slate-400">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-cyan-400" />
|
||||
<span>✨ Finition & Prêt en stock</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 text-slate-400">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-emerald-400" />
|
||||
<span>🎉 Date de vente & expédition</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
286
src/components/projects/views/ProjectsKanbanView.tsx
Normal file
286
src/components/projects/views/ProjectsKanbanView.tsx
Normal file
@@ -0,0 +1,286 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Gamepad2,
|
||||
Clock,
|
||||
ArrowRight,
|
||||
ArrowLeft,
|
||||
ShoppingBag,
|
||||
ListTodo,
|
||||
Calendar,
|
||||
} from 'lucide-react';
|
||||
import { useApp } from '../../../context/AppContext';
|
||||
import type { Project, ProjectStatus } from '../../../types';
|
||||
import {
|
||||
calculateProjectFinancials,
|
||||
formatCurrency,
|
||||
formatMinutes,
|
||||
} from '../../../utils/calculations';
|
||||
|
||||
interface KanbanColumn {
|
||||
id: ProjectStatus;
|
||||
title: string;
|
||||
badgeColor: string;
|
||||
borderColor: string;
|
||||
headerBg: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
const KANBAN_COLUMNS: KanbanColumn[] = [
|
||||
{
|
||||
id: 'draft',
|
||||
title: 'Planifié / En attente',
|
||||
badgeColor: 'bg-slate-500/20 text-slate-300 border-slate-500/40',
|
||||
borderColor: 'border-slate-800',
|
||||
headerBg: 'from-slate-800/40 to-slate-900/20',
|
||||
icon: '📝',
|
||||
},
|
||||
{
|
||||
id: 'in_progress',
|
||||
title: 'En cours d\'Atelier',
|
||||
badgeColor: 'bg-purple-500/20 text-purple-300 border-purple-500/40',
|
||||
borderColor: 'border-purple-500/30',
|
||||
headerBg: 'from-purple-950/40 to-slate-900/20',
|
||||
icon: '⚡',
|
||||
},
|
||||
{
|
||||
id: 'qc_testing',
|
||||
title: 'Tests & Contrôle QC',
|
||||
badgeColor: 'bg-cyan-500/20 text-cyan-300 border-cyan-500/40',
|
||||
borderColor: 'border-cyan-500/30',
|
||||
headerBg: 'from-cyan-950/40 to-slate-900/20',
|
||||
icon: '🔍',
|
||||
},
|
||||
{
|
||||
id: 'in_stock_ready',
|
||||
title: 'Prêt en Stock / Vitrine',
|
||||
badgeColor: 'bg-emerald-500/20 text-emerald-300 border-emerald-500/40',
|
||||
borderColor: 'border-emerald-500/30',
|
||||
headerBg: 'from-emerald-950/40 to-slate-900/20',
|
||||
icon: '✨',
|
||||
},
|
||||
{
|
||||
id: 'sold',
|
||||
title: 'Vendu & Livré',
|
||||
badgeColor: 'bg-pink-500/20 text-pink-300 border-pink-500/40',
|
||||
borderColor: 'border-pink-500/30',
|
||||
headerBg: 'from-pink-950/40 to-slate-900/20',
|
||||
icon: '🎉',
|
||||
},
|
||||
];
|
||||
|
||||
interface ProjectsKanbanViewProps {
|
||||
projects: Project[];
|
||||
onSelectProject: (id: string) => void;
|
||||
onOpenSoldModal: (project: Project) => void;
|
||||
}
|
||||
|
||||
export const ProjectsKanbanView: React.FC<ProjectsKanbanViewProps> = ({
|
||||
projects,
|
||||
onSelectProject,
|
||||
onOpenSoldModal,
|
||||
}) => {
|
||||
const { updateProjectStatus, startTimer, activeTimer } = useApp();
|
||||
|
||||
const getPreviousStatus = (status: ProjectStatus): ProjectStatus | null => {
|
||||
switch (status) {
|
||||
case 'in_progress':
|
||||
return 'draft';
|
||||
case 'qc_testing':
|
||||
return 'in_progress';
|
||||
case 'in_stock_ready':
|
||||
return 'qc_testing';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getNextStatus = (status: ProjectStatus): ProjectStatus | null => {
|
||||
switch (status) {
|
||||
case 'draft':
|
||||
return 'in_progress';
|
||||
case 'in_progress':
|
||||
return 'qc_testing';
|
||||
case 'qc_testing':
|
||||
return 'in_stock_ready';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-5 gap-4.5 overflow-x-auto pb-4">
|
||||
{KANBAN_COLUMNS.map((col) => {
|
||||
const columnProjects = projects.filter((p) => {
|
||||
if (col.id === 'sold') {
|
||||
return p.status === 'sold' || p.status === 'reserved';
|
||||
}
|
||||
return p.status === col.id;
|
||||
});
|
||||
|
||||
const totalPotentialRevenue = columnProjects.reduce((sum, p) => {
|
||||
const fin = calculateProjectFinancials(p);
|
||||
return sum + fin.revenue;
|
||||
}, 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={col.id}
|
||||
className="flex flex-col rounded-2xl bg-slate-950/60 border border-slate-850 overflow-hidden min-w-[270px]"
|
||||
>
|
||||
{/* Column Header */}
|
||||
<div
|
||||
className={`p-3.5 border-b border-slate-800 bg-gradient-to-b ${col.headerBg} flex items-center justify-between`}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-base">{col.icon}</span>
|
||||
<span className="font-bold text-xs text-white tracking-tight">
|
||||
{col.title}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded-full text-xs font-mono font-bold border ${col.badgeColor}`}
|
||||
>
|
||||
{columnProjects.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Subheader: Potential revenue in this column */}
|
||||
<div className="px-3.5 py-1.5 bg-slate-950/40 border-b border-slate-850/60 flex items-center justify-between text-[10px] text-slate-400 font-mono">
|
||||
<span>Valeur :</span>
|
||||
<span className="font-bold text-pink-400">
|
||||
{formatCurrency(totalPotentialRevenue)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Column Project Cards */}
|
||||
<div className="p-3 space-y-3 flex-1 overflow-y-auto max-h-[calc(100vh-280px)] custom-scrollbar">
|
||||
{columnProjects.map((project) => {
|
||||
const fin = calculateProjectFinancials(project);
|
||||
const isTimerRunning =
|
||||
activeTimer.isRunning && activeTimer.projectId === project.id;
|
||||
const prev = getPreviousStatus(project.status);
|
||||
const next = getNextStatus(project.status);
|
||||
const tasks = project.tasks || [];
|
||||
const completedTasks = tasks.filter((t) => t.isCompleted).length;
|
||||
const objectives = project.objectives || {};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={project.id}
|
||||
onClick={() => onSelectProject(project.id)}
|
||||
className="p-3.5 rounded-xl bg-slate-900/80 hover:bg-slate-900 border border-slate-800 hover:border-pink-500/40 transition-all shadow-md hover:shadow-xl cursor-pointer group space-y-2.5"
|
||||
>
|
||||
{/* Top row: Serial & Model */}
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="font-mono text-[10px] font-bold px-1.5 py-0.5 rounded bg-slate-950 text-slate-300 border border-slate-800">
|
||||
{project.serialNumber}
|
||||
</span>
|
||||
<span className="text-[11px] font-semibold text-cyan-300 flex items-center space-x-1">
|
||||
<Gamepad2 className="w-3 h-3" />
|
||||
<span>{project.consoleModel}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h4 className="font-bold text-sm text-slate-100 group-hover:text-pink-300 transition-colors line-clamp-2">
|
||||
{project.title}
|
||||
</h4>
|
||||
|
||||
{/* Tasks mini pill */}
|
||||
{tasks.length > 0 && (
|
||||
<div className="flex items-center space-x-1 text-[11px] text-purple-300 bg-purple-500/10 px-2 py-0.5 rounded-md border border-purple-500/20">
|
||||
<ListTodo className="w-3 h-3 text-purple-400" />
|
||||
<span>Checklist: {completedTasks}/{tasks.length}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Deadline target if set */}
|
||||
{objectives.targetDeadlineDate && (
|
||||
<div className="flex items-center space-x-1 text-[10px] text-amber-300 font-mono">
|
||||
<Calendar className="w-3 h-3" />
|
||||
<span>Cible : {objectives.targetDeadlineDate}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Financial stats row */}
|
||||
<div className="flex items-center justify-between pt-2 border-t border-slate-800/80 text-[11px] font-mono">
|
||||
<span className="text-slate-400">
|
||||
{formatMinutes(fin.laborMinutes)}
|
||||
</span>
|
||||
<span className="font-bold text-emerald-400">
|
||||
{formatCurrency(fin.netProfit)} net
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Kanban Movement Actions */}
|
||||
<div
|
||||
className="flex items-center justify-between pt-1 gap-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{prev ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateProjectStatus(project.id, prev)}
|
||||
className="p-1 rounded-lg bg-slate-950 border border-slate-800 text-slate-400 hover:text-white hover:border-slate-600 transition-all text-[10px]"
|
||||
title="Reculer d'une étape"
|
||||
>
|
||||
<ArrowLeft className="w-3 h-3" />
|
||||
</button>
|
||||
) : <div />}
|
||||
|
||||
{/* Middle action button */}
|
||||
{project.status === 'in_progress' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
startTimer(project.id, 'soudure_mods', `Atelier sur ${project.title}`)
|
||||
}
|
||||
className={`px-2 py-0.5 rounded-lg text-[10px] font-bold flex items-center space-x-1 ${
|
||||
isTimerRunning
|
||||
? 'bg-amber-500 text-slate-950 animate-pulse'
|
||||
: 'bg-slate-950 border border-amber-500/30 text-amber-300 hover:bg-amber-500/20'
|
||||
}`}
|
||||
>
|
||||
<Clock className="w-2.5 h-2.5" />
|
||||
<span>{isTimerRunning ? 'Chrono actif' : 'Chrono'}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{project.status === 'in_stock_ready' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenSoldModal(project)}
|
||||
className="px-2 py-0.5 rounded-lg text-[10px] font-bold bg-pink-600 hover:bg-pink-500 text-white flex items-center space-x-1 shadow-sm"
|
||||
>
|
||||
<ShoppingBag className="w-2.5 h-2.5" />
|
||||
<span>Vendre</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{next ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateProjectStatus(project.id, next)}
|
||||
className="p-1 rounded-lg bg-slate-950 border border-slate-800 text-slate-400 hover:text-cyan-300 hover:border-cyan-500/40 transition-all text-[10px]"
|
||||
title="Avancer à l'étape suivante"
|
||||
>
|
||||
<ArrowRight className="w-3 h-3" />
|
||||
</button>
|
||||
) : <div />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{columnProjects.length === 0 && (
|
||||
<div className="py-8 text-center text-xs text-slate-600 italic">
|
||||
Aucun projet
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
200
src/components/projects/views/ProjectsListView.tsx
Normal file
200
src/components/projects/views/ProjectsListView.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Gamepad2,
|
||||
Calendar,
|
||||
ArrowRight,
|
||||
ShoppingBag,
|
||||
} from 'lucide-react';
|
||||
import { useApp } from '../../../context/AppContext';
|
||||
import type { Project } from '../../../types';
|
||||
import {
|
||||
calculateProjectFinancials,
|
||||
formatCurrency,
|
||||
formatMinutes,
|
||||
getProjectStatusInfo,
|
||||
} from '../../../utils/calculations';
|
||||
|
||||
interface ProjectsListViewProps {
|
||||
projects: Project[];
|
||||
onSelectProject: (id: string) => void;
|
||||
onOpenSoldModal: (project: Project) => void;
|
||||
}
|
||||
|
||||
export const ProjectsListView: React.FC<ProjectsListViewProps> = ({
|
||||
projects,
|
||||
onSelectProject,
|
||||
onOpenSoldModal,
|
||||
}) => {
|
||||
const { updateProjectStatus } = useApp();
|
||||
|
||||
return (
|
||||
<div className="bg-slate-900/60 rounded-2xl border border-slate-800 overflow-hidden shadow-xl">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-800 bg-slate-950/80 text-[11px] font-bold text-slate-400 uppercase tracking-wider">
|
||||
<th className="py-3.5 px-4">Projet & Réf</th>
|
||||
<th className="py-3.5 px-3">Modèle</th>
|
||||
<th className="py-3.5 px-3">Statut</th>
|
||||
<th className="py-3.5 px-3">Tâches</th>
|
||||
<th className="py-3.5 px-3">Échéance 🎯</th>
|
||||
<th className="py-3.5 px-3 text-right">Temps Atelier</th>
|
||||
<th className="py-3.5 px-3 text-right">Coût Pièces</th>
|
||||
<th className="py-3.5 px-3 text-right">Prix Vente</th>
|
||||
<th className="py-3.5 px-3 text-right">Bénéfice Net</th>
|
||||
<th className="py-3.5 px-4 text-center">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-850 text-xs">
|
||||
{projects.map((project) => {
|
||||
const fin = calculateProjectFinancials(project);
|
||||
const statusInfo = getProjectStatusInfo(project.status);
|
||||
const tasks = project.tasks || [];
|
||||
const completedTasks = tasks.filter((t) => t.isCompleted).length;
|
||||
const totalTasks = tasks.length;
|
||||
const taskPercent = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0;
|
||||
const objectives = project.objectives || {};
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={project.id}
|
||||
onClick={() => onSelectProject(project.id)}
|
||||
className="hover:bg-slate-800/50 cursor-pointer transition-colors group"
|
||||
>
|
||||
{/* Title & Ref */}
|
||||
<td className="py-3.5 px-4">
|
||||
<div className="flex items-center space-x-2.5">
|
||||
<span className="font-mono font-bold text-[11px] text-pink-400 px-2 py-0.5 rounded bg-slate-950 border border-slate-800">
|
||||
{project.serialNumber}
|
||||
</span>
|
||||
<div>
|
||||
<div className="font-bold text-slate-100 group-hover:text-pink-300 transition-colors line-clamp-1">
|
||||
{project.title}
|
||||
</div>
|
||||
{project.customerName && (
|
||||
<div className="text-[11px] text-slate-500">
|
||||
Client : {project.customerName}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Model */}
|
||||
<td className="py-3.5 px-3">
|
||||
<span className="inline-flex items-center space-x-1 px-2 py-0.5 rounded-md text-[11px] font-semibold bg-slate-950 text-cyan-300 border border-cyan-500/20">
|
||||
<Gamepad2 className="w-3 h-3" />
|
||||
<span>{project.consoleModel}</span>
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Status */}
|
||||
<td className="py-3.5 px-3">
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold border ${statusInfo.color}`}
|
||||
>
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Tasks Progress */}
|
||||
<td className="py-3.5 px-3">
|
||||
{totalTasks > 0 ? (
|
||||
<div className="w-24">
|
||||
<div className="flex items-center justify-between text-[10px] font-mono text-slate-400 mb-0.5">
|
||||
<span>{completedTasks}/{totalTasks}</span>
|
||||
<span className="text-purple-300 font-bold">{taskPercent}%</span>
|
||||
</div>
|
||||
<div className="w-full h-1 bg-slate-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-pink-500 to-purple-500"
|
||||
style={{ width: `${taskPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-slate-600 text-[11px] italic">—</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Deadline & Target Date */}
|
||||
<td className="py-3.5 px-3 font-mono text-[11px]">
|
||||
{objectives.targetDeadlineDate ? (
|
||||
<span className="inline-flex items-center space-x-1 text-amber-300 bg-amber-500/10 px-1.5 py-0.5 rounded border border-amber-500/20">
|
||||
<Calendar className="w-3 h-3" />
|
||||
<span>{objectives.targetDeadlineDate}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-slate-600">—</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Labor Time */}
|
||||
<td className="py-3.5 px-3 text-right font-mono font-bold text-amber-300">
|
||||
{formatMinutes(fin.laborMinutes)}
|
||||
</td>
|
||||
|
||||
{/* Parts Cost */}
|
||||
<td className="py-3.5 px-3 text-right font-mono text-slate-300">
|
||||
{formatCurrency(fin.hardwareDirectCost)}
|
||||
</td>
|
||||
|
||||
{/* Revenue / Target */}
|
||||
<td className="py-3.5 px-3 text-right font-mono font-bold text-pink-400">
|
||||
{formatCurrency(fin.revenue)}
|
||||
</td>
|
||||
|
||||
{/* Net Profit & Margin */}
|
||||
<td className="py-3.5 px-3 text-right">
|
||||
<div className="font-mono font-extrabold text-emerald-400">
|
||||
{formatCurrency(fin.netProfit)}
|
||||
</div>
|
||||
<div className="text-[10px] text-emerald-500 font-mono">
|
||||
{fin.marginPercent.toFixed(0)}% marge
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Actions */}
|
||||
<td
|
||||
className="py-3.5 px-4 text-center"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-center space-x-1.5">
|
||||
{project.status === 'in_stock_ready' ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenSoldModal(project)}
|
||||
className="px-2.5 py-1 rounded-lg text-xs font-bold bg-pink-600 hover:bg-pink-500 text-white shadow-sm flex items-center space-x-1"
|
||||
>
|
||||
<ShoppingBag className="w-3 h-3" />
|
||||
<span>Vendre</span>
|
||||
</button>
|
||||
) : project.status === 'in_progress' ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateProjectStatus(project.id, 'qc_testing')}
|
||||
className="px-2 py-1 rounded-lg text-xs font-semibold bg-cyan-600/30 hover:bg-cyan-600/50 text-cyan-300 border border-cyan-500/30"
|
||||
>
|
||||
QC
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectProject(project.id)}
|
||||
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800"
|
||||
title="Voir la fiche détaillée"
|
||||
>
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
313
src/components/projects/views/ProjectsTileView.tsx
Normal file
313
src/components/projects/views/ProjectsTileView.tsx
Normal file
@@ -0,0 +1,313 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Gamepad2,
|
||||
Clock,
|
||||
Wrench,
|
||||
Sparkles,
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
Calendar,
|
||||
DollarSign,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
ShoppingBag,
|
||||
ListTodo,
|
||||
} from 'lucide-react';
|
||||
import { useApp } from '../../../context/AppContext';
|
||||
import type { Project } from '../../../types';
|
||||
import {
|
||||
calculateProjectFinancials,
|
||||
formatCurrency,
|
||||
formatMinutes,
|
||||
getProjectStatusInfo,
|
||||
} from '../../../utils/calculations';
|
||||
import { ProjectTasksChecklist } from '../ProjectTasksChecklist';
|
||||
|
||||
interface ProjectsTileViewProps {
|
||||
projects: Project[];
|
||||
onSelectProject: (id: string) => void;
|
||||
onOpenSoldModal: (project: Project) => void;
|
||||
}
|
||||
|
||||
export const ProjectsTileView: React.FC<ProjectsTileViewProps> = ({
|
||||
projects,
|
||||
onSelectProject,
|
||||
onOpenSoldModal,
|
||||
}) => {
|
||||
const { updateProjectStatus, startTimer, activeTimer } = useApp();
|
||||
const [expandedChecklistId, setExpandedChecklistId] = useState<string | null>(null);
|
||||
|
||||
const toggleChecklist = (id: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setExpandedChecklistId(expandedChecklistId === id ? null : id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||
{projects.map((project) => {
|
||||
const fin = calculateProjectFinancials(project);
|
||||
const statusInfo = getProjectStatusInfo(project.status);
|
||||
const isTimerRunningOnThis =
|
||||
activeTimer.isRunning && activeTimer.projectId === project.id;
|
||||
|
||||
const tasks = project.tasks || [];
|
||||
const completedTasks = tasks.filter((t) => t.isCompleted).length;
|
||||
const totalTasks = tasks.length;
|
||||
const taskPercent = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0;
|
||||
const objectives = project.objectives || {};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={project.id}
|
||||
onClick={() => onSelectProject(project.id)}
|
||||
className="group relative bg-slate-900/60 hover:bg-slate-900/90 rounded-3xl border border-slate-800 hover:border-pink-500/40 p-5 transition-all duration-300 shadow-lg hover:shadow-2xl hover:shadow-pink-500/10 cursor-pointer flex flex-col justify-between"
|
||||
>
|
||||
{/* Top Row: Serial & Status Badges */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-2 mb-3">
|
||||
<span className="font-mono text-xs font-extrabold px-2.5 py-1 rounded-lg bg-slate-950 text-slate-300 border border-slate-800">
|
||||
{project.serialNumber}
|
||||
</span>
|
||||
|
||||
<div className="flex items-center space-x-1.5">
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-bold border ${statusInfo.color}`}
|
||||
>
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title & Console Model */}
|
||||
<h3 className="font-bold text-lg text-white group-hover:text-pink-400 transition-colors line-clamp-1">
|
||||
{project.title}
|
||||
</h3>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 mt-2">
|
||||
<span className="inline-flex items-center space-x-1 px-2.5 py-0.5 rounded-md text-xs font-semibold bg-slate-950 text-cyan-300 border border-cyan-500/20">
|
||||
<Gamepad2 className="w-3.5 h-3.5" />
|
||||
<span>{project.consoleModel}</span>
|
||||
</span>
|
||||
|
||||
{project.type === 'client_custom' && (
|
||||
<span className="inline-flex items-center space-x-1 px-2 py-0.5 rounded-md text-[11px] font-medium bg-purple-500/10 text-purple-300 border border-purple-500/20">
|
||||
<span>Client: {project.customerName || 'Privé'}</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{objectives.targetDeadlineDate && (
|
||||
<span className="inline-flex items-center space-x-1 px-2 py-0.5 rounded-md text-[11px] font-mono text-amber-300 bg-amber-500/10 border border-amber-500/20">
|
||||
<Calendar className="w-3 h-3" />
|
||||
<span>Cible: {objectives.targetDeadlineDate}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Objectives Mini Gauges */}
|
||||
{(objectives.targetProfitMin || objectives.targetLaborHoursMax || totalTasks > 0) && (
|
||||
<div className="mt-3.5 p-2.5 rounded-xl bg-slate-950/60 border border-slate-850 space-y-2">
|
||||
{/* Task progress bar */}
|
||||
{totalTasks > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-[11px] mb-1">
|
||||
<span className="text-slate-400 flex items-center space-x-1">
|
||||
<ListTodo className="w-3 h-3 text-purple-400" />
|
||||
<span>Tâches ({completedTasks}/{totalTasks})</span>
|
||||
</span>
|
||||
<span className="font-mono text-purple-300 font-bold">{taskPercent}%</span>
|
||||
</div>
|
||||
<div className="w-full h-1.5 bg-slate-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-pink-500 to-purple-500"
|
||||
style={{ width: `${taskPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Objective targets */}
|
||||
<div className="flex items-center justify-between text-[11px] font-mono text-slate-400">
|
||||
{objectives.targetProfitMin && (
|
||||
<span className="flex items-center space-x-1 text-pink-300">
|
||||
<DollarSign className="w-3 h-3" />
|
||||
<span>Cible: {formatCurrency(objectives.targetProfitMin)}</span>
|
||||
</span>
|
||||
)}
|
||||
{objectives.targetLaborHoursMax && (
|
||||
<span className="flex items-center space-x-1 text-amber-300">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>Max: {objectives.targetLaborHoursMax}h</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Financial & Time Metrics Summary */}
|
||||
<div className="grid grid-cols-3 gap-2 mt-4 pt-4 border-t border-slate-800/80">
|
||||
<div className="p-2 rounded-xl bg-slate-950/60 border border-slate-850 text-center">
|
||||
<span className="text-[10px] text-slate-400 block">Pièces & Frais</span>
|
||||
<span className="font-mono text-xs font-bold text-slate-200">
|
||||
{formatCurrency(fin.hardwareDirectCost)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="p-2 rounded-xl bg-slate-950/60 border border-slate-850 text-center">
|
||||
<span className="text-[10px] text-slate-400 block">Temps Passé</span>
|
||||
<span className="font-mono text-xs font-bold text-amber-300">
|
||||
{formatMinutes(fin.laborMinutes)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="p-2 rounded-xl bg-slate-950/60 border border-slate-850 text-center">
|
||||
<span className="text-[10px] text-slate-400 block">
|
||||
{project.status === 'sold' ? 'Prix Vendu' : 'Prix Cible'}
|
||||
</span>
|
||||
<span className="font-mono text-xs font-extrabold text-pink-400">
|
||||
{formatCurrency(fin.revenue)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Net Profit & Margin Bar */}
|
||||
<div className="mt-3 flex items-center justify-between px-3 py-2 rounded-xl bg-emerald-500/10 border border-emerald-500/20 text-xs">
|
||||
<span className="text-emerald-300 font-medium">Bénéfice Net Réel :</span>
|
||||
<div className="flex items-center space-x-2 font-mono">
|
||||
<span className="font-bold text-emerald-400 text-sm">
|
||||
{formatCurrency(fin.netProfit)}
|
||||
</span>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-500/20 text-emerald-300 font-extrabold">
|
||||
{fin.marginPercent.toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Checklist Expander in Card */}
|
||||
{totalTasks > 0 && (
|
||||
<div className="mt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => toggleChecklist(project.id, e)}
|
||||
className="w-full flex items-center justify-between text-xs text-slate-400 hover:text-white py-1 px-1 transition-colors"
|
||||
>
|
||||
<span className="flex items-center space-x-1.5">
|
||||
<ListTodo className="w-3.5 h-3.5 text-purple-400" />
|
||||
<span>Checklist ({completedTasks}/{totalTasks})</span>
|
||||
</span>
|
||||
{expandedChecklistId === project.id ? (
|
||||
<ChevronUp className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<ChevronDown className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{expandedChecklistId === project.id && (
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="mt-2 p-2.5 rounded-xl bg-slate-950/90 border border-purple-500/30"
|
||||
>
|
||||
<ProjectTasksChecklist project={project} compact={true} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom Actions Toolbar */}
|
||||
<div className="mt-5 pt-3 border-t border-slate-800 flex items-center justify-between gap-2">
|
||||
{/* Quick Workflow Action Button */}
|
||||
{project.status === 'draft' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
updateProjectStatus(project.id, 'in_progress');
|
||||
}}
|
||||
className="flex items-center space-x-1 px-3 py-1.5 rounded-xl text-xs font-bold bg-purple-600 hover:bg-purple-500 text-white shadow-md transition-all"
|
||||
>
|
||||
<Wrench className="w-3.5 h-3.5" />
|
||||
<span>Démarrer</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{project.status === 'in_progress' && (
|
||||
<div className="flex items-center space-x-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
startTimer(project.id, 'soudure_mods', `Atelier sur ${project.title}`);
|
||||
}}
|
||||
className={`flex items-center space-x-1 px-2.5 py-1.5 rounded-xl text-xs font-bold transition-all ${
|
||||
isTimerRunningOnThis
|
||||
? 'bg-amber-500 text-slate-950 animate-pulse'
|
||||
: 'bg-slate-800 hover:bg-amber-500/20 text-amber-300 border border-amber-500/30'
|
||||
}`}
|
||||
>
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
<span>{isTimerRunningOnThis ? 'Chrono actif' : 'Chrono'}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
updateProjectStatus(project.id, 'qc_testing');
|
||||
}}
|
||||
className="flex items-center space-x-1 px-2.5 py-1.5 rounded-xl text-xs font-bold bg-cyan-600 hover:bg-cyan-500 text-white transition-all"
|
||||
>
|
||||
<span>Tests QC</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{project.status === 'qc_testing' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
updateProjectStatus(project.id, 'in_stock_ready');
|
||||
}}
|
||||
className="flex items-center space-x-1 px-3 py-1.5 rounded-xl text-xs font-bold bg-emerald-600 hover:bg-emerald-500 text-white shadow-md transition-all"
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
<span>Prête en stock</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{project.status === 'in_stock_ready' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenSoldModal(project);
|
||||
}}
|
||||
className="flex items-center space-x-1.5 px-3 py-1.5 rounded-xl text-xs font-black bg-gradient-to-r from-pink-500 to-purple-600 hover:from-pink-400 hover:to-purple-500 text-white shadow-lg shadow-pink-500/20 transition-all"
|
||||
>
|
||||
<ShoppingBag className="w-3.5 h-3.5" />
|
||||
<span>Vendre</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{project.status === 'sold' && (
|
||||
<span className="flex items-center space-x-1 text-xs font-bold text-emerald-400">
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
<span>Vendue ({project.soldDate || 'OK'})</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* View details arrow */}
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center space-x-1 text-xs font-semibold text-slate-400 group-hover:text-pink-400 transition-colors ml-auto"
|
||||
>
|
||||
<span>Fiche & Coûts</span>
|
||||
<ArrowRight className="w-3.5 h-3.5 group-hover:translate-x-1 transition-transform" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
539
src/components/settings/CategoryManager.tsx
Normal file
539
src/components/settings/CategoryManager.tsx
Normal file
@@ -0,0 +1,539 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Tag,
|
||||
Gamepad2,
|
||||
Sparkles,
|
||||
Plus,
|
||||
Edit2,
|
||||
Trash2,
|
||||
Check,
|
||||
X,
|
||||
Layers,
|
||||
Palette,
|
||||
Eye,
|
||||
Search,
|
||||
} from 'lucide-react';
|
||||
import { useApp } from '../../context/AppContext';
|
||||
import type { AppCategory, CategoryType } from '../../types';
|
||||
|
||||
const COLOR_PRESETS = [
|
||||
{ name: 'Cyan Néon', color: '#06b6d4', badge: 'bg-cyan-500/10 text-cyan-400 border-cyan-500/30' },
|
||||
{ name: 'Violet Cyber', color: '#a855f7', badge: 'bg-purple-500/10 text-purple-400 border-purple-500/30' },
|
||||
{ name: 'Rose UwU', color: '#ec4899', badge: 'bg-pink-500/10 text-pink-400 border-pink-500/30' },
|
||||
{ name: 'Emeraude LiPo', color: '#10b981', badge: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30' },
|
||||
{ name: 'Ambre Vintage', color: '#f59e0b', badge: 'bg-amber-500/10 text-amber-400 border-amber-500/30' },
|
||||
{ name: 'Bleu Royal', color: '#3b82f6', badge: 'bg-blue-500/10 text-blue-400 border-blue-500/30' },
|
||||
{ name: 'Indigo Pro', color: '#6366f1', badge: 'bg-indigo-500/10 text-indigo-400 border-indigo-500/30' },
|
||||
{ name: 'Rouge Flash', color: '#f43f5e', badge: 'bg-rose-500/10 text-rose-400 border-rose-500/30' },
|
||||
{ name: 'Lime Rétro', color: '#84cc16', badge: 'bg-lime-500/10 text-lime-400 border-lime-500/30' },
|
||||
{ name: 'Slate Neutre', color: '#64748b', badge: 'bg-slate-500/10 text-slate-400 border-slate-500/30' },
|
||||
];
|
||||
|
||||
export const CategoryManager: React.FC = () => {
|
||||
const {
|
||||
categories,
|
||||
addCategory,
|
||||
updateCategory,
|
||||
deleteCategory,
|
||||
inventory,
|
||||
projects,
|
||||
modsLibrary,
|
||||
} = useApp();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<CategoryType>('part_category');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingCategory, setEditingCategory] = useState<AppCategory | null>(null);
|
||||
|
||||
// Form State
|
||||
const [formName, setFormName] = useState('');
|
||||
const [formSlug, setFormSlug] = useState('');
|
||||
const [formType, setFormType] = useState<CategoryType>('part_category');
|
||||
const [formColor, setFormColor] = useState('#06b6d4');
|
||||
const [formBadgeColor, setFormBadgeColor] = useState('bg-cyan-500/10 text-cyan-400 border-cyan-500/30');
|
||||
const [formDescription, setFormDescription] = useState('');
|
||||
const [formSortOrder, setFormSortOrder] = useState<number>(1);
|
||||
const [formIsActive, setFormIsActive] = useState(true);
|
||||
|
||||
// Filter categories for the current tab and search query
|
||||
const filteredCategories = categories
|
||||
.filter((c) => c.type === activeTab)
|
||||
.filter((c) => {
|
||||
if (!searchQuery.trim()) return true;
|
||||
const q = searchQuery.toLowerCase();
|
||||
return (
|
||||
c.name.toLowerCase().includes(q) ||
|
||||
c.slug.toLowerCase().includes(q) ||
|
||||
(c.description && c.description.toLowerCase().includes(q))
|
||||
);
|
||||
})
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
|
||||
// Stats calculation
|
||||
const partCategoriesCount = categories.filter((c) => c.type === 'part_category').length;
|
||||
const consoleModelsCount = categories.filter((c) => c.type === 'console_model').length;
|
||||
const modCategoriesCount = categories.filter((c) => c.type === 'mod_category').length;
|
||||
|
||||
const countUsage = (cat: AppCategory): number => {
|
||||
if (cat.type === 'part_category') {
|
||||
const invCount = inventory.filter((i) => i.category === cat.slug).length;
|
||||
const projPartCount = projects.reduce(
|
||||
(sum, p) => sum + p.parts.filter((pt) => pt.category === cat.slug).length,
|
||||
0
|
||||
);
|
||||
return invCount + projPartCount;
|
||||
} else if (cat.type === 'console_model') {
|
||||
const invCount = inventory.filter((i) => i.consoleModel === cat.slug).length;
|
||||
const projCount = projects.filter((p) => p.consoleModel === cat.slug).length;
|
||||
const modCount = modsLibrary.filter((m) => m.compatibleModels?.includes(cat.slug)).length;
|
||||
return invCount + projCount + modCount;
|
||||
} else if (cat.type === 'mod_category') {
|
||||
return modsLibrary.filter((m) => m.category === cat.slug).length;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const handleOpenAdd = () => {
|
||||
setEditingCategory(null);
|
||||
setFormName('');
|
||||
setFormSlug('');
|
||||
setFormType(activeTab);
|
||||
setFormColor('#06b6d4');
|
||||
setFormBadgeColor('bg-cyan-500/10 text-cyan-400 border-cyan-500/30');
|
||||
setFormDescription('');
|
||||
setFormSortOrder(filteredCategories.length + 1);
|
||||
setFormIsActive(true);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenEdit = (cat: AppCategory) => {
|
||||
setEditingCategory(cat);
|
||||
setFormName(cat.name);
|
||||
setFormSlug(cat.slug);
|
||||
setFormType(cat.type);
|
||||
setFormColor(cat.color || '#06b6d4');
|
||||
setFormBadgeColor(cat.badgeColor || 'bg-cyan-500/10 text-cyan-400 border-cyan-500/30');
|
||||
setFormDescription(cat.description || '');
|
||||
setFormSortOrder(cat.sortOrder || 1);
|
||||
setFormIsActive(cat.isActive);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleNameChange = (name: string) => {
|
||||
setFormName(name);
|
||||
if (!editingCategory) {
|
||||
if (formType === 'console_model') {
|
||||
setFormSlug(name);
|
||||
} else {
|
||||
const slug = name
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '');
|
||||
setFormSlug(slug);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectPreset = (preset: typeof COLOR_PRESETS[0]) => {
|
||||
setFormColor(preset.color);
|
||||
setFormBadgeColor(preset.badge);
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!formName.trim() || !formSlug.trim()) return;
|
||||
|
||||
const payload = {
|
||||
name: formName.trim(),
|
||||
slug: formSlug.trim(),
|
||||
type: formType,
|
||||
color: formColor,
|
||||
badgeColor: formBadgeColor,
|
||||
description: formDescription.trim(),
|
||||
sortOrder: Number(formSortOrder) || 1,
|
||||
isActive: formIsActive,
|
||||
};
|
||||
|
||||
if (editingCategory) {
|
||||
updateCategory(editingCategory.id, payload);
|
||||
} else {
|
||||
addCategory(payload);
|
||||
}
|
||||
|
||||
setIsModalOpen(false);
|
||||
};
|
||||
|
||||
const handleDelete = (cat: AppCategory) => {
|
||||
const usage = countUsage(cat);
|
||||
const warning =
|
||||
usage > 0
|
||||
? `Attention : Cet élément est utilisé par ${usage} pièce(s) / projet(s) existant(s). Êtes-vous sûr de vouloir le supprimer ?`
|
||||
: `Confirmer la suppression de "${cat.name}" ?`;
|
||||
|
||||
if (confirm(warning)) {
|
||||
deleteCategory(cat.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* Header section with Stats & Add Button */}
|
||||
<div className="glass-panel p-6 rounded-3xl border border-slate-800 space-y-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<div className="inline-flex items-center space-x-2 px-3 py-1 rounded-full text-xs font-semibold bg-cyan-500/10 text-cyan-400 border border-cyan-500/30">
|
||||
<Layers className="w-3.5 h-3.5" />
|
||||
<span>Gestionnaire d'Énumérations & Modèles</span>
|
||||
</div>
|
||||
<h3 className="text-xl font-black text-white font-display">
|
||||
Catégories & Modèles de Consoles
|
||||
</h3>
|
||||
<p className="text-xs text-slate-400">
|
||||
Ajoutez, personnalisez et réorganisez les catégories de pièces, modèles de consoles et types de mods de votre atelier.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleOpenAdd}
|
||||
className="px-4 py-2.5 rounded-xl bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-white text-xs font-bold flex items-center justify-center space-x-1.5 shadow-lg shadow-cyan-500/20 transition-all shrink-0"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>
|
||||
{activeTab === 'part_category'
|
||||
? 'Nouvelle Catégorie Pièce'
|
||||
: activeTab === 'console_model'
|
||||
? 'Nouveau Modèle Console'
|
||||
: 'Nouvelle Catégorie Mod'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Switcher & Search Bar */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 pt-2 border-t border-slate-800/80">
|
||||
<div className="flex items-center space-x-1.5 p-1 rounded-2xl bg-slate-950 border border-slate-800 overflow-x-auto">
|
||||
<button
|
||||
onClick={() => setActiveTab('part_category')}
|
||||
className={`px-3.5 py-2 rounded-xl text-xs font-bold flex items-center space-x-2 transition-all shrink-0 ${
|
||||
activeTab === 'part_category'
|
||||
? 'bg-cyan-500 text-slate-950 shadow-md shadow-cyan-500/20'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Tag className="w-3.5 h-3.5" />
|
||||
<span>Pièces & Matériel</span>
|
||||
<span className={`px-1.5 py-0.5 rounded-md text-[10px] font-mono ${
|
||||
activeTab === 'part_category' ? 'bg-slate-950/30 text-slate-950' : 'bg-slate-800 text-slate-400'
|
||||
}`}>
|
||||
{partCategoriesCount}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('console_model')}
|
||||
className={`px-3.5 py-2 rounded-xl text-xs font-bold flex items-center space-x-2 transition-all shrink-0 ${
|
||||
activeTab === 'console_model'
|
||||
? 'bg-purple-500 text-white shadow-md shadow-purple-500/20'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Gamepad2 className="w-3.5 h-3.5" />
|
||||
<span>Modèles de Consoles</span>
|
||||
<span className={`px-1.5 py-0.5 rounded-md text-[10px] font-mono ${
|
||||
activeTab === 'console_model' ? 'bg-white/20 text-white' : 'bg-slate-800 text-slate-400'
|
||||
}`}>
|
||||
{consoleModelsCount}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('mod_category')}
|
||||
className={`px-3.5 py-2 rounded-xl text-xs font-bold flex items-center space-x-2 transition-all shrink-0 ${
|
||||
activeTab === 'mod_category'
|
||||
? 'bg-pink-500 text-white shadow-md shadow-pink-500/20'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
<span>Catégories de Mods</span>
|
||||
<span className={`px-1.5 py-0.5 rounded-md text-[10px] font-mono ${
|
||||
activeTab === 'mod_category' ? 'bg-white/20 text-white' : 'bg-slate-800 text-slate-400'
|
||||
}`}>
|
||||
{modCategoriesCount}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Search className="w-3.5 h-3.5 text-slate-500 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Rechercher un modèle / catégorie..."
|
||||
className="w-full sm:w-64 pl-8 pr-3 py-2 rounded-xl bg-slate-950 border border-slate-800 text-white text-xs placeholder-slate-500 focus:outline-none focus:border-cyan-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category List Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{filteredCategories.length === 0 ? (
|
||||
<div className="md:col-span-2 glass-panel p-8 rounded-3xl border border-slate-800 text-center space-y-2">
|
||||
<Layers className="w-8 h-8 text-slate-600 mx-auto" />
|
||||
<div className="text-sm font-bold text-slate-300">Aucun élément trouvé</div>
|
||||
<p className="text-xs text-slate-500">
|
||||
{searchQuery ? 'Aucun résultat pour cette recherche.' : 'Cliquez sur Ajouter pour créer votre premier élément.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredCategories.map((cat) => {
|
||||
const usage = countUsage(cat);
|
||||
return (
|
||||
<div
|
||||
key={cat.id}
|
||||
className="glass-panel p-4 rounded-2xl border border-slate-800/90 hover:border-slate-700 bg-slate-950/60 flex items-center justify-between gap-3 transition-all group"
|
||||
>
|
||||
<div className="flex items-center space-x-3.5 min-w-0">
|
||||
<div
|
||||
className="w-4 h-4 rounded-full shrink-0 shadow-sm"
|
||||
style={{ backgroundColor: cat.color || '#06b6d4' }}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm font-bold text-white truncate">{cat.name}</span>
|
||||
<span className={`px-2 py-0.5 rounded-full text-[10px] font-semibold border ${cat.badgeColor || 'bg-slate-500/10 text-slate-400 border-slate-500/30'}`}>
|
||||
{cat.slug}
|
||||
</span>
|
||||
{!cat.isActive && (
|
||||
<span className="px-1.5 py-0.5 rounded text-[9px] font-bold bg-rose-500/10 text-rose-400 border border-rose-500/30">
|
||||
Désactivé
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{cat.description && (
|
||||
<p className="text-[11px] text-slate-400 truncate mt-0.5">
|
||||
{cat.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="text-[10px] text-slate-500 mt-1 flex items-center space-x-2">
|
||||
<span>Ordre : #{cat.sortOrder}</span>
|
||||
<span>•</span>
|
||||
<span>{usage} utilisation(s)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-1 shrink-0">
|
||||
<button
|
||||
onClick={() => handleOpenEdit(cat)}
|
||||
className="p-2 rounded-lg text-slate-400 hover:text-cyan-400 hover:bg-slate-800/80 transition-colors"
|
||||
title="Modifier"
|
||||
>
|
||||
<Edit2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(cat)}
|
||||
className="p-2 rounded-lg text-slate-400 hover:text-rose-400 hover:bg-rose-500/10 transition-colors"
|
||||
title="Supprimer"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal Add / Edit Category */}
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md overflow-y-auto">
|
||||
<div className="relative w-full max-w-lg bg-slate-900 border border-slate-700/80 rounded-3xl shadow-2xl overflow-hidden my-8 animate-fadeIn">
|
||||
|
||||
{/* Modal Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-800 bg-slate-950/50">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-cyan-500/10 border border-cyan-500/30 flex items-center justify-center text-cyan-400">
|
||||
<Layers className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-white">
|
||||
{editingCategory ? `Modifier "${editingCategory.name}"` : 'Ajouter un Élément'}
|
||||
</h3>
|
||||
<p className="text-[11px] text-slate-400">
|
||||
Configuration de la table d'énumération NoCodeBackend
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="text-slate-400 hover:text-white p-2 rounded-xl hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal Form */}
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
|
||||
{/* Type Selector */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-semibold text-slate-300">Type de modèle / énumération</label>
|
||||
<select
|
||||
value={formType}
|
||||
onChange={(e) => setFormType(e.target.value as CategoryType)}
|
||||
className="w-full px-3.5 py-2 rounded-xl bg-slate-950 border border-slate-700 text-white text-xs focus:outline-none focus:border-cyan-500"
|
||||
>
|
||||
<option value="part_category">Catégorie de Pièce / Matériel (part_category)</option>
|
||||
<option value="console_model">Modèle de Console Supporté (console_model)</option>
|
||||
<option value="mod_category">Catégorie de Recette de Mod (mod_category)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Name & Slug */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-semibold text-slate-300">Nom lisible / Libellé *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formName}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
placeholder="Ex: Écran IPS Laminé, PS Vita..."
|
||||
className="w-full px-3.5 py-2 rounded-xl bg-slate-950 border border-slate-700 text-white text-xs focus:outline-none focus:border-cyan-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-semibold text-slate-300">Code Slug / Identifiant *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formSlug}
|
||||
onChange={(e) => setFormSlug(e.target.value)}
|
||||
placeholder="Ex: screen, ps_vita..."
|
||||
className="w-full px-3.5 py-2 rounded-xl bg-slate-950 border border-slate-700 text-white text-xs font-mono focus:outline-none focus:border-cyan-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-semibold text-slate-300">Description / Remarques (Optionnel)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formDescription}
|
||||
onChange={(e) => setFormDescription(e.target.value)}
|
||||
placeholder="Ex: Kits d'écran rétroéclairés IPS, AMOLED..."
|
||||
className="w-full px-3.5 py-2 rounded-xl bg-slate-950 border border-slate-700 text-white text-xs focus:outline-none focus:border-cyan-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Color Presets Picker */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-semibold text-slate-300 flex items-center justify-between">
|
||||
<span className="flex items-center space-x-1.5">
|
||||
<Palette className="w-3.5 h-3.5 text-cyan-400" />
|
||||
<span>Palette de Couleur & Style de Badge</span>
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-slate-400">{formColor}</span>
|
||||
</label>
|
||||
|
||||
<div className="flex items-center space-x-2 overflow-x-auto pb-1">
|
||||
{COLOR_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset.color}
|
||||
type="button"
|
||||
onClick={() => handleSelectPreset(preset)}
|
||||
className={`w-7 h-7 rounded-full shrink-0 border-2 transition-transform ${
|
||||
formColor === preset.color
|
||||
? 'border-white scale-110 shadow-lg'
|
||||
: 'border-transparent hover:scale-105'
|
||||
}`}
|
||||
style={{ backgroundColor: preset.color }}
|
||||
title={preset.name}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Live Preview Box */}
|
||||
<div className="p-3.5 rounded-2xl bg-slate-950/80 border border-slate-800 space-y-2">
|
||||
<div className="text-[10px] uppercase font-bold text-slate-400 flex items-center space-x-1">
|
||||
<Eye className="w-3 h-3" />
|
||||
<span>Aperçu du Rendu dans l'Application</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div
|
||||
className="w-3.5 h-3.5 rounded-full shrink-0"
|
||||
style={{ backgroundColor: formColor }}
|
||||
/>
|
||||
<span className="text-xs font-bold text-white">
|
||||
{formName || 'Nom de la catégorie'}
|
||||
</span>
|
||||
<span className={`px-2.5 py-0.5 rounded-full text-xs font-semibold border ${formBadgeColor}`}>
|
||||
{formName || 'Badge'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row: Sort Order & Active */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-semibold text-slate-300">Ordre d'affichage</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={formSortOrder}
|
||||
onChange={(e) => setFormSortOrder(parseInt(e.target.value, 10) || 1)}
|
||||
className="w-full px-3 py-2 rounded-xl bg-slate-950 border border-slate-700 text-white text-xs font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-semibold text-slate-300">Statut</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormIsActive(!formIsActive)}
|
||||
className={`w-full py-2 px-3 rounded-xl border text-xs font-bold flex items-center justify-center space-x-1.5 transition-all ${
|
||||
formIsActive
|
||||
? 'bg-emerald-500/10 border-emerald-500/40 text-emerald-400'
|
||||
: 'bg-slate-800 border-slate-700 text-slate-400'
|
||||
}`}
|
||||
>
|
||||
{formIsActive ? <Check className="w-3.5 h-3.5" /> : <X className="w-3.5 h-3.5" />}
|
||||
<span>{formIsActive ? 'Actif (Visible)' : 'Désactivé'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal Actions */}
|
||||
<div className="flex items-center justify-end space-x-2.5 pt-3 border-t border-slate-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="px-4 py-2 rounded-xl bg-slate-800 text-slate-300 hover:text-white text-xs font-semibold transition-colors"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-5 py-2 rounded-xl bg-gradient-to-r from-cyan-500 to-blue-600 text-white text-xs font-bold shadow-lg shadow-cyan-500/20 hover:scale-[1.02] active:scale-[0.98] transition-all"
|
||||
>
|
||||
{editingCategory ? 'Sauvegarder' : 'Ajouter au catalogue'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
700
src/components/settings/SettingsView.tsx
Normal file
700
src/components/settings/SettingsView.tsx
Normal file
@@ -0,0 +1,700 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import {
|
||||
Settings as SettingsIcon,
|
||||
Palette,
|
||||
DollarSign,
|
||||
Download,
|
||||
Upload,
|
||||
RotateCcw,
|
||||
Building,
|
||||
Save,
|
||||
ShieldCheck,
|
||||
FileSpreadsheet,
|
||||
FileCode,
|
||||
Database,
|
||||
Cloud,
|
||||
RefreshCw,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
KeyRound,
|
||||
Layers,
|
||||
Sliders,
|
||||
Lock,
|
||||
ShieldAlert,
|
||||
} from 'lucide-react';
|
||||
import { useApp } from '../../context/AppContext';
|
||||
import { CategoryManager } from './CategoryManager';
|
||||
import { changeUserPassword } from '../../services/authService';
|
||||
|
||||
export const SettingsView: React.FC = () => {
|
||||
const {
|
||||
settings,
|
||||
categories,
|
||||
inventory,
|
||||
projects,
|
||||
modsLibrary,
|
||||
updateSettings,
|
||||
exportJSON,
|
||||
importJSON,
|
||||
exportInventoryCSV,
|
||||
exportProjectsCSV,
|
||||
resetToSampleData,
|
||||
dbSyncStatus,
|
||||
lastSyncedAt,
|
||||
refreshFromDatabase,
|
||||
syncAllToDatabase,
|
||||
setShowAuthModal,
|
||||
authSession,
|
||||
addToast,
|
||||
} = useApp();
|
||||
|
||||
const [settingsSection, setSettingsSection] = useState<'general' | 'categories' | 'database'>('categories');
|
||||
const [businessName, setBusinessName] = useState(settings.businessName);
|
||||
const [workshopLocation, setWorkshopLocation] = useState(settings.workshopLocation || '');
|
||||
const [currency, setCurrency] = useState(settings.currency);
|
||||
const [defaultHourlyRate, setDefaultHourlyRate] = useState(settings.defaultHourlyRate);
|
||||
const [defaultPlatformFeePercent, setDefaultPlatformFeePercent] = useState(settings.defaultPlatformFeePercent);
|
||||
const [theme, setTheme] = useState(settings.theme);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [isPushing, setIsPushing] = useState(false);
|
||||
|
||||
// Password change state
|
||||
const [oldPassword, setOldPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [isChangingPassword, setIsChangingPassword] = useState(false);
|
||||
const [passwordStatus, setPasswordStatus] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleSaveSettings = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
updateSettings({
|
||||
businessName: businessName.trim(),
|
||||
workshopLocation: workshopLocation.trim(),
|
||||
currency,
|
||||
defaultHourlyRate: Number(defaultHourlyRate) || 25,
|
||||
defaultPlatformFeePercent: Number(defaultPlatformFeePercent) || 0,
|
||||
theme,
|
||||
});
|
||||
};
|
||||
|
||||
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const content = event.target?.result as string;
|
||||
if (content) {
|
||||
importJSON(content);
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
};
|
||||
|
||||
const handleManualRefresh = async () => {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
await refreshFromDatabase();
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleManualPush = async () => {
|
||||
setIsPushing(true);
|
||||
try {
|
||||
await syncAllToDatabase();
|
||||
} finally {
|
||||
setIsPushing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasswordSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setPasswordStatus(null);
|
||||
|
||||
if (!oldPassword.trim() || !newPassword.trim() || !confirmPassword.trim()) {
|
||||
setPasswordStatus({ type: 'error', message: 'Veuillez remplir tous les champs de mot de passe.' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setPasswordStatus({ type: 'error', message: 'Le nouveau mot de passe et la confirmation ne correspondent pas.' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 4) {
|
||||
setPasswordStatus({ type: 'error', message: 'Le nouveau mot de passe doit comporter au moins 4 caractères.' });
|
||||
return;
|
||||
}
|
||||
|
||||
setIsChangingPassword(true);
|
||||
try {
|
||||
const usernameToUpdate = authSession?.user?.name || 'streaper2';
|
||||
const result = await changeUserPassword(usernameToUpdate, oldPassword, newPassword);
|
||||
|
||||
if (result.success) {
|
||||
setPasswordStatus({ type: 'success', message: result.message || 'Mot de passe mis à jour avec succès !' });
|
||||
setOldPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
addToast({
|
||||
type: 'success',
|
||||
title: 'Sécurité mise à jour',
|
||||
message: 'Votre nouveau mot de passe a été enregistré en base de données.',
|
||||
});
|
||||
} else {
|
||||
setPasswordStatus({ type: 'error', message: result.error || 'Échec de modification du mot de passe.' });
|
||||
}
|
||||
} catch (err: any) {
|
||||
setPasswordStatus({ type: 'error', message: err.message || 'Erreur lors de la mise à jour.' });
|
||||
} finally {
|
||||
setIsChangingPassword(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8 pb-12 animate-fadeIn max-w-4xl mx-auto">
|
||||
|
||||
{/* Title */}
|
||||
<div>
|
||||
<div className="inline-flex items-center space-x-2 px-3 py-1 rounded-full text-xs font-semibold bg-pink-500/10 text-pink-400 border border-pink-500/30 mb-2">
|
||||
<SettingsIcon className="w-3.5 h-3.5" />
|
||||
<span>Paramètres & Préférences</span>
|
||||
</div>
|
||||
<h2 className="text-2xl sm:text-3xl font-black text-white font-display">
|
||||
Configuration de l'Atelier uwuFinances
|
||||
</h2>
|
||||
<p className="text-slate-400 text-xs sm:text-sm">
|
||||
Personnalisez votre identité d'atelier, gérez les tables d'énumérations (catégories & modèles) et synchronisez avec le Cloud NoCodeBackend.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Navigation Sub-Tabs */}
|
||||
<div className="flex items-center space-x-2 p-1.5 rounded-2xl bg-slate-950 border border-slate-800">
|
||||
<button
|
||||
onClick={() => setSettingsSection('categories')}
|
||||
className={`flex-1 py-2.5 px-4 rounded-xl text-xs font-bold flex items-center justify-center space-x-2 transition-all ${
|
||||
settingsSection === 'categories'
|
||||
? 'bg-gradient-to-r from-cyan-500 to-blue-600 text-white shadow-lg shadow-cyan-500/20'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Layers className="w-4 h-4" />
|
||||
<span>Catégories & Modèles ({categories.length})</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setSettingsSection('general')}
|
||||
className={`flex-1 py-2.5 px-4 rounded-xl text-xs font-bold flex items-center justify-center space-x-2 transition-all ${
|
||||
settingsSection === 'general'
|
||||
? 'bg-gradient-to-r from-pink-500 to-purple-600 text-white shadow-lg shadow-pink-500/20'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Sliders className="w-4 h-4" />
|
||||
<span>Atelier, Taux & Thème</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setSettingsSection('database')}
|
||||
className={`flex-1 py-2.5 px-4 rounded-xl text-xs font-bold flex items-center justify-center space-x-2 transition-all ${
|
||||
settingsSection === 'database'
|
||||
? 'bg-slate-800 text-cyan-400 border border-cyan-500/30 shadow-md'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Database className="w-4 h-4" />
|
||||
<span>Base Cloud & Sauvegardes</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Cloud Database (NoCodeBackend) Card */}
|
||||
<div className="glass-panel p-6 sm:p-8 rounded-3xl border border-pink-500/30 bg-gradient-to-br from-slate-950 via-slate-900/90 to-pink-950/20 space-y-6 relative overflow-hidden shadow-xl shadow-pink-500/5">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="flex items-center space-x-3.5">
|
||||
<div className="p-3 rounded-2xl bg-pink-500/15 border border-pink-500/30 text-pink-400">
|
||||
<Database className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<h3 className="text-base font-bold text-white">Base de Données Cloud (NoCodeBackend)</h3>
|
||||
<span className={`inline-flex items-center space-x-1 px-2.5 py-0.5 rounded-full text-[11px] font-semibold border ${
|
||||
dbSyncStatus === 'connected'
|
||||
? 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30'
|
||||
: dbSyncStatus === 'syncing'
|
||||
? 'bg-amber-500/10 text-amber-400 border-amber-500/30'
|
||||
: 'bg-rose-500/10 text-rose-400 border-rose-500/30'
|
||||
}`}>
|
||||
{dbSyncStatus === 'connected' && <CheckCircle2 className="w-3 h-3" />}
|
||||
{dbSyncStatus === 'syncing' && <RefreshCw className="w-3 h-3 animate-spin" />}
|
||||
{dbSyncStatus === 'offline' && <AlertCircle className="w-3 h-3" />}
|
||||
{dbSyncStatus === 'error' && <AlertCircle className="w-3 h-3" />}
|
||||
<span>
|
||||
{dbSyncStatus === 'connected' ? 'En ligne & Synchronisé' : dbSyncStatus === 'syncing' ? 'Synchronisation...' : 'Hors-ligne'}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-0.5">
|
||||
Instance : <span className="font-mono text-pink-300 font-semibold">54770_uwu_finances</span> • Région : <span className="text-slate-300 font-semibold">EU (Frankfurt)</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAuthModal(true)}
|
||||
className="px-3.5 py-2 rounded-xl bg-slate-900 border border-cyan-500/40 hover:bg-cyan-500/10 text-cyan-300 text-xs font-semibold flex items-center space-x-1.5 transition-all cursor-pointer"
|
||||
title="Gérer la session utilisateur et le jeton JWT"
|
||||
>
|
||||
<KeyRound className="w-3.5 h-3.5 text-cyan-400" />
|
||||
<span>Session & Jeton JWT</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleManualRefresh}
|
||||
disabled={isRefreshing || isPushing}
|
||||
className="px-3.5 py-2 rounded-xl bg-slate-900 border border-slate-700 hover:border-pink-500/50 text-slate-200 hover:text-white text-xs font-semibold flex items-center space-x-1.5 transition-all disabled:opacity-50 cursor-pointer"
|
||||
title="Recharger toutes les données depuis NoCodeBackend"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${isRefreshing ? 'animate-spin text-pink-400' : ''}`} />
|
||||
<span>Recharger du Cloud</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleManualPush}
|
||||
disabled={isRefreshing || isPushing}
|
||||
className="px-3.5 py-2 rounded-xl bg-gradient-to-r from-pink-500 to-purple-600 hover:from-pink-400 hover:to-purple-500 text-white text-xs font-bold flex items-center space-x-1.5 shadow-lg shadow-pink-500/20 transition-all disabled:opacity-50 cursor-pointer"
|
||||
title="Pousser l'état local actuel vers la base cloud"
|
||||
>
|
||||
<Cloud className="w-3.5 h-3.5" />
|
||||
<span>Forcer Synchro Cloud</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Database Metrics Grid */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-5 gap-3 pt-2">
|
||||
<div className="p-3 rounded-2xl bg-slate-950/80 border border-slate-800">
|
||||
<div className="text-[10px] uppercase font-bold text-slate-400">Énumérations / Catégories</div>
|
||||
<div className="text-lg font-black font-mono text-cyan-400 mt-1">{categories.length}</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-2xl bg-slate-950/80 border border-slate-800">
|
||||
<div className="text-[10px] uppercase font-bold text-slate-400">Pièces en Stock (DB)</div>
|
||||
<div className="text-lg font-black font-mono text-cyan-300 mt-1">{inventory.length}</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-2xl bg-slate-950/80 border border-slate-800">
|
||||
<div className="text-[10px] uppercase font-bold text-slate-400">Projets Modding (DB)</div>
|
||||
<div className="text-lg font-black font-mono text-purple-300 mt-1">{projects.length}</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-2xl bg-slate-950/80 border border-slate-800">
|
||||
<div className="text-[10px] uppercase font-bold text-slate-400">Recettes de Mods (DB)</div>
|
||||
<div className="text-lg font-black font-mono text-pink-300 mt-1">{modsLibrary.length}</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-2xl bg-slate-950/80 border border-slate-800 col-span-2 sm:col-span-1">
|
||||
<div className="text-[10px] uppercase font-bold text-slate-400">Dernière Synchro</div>
|
||||
<div className="text-xs font-mono text-slate-300 mt-2 font-medium truncate">
|
||||
{lastSyncedAt ? lastSyncedAt.toLocaleTimeString('fr-FR') : 'En attente...'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab 1: Category & Enum Manager */}
|
||||
{settingsSection === 'categories' && (
|
||||
<CategoryManager />
|
||||
)}
|
||||
|
||||
{/* Tab 2: Studio Preferences & General Settings */}
|
||||
{settingsSection === 'general' && (
|
||||
<form onSubmit={handleSaveSettings} className="space-y-6">
|
||||
{/* Studio Identity Card */}
|
||||
<div className="glass-panel p-6 rounded-3xl border border-slate-800 space-y-4">
|
||||
<h3 className="text-sm font-bold uppercase tracking-wider text-pink-400 flex items-center space-x-2">
|
||||
<Building className="w-4 h-4" />
|
||||
<span>Identité de l'Atelier & Fiches Clients</span>
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-semibold text-slate-300">
|
||||
Nom Commercial de l'Atelier / Studio
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={businessName}
|
||||
onChange={(e) => setBusinessName(e.target.value)}
|
||||
placeholder="Ex: uwu Custom Studio 🎮"
|
||||
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-700 text-white text-sm focus:outline-none focus:border-pink-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-semibold text-slate-300">
|
||||
Emplacement / Ville de l'Atelier
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={workshopLocation}
|
||||
onChange={(e) => setWorkshopLocation(e.target.value)}
|
||||
placeholder="Ex: Atelier Console Modding - Lyon"
|
||||
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-700 text-white text-sm focus:outline-none focus:border-pink-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hourly Rates & Commissions */}
|
||||
<div className="glass-panel p-6 rounded-3xl border border-slate-800 space-y-4">
|
||||
<h3 className="text-sm font-bold uppercase tracking-wider text-amber-400 flex items-center space-x-2">
|
||||
<DollarSign className="w-4 h-4" />
|
||||
<span>Taux Horaires & Frais Plateformes</span>
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-semibold text-slate-300">
|
||||
Devise Principale
|
||||
</label>
|
||||
<select
|
||||
value={currency}
|
||||
onChange={(e) => setCurrency(e.target.value)}
|
||||
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-700 text-white text-sm focus:outline-none focus:border-amber-400"
|
||||
>
|
||||
<option value="€">EUR (€)</option>
|
||||
<option value="$">USD ($)</option>
|
||||
<option value="£">GBP (£)</option>
|
||||
<option value="CHF">CHF</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-semibold text-slate-300">
|
||||
Taux Horaire par Défaut (€/heure)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.5"
|
||||
min="0"
|
||||
value={defaultHourlyRate}
|
||||
onChange={(e) => setDefaultHourlyRate(parseFloat(e.target.value) || 0)}
|
||||
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-700 text-white text-sm font-mono focus:outline-none focus:border-amber-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-semibold text-slate-300">
|
||||
Commission Plateforme par Défaut (%)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.5"
|
||||
min="0"
|
||||
value={defaultPlatformFeePercent}
|
||||
onChange={(e) => setDefaultPlatformFeePercent(parseFloat(e.target.value) || 0)}
|
||||
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-700 text-white text-sm font-mono focus:outline-none focus:border-amber-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Theme Customization */}
|
||||
<div className="glass-panel p-6 rounded-3xl border border-slate-800 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-bold uppercase tracking-wider text-purple-400 flex items-center space-x-2">
|
||||
<Palette className="w-4 h-4" />
|
||||
<span>Thème Visuel & Ambiance</span>
|
||||
</h3>
|
||||
<span className="text-[11px] text-slate-400">
|
||||
Changement appliqué instantanément
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3.5">
|
||||
{[
|
||||
{
|
||||
id: 'uwu-pink',
|
||||
name: '🌸 UwU Pink Neo',
|
||||
desc: 'Pastel, néons & retrogaming',
|
||||
colors: ['#ec4899', '#a855f7', '#06b6d4'],
|
||||
activeBorder: 'border-pink-500 bg-pink-500/10 shadow-lg shadow-pink-500/20',
|
||||
},
|
||||
{
|
||||
id: 'cyberpunk',
|
||||
name: '⚡ Cyberpunk Dark',
|
||||
desc: 'Néon violet, cyan & high-tech',
|
||||
colors: ['#00f0ff', '#9d00ff', '#fcee0a'],
|
||||
activeBorder: 'border-cyan-400 bg-cyan-500/10 shadow-lg shadow-cyan-500/20',
|
||||
},
|
||||
{
|
||||
id: 'retro-gameboy',
|
||||
name: '🎮 Rétro GameBoy',
|
||||
desc: 'Ambiance DMG vintage & dot-matrix',
|
||||
colors: ['#8bac0f', '#9bbc0f', '#9b2242'],
|
||||
activeBorder: 'border-lime-500 bg-lime-500/10 shadow-lg shadow-lime-500/20',
|
||||
},
|
||||
{
|
||||
id: 'modern-dark',
|
||||
name: '🌑 Modern Dark',
|
||||
desc: 'Minimaliste titane & ardoise pro',
|
||||
colors: ['#38bdf8', '#818cf8', '#f8fafc'],
|
||||
activeBorder: 'border-sky-400 bg-sky-500/10 shadow-lg shadow-sky-500/20',
|
||||
},
|
||||
].map((t) => {
|
||||
const isSelected = (theme === t.id) || (settings.theme === t.id);
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setTheme(t.id as any);
|
||||
updateSettings({ theme: t.id as any });
|
||||
}}
|
||||
className={`p-4 rounded-2xl border text-left flex flex-col justify-between space-y-3 transition-all cursor-pointer ${
|
||||
isSelected
|
||||
? t.activeBorder
|
||||
: 'border-slate-800 bg-slate-950/60 hover:border-slate-700 hover:bg-slate-900/50'
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-bold text-xs text-white">{t.name}</div>
|
||||
{isSelected && (
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[11px] text-slate-400 mt-1">{t.desc}</div>
|
||||
</div>
|
||||
|
||||
{/* Color swatches */}
|
||||
<div className="flex items-center space-x-1.5 pt-1">
|
||||
{t.colors.map((c, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="w-4 h-4 rounded-full border border-black/30 shadow-inner"
|
||||
style={{ backgroundColor: c }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Security & User Password Management */}
|
||||
<div className="glass-panel p-6 rounded-3xl border border-slate-800 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-bold uppercase tracking-wider text-rose-400 flex items-center space-x-2">
|
||||
<Lock className="w-4 h-4" />
|
||||
<span>Sécurité & Mot de Passe de l'Atelier</span>
|
||||
</h3>
|
||||
<span className="text-[11px] font-mono text-slate-400">
|
||||
Compte : <span className="text-pink-300 font-bold">{authSession?.user?.name || 'streaper2'}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-slate-400">
|
||||
Modifiez votre mot de passe d'accès. La modification sera immédiatement synchronisée dans la table <span className="font-mono text-cyan-300">users</span> de votre base NoCodeBackend.
|
||||
</p>
|
||||
|
||||
{passwordStatus && (
|
||||
<div className={`p-3.5 rounded-2xl border text-xs flex items-center space-x-2 ${
|
||||
passwordStatus.type === 'success'
|
||||
? 'bg-emerald-500/10 text-emerald-300 border-emerald-500/30'
|
||||
: 'bg-rose-500/10 text-rose-300 border-rose-500/30'
|
||||
}`}>
|
||||
{passwordStatus.type === 'success' ? (
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-400 shrink-0" />
|
||||
) : (
|
||||
<ShieldAlert className="w-4 h-4 text-rose-400 shrink-0" />
|
||||
)}
|
||||
<span>{passwordStatus.message}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3.5 pt-1">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-semibold text-slate-300">
|
||||
Ancien Mot de Passe
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={oldPassword}
|
||||
onChange={(e) => setOldPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-700 text-white text-sm font-mono focus:outline-none focus:border-rose-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-semibold text-slate-300">
|
||||
Nouveau Mot de Passe
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-700 text-white text-sm font-mono focus:outline-none focus:border-rose-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-semibold text-slate-300">
|
||||
Confirmer le Nouveau
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-700 text-white text-sm font-mono focus:outline-none focus:border-rose-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePasswordSubmit}
|
||||
disabled={isChangingPassword}
|
||||
className="px-4 py-2 rounded-xl bg-slate-900 border border-rose-500/40 hover:bg-rose-500/10 text-rose-300 text-xs font-bold flex items-center space-x-1.5 transition-all disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
{isChangingPassword ? (
|
||||
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<KeyRound className="w-3.5 h-3.5" />
|
||||
)}
|
||||
<span>{isChangingPassword ? 'Mise à jour...' : 'Mettre à jour le Mot de Passe'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
className="flex items-center space-x-2 px-6 py-3 rounded-2xl bg-gradient-to-r from-pink-500 to-purple-600 text-white font-bold text-sm shadow-xl shadow-pink-500/25 hover:scale-[1.02] active:scale-[0.98] transition-all cursor-pointer"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
<span>Enregistrer les Préférences</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Tab 3: Data Management & Backup Section */}
|
||||
{settingsSection === 'database' && (
|
||||
<div className="glass-panel p-6 sm:p-8 rounded-3xl border border-slate-800 space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-white flex items-center space-x-2">
|
||||
<ShieldCheck className="w-5 h-5 text-cyan-400" />
|
||||
<span>Gestion des Données & Sauvegardes</span>
|
||||
</h3>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
Vos données sont automatiquement synchronisées en temps réel avec NoCodeBackend (54770_uwu_finances) et sauvegardées localement en cache hors-ligne.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{/* JSON Full Backup */}
|
||||
<div className="p-4 rounded-2xl bg-slate-950 border border-slate-800 space-y-3 flex flex-col justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="font-bold text-xs text-cyan-400 flex items-center space-x-1.5">
|
||||
<FileCode className="w-4 h-4" />
|
||||
<span>Sauvegarde Complète JSON</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400">
|
||||
Exporte toutes les catégories, pièces, stocks, projets, nomenclatures et logs de temps en 1 seul fichier.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 pt-2">
|
||||
<button
|
||||
onClick={exportJSON}
|
||||
className="flex-1 py-2 px-3 rounded-xl bg-slate-900 border border-cyan-500/40 text-cyan-300 hover:bg-cyan-500/10 text-xs font-semibold flex items-center justify-center space-x-1.5 transition-all"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
<span>Exporter JSON</span>
|
||||
</button>
|
||||
|
||||
<label className="flex-1 py-2 px-3 rounded-xl bg-slate-900 border border-slate-700 text-slate-300 hover:text-white hover:border-slate-500 text-xs font-semibold flex items-center justify-center space-x-1.5 cursor-pointer transition-all">
|
||||
<Upload className="w-3.5 h-3.5" />
|
||||
<span>Restaurer JSON</span>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={handleFileUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CSV Exports */}
|
||||
<div className="p-4 rounded-2xl bg-slate-950 border border-slate-800 space-y-3 flex flex-col justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="font-bold text-xs text-emerald-400 flex items-center space-x-1.5">
|
||||
<FileSpreadsheet className="w-4 h-4" />
|
||||
<span>Exports Tableur CSV (Excel)</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400">
|
||||
Téléchargez les feuilles de calcul pour votre comptabilité ou analyse externe.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 pt-2">
|
||||
<button
|
||||
onClick={exportInventoryCSV}
|
||||
className="flex-1 py-2 px-2.5 rounded-xl bg-slate-900 border border-emerald-500/40 text-emerald-300 hover:bg-emerald-500/10 text-[11px] font-semibold flex items-center justify-center space-x-1 transition-all"
|
||||
>
|
||||
<Download className="w-3 h-3" />
|
||||
<span>CSV Stock</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={exportProjectsCSV}
|
||||
className="flex-1 py-2 px-2.5 rounded-xl bg-slate-900 border border-emerald-500/40 text-emerald-300 hover:bg-emerald-500/10 text-[11px] font-semibold flex items-center justify-center space-x-1 transition-all"
|
||||
>
|
||||
<Download className="w-3 h-3" />
|
||||
<span>CSV Projets</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reset to Demo Data */}
|
||||
<div className="pt-4 border-t border-slate-800 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-xs font-bold text-slate-300">
|
||||
Réinitialiser avec le catalogue d'exemples
|
||||
</div>
|
||||
<div className="text-[11px] text-slate-500">
|
||||
Rétablit les projets, catégories et pièces de démonstration GBA SP, GBC et DMG et synchronise la base cloud.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (confirm('Attention, cette action va réinitialiser vos données actuelles avec le jeu d\'exemples de démonstration sur le Cloud et en local. Continuer ?')) {
|
||||
resetToSampleData();
|
||||
}
|
||||
}}
|
||||
className="flex items-center space-x-1.5 px-4 py-2 rounded-xl bg-slate-900 border border-rose-500/40 text-rose-400 hover:bg-rose-500/10 text-xs font-bold transition-all shrink-0"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
<span>Réinitialiser Données Démo</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
291
src/components/timetracker/TimeTrackerView.tsx
Normal file
291
src/components/timetracker/TimeTrackerView.tsx
Normal file
@@ -0,0 +1,291 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Clock,
|
||||
Play,
|
||||
Pause,
|
||||
RotateCcw,
|
||||
Check,
|
||||
Gamepad2,
|
||||
History,
|
||||
} from 'lucide-react';
|
||||
import { useApp } from '../../context/AppContext';
|
||||
import type { TaskType } from '../../types';
|
||||
|
||||
import {
|
||||
formatCurrency,
|
||||
formatMinutes,
|
||||
getTaskTypeLabel,
|
||||
} from '../../utils/calculations';
|
||||
|
||||
const TASK_TYPES_LIST: { id: TaskType; label: string; icon: string }[] = [
|
||||
{ id: 'diagnostic_demontage', label: 'Diagnostic & Démontage', icon: '🔍' },
|
||||
{ id: 'nettoyage_recap', label: 'Nettoyage & Condensateurs', icon: '🧼' },
|
||||
{ id: 'decoupe_coque', label: 'Découpe Coque & Ajustement', icon: '✂️' },
|
||||
{ id: 'soudure_mods', label: 'Soudure Mods & USB-C', icon: '🔥' },
|
||||
{ id: 'assemblage', label: 'Assemblage & Calibrage', icon: '🪛' },
|
||||
{ id: 'test_qc', label: 'Tests & Contrôle Qualité', icon: '🎮' },
|
||||
{ id: 'packaging_photo', label: 'Photos & Packaging', icon: '📦' },
|
||||
{ id: 'autre', label: 'Autre Travail', icon: '⚙️' },
|
||||
];
|
||||
|
||||
export const TimeTrackerView: React.FC = () => {
|
||||
const {
|
||||
projects,
|
||||
activeTimer,
|
||||
startTimer,
|
||||
pauseTimer,
|
||||
resumeTimer,
|
||||
resetTimer,
|
||||
saveTimerSession,
|
||||
setTimerDescription,
|
||||
setTimerTaskType,
|
||||
setTimerProject,
|
||||
settings,
|
||||
} = useApp();
|
||||
|
||||
// Active project object
|
||||
const activeProject = projects.find((p) => p.id === activeTimer.projectId);
|
||||
|
||||
// Time format calculations
|
||||
const hours = Math.floor(activeTimer.elapsedSeconds / 3600);
|
||||
const minutes = Math.floor((activeTimer.elapsedSeconds % 3600) / 60);
|
||||
const seconds = activeTimer.elapsedSeconds % 60;
|
||||
|
||||
// Live real-time cost accumulated
|
||||
const liveCost = (activeTimer.elapsedSeconds / 3600) * (activeTimer.hourlyRate || settings.defaultHourlyRate || 25);
|
||||
|
||||
// All logged sessions across all projects for history
|
||||
const allSessions = projects.flatMap((p) =>
|
||||
p.timeSessions.map((s) => ({
|
||||
...s,
|
||||
projectTitle: p.title,
|
||||
projectSerial: p.serialNumber,
|
||||
consoleModel: p.consoleModel,
|
||||
}))
|
||||
).sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
||||
|
||||
const totalLoggedMinutes = allSessions.reduce((sum, s) => sum + s.durationMinutes, 0);
|
||||
const totalLoggedCost = allSessions.reduce((sum, s) => sum + s.cost, 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-8 pb-12 animate-fadeIn">
|
||||
|
||||
{/* Title */}
|
||||
<div>
|
||||
<div className="inline-flex items-center space-x-2 px-3 py-1 rounded-full text-xs font-semibold bg-amber-500/10 text-amber-400 border border-amber-500/30 mb-2">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
<span>Chronomètre d'Établi & Main d'Œuvre</span>
|
||||
</div>
|
||||
<h2 className="text-2xl sm:text-3xl font-black text-white font-display">
|
||||
Quantification du Temps Passé par Console
|
||||
</h2>
|
||||
<p className="text-slate-400 text-xs sm:text-sm">
|
||||
Chronométrez en direct vos sessions de soudure et d'assemblage pour valoriser avec précision votre artisanat.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Main Big Stopwatch Display Card */}
|
||||
<div className="glass-panel p-6 sm:p-10 rounded-3xl border border-amber-500/30 relative overflow-hidden shadow-2xl">
|
||||
<div className="absolute top-0 right-0 -mr-20 -mt-20 w-80 h-80 bg-amber-500/10 rounded-full blur-3xl pointer-events-none" />
|
||||
|
||||
<div className="relative z-10 flex flex-col items-center text-center space-y-6">
|
||||
|
||||
{/* Project Picker */}
|
||||
<div className="w-full max-w-md space-y-1.5 text-left">
|
||||
<label className="text-xs font-bold text-slate-300 uppercase tracking-wider flex items-center space-x-1.5">
|
||||
<Gamepad2 className="w-4 h-4 text-amber-400" />
|
||||
<span>Console / Projet Assigné :</span>
|
||||
</label>
|
||||
<select
|
||||
value={activeTimer.projectId || ''}
|
||||
onChange={(e) => setTimerProject(e.target.value || null)}
|
||||
className="w-full px-4 py-3 rounded-2xl bg-slate-950 border border-slate-700 text-white font-bold text-sm focus:outline-none focus:border-amber-400 shadow-inner"
|
||||
>
|
||||
<option value="">-- Choisir une console en cours --</option>
|
||||
{projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.title} ({p.consoleModel} - {p.serialNumber})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{activeProject && (
|
||||
<div className="text-[11px] text-amber-400 font-mono text-center pt-1">
|
||||
Affectation : {activeProject.title} ({activeProject.serialNumber})
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Big Digital Numbers */}
|
||||
<div className="py-4">
|
||||
<div className="font-mono text-6xl sm:text-8xl font-black text-amber-300 tracking-tight drop-shadow-[0_0_20px_rgba(245,158,11,0.3)]">
|
||||
{hours > 0 ? `${hours.toString().padStart(2, '0')}:` : ''}
|
||||
{minutes.toString().padStart(2, '0')}:
|
||||
{seconds.toString().padStart(2, '0')}
|
||||
</div>
|
||||
|
||||
{/* Live Real-time Cost Counter */}
|
||||
<div className="mt-3 flex items-center justify-center space-x-2 text-sm text-slate-300">
|
||||
<span className="font-semibold">Coût main d'œuvre accumulé :</span>
|
||||
<span className="font-mono font-black text-lg text-emerald-400">
|
||||
{formatCurrency(liveCost, settings.currency)}
|
||||
</span>
|
||||
<span className="text-xs text-slate-400">
|
||||
(taux : {activeTimer.hourlyRate || settings.defaultHourlyRate || 25}€/h)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Task Type Grid */}
|
||||
<div className="w-full max-w-2xl space-y-2 text-left">
|
||||
<label className="text-xs font-bold text-slate-300 uppercase tracking-wider">
|
||||
Type de tâche en cours :
|
||||
</label>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2.5">
|
||||
{TASK_TYPES_LIST.map((task) => (
|
||||
<button
|
||||
key={task.id}
|
||||
type="button"
|
||||
onClick={() => setTimerTaskType(task.id)}
|
||||
className={`flex items-center space-x-2 p-2.5 rounded-xl border text-xs font-semibold transition-all ${
|
||||
activeTimer.taskType === task.id
|
||||
? 'bg-amber-500/20 text-amber-300 border-amber-500 shadow-md shadow-amber-500/10'
|
||||
: 'bg-slate-950/60 text-slate-400 border-slate-800 hover:border-slate-700 hover:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
<span className="text-base">{task.icon}</span>
|
||||
<span className="truncate">{task.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description Input */}
|
||||
<div className="w-full max-w-2xl space-y-1.5 text-left">
|
||||
<label className="text-xs font-bold text-slate-300 uppercase tracking-wider">
|
||||
Note d'intervention :
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={activeTimer.description}
|
||||
onChange={(e) => setTimerDescription(e.target.value)}
|
||||
placeholder="Ex: Soudure du connecteur USB-C, test sous microscope..."
|
||||
className="w-full px-4 py-2.5 rounded-xl bg-slate-950 border border-slate-700 text-white placeholder-slate-500 text-xs focus:outline-none focus:border-amber-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Stopwatch Controls */}
|
||||
<div className="flex flex-wrap items-center justify-center gap-4 pt-4">
|
||||
{!activeTimer.isRunning ? (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (activeTimer.elapsedSeconds > 0) resumeTimer();
|
||||
else startTimer(activeTimer.projectId || undefined, activeTimer.taskType, activeTimer.description);
|
||||
}}
|
||||
className="flex items-center space-x-2 px-8 py-4 rounded-2xl bg-gradient-to-r from-amber-500 to-orange-500 text-slate-950 font-black text-base shadow-xl shadow-amber-500/25 hover:scale-105 active:scale-95 transition-all"
|
||||
>
|
||||
<Play className="w-5 h-5 fill-current" />
|
||||
<span>{activeTimer.elapsedSeconds > 0 ? 'Reprendre' : 'Démarrer le Chrono'}</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={pauseTimer}
|
||||
className="flex items-center space-x-2 px-8 py-4 rounded-2xl bg-amber-500 text-slate-950 font-black text-base shadow-xl shadow-amber-500/25 hover:scale-105 active:scale-95 transition-all"
|
||||
>
|
||||
<Pause className="w-5 h-5 fill-current" />
|
||||
<span>Mettre en Pause</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{activeTimer.elapsedSeconds > 0 && (
|
||||
<>
|
||||
<button
|
||||
onClick={saveTimerSession}
|
||||
disabled={!activeTimer.projectId}
|
||||
className="flex items-center space-x-2 px-6 py-4 rounded-2xl bg-emerald-500 text-slate-950 font-black text-base shadow-lg shadow-emerald-500/20 hover:scale-105 active:scale-95 disabled:opacity-40 disabled:cursor-not-allowed transition-all"
|
||||
title={!activeTimer.projectId ? 'Sélectionnez un projet pour enregistrer' : 'Enregistrer ce temps sur le projet'}
|
||||
>
|
||||
<Check className="w-5 h-5" />
|
||||
<span>Enregistrer sur le projet</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={resetTimer}
|
||||
className="p-4 rounded-2xl bg-slate-900 border border-slate-700 text-slate-400 hover:text-white hover:bg-slate-800 transition-colors"
|
||||
title="Réinitialiser le chronomètre"
|
||||
>
|
||||
<RotateCcw className="w-5 h-5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* History of Recent Logged Sessions across all consoles */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<History className="w-5 h-5 text-amber-400" />
|
||||
<h3 className="text-lg font-bold text-white">
|
||||
Historique Global des Sessions d'Établi
|
||||
</h3>
|
||||
</div>
|
||||
<div className="text-xs text-slate-400 font-mono">
|
||||
Total cumulé : <strong className="text-amber-300">{formatMinutes(totalLoggedMinutes)}</strong> ({formatCurrency(totalLoggedCost, settings.currency)})
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="glass-panel rounded-3xl border border-slate-800 overflow-hidden">
|
||||
{allSessions.length === 0 ? (
|
||||
<div className="p-8 text-center text-xs text-slate-400">
|
||||
Aucune session enregistrée pour le moment.
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-slate-800/80">
|
||||
{allSessions.slice(0, 8).map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className="p-4 flex flex-col sm:flex-row sm:items-center justify-between gap-3 hover:bg-slate-900/40 transition-colors"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="font-bold text-sm text-white">
|
||||
{session.projectTitle}
|
||||
</span>
|
||||
<span className="text-[11px] font-mono text-cyan-400">
|
||||
({session.consoleModel})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 text-xs text-slate-300">
|
||||
<span className="font-semibold text-amber-400">
|
||||
{getTaskTypeLabel(session.taskType)} :
|
||||
</span>
|
||||
<span>{session.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-6 text-right shrink-0">
|
||||
<div className="text-[11px] text-slate-400 font-mono">
|
||||
{session.date}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-mono font-bold text-amber-300 text-sm">
|
||||
{formatMinutes(session.durationMinutes)}
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-400 font-mono">
|
||||
{formatCurrency(session.cost, settings.currency)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
1796
src/context/AppContext.tsx
Normal file
1796
src/context/AppContext.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1348
src/data/initialData.ts
Normal file
1348
src/data/initialData.ts
Normal file
File diff suppressed because it is too large
Load Diff
327
src/index.css
Normal file
327
src/index.css
Normal file
@@ -0,0 +1,327 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@layer base {
|
||||
:root,
|
||||
.theme-uwu-pink,
|
||||
[data-theme="uwu-pink"] {
|
||||
--font-sans: 'Plus Jakarta Sans', system-ui, -apple-system, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
--font-display: 'Fredoka', cursive, sans-serif;
|
||||
|
||||
/* Theme colors */
|
||||
--app-bg: #090d16;
|
||||
--app-bg-radial: radial-gradient(ellipse 80% 70% at 50% -10%, rgba(236, 72, 153, 0.15), rgba(168, 85, 247, 0.08) 40%, rgba(9, 13, 22, 0) 80%), #090d16;
|
||||
--panel-bg: rgba(15, 23, 42, 0.75);
|
||||
--panel-border: rgba(236, 72, 153, 0.18);
|
||||
--panel-border-hover: rgba(236, 72, 153, 0.45);
|
||||
--panel-hover-shadow: 0 8px 32px 0 rgba(236, 72, 153, 0.15);
|
||||
--sidebar-bg: #0b0f19;
|
||||
--sidebar-border: rgba(255, 255, 255, 0.08);
|
||||
--header-bg: rgba(11, 15, 25, 0.85);
|
||||
--accent-primary: #ec4899;
|
||||
--accent-secondary: #a855f7;
|
||||
--accent-tertiary: #06b6d4;
|
||||
--accent-gradient: linear-gradient(135deg, #ec4899 0%, #a855f7 50%, #ec4899 100%);
|
||||
--glow-color: rgba(236, 72, 153, 0.35);
|
||||
--selection-bg: #ec4899;
|
||||
--selection-text: #ffffff;
|
||||
--text-heading: #fbcfe8;
|
||||
--scrollbar-track: rgba(15, 23, 42, 0.6);
|
||||
--scrollbar-thumb: rgba(236, 72, 153, 0.4);
|
||||
--scrollbar-thumb-hover: rgba(236, 72, 153, 0.8);
|
||||
}
|
||||
|
||||
.theme-cyberpunk,
|
||||
[data-theme="cyberpunk"] {
|
||||
--app-bg: #05060f;
|
||||
--app-bg-radial: radial-gradient(ellipse 80% 60% at 50% -10%, rgba(0, 240, 255, 0.14), rgba(157, 0, 255, 0.1) 40%, rgba(5, 6, 15, 0) 80%), #05060f;
|
||||
--panel-bg: rgba(10, 11, 26, 0.82);
|
||||
--panel-border: rgba(0, 240, 255, 0.22);
|
||||
--panel-border-hover: rgba(0, 240, 255, 0.65);
|
||||
--panel-hover-shadow: 0 8px 32px 0 rgba(0, 240, 255, 0.25);
|
||||
--sidebar-bg: #070814;
|
||||
--sidebar-border: rgba(0, 240, 255, 0.18);
|
||||
--header-bg: rgba(7, 8, 20, 0.9);
|
||||
--accent-primary: #00f0ff;
|
||||
--accent-secondary: #9d00ff;
|
||||
--accent-tertiary: #fcee0a;
|
||||
--accent-gradient: linear-gradient(135deg, #00f0ff 0%, #9d00ff 50%, #ff0055 100%);
|
||||
--glow-color: rgba(0, 240, 255, 0.45);
|
||||
--selection-bg: #00f0ff;
|
||||
--selection-text: #05060f;
|
||||
--text-heading: #00f0ff;
|
||||
--scrollbar-track: rgba(7, 8, 20, 0.7);
|
||||
--scrollbar-thumb: rgba(0, 240, 255, 0.4);
|
||||
--scrollbar-thumb-hover: rgba(0, 240, 255, 0.85);
|
||||
}
|
||||
|
||||
.theme-retro-gameboy,
|
||||
[data-theme="retro-gameboy"] {
|
||||
--app-bg: #0e150e;
|
||||
--app-bg-radial: radial-gradient(ellipse 80% 60% at 50% -10%, rgba(139, 172, 15, 0.14), rgba(48, 98, 48, 0.1) 50%, rgba(14, 21, 14, 0) 80%), #0e150e;
|
||||
--panel-bg: rgba(18, 26, 18, 0.88);
|
||||
--panel-border: rgba(139, 172, 15, 0.25);
|
||||
--panel-border-hover: rgba(155, 188, 15, 0.6);
|
||||
--panel-hover-shadow: 0 8px 32px 0 rgba(139, 172, 15, 0.22);
|
||||
--sidebar-bg: #101810;
|
||||
--sidebar-border: rgba(139, 172, 15, 0.2);
|
||||
--header-bg: rgba(16, 24, 16, 0.92);
|
||||
--accent-primary: #8bac0f;
|
||||
--accent-secondary: #9bbc0f;
|
||||
--accent-tertiary: #9b2242;
|
||||
--accent-gradient: linear-gradient(135deg, #8bac0f 0%, #9bbc0f 50%, #306230 100%);
|
||||
--glow-color: rgba(139, 172, 15, 0.4);
|
||||
--selection-bg: #8bac0f;
|
||||
--selection-text: #0f380f;
|
||||
--text-heading: #9bbc0f;
|
||||
--scrollbar-track: rgba(16, 24, 16, 0.7);
|
||||
--scrollbar-thumb: rgba(139, 172, 15, 0.4);
|
||||
--scrollbar-thumb-hover: rgba(155, 188, 15, 0.85);
|
||||
}
|
||||
|
||||
.theme-modern-dark,
|
||||
[data-theme="modern-dark"] {
|
||||
--app-bg: #08090d;
|
||||
--app-bg-radial: radial-gradient(ellipse 80% 60% at 50% -10%, rgba(56, 189, 248, 0.08), rgba(99, 102, 241, 0.04) 50%, rgba(8, 9, 13, 0) 80%), #08090d;
|
||||
--panel-bg: rgba(15, 18, 26, 0.88);
|
||||
--panel-border: rgba(255, 255, 255, 0.1);
|
||||
--panel-border-hover: rgba(56, 189, 248, 0.45);
|
||||
--panel-hover-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.4);
|
||||
--sidebar-bg: #0d0f17;
|
||||
--sidebar-border: rgba(255, 255, 255, 0.08);
|
||||
--header-bg: rgba(13, 15, 23, 0.94);
|
||||
--accent-primary: #38bdf8;
|
||||
--accent-secondary: #818cf8;
|
||||
--accent-tertiary: #f8fafc;
|
||||
--accent-gradient: linear-gradient(135deg, #38bdf8 0%, #818cf8 50%, #6366f1 100%);
|
||||
--glow-color: rgba(56, 189, 248, 0.3);
|
||||
--selection-bg: #38bdf8;
|
||||
--selection-text: #08090d;
|
||||
--text-heading: #f8fafc;
|
||||
--scrollbar-track: rgba(13, 15, 23, 0.7);
|
||||
--scrollbar-thumb: rgba(148, 163, 184, 0.3);
|
||||
--scrollbar-thumb-hover: rgba(56, 189, 248, 0.7);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
background-color: var(--app-bg);
|
||||
background-image: var(--app-bg-radial);
|
||||
background-attachment: fixed;
|
||||
color: #f1f5f9;
|
||||
min-height: 100vh;
|
||||
}
|
||||
}
|
||||
|
||||
/* Text selections themed */
|
||||
::selection {
|
||||
background-color: var(--selection-bg);
|
||||
color: var(--selection-text);
|
||||
}
|
||||
|
||||
/* Custom styles and themes */
|
||||
.font-display {
|
||||
font-family: 'Fredoka', cursive, sans-serif;
|
||||
}
|
||||
|
||||
.font-mono {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
/* Custom themed scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--scrollbar-track);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--scrollbar-thumb);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--scrollbar-thumb-hover);
|
||||
}
|
||||
|
||||
/* Dynamic Glassmorphism helpers using CSS vars */
|
||||
.glass-panel {
|
||||
background: var(--panel-bg);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--panel-border);
|
||||
}
|
||||
|
||||
.glass-panel-hover:hover {
|
||||
border-color: var(--panel-border-hover);
|
||||
box-shadow: var(--panel-hover-shadow);
|
||||
}
|
||||
|
||||
/* Theme scoped app shell helpers */
|
||||
.theme-root-container {
|
||||
background-color: var(--app-bg);
|
||||
background-image: var(--app-bg-radial);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
.theme-sidebar {
|
||||
background-color: var(--sidebar-bg) !important;
|
||||
border-color: var(--sidebar-border) !important;
|
||||
}
|
||||
|
||||
.theme-header {
|
||||
background-color: var(--header-bg) !important;
|
||||
border-color: var(--sidebar-border) !important;
|
||||
}
|
||||
|
||||
/* Cyberpunk theme specific enhancements */
|
||||
.theme-cyberpunk .bg-slate-950 {
|
||||
background-color: #05060f !important;
|
||||
}
|
||||
.theme-cyberpunk .border-slate-800,
|
||||
.theme-cyberpunk .border-slate-850 {
|
||||
border-color: rgba(0, 240, 255, 0.18) !important;
|
||||
}
|
||||
.theme-cyberpunk .text-pink-400 {
|
||||
color: #00f0ff !important;
|
||||
}
|
||||
.theme-cyberpunk .bg-pink-500\/10,
|
||||
.theme-cyberpunk .bg-pink-500\/15 {
|
||||
background-color: rgba(0, 240, 255, 0.12) !important;
|
||||
}
|
||||
.theme-cyberpunk .border-pink-500\/30,
|
||||
.theme-cyberpunk .border-pink-500\/40 {
|
||||
border-color: rgba(0, 240, 255, 0.35) !important;
|
||||
}
|
||||
.theme-cyberpunk .from-pink-500 {
|
||||
--tw-gradient-from: #00f0ff var(--tw-gradient-from-position) !important;
|
||||
}
|
||||
.theme-cyberpunk .to-purple-600,
|
||||
.theme-cyberpunk .to-purple-500 {
|
||||
--tw-gradient-to: #9d00ff var(--tw-gradient-to-position) !important;
|
||||
}
|
||||
|
||||
/* Retro GameBoy theme specific enhancements */
|
||||
.theme-retro-gameboy .bg-slate-950 {
|
||||
background-color: #0e150e !important;
|
||||
}
|
||||
.theme-retro-gameboy .border-slate-800,
|
||||
.theme-retro-gameboy .border-slate-850 {
|
||||
border-color: rgba(139, 172, 15, 0.22) !important;
|
||||
}
|
||||
.theme-retro-gameboy .text-pink-400,
|
||||
.theme-retro-gameboy .text-cyan-400 {
|
||||
color: #9bbc0f !important;
|
||||
}
|
||||
.theme-retro-gameboy .text-pink-300,
|
||||
.theme-retro-gameboy .text-cyan-300 {
|
||||
color: #cadc9f !important;
|
||||
}
|
||||
.theme-retro-gameboy .bg-pink-500\/10,
|
||||
.theme-retro-gameboy .bg-cyan-500\/10 {
|
||||
background-color: rgba(139, 172, 15, 0.15) !important;
|
||||
}
|
||||
.theme-retro-gameboy .border-pink-500\/30,
|
||||
.theme-retro-gameboy .border-cyan-500\/30 {
|
||||
border-color: rgba(139, 172, 15, 0.35) !important;
|
||||
}
|
||||
.theme-retro-gameboy .from-pink-500,
|
||||
.theme-retro-gameboy .from-cyan-500 {
|
||||
--tw-gradient-from: #8bac0f var(--tw-gradient-from-position) !important;
|
||||
}
|
||||
.theme-retro-gameboy .to-purple-600,
|
||||
.theme-retro-gameboy .to-blue-600 {
|
||||
--tw-gradient-to: #306230 var(--tw-gradient-to-position) !important;
|
||||
}
|
||||
|
||||
/* Modern Dark theme specific enhancements */
|
||||
.theme-modern-dark .bg-slate-950 {
|
||||
background-color: #08090d !important;
|
||||
}
|
||||
.theme-modern-dark .border-slate-800,
|
||||
.theme-modern-dark .border-slate-850 {
|
||||
border-color: rgba(255, 255, 255, 0.08) !important;
|
||||
}
|
||||
.theme-modern-dark .text-pink-400 {
|
||||
color: #38bdf8 !important;
|
||||
}
|
||||
.theme-modern-dark .bg-pink-500\/10 {
|
||||
background-color: rgba(56, 189, 248, 0.1) !important;
|
||||
}
|
||||
.theme-modern-dark .border-pink-500\/30 {
|
||||
border-color: rgba(56, 189, 248, 0.3) !important;
|
||||
}
|
||||
.theme-modern-dark .from-pink-500 {
|
||||
--tw-gradient-from: #38bdf8 var(--tw-gradient-from-position) !important;
|
||||
}
|
||||
.theme-modern-dark .to-purple-600 {
|
||||
--tw-gradient-to: #6366f1 var(--tw-gradient-to-position) !important;
|
||||
}
|
||||
|
||||
/* Glow effects */
|
||||
.glow-pink {
|
||||
box-shadow: 0 0 25px -5px var(--glow-color);
|
||||
}
|
||||
|
||||
.glow-cyan {
|
||||
box-shadow: 0 0 25px -5px rgba(6, 182, 212, 0.35);
|
||||
}
|
||||
|
||||
.glow-purple {
|
||||
box-shadow: 0 0 25px -5px rgba(168, 85, 247, 0.35);
|
||||
}
|
||||
|
||||
/* Pulse animation for active timer */
|
||||
@keyframes pulse-ring {
|
||||
0% {
|
||||
transform: scale(0.95);
|
||||
opacity: 0.8;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.08);
|
||||
opacity: 0.3;
|
||||
}
|
||||
100% {
|
||||
transform: scale(0.95);
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-pulse-ring {
|
||||
animation: pulse-ring 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
/* Print formatting for build certificate / build sheet */
|
||||
@media print {
|
||||
body {
|
||||
background: white !important;
|
||||
color: black !important;
|
||||
}
|
||||
.no-print {
|
||||
display: none !important;
|
||||
}
|
||||
.print-only {
|
||||
display: block !important;
|
||||
}
|
||||
.glass-panel {
|
||||
background: white !important;
|
||||
border: 1px solid #ccc !important;
|
||||
box-shadow: none !important;
|
||||
color: black !important;
|
||||
}
|
||||
.printable-label-sheet {
|
||||
background: white !important;
|
||||
border: none !important;
|
||||
padding: 0 !important;
|
||||
max-height: none !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
.label-item {
|
||||
page-break-inside: avoid;
|
||||
break-inside: avoid;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
||||
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>,
|
||||
)
|
||||
375
src/services/authService.ts
Normal file
375
src/services/authService.ts
Normal file
@@ -0,0 +1,375 @@
|
||||
import type { AuthUser, AuthSession, JwtPayload } from '../types';
|
||||
import { ncbUsers } from './ncbApi';
|
||||
|
||||
const STORAGE_KEY_AUTH = 'uwu_auth_session_v1';
|
||||
const JWT_SECRET_SIGNATURE_KEY = 'uwuFinances_jwt_modding_secret_2026';
|
||||
|
||||
// Primary registered user credentials in Database (NoCodeBackend)
|
||||
export const DEFAULT_ADMIN_USER: AuthUser = {
|
||||
id: 'user_streaper2',
|
||||
name: 'streaper2',
|
||||
email: 'streaper2@uwufinances.studio',
|
||||
role: 'admin',
|
||||
workshopName: 'uwu Custom Studio 🎮',
|
||||
ncbInstance: '54770_uwu_finances',
|
||||
avatarUrl: 'https://api.dicebear.com/7.x/bottts/svg?seed=streaper2',
|
||||
lastLoginAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
export const GUEST_UNAUTHENTICATED_SESSION: AuthSession = {
|
||||
user: {
|
||||
id: '',
|
||||
name: '',
|
||||
email: '',
|
||||
role: 'viewer',
|
||||
lastLoginAt: '',
|
||||
},
|
||||
token: '',
|
||||
expiresAt: 0,
|
||||
isAuthenticated: false,
|
||||
};
|
||||
|
||||
// Helper for Base64 URL Encoding / Decoding
|
||||
export function base64UrlEncode(str: string): string {
|
||||
try {
|
||||
const utf8Bytes = new TextEncoder().encode(str);
|
||||
let binary = '';
|
||||
utf8Bytes.forEach((byte) => (binary += String.fromCharCode(byte)));
|
||||
return btoa(binary)
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
} catch {
|
||||
return btoa(unescape(encodeURIComponent(str)))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
}
|
||||
}
|
||||
|
||||
export function base64UrlDecode(str: string): string {
|
||||
try {
|
||||
let base64 = str.replace(/-/g, '+').replace(/_/g, '/');
|
||||
while (base64.length % 4) {
|
||||
base64 += '=';
|
||||
}
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return new TextDecoder().decode(bytes);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// Simple deterministic signature mock (for browser client-side JWTs)
|
||||
function generateMockSignature(headerB64: string, payloadB64: string, secret: string): string {
|
||||
const data = `${headerB64}.${payloadB64}.${secret}`;
|
||||
let hash = 0;
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const char = data.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash |= 0;
|
||||
}
|
||||
return base64UrlEncode(`sig_${Math.abs(hash).toString(36)}_${Date.now().toString(36)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a standard RFC 7519 compliant JWT string
|
||||
*/
|
||||
export function createJwtToken(
|
||||
user: AuthUser,
|
||||
instance: string = '54770_uwu_finances',
|
||||
expiresInHours: number = 720 // Default 30 days
|
||||
): { token: string; payload: JwtPayload } {
|
||||
const header = {
|
||||
alg: 'HS256',
|
||||
typ: 'JWT',
|
||||
};
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const exp = now + expiresInHours * 3600;
|
||||
|
||||
const payload: JwtPayload = {
|
||||
sub: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
instance: instance || '54770_uwu_finances',
|
||||
iat: now,
|
||||
exp,
|
||||
iss: 'uwuFinances-ncb-auth',
|
||||
};
|
||||
|
||||
const headerB64 = base64UrlEncode(JSON.stringify(header));
|
||||
const payloadB64 = base64UrlEncode(JSON.stringify(payload));
|
||||
const signatureB64 = generateMockSignature(headerB64, payloadB64, JWT_SECRET_SIGNATURE_KEY);
|
||||
|
||||
const token = `${headerB64}.${payloadB64}.${signatureB64}`;
|
||||
|
||||
return { token, payload };
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes and parses a JWT token string
|
||||
*/
|
||||
export function decodeJwtToken(token: string): {
|
||||
header: Record<string, unknown> | null;
|
||||
payload: JwtPayload | null;
|
||||
isValid: boolean;
|
||||
isExpired: boolean;
|
||||
timeLeftFormatted: string;
|
||||
} {
|
||||
if (!token || typeof token !== 'string') {
|
||||
return { header: null, payload: null, isValid: false, isExpired: true, timeLeftFormatted: 'Invalide' };
|
||||
}
|
||||
|
||||
const parts = token.trim().split('.');
|
||||
if (parts.length < 2) {
|
||||
return { header: null, payload: null, isValid: false, isExpired: true, timeLeftFormatted: 'Format invalide' };
|
||||
}
|
||||
|
||||
try {
|
||||
const headerJson = base64UrlDecode(parts[0]);
|
||||
const payloadJson = base64UrlDecode(parts[1]);
|
||||
|
||||
const header = JSON.parse(headerJson);
|
||||
const payload = JSON.parse(payloadJson) as JwtPayload;
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const isExpired = payload.exp ? payload.exp < now : false;
|
||||
|
||||
let timeLeftFormatted = 'Jamais';
|
||||
if (payload.exp) {
|
||||
const diffSec = payload.exp - now;
|
||||
if (diffSec <= 0) {
|
||||
timeLeftFormatted = 'Expiré';
|
||||
} else if (diffSec < 3600) {
|
||||
timeLeftFormatted = `${Math.floor(diffSec / 60)} min restantes`;
|
||||
} else if (diffSec < 86400) {
|
||||
timeLeftFormatted = `${Math.floor(diffSec / 3600)}h restantes`;
|
||||
} else {
|
||||
timeLeftFormatted = `${Math.floor(diffSec / 86400)} jours restants`;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
header,
|
||||
payload,
|
||||
isValid: true,
|
||||
isExpired,
|
||||
timeLeftFormatted,
|
||||
};
|
||||
} catch {
|
||||
return { header: null, payload: null, isValid: false, isExpired: true, timeLeftFormatted: 'Erreur décodage' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticates against NoCodeBackend Database (`users` table)
|
||||
* Expected credentials: username: streaper2, password: Kency1313 (or updated password)
|
||||
*/
|
||||
export async function authenticateWithDatabase(
|
||||
usernameInput: string,
|
||||
passwordInput: string
|
||||
): Promise<{ success: boolean; user?: AuthUser; error?: string }> {
|
||||
const cleanUsername = usernameInput.trim();
|
||||
const cleanPassword = passwordInput.trim();
|
||||
|
||||
if (!cleanUsername || !cleanPassword) {
|
||||
return { success: false, error: 'Veuillez saisir votre nom d\'utilisateur et mot de passe.' };
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Check in NoCodeBackend database table 'users'
|
||||
const dbUsers = await ncbUsers.getAll();
|
||||
const matchedUser = dbUsers.find(
|
||||
(u) =>
|
||||
String(u.username || '').toLowerCase() === cleanUsername.toLowerCase() &&
|
||||
String(u.password || '') === cleanPassword
|
||||
);
|
||||
|
||||
if (matchedUser) {
|
||||
const user: AuthUser = {
|
||||
id: String(matchedUser.id || 'user_streaper2'),
|
||||
name: String(matchedUser.username || cleanUsername),
|
||||
email: String(matchedUser.email || `${cleanUsername}@uwufinances.studio`),
|
||||
role: (matchedUser.role as any) || 'admin',
|
||||
workshopName: 'uwu Custom Studio 🎮',
|
||||
ncbInstance: '54770_uwu_finances',
|
||||
avatarUrl: String(matchedUser.avatar_url || `https://api.dicebear.com/7.x/bottts/svg?seed=${cleanUsername}`),
|
||||
lastLoginAt: new Date().toISOString(),
|
||||
};
|
||||
return { success: true, user };
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('NCB users query failed, checking cached/fallback credentials', err);
|
||||
}
|
||||
|
||||
// 2. Direct secure fallback verification for initial streaper2 / Kency1313
|
||||
if (cleanUsername === 'streaper2' && cleanPassword === 'Kency1313') {
|
||||
return {
|
||||
success: true,
|
||||
user: {
|
||||
...DEFAULT_ADMIN_USER,
|
||||
lastLoginAt: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'Identifiant ou mot de passe incorrect. Vérifiez vos accès.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes user password in NoCodeBackend Database (`users` table)
|
||||
*/
|
||||
export async function changeUserPassword(
|
||||
username: string,
|
||||
oldPasswordInput: string,
|
||||
newPasswordInput: string
|
||||
): Promise<{ success: boolean; message?: string; error?: string }> {
|
||||
const cleanUsername = username.trim();
|
||||
const cleanOldPassword = oldPasswordInput.trim();
|
||||
const cleanNewPassword = newPasswordInput.trim();
|
||||
|
||||
if (!cleanOldPassword || !cleanNewPassword) {
|
||||
return { success: false, error: 'Tous les champs de mot de passe sont requis.' };
|
||||
}
|
||||
|
||||
if (cleanNewPassword.length < 4) {
|
||||
return { success: false, error: 'Le nouveau mot de passe doit contenir au minimum 4 caractères.' };
|
||||
}
|
||||
|
||||
try {
|
||||
const dbUsers = await ncbUsers.getAll();
|
||||
const matchedUser = dbUsers.find(
|
||||
(u) =>
|
||||
String(u.username || '').toLowerCase() === cleanUsername.toLowerCase() &&
|
||||
String(u.password || '') === cleanOldPassword
|
||||
);
|
||||
|
||||
if (!matchedUser) {
|
||||
// Check fallback password for streaper2
|
||||
if (cleanUsername === 'streaper2' && cleanOldPassword === 'Kency1313') {
|
||||
// Try creating / updating in DB
|
||||
try {
|
||||
await ncbUsers.create({
|
||||
id: 'user_streaper2',
|
||||
username: 'streaper2',
|
||||
password: cleanNewPassword,
|
||||
email: 'streaper2@uwufinances.studio',
|
||||
role: 'admin',
|
||||
avatar_url: 'https://api.dicebear.com/7.x/bottts/svg?seed=streaper2',
|
||||
});
|
||||
} catch {
|
||||
await ncbUsers.update('user_streaper2', { password: cleanNewPassword });
|
||||
}
|
||||
return { success: true, message: 'Mot de passe mis à jour avec succès en base de données !' };
|
||||
}
|
||||
return { success: false, error: 'L\'ancien mot de passe saisi est incorrect.' };
|
||||
}
|
||||
|
||||
// Update in DB
|
||||
await ncbUsers.update(String(matchedUser.id), { password: cleanNewPassword });
|
||||
return { success: true, message: 'Votre mot de passe a été modifié et synchronisé sur NoCodeBackend !' };
|
||||
} catch (err: any) {
|
||||
return { success: false, error: `Erreur lors de la mise à jour en BDD: ${err.message || 'Échec réseau'}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Session persistence helpers
|
||||
* Returns GUEST_UNAUTHENTICATED_SESSION if not logged in or session is expired.
|
||||
* WILL NOT automatically auto-login on refresh if user logged out!
|
||||
*/
|
||||
export function getSavedAuthSession(): AuthSession {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY_AUTH);
|
||||
if (raw) {
|
||||
const parsed: AuthSession = JSON.parse(raw);
|
||||
if (parsed && parsed.isAuthenticated && parsed.token) {
|
||||
const decoded = decodeJwtToken(parsed.token);
|
||||
if (decoded.isValid && !decoded.isExpired) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore error
|
||||
}
|
||||
|
||||
// Not logged in by default
|
||||
return GUEST_UNAUTHENTICATED_SESSION;
|
||||
}
|
||||
|
||||
export function saveAuthSession(session: AuthSession): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY_AUTH, JSON.stringify(session));
|
||||
} catch (err) {
|
||||
console.warn('Failed to persist auth session to localStorage', err);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearAuthSession(): void {
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY_AUTH);
|
||||
} catch (err) {
|
||||
console.warn('Failed to clear auth session', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test connectivity with NoCodeBackend instance
|
||||
*/
|
||||
export async function testNcbConnection(
|
||||
instance: string = '54770_uwu_finances',
|
||||
customKey?: string
|
||||
): Promise<{ success: boolean; latencyMs: number; message: string }> {
|
||||
const start = performance.now();
|
||||
try {
|
||||
const endpoint = import.meta.env.DEV
|
||||
? `/api/ncb/read/categories?limit=1&Instance=${encodeURIComponent(instance)}`
|
||||
: `https://eu-api.nocodebackend.com/read/categories?limit=1&Instance=${encodeURIComponent(instance)}`;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (customKey) {
|
||||
headers['Authorization'] = `Bearer ${customKey}`;
|
||||
}
|
||||
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
});
|
||||
|
||||
const latencyMs = Math.round(performance.now() - start);
|
||||
|
||||
if (res.ok) {
|
||||
return {
|
||||
success: true,
|
||||
latencyMs,
|
||||
message: `Connecté avec succès à NoCodeBackend (Instance ${instance}) en ${latencyMs}ms.`,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
latencyMs,
|
||||
message: `Erreur API NCB [${res.status}]: ${res.statusText}`,
|
||||
};
|
||||
}
|
||||
} catch (err: any) {
|
||||
const latencyMs = Math.round(performance.now() - start);
|
||||
return {
|
||||
success: false,
|
||||
latencyMs,
|
||||
message: `Impossible de joindre le serveur NoCodeBackend: ${err.message || 'Erreur réseau'}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
188
src/services/ncbApi.ts
Normal file
188
src/services/ncbApi.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* NCB (NoCodeBackend) API Service
|
||||
* Database: 54770_uwu_finances (EU Region)
|
||||
*
|
||||
* All calls go through /api/ncb proxy (defined in vite.config.ts)
|
||||
* which injects the Authorization header server-side.
|
||||
*/
|
||||
|
||||
const NCB_INSTANCE = (import.meta.env.VITE_NCB_INSTANCE as string) || '54770_uwu_finances';
|
||||
|
||||
// Base URL — proxied through Vite dev server in dev, direct in prod
|
||||
const BASE = import.meta.env.DEV
|
||||
? '/api/ncb'
|
||||
: (import.meta.env.VITE_NCB_DATA_API_URL as string || 'https://eu-api.nocodebackend.com');
|
||||
|
||||
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
|
||||
interface NCBRequestOptions {
|
||||
method?: HttpMethod;
|
||||
body?: Record<string, unknown>;
|
||||
params?: Record<string, string | number>;
|
||||
}
|
||||
|
||||
export async function ncbRequest<T>(
|
||||
path: string,
|
||||
options: NCBRequestOptions = {}
|
||||
): Promise<T> {
|
||||
const { method = 'GET', body, params } = options;
|
||||
|
||||
// Build full URL
|
||||
const baseOrigin = typeof window !== 'undefined' ? window.location.origin : 'http://localhost:5173';
|
||||
const url = new URL(path.startsWith('http') ? path : `${BASE}/${path.replace(/^\//, '')}`, baseOrigin);
|
||||
url.searchParams.set('Instance', NCB_INSTANCE);
|
||||
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== null) {
|
||||
url.searchParams.set(k, String(v));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
// Attach session JWT token if user is authenticated
|
||||
try {
|
||||
const rawSession = localStorage.getItem('uwu_auth_session_v1');
|
||||
if (rawSession) {
|
||||
const parsed = JSON.parse(rawSession);
|
||||
if (parsed?.token) {
|
||||
headers['X-JWT-Token'] = parsed.token;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore localStorage parse errors
|
||||
}
|
||||
|
||||
// In production, inject secret key directly
|
||||
if (!import.meta.env.DEV) {
|
||||
const secretKey = import.meta.env.VITE_NCB_SECRET_KEY;
|
||||
if (secretKey) {
|
||||
headers['Authorization'] = `Bearer ${secretKey}`;
|
||||
}
|
||||
headers['X-Database-Instance'] = NCB_INSTANCE;
|
||||
}
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errText = await res.text();
|
||||
throw new Error(`NCB API Error [${res.status}] on ${path}: ${errText}`);
|
||||
}
|
||||
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Generic CRUD helpers
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
export async function ncbGetAll<T>(table: string, extra?: Record<string, string | number>): Promise<T[]> {
|
||||
const params: Record<string, string | number> = { limit: 500, ...(extra || {}) };
|
||||
const data = await ncbRequest<{ data?: T[]; status?: string }>(`read/${table}`, { params });
|
||||
return data.data ?? [];
|
||||
}
|
||||
|
||||
export async function ncbGetOne<T>(table: string, id: string): Promise<T | null> {
|
||||
try {
|
||||
const data = await ncbRequest<{ data?: T; status?: string }>(`read/${table}/${id}`);
|
||||
return data.data ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function ncbCreate<T extends Record<string, unknown>>(table: string, record: T): Promise<T> {
|
||||
await ncbRequest(`create/${table}`, { method: 'POST', body: record });
|
||||
return record;
|
||||
}
|
||||
|
||||
export async function ncbUpdate<T extends Record<string, unknown>>(table: string, id: string, updates: Partial<T>): Promise<void> {
|
||||
await ncbRequest(`update/${table}/${id}`, { method: 'PUT', body: updates as Record<string, unknown> });
|
||||
}
|
||||
|
||||
export async function ncbDelete(table: string, id: string): Promise<void> {
|
||||
await ncbRequest(`delete/${table}/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Typed helpers per entity
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
export const ncbInventory = {
|
||||
getAll: () => ncbGetAll<Record<string, unknown>>('inventory_items'),
|
||||
create: (data: Record<string, unknown>) => ncbCreate('inventory_items', data),
|
||||
update: (id: string, data: Partial<Record<string, unknown>>) => ncbUpdate('inventory_items', id, data),
|
||||
delete: (id: string) => ncbDelete('inventory_items', id),
|
||||
};
|
||||
|
||||
export const ncbProjects = {
|
||||
getAll: () => ncbGetAll<Record<string, unknown>>('projects'),
|
||||
create: (data: Record<string, unknown>) => ncbCreate('projects', data),
|
||||
update: (id: string, data: Partial<Record<string, unknown>>) => ncbUpdate('projects', id, data),
|
||||
delete: (id: string) => ncbDelete('projects', id),
|
||||
};
|
||||
|
||||
export const ncbProjectParts = {
|
||||
getAll: (projectId?: string) =>
|
||||
ncbGetAll<Record<string, unknown>>('project_parts', projectId ? { project_id: projectId } : {}),
|
||||
create: (data: Record<string, unknown>) => ncbCreate('project_parts', data),
|
||||
update: (id: string, data: Partial<Record<string, unknown>>) => ncbUpdate('project_parts', id, data),
|
||||
delete: (id: string) => ncbDelete('project_parts', id),
|
||||
};
|
||||
|
||||
export const ncbProjectTasks = {
|
||||
getAll: (projectId?: string) =>
|
||||
ncbGetAll<Record<string, unknown>>('project_tasks', projectId ? { project_id: projectId } : {}),
|
||||
create: (data: Record<string, unknown>) => ncbCreate('project_tasks', data),
|
||||
update: (id: string, data: Partial<Record<string, unknown>>) => ncbUpdate('project_tasks', id, data),
|
||||
delete: (id: string) => ncbDelete('project_tasks', id),
|
||||
};
|
||||
|
||||
export const ncbTimeSessions = {
|
||||
getAll: (projectId?: string) =>
|
||||
ncbGetAll<Record<string, unknown>>('time_sessions', projectId ? { project_id: projectId } : {}),
|
||||
create: (data: Record<string, unknown>) => ncbCreate('time_sessions', data),
|
||||
update: (id: string, data: Partial<Record<string, unknown>>) => ncbUpdate('time_sessions', id, data),
|
||||
delete: (id: string) => ncbDelete('time_sessions', id),
|
||||
};
|
||||
|
||||
export const ncbModsLibrary = {
|
||||
getAll: () => ncbGetAll<Record<string, unknown>>('mods_library'),
|
||||
create: (data: Record<string, unknown>) => ncbCreate('mods_library', data),
|
||||
update: (id: string, data: Partial<Record<string, unknown>>) => ncbUpdate('mods_library', id, data),
|
||||
delete: (id: string) => ncbDelete('mods_library', id),
|
||||
};
|
||||
|
||||
export const ncbSettings = {
|
||||
get: () => ncbGetAll<Record<string, unknown>>('app_settings'),
|
||||
upsert: async (id: string, data: Record<string, unknown>) => {
|
||||
try {
|
||||
await ncbUpdate('app_settings', id, data);
|
||||
} catch {
|
||||
await ncbCreate('app_settings', { id, ...data });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const ncbCategories = {
|
||||
getAll: () => ncbGetAll<Record<string, unknown>>('categories'),
|
||||
create: (data: Record<string, unknown>) => ncbCreate('categories', data),
|
||||
update: (id: string, data: Partial<Record<string, unknown>>) => ncbUpdate('categories', id, data),
|
||||
delete: (id: string) => ncbDelete('categories', id),
|
||||
};
|
||||
|
||||
export const ncbUsers = {
|
||||
getAll: () => ncbGetAll<Record<string, unknown>>('users'),
|
||||
getOne: (id: string) => ncbGetOne<Record<string, unknown>>('users', id),
|
||||
create: (data: Record<string, unknown>) => ncbCreate('users', data),
|
||||
update: (id: string, data: Partial<Record<string, unknown>>) => ncbUpdate('users', id, data),
|
||||
};
|
||||
|
||||
592
src/services/ncbSync.ts
Normal file
592
src/services/ncbSync.ts
Normal file
@@ -0,0 +1,592 @@
|
||||
/**
|
||||
* NCB Sync Service
|
||||
* Handles bidirectional sync between the app state and NoCodeBackend database (54770_uwu_finances).
|
||||
*
|
||||
* Strategy:
|
||||
* - On load: fetch all data from NCB, merge with localStorage (NCB authoritative if data present)
|
||||
* - If NCB is fresh/empty: automatically seed NCB from local/initial data
|
||||
* - On mutations: asynchronously write to NCB immediately (optimistic update in React state)
|
||||
* - On error: fall back to localStorage gracefully
|
||||
*/
|
||||
|
||||
import type {
|
||||
InventoryItem,
|
||||
Project,
|
||||
ProjectPart,
|
||||
ProjectTask,
|
||||
TimeSession,
|
||||
ModRecipe,
|
||||
AppSettings,
|
||||
AppCategory,
|
||||
} from '../types';
|
||||
|
||||
import {
|
||||
ncbInventory,
|
||||
ncbProjects,
|
||||
ncbProjectParts,
|
||||
ncbProjectTasks,
|
||||
ncbTimeSessions,
|
||||
ncbModsLibrary,
|
||||
ncbSettings,
|
||||
ncbCategories,
|
||||
} from './ncbApi';
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Mapping helpers: DB snake_case ↔ App camelCase
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type AnyRecord = Record<string, any>;
|
||||
|
||||
function safeJsonParse<T>(jsonStr: unknown, fallback: T): T {
|
||||
if (typeof jsonStr !== 'string') return fallback;
|
||||
try {
|
||||
return JSON.parse(jsonStr) as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export function toInventoryItem(row: AnyRecord): InventoryItem {
|
||||
return {
|
||||
id: String(row.id),
|
||||
sku: String(row.sku || ''),
|
||||
name: String(row.name || ''),
|
||||
category: row.category,
|
||||
consoleModel: row.console_model,
|
||||
supplier: String(row.supplier || ''),
|
||||
orderDate: String(row.order_date || ''),
|
||||
status: row.status || 'in_stock',
|
||||
unitPurchasePrice: parseFloat(row.unit_purchase_price) || 0,
|
||||
shippingCostShare: parseFloat(row.shipping_cost_share) || 0,
|
||||
taxImportShare: parseFloat(row.tax_import_share) || 0,
|
||||
unitCostWithFees: parseFloat(row.unit_cost_with_fees) || 0,
|
||||
quantityInStock: parseInt(row.quantity_in_stock, 10) || 0,
|
||||
quantityReserved: parseInt(row.quantity_reserved, 10) || 0,
|
||||
minStockAlert: parseInt(row.min_stock_alert, 10) || 1,
|
||||
notes: row.notes || undefined,
|
||||
imageUrl: row.image_url || undefined,
|
||||
locationInWorkshop: row.location_in_workshop || undefined,
|
||||
linkedProjectId: row.linked_project_id || undefined,
|
||||
linkedProjectTitle: row.linked_project_title || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function fromInventoryItem(item: InventoryItem): AnyRecord {
|
||||
return {
|
||||
id: item.id,
|
||||
sku: item.sku || '',
|
||||
name: item.name || '',
|
||||
category: item.category || 'other',
|
||||
console_model: item.consoleModel || 'Autre / Custom',
|
||||
supplier: item.supplier || '',
|
||||
order_date: item.orderDate || '',
|
||||
status: item.status || 'in_stock',
|
||||
unit_purchase_price: item.unitPurchasePrice || 0,
|
||||
shipping_cost_share: item.shippingCostShare || 0,
|
||||
tax_import_share: item.taxImportShare || 0,
|
||||
unit_cost_with_fees: item.unitCostWithFees || 0,
|
||||
quantity_in_stock: item.quantityInStock || 0,
|
||||
quantity_reserved: item.quantityReserved || 0,
|
||||
min_stock_alert: item.minStockAlert || 1,
|
||||
notes: item.notes || null,
|
||||
image_url: item.imageUrl || null,
|
||||
location_in_workshop: item.locationInWorkshop || null,
|
||||
linked_project_id: item.linkedProjectId || null,
|
||||
linked_project_title: item.linkedProjectTitle || null,
|
||||
};
|
||||
}
|
||||
|
||||
export function toProject(
|
||||
row: AnyRecord,
|
||||
parts: ProjectPart[] = [],
|
||||
tasks: ProjectTask[] = [],
|
||||
timeSessions: TimeSession[] = []
|
||||
): Project {
|
||||
return {
|
||||
id: String(row.id),
|
||||
title: String(row.title || ''),
|
||||
serialNumber: String(row.serial_number || ''),
|
||||
consoleModel: row.console_model || 'Game Boy Advance',
|
||||
type: row.type || 'showcase_sale',
|
||||
status: row.status || 'draft',
|
||||
customerName: row.customer_name || undefined,
|
||||
customerContact: row.customer_contact || undefined,
|
||||
startDate: row.start_date || '',
|
||||
completedDate: row.completed_date || undefined,
|
||||
soldDate: row.sold_date || undefined,
|
||||
salePriceTarget: parseFloat(row.sale_price_target) || 0,
|
||||
salePriceActual: row.sale_price_actual !== null && row.sale_price_actual !== undefined
|
||||
? parseFloat(row.sale_price_actual)
|
||||
: undefined,
|
||||
salesChannel: row.sales_channel || undefined,
|
||||
hourlyRateDefault: parseFloat(row.hourly_rate_default) || 25,
|
||||
modsList: safeJsonParse<string[]>(row.mods_list, []),
|
||||
objectives: row.objectives ? safeJsonParse(row.objectives, undefined) : undefined,
|
||||
additionalFees: row.additional_fees ? safeJsonParse(row.additional_fees, {
|
||||
consumablesEst: 0,
|
||||
packagingCost: 0,
|
||||
shippingCost: 0,
|
||||
platformFeePercent: 0,
|
||||
platformFeeFixed: 0,
|
||||
otherFees: 0,
|
||||
}) : {
|
||||
consumablesEst: 3,
|
||||
packagingCost: 2,
|
||||
shippingCost: 0,
|
||||
platformFeePercent: 5,
|
||||
platformFeeFixed: 0,
|
||||
otherFees: 0,
|
||||
},
|
||||
notes: row.notes || '',
|
||||
photoUrl: row.image_url || undefined,
|
||||
warrantyMonths: row.warranty_months ? parseInt(row.warranty_months, 10) : undefined,
|
||||
parts,
|
||||
tasks,
|
||||
timeSessions,
|
||||
};
|
||||
}
|
||||
|
||||
export function fromProject(project: Project): AnyRecord {
|
||||
return {
|
||||
id: project.id,
|
||||
title: project.title,
|
||||
serial_number: project.serialNumber || '',
|
||||
console_model: project.consoleModel,
|
||||
type: project.type,
|
||||
status: project.status,
|
||||
customer_name: project.customerName || null,
|
||||
customer_contact: project.customerContact || null,
|
||||
start_date: project.startDate || '',
|
||||
completed_date: project.completedDate || null,
|
||||
sold_date: project.soldDate || null,
|
||||
sale_price_target: project.salePriceTarget || 0,
|
||||
sale_price_actual: project.salePriceActual ?? null,
|
||||
sales_channel: project.salesChannel || null,
|
||||
hourly_rate_default: project.hourlyRateDefault || 25,
|
||||
mods_list: JSON.stringify(project.modsList || []),
|
||||
objectives: project.objectives ? JSON.stringify(project.objectives) : null,
|
||||
additional_fees: project.additionalFees ? JSON.stringify(project.additionalFees) : null,
|
||||
notes: project.notes || null,
|
||||
image_url: project.photoUrl || null,
|
||||
warranty_months: project.warrantyMonths || 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function toProjectPart(row: AnyRecord): ProjectPart {
|
||||
return {
|
||||
partId: row.part_id || '',
|
||||
partName: row.part_name || '',
|
||||
category: row.category || 'other',
|
||||
unitCost: parseFloat(row.unit_cost) || 0,
|
||||
quantity: parseInt(row.quantity, 10) || 1,
|
||||
totalCost: parseFloat(row.total_cost) || 0,
|
||||
status: row.status || 'allocated',
|
||||
supplier: row.supplier || undefined,
|
||||
expectedDeliveryDate: row.expected_delivery_date || undefined,
|
||||
purchaseUrl: row.purchase_url || undefined,
|
||||
notes: row.notes || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function toProjectTask(row: AnyRecord): ProjectTask {
|
||||
return {
|
||||
id: String(row.id),
|
||||
title: String(row.title || ''),
|
||||
isCompleted: Boolean(row.is_completed),
|
||||
dueDate: row.due_date || undefined,
|
||||
estimatedMinutes: row.estimated_minutes ? parseInt(row.estimated_minutes, 10) : undefined,
|
||||
category: row.category || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function toTimeSession(row: AnyRecord): TimeSession {
|
||||
return {
|
||||
id: String(row.id),
|
||||
projectId: String(row.project_id),
|
||||
taskType: row.task_type || 'soudure_mods',
|
||||
description: String(row.description || ''),
|
||||
date: String(row.session_date || ''),
|
||||
durationMinutes: parseInt(row.duration_minutes, 10) || 0,
|
||||
hourlyRate: parseFloat(row.hourly_rate) || 25,
|
||||
cost: parseFloat(row.cost) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function toModRecipe(row: AnyRecord): ModRecipe {
|
||||
return {
|
||||
id: String(row.id),
|
||||
name: String(row.name || ''),
|
||||
category: row.category || 'other',
|
||||
compatibleModels: safeJsonParse(row.compatible_models, []),
|
||||
description: String(row.description || ''),
|
||||
installationNotes: row.installation_notes || undefined,
|
||||
difficulty: row.difficulty || 'medium',
|
||||
estimatedMinutes: parseInt(row.estimated_minutes, 10) || 60,
|
||||
suggestedAddedValue: parseFloat(row.suggested_added_value) || 0,
|
||||
requiredParts: safeJsonParse(row.required_parts, []),
|
||||
checklistTasks: safeJsonParse(row.checklist_tasks, []),
|
||||
tags: row.tags ? safeJsonParse(row.tags, undefined) : undefined,
|
||||
badgeColor: row.badge_color || undefined,
|
||||
isPopular: Boolean(row.is_popular),
|
||||
};
|
||||
}
|
||||
|
||||
export function fromModRecipe(mod: ModRecipe): AnyRecord {
|
||||
return {
|
||||
id: mod.id,
|
||||
name: mod.name,
|
||||
category: mod.category,
|
||||
compatible_models: JSON.stringify(mod.compatibleModels || []),
|
||||
description: mod.description || '',
|
||||
installation_notes: mod.installationNotes || null,
|
||||
difficulty: mod.difficulty,
|
||||
estimated_minutes: mod.estimatedMinutes || 60,
|
||||
suggested_added_value: mod.suggestedAddedValue || 0,
|
||||
required_parts: JSON.stringify(mod.requiredParts || []),
|
||||
checklist_tasks: JSON.stringify(mod.checklistTasks || []),
|
||||
tags: mod.tags ? JSON.stringify(mod.tags) : null,
|
||||
badge_color: mod.badgeColor || null,
|
||||
is_popular: mod.isPopular ? 1 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function toAppSettings(row: AnyRecord): AppSettings {
|
||||
return {
|
||||
currency: row.currency || '€',
|
||||
defaultHourlyRate: parseFloat(row.default_hourly_rate) || 25,
|
||||
defaultPlatformFeePercent: parseFloat(row.default_platform_fee_percent) || 5,
|
||||
theme: row.theme || 'uwu-pink',
|
||||
businessName: row.business_name || 'uwu Custom Studio 🎮',
|
||||
workshopLocation: row.workshop_location || 'Atelier Console Modding - Lyon',
|
||||
logoText: row.logo_text || 'uwuFinances',
|
||||
};
|
||||
}
|
||||
|
||||
export function fromAppSettings(s: AppSettings): AnyRecord {
|
||||
return {
|
||||
id: 'default',
|
||||
currency: s.currency,
|
||||
default_hourly_rate: s.defaultHourlyRate,
|
||||
default_platform_fee_percent: s.defaultPlatformFeePercent,
|
||||
theme: s.theme,
|
||||
business_name: s.businessName,
|
||||
workshop_location: s.workshopLocation,
|
||||
logo_text: s.logoText,
|
||||
};
|
||||
}
|
||||
|
||||
export function toAppCategory(row: AnyRecord): AppCategory {
|
||||
return {
|
||||
id: String(row.id),
|
||||
name: String(row.name || ''),
|
||||
slug: String(row.slug || ''),
|
||||
type: (row.type as any) || 'part_category',
|
||||
color: row.color || undefined,
|
||||
badgeColor: row.badge_color || undefined,
|
||||
icon: row.icon || undefined,
|
||||
description: row.description || undefined,
|
||||
sortOrder: parseInt(row.sort_order, 10) || 0,
|
||||
isActive: row.is_active !== 0 && row.is_active !== false,
|
||||
};
|
||||
}
|
||||
|
||||
export function fromAppCategory(c: AppCategory): AnyRecord {
|
||||
return {
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
slug: c.slug,
|
||||
type: c.type,
|
||||
color: c.color || null,
|
||||
badge_color: c.badgeColor || null,
|
||||
icon: c.icon || null,
|
||||
description: c.description || null,
|
||||
sort_order: c.sortOrder || 0,
|
||||
is_active: c.isActive ? 1 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Sync Controller
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
let syncEnabled = true;
|
||||
|
||||
export function enableSync() {
|
||||
syncEnabled = true;
|
||||
}
|
||||
|
||||
export function disableSync() {
|
||||
syncEnabled = false;
|
||||
}
|
||||
|
||||
/** Load all data from NCB — call on app init */
|
||||
export async function loadAllFromNCB(): Promise<{
|
||||
inventory: InventoryItem[];
|
||||
projects: Project[];
|
||||
modsLibrary: ModRecipe[];
|
||||
settings: AppSettings | null;
|
||||
categories: AppCategory[];
|
||||
hasData: boolean;
|
||||
}> {
|
||||
try {
|
||||
const [invRows, projRows, partRows, taskRows, sessionRows, modsRows, settingsRows, catRows] = await Promise.all([
|
||||
ncbInventory.getAll(),
|
||||
ncbProjects.getAll(),
|
||||
ncbProjectParts.getAll(),
|
||||
ncbProjectTasks.getAll(),
|
||||
ncbTimeSessions.getAll(),
|
||||
ncbModsLibrary.getAll(),
|
||||
ncbSettings.get(),
|
||||
ncbCategories.getAll(),
|
||||
]);
|
||||
|
||||
const inventory = invRows.map(toInventoryItem);
|
||||
|
||||
const projects = projRows.map((pRow) => {
|
||||
const pid = String(pRow.id);
|
||||
const parts = partRows.filter((r) => String(r.project_id) === pid).map(toProjectPart);
|
||||
const tasks = taskRows.filter((r) => String(r.project_id) === pid).map(toProjectTask);
|
||||
const sessions = sessionRows.filter((r) => String(r.project_id) === pid).map(toTimeSession);
|
||||
return toProject(pRow, parts, tasks, sessions);
|
||||
});
|
||||
|
||||
const modsLibrary = modsRows.map(toModRecipe);
|
||||
|
||||
const categories = catRows.map(toAppCategory);
|
||||
|
||||
let settings: AppSettings | null = null;
|
||||
if (settingsRows.length > 0) {
|
||||
settings = toAppSettings(settingsRows[0]);
|
||||
}
|
||||
|
||||
const hasData = inventory.length > 0 || projects.length > 0 || modsLibrary.length > 0 || categories.length > 0;
|
||||
|
||||
return { inventory, projects, modsLibrary, settings, categories, hasData };
|
||||
} catch (err) {
|
||||
console.warn('[NCB] Failed to load data from NCB, using local fallback:', err);
|
||||
return { inventory: [], projects: [], modsLibrary: [], settings: null, categories: [], hasData: false };
|
||||
}
|
||||
}
|
||||
|
||||
/** Seed NCB database with initial or local data if database is empty */
|
||||
export async function seedNCBWithInitialData(
|
||||
inventory: InventoryItem[],
|
||||
projects: Project[],
|
||||
modsLibrary: ModRecipe[],
|
||||
settings: AppSettings,
|
||||
categories: AppCategory[] = []
|
||||
): Promise<void> {
|
||||
try {
|
||||
console.log('[NCB] Seeding database with initial data...');
|
||||
|
||||
// 1. Settings
|
||||
await ncbSettings.upsert('default', fromAppSettings(settings));
|
||||
|
||||
// 2. Categories
|
||||
for (const cat of categories) {
|
||||
await ncbCategories.create(fromAppCategory(cat));
|
||||
}
|
||||
|
||||
// 3. Inventory
|
||||
for (const item of inventory) {
|
||||
await ncbInventory.create(fromInventoryItem(item));
|
||||
}
|
||||
|
||||
// 4. Mods Library
|
||||
for (const mod of modsLibrary) {
|
||||
await ncbModsLibrary.create(fromModRecipe(mod));
|
||||
}
|
||||
|
||||
// 5. Projects (including parts, tasks, sessions)
|
||||
for (const proj of projects) {
|
||||
await ncbProjects.create(fromProject(proj));
|
||||
for (let i = 0; i < proj.parts.length; i++) {
|
||||
const p = proj.parts[i];
|
||||
await ncbProjectParts.create({
|
||||
id: `${proj.id}-part-${i}-${Date.now()}`,
|
||||
project_id: proj.id,
|
||||
part_id: p.partId || null,
|
||||
part_name: p.partName,
|
||||
category: p.category,
|
||||
unit_cost: p.unitCost,
|
||||
quantity: p.quantity,
|
||||
total_cost: p.totalCost,
|
||||
status: p.status || 'allocated',
|
||||
supplier: p.supplier || null,
|
||||
purchase_url: p.purchaseUrl || null,
|
||||
notes: p.notes || null,
|
||||
sort_order: i,
|
||||
});
|
||||
}
|
||||
for (let i = 0; i < (proj.tasks || []).length; i++) {
|
||||
const t = proj.tasks![i];
|
||||
await ncbProjectTasks.create({
|
||||
id: t.id || `${proj.id}-task-${i}`,
|
||||
project_id: proj.id,
|
||||
title: t.title,
|
||||
is_completed: t.isCompleted ? 1 : 0,
|
||||
due_date: t.dueDate || null,
|
||||
estimated_minutes: t.estimatedMinutes || null,
|
||||
category: t.category || null,
|
||||
sort_order: i,
|
||||
});
|
||||
}
|
||||
for (const s of proj.timeSessions || []) {
|
||||
await ncbTimeSessions.create({
|
||||
id: s.id,
|
||||
project_id: proj.id,
|
||||
task_type: s.taskType,
|
||||
description: s.description || '',
|
||||
session_date: s.date,
|
||||
duration_minutes: s.durationMinutes,
|
||||
hourly_rate: s.hourlyRate,
|
||||
cost: s.cost,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[NCB] Seeding completed successfully.');
|
||||
} catch (e) {
|
||||
console.error('[NCB] Seeding failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Inventory Sync ───
|
||||
export async function syncCreateInventory(item: InventoryItem): Promise<void> {
|
||||
if (!syncEnabled) return;
|
||||
try {
|
||||
await ncbInventory.create(fromInventoryItem(item));
|
||||
} catch (e) { console.warn('[NCB] syncCreateInventory failed', e); }
|
||||
}
|
||||
|
||||
export async function syncUpdateInventory(item: InventoryItem): Promise<void> {
|
||||
if (!syncEnabled) return;
|
||||
try {
|
||||
await ncbInventory.update(item.id, fromInventoryItem(item));
|
||||
} catch (e) { console.warn('[NCB] syncUpdateInventory failed', e); }
|
||||
}
|
||||
|
||||
export async function syncDeleteInventory(id: string): Promise<void> {
|
||||
if (!syncEnabled) return;
|
||||
try {
|
||||
await ncbInventory.delete(id);
|
||||
} catch (e) { console.warn('[NCB] syncDeleteInventory failed', e); }
|
||||
}
|
||||
|
||||
// ─── Project Sync ───
|
||||
export async function syncCreateProject(project: Project): Promise<void> {
|
||||
if (!syncEnabled) return;
|
||||
try {
|
||||
await ncbProjects.create(fromProject(project));
|
||||
for (let i = 0; i < project.parts.length; i++) {
|
||||
const p = project.parts[i];
|
||||
await ncbProjectParts.create({
|
||||
id: `${project.id}-part-${i}-${Date.now()}`,
|
||||
project_id: project.id,
|
||||
part_id: p.partId || null,
|
||||
part_name: p.partName,
|
||||
category: p.category,
|
||||
unit_cost: p.unitCost,
|
||||
quantity: p.quantity,
|
||||
total_cost: p.totalCost,
|
||||
status: p.status || 'allocated',
|
||||
supplier: p.supplier || null,
|
||||
purchase_url: p.purchaseUrl || null,
|
||||
notes: p.notes || null,
|
||||
sort_order: i,
|
||||
});
|
||||
}
|
||||
for (let i = 0; i < (project.tasks || []).length; i++) {
|
||||
const t = project.tasks![i];
|
||||
await ncbProjectTasks.create({
|
||||
id: t.id,
|
||||
project_id: project.id,
|
||||
title: t.title,
|
||||
is_completed: t.isCompleted ? 1 : 0,
|
||||
due_date: t.dueDate || null,
|
||||
estimated_minutes: t.estimatedMinutes || null,
|
||||
category: t.category || null,
|
||||
sort_order: i,
|
||||
});
|
||||
}
|
||||
for (const s of project.timeSessions || []) {
|
||||
await ncbTimeSessions.create({
|
||||
id: s.id,
|
||||
project_id: project.id,
|
||||
task_type: s.taskType,
|
||||
description: s.description || '',
|
||||
session_date: s.date,
|
||||
duration_minutes: s.durationMinutes,
|
||||
hourly_rate: s.hourlyRate,
|
||||
cost: s.cost,
|
||||
});
|
||||
}
|
||||
} catch (e) { console.warn('[NCB] syncCreateProject failed', e); }
|
||||
}
|
||||
|
||||
export async function syncUpdateProject(project: Project): Promise<void> {
|
||||
if (!syncEnabled) return;
|
||||
try {
|
||||
await ncbProjects.update(project.id, fromProject(project));
|
||||
} catch (e) { console.warn('[NCB] syncUpdateProject failed', e); }
|
||||
}
|
||||
|
||||
export async function syncDeleteProject(id: string): Promise<void> {
|
||||
if (!syncEnabled) return;
|
||||
try {
|
||||
await ncbProjects.delete(id);
|
||||
} catch (e) { console.warn('[NCB] syncDeleteProject failed', e); }
|
||||
}
|
||||
|
||||
// ─── Mod Sync ───
|
||||
export async function syncCreateMod(mod: ModRecipe): Promise<void> {
|
||||
if (!syncEnabled) return;
|
||||
try {
|
||||
await ncbModsLibrary.create(fromModRecipe(mod));
|
||||
} catch (e) { console.warn('[NCB] syncCreateMod failed', e); }
|
||||
}
|
||||
|
||||
export async function syncUpdateMod(mod: ModRecipe): Promise<void> {
|
||||
if (!syncEnabled) return;
|
||||
try {
|
||||
await ncbModsLibrary.update(mod.id, fromModRecipe(mod));
|
||||
} catch (e) { console.warn('[NCB] syncUpdateMod failed', e); }
|
||||
}
|
||||
|
||||
export async function syncDeleteMod(id: string): Promise<void> {
|
||||
if (!syncEnabled) return;
|
||||
try {
|
||||
await ncbModsLibrary.delete(id);
|
||||
} catch (e) { console.warn('[NCB] syncDeleteMod failed', e); }
|
||||
}
|
||||
|
||||
// ─── Settings Sync ───
|
||||
export async function syncSettings(settings: AppSettings): Promise<void> {
|
||||
if (!syncEnabled) return;
|
||||
try {
|
||||
await ncbSettings.upsert('default', fromAppSettings(settings));
|
||||
} catch (e) { console.warn('[NCB] syncSettings failed', e); }
|
||||
}
|
||||
|
||||
// ─── Categories Sync ───
|
||||
export async function syncCreateCategory(category: AppCategory): Promise<void> {
|
||||
if (!syncEnabled) return;
|
||||
try {
|
||||
await ncbCategories.create(fromAppCategory(category));
|
||||
} catch (e) { console.warn('[NCB] syncCreateCategory failed', e); }
|
||||
}
|
||||
|
||||
export async function syncUpdateCategory(category: AppCategory): Promise<void> {
|
||||
if (!syncEnabled) return;
|
||||
try {
|
||||
await ncbCategories.update(category.id, fromAppCategory(category));
|
||||
} catch (e) { console.warn('[NCB] syncUpdateCategory failed', e); }
|
||||
}
|
||||
|
||||
export async function syncDeleteCategory(id: string): Promise<void> {
|
||||
if (!syncEnabled) return;
|
||||
try {
|
||||
await ncbCategories.delete(id);
|
||||
} catch (e) { console.warn('[NCB] syncDeleteCategory failed', e); }
|
||||
}
|
||||
287
src/types/index.ts
Normal file
287
src/types/index.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
export type CategoryType = 'part_category' | 'console_model' | 'mod_category';
|
||||
|
||||
export interface AppCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
type: CategoryType;
|
||||
color?: string;
|
||||
badgeColor?: string;
|
||||
icon?: string;
|
||||
description?: string;
|
||||
sortOrder: number;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export type ConsoleModel =
|
||||
| 'Game Boy DMG'
|
||||
| 'Game Boy Pocket'
|
||||
| 'Game Boy Light'
|
||||
| 'Game Boy Color'
|
||||
| 'Game Boy Advance'
|
||||
| 'GBA SP'
|
||||
| 'Game Boy Micro'
|
||||
| 'Nintendo DS Lite'
|
||||
| 'Nintendo DSi'
|
||||
| 'Nintendo 3DS / 3DS XL'
|
||||
| 'New 3DS / New 2DS'
|
||||
| 'PSP 1000 / 2000 / 3000'
|
||||
| 'PS Vita'
|
||||
| 'Nintendo Switch / Lite / OLED'
|
||||
| 'Sega Game Gear'
|
||||
| 'WonderSwan'
|
||||
| 'Manette Pro / Rétro'
|
||||
| 'Autre / Custom'
|
||||
| (string & {});
|
||||
|
||||
export type PartCategory =
|
||||
| 'console_base'
|
||||
| 'screen'
|
||||
| 'shell'
|
||||
| 'buttons_pads'
|
||||
| 'motherboard_electronics'
|
||||
| 'cartridge_games'
|
||||
| 'battery_power'
|
||||
| 'sound_amp'
|
||||
| 'consumables'
|
||||
| 'packaging_accessories'
|
||||
| 'other'
|
||||
| (string & {});
|
||||
|
||||
export type ModCategory =
|
||||
| 'screen'
|
||||
| 'battery_power'
|
||||
| 'audio_sound'
|
||||
| 'shell_aesthetics'
|
||||
| 'buttons_led'
|
||||
| 'motherboard_recap'
|
||||
| 'storage_expansion'
|
||||
| 'complete_kit'
|
||||
| 'other'
|
||||
| (string & {});
|
||||
|
||||
export interface ModRequiredPart {
|
||||
partId?: string; // Référence optionnelle à un InventoryItem
|
||||
partName: string;
|
||||
category: PartCategory;
|
||||
quantity: number;
|
||||
estimatedCost: number;
|
||||
}
|
||||
|
||||
export interface ModRecipe {
|
||||
id: string;
|
||||
name: string;
|
||||
category: ModCategory;
|
||||
compatibleModels: ConsoleModel[];
|
||||
description: string;
|
||||
installationNotes?: string;
|
||||
difficulty: 'easy' | 'medium' | 'hard' | 'expert';
|
||||
estimatedMinutes: number;
|
||||
suggestedAddedValue: number;
|
||||
requiredParts: ModRequiredPart[];
|
||||
checklistTasks: string[];
|
||||
tags?: string[];
|
||||
badgeColor?: string;
|
||||
isPopular?: boolean;
|
||||
}
|
||||
|
||||
export type InventoryStatus =
|
||||
| 'in_stock'
|
||||
| 'ordered'
|
||||
| 'planned_order'
|
||||
| 'low_stock'
|
||||
| 'out_of_stock';
|
||||
|
||||
export interface InventoryItem {
|
||||
id: string;
|
||||
sku: string;
|
||||
name: string;
|
||||
category: PartCategory;
|
||||
consoleModel: ConsoleModel;
|
||||
supplier: string;
|
||||
orderDate: string; // YYYY-MM-DD
|
||||
status: InventoryStatus;
|
||||
unitPurchasePrice: number;
|
||||
shippingCostShare: number; // Frais de port alloués à la pièce
|
||||
taxImportShare: number; // Douane/TVA allouée
|
||||
unitCostWithFees: number; // Coût unitaire réel
|
||||
quantityInStock: number; // Quantité dispo
|
||||
quantityReserved: number; // Quantité allouée dans des projets en cours
|
||||
minStockAlert: number;
|
||||
notes?: string;
|
||||
imageUrl?: string;
|
||||
locationInWorkshop?: string; // Tiroir A1, Bac B2...
|
||||
linkedProjectId?: string; // ID du projet nécessitant cet approvisionnement
|
||||
linkedProjectTitle?: string; // Nom du projet pour affichage rapide
|
||||
}
|
||||
|
||||
export type ProjectPartStatus = 'allocated' | 'to_order' | 'ordered' | 'received';
|
||||
|
||||
export interface ProjectPart {
|
||||
partId: string;
|
||||
partName: string;
|
||||
category: PartCategory;
|
||||
unitCost: number;
|
||||
quantity: number;
|
||||
totalCost: number;
|
||||
status?: ProjectPartStatus; // 'allocated' (En stock) | 'to_order' (À approvisionner) | 'ordered' (En commande)
|
||||
supplier?: string;
|
||||
expectedDeliveryDate?: string;
|
||||
purchaseUrl?: string; // URL du site fournisseur pour acheter la pièce
|
||||
notes?: string; // Notes libres sur la pièce
|
||||
}
|
||||
|
||||
export type TaskType =
|
||||
| 'diagnostic_demontage'
|
||||
| 'nettoyage_recap'
|
||||
| 'decoupe_coque'
|
||||
| 'soudure_mods'
|
||||
| 'assemblage'
|
||||
| 'test_qc'
|
||||
| 'packaging_photo'
|
||||
| 'autre';
|
||||
|
||||
export interface TimeSession {
|
||||
id: string;
|
||||
projectId: string;
|
||||
taskType: TaskType;
|
||||
description: string;
|
||||
date: string; // YYYY-MM-DD
|
||||
durationMinutes: number;
|
||||
hourlyRate: number;
|
||||
cost: number;
|
||||
}
|
||||
|
||||
export type ProjectStatus =
|
||||
| 'draft'
|
||||
| 'in_progress'
|
||||
| 'qc_testing'
|
||||
| 'in_stock_ready'
|
||||
| 'reserved'
|
||||
| 'sold';
|
||||
|
||||
export type ProjectType =
|
||||
| 'showcase_sale'
|
||||
| 'client_custom'
|
||||
| 'personal_collection'
|
||||
| 'repair_mod_only';
|
||||
|
||||
export type SalesChannel =
|
||||
| 'vinted'
|
||||
| 'leboncoin'
|
||||
| 'etsy'
|
||||
| 'ebay'
|
||||
| 'direct_shop'
|
||||
| 'convention'
|
||||
| 'autre';
|
||||
|
||||
export interface ProjectAdditionalFees {
|
||||
consumablesEst: number; // Étain, tresse, flux, alcool IPA, câbles
|
||||
packagingCost: number; // Boîte kraft, sticker, sachet bulle
|
||||
shippingCost: number; // Envoi au client ou livraison
|
||||
platformFeePercent: number; // % commission de la plateforme
|
||||
platformFeeFixed: number; // Frais fixes plateforme
|
||||
otherFees: number; // Accessoires offerts, dragonne, etc.
|
||||
}
|
||||
|
||||
export interface ProjectTask {
|
||||
id: string;
|
||||
title: string;
|
||||
isCompleted: boolean;
|
||||
dueDate?: string; // YYYY-MM-DD
|
||||
estimatedMinutes?: number;
|
||||
category?: 'diagnostic' | 'nettoyage' | 'soudure' | 'assemblage' | 'test_qc' | 'packaging';
|
||||
}
|
||||
|
||||
export interface ProjectObjectives {
|
||||
targetDeadlineDate?: string; // Date d'échéance souhaitée (YYYY-MM-DD)
|
||||
targetProfitMin?: number; // Objectif de gain net (€)
|
||||
targetLaborHoursMax?: number; // Objectif de temps de travail max (heures)
|
||||
targetMarginPercentMin?: number; // Objectif % de marge minimale
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
id: string;
|
||||
title: string;
|
||||
serialNumber: string; // Ex: "UWU-GBA-001"
|
||||
consoleModel: ConsoleModel;
|
||||
type: ProjectType;
|
||||
status: ProjectStatus;
|
||||
customerName?: string;
|
||||
customerContact?: string;
|
||||
startDate: string;
|
||||
completedDate?: string;
|
||||
soldDate?: string;
|
||||
salesChannel?: SalesChannel;
|
||||
parts: ProjectPart[];
|
||||
timeSessions: TimeSession[];
|
||||
tasks?: ProjectTask[];
|
||||
objectives?: ProjectObjectives;
|
||||
hourlyRateDefault: number;
|
||||
additionalFees: ProjectAdditionalFees;
|
||||
salePriceTarget: number;
|
||||
salePriceActual?: number;
|
||||
notes?: string;
|
||||
photoUrl?: string;
|
||||
modsList: string[]; // Liste des mods faits
|
||||
warrantyMonths?: number;
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
currency: string;
|
||||
defaultHourlyRate: number;
|
||||
defaultPlatformFeePercent: number;
|
||||
theme: 'uwu-pink' | 'cyberpunk' | 'retro-gameboy' | 'modern-dark';
|
||||
businessName: string;
|
||||
workshopLocation: string;
|
||||
logoText: string;
|
||||
}
|
||||
|
||||
export interface ProjectFinancials {
|
||||
partsCost: number; // Coût total des pièces (Allouées + En prévision)
|
||||
partsCostAllocated: number; // Coût des pièces déjà en stock / allouées
|
||||
partsCostPlanned: number; // Coût des pièces restant à approvisionner (à prévoir)
|
||||
laborMinutes: number;
|
||||
laborCost: number;
|
||||
consumablesAndPackagingCost: number;
|
||||
shippingAndPlatformCost: number;
|
||||
totalCostOfGoodsSold: number; // Coût total de revient (Matériel + Temps + Frais)
|
||||
hardwareDirectCost: number; // Coût pièces + consommables + packaging sans le temps
|
||||
revenue: number; // Prix vendu ou ciblé
|
||||
grossProfit: number; // CA - Coûts matériels directs
|
||||
netProfit: number; // CA - Coût total avec temps et frais
|
||||
marginPercent: number; // (netProfit / revenue) * 100
|
||||
effectiveHourlyRate: number; // Gain horaire réel dégagé sur ce projet
|
||||
}
|
||||
|
||||
export type UserRole = 'admin' | 'modder' | 'viewer';
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: UserRole;
|
||||
avatarUrl?: string;
|
||||
workshopName?: string;
|
||||
ncbInstance?: string;
|
||||
lastLoginAt: string;
|
||||
}
|
||||
|
||||
export interface JwtPayload {
|
||||
sub: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: UserRole;
|
||||
instance: string;
|
||||
iat: number;
|
||||
exp: number;
|
||||
iss: string;
|
||||
}
|
||||
|
||||
export interface AuthSession {
|
||||
user: AuthUser;
|
||||
token: string;
|
||||
expiresAt: number;
|
||||
isAuthenticated: boolean;
|
||||
}
|
||||
|
||||
251
src/utils/calculations.ts
Normal file
251
src/utils/calculations.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import type { Project, ProjectFinancials, InventoryItem } from '../types';
|
||||
|
||||
|
||||
/**
|
||||
* Calcule tous les indicateurs financiers pour un projet donné
|
||||
*/
|
||||
export function calculateProjectFinancials(
|
||||
project: Project,
|
||||
overrideSalePrice?: number
|
||||
): ProjectFinancials {
|
||||
const partsCostAllocated = project.parts
|
||||
.filter((p) => !p.status || p.status === 'allocated' || p.status === 'received')
|
||||
.reduce((sum, p) => sum + (p.totalCost || 0), 0);
|
||||
|
||||
const partsCostPlanned = project.parts
|
||||
.filter((p) => p.status === 'to_order' || p.status === 'ordered')
|
||||
.reduce((sum, p) => sum + (p.totalCost || 0), 0);
|
||||
|
||||
const partsCost = partsCostAllocated + partsCostPlanned;
|
||||
|
||||
const laborMinutes = project.timeSessions.reduce(
|
||||
(sum, t) => sum + (t.durationMinutes || 0),
|
||||
0
|
||||
);
|
||||
|
||||
const laborCost = project.timeSessions.reduce(
|
||||
(sum, t) => sum + (t.cost || (t.durationMinutes / 60) * (t.hourlyRate || project.hourlyRateDefault || 25)),
|
||||
0
|
||||
);
|
||||
|
||||
const consumablesAndPackagingCost =
|
||||
(project.additionalFees?.consumablesEst || 0) +
|
||||
(project.additionalFees?.packagingCost || 0) +
|
||||
(project.additionalFees?.otherFees || 0);
|
||||
|
||||
const revenue = overrideSalePrice !== undefined
|
||||
? overrideSalePrice
|
||||
: (project.status === 'sold' && project.salePriceActual !== undefined && project.salePriceActual !== null
|
||||
? project.salePriceActual
|
||||
: (project.salePriceTarget || 0));
|
||||
|
||||
const platformFeeCalculated =
|
||||
(revenue * (project.additionalFees?.platformFeePercent || 0)) / 100 +
|
||||
(project.additionalFees?.platformFeeFixed || 0);
|
||||
|
||||
const shippingAndPlatformCost =
|
||||
(project.additionalFees?.shippingCost || 0) + platformFeeCalculated;
|
||||
|
||||
const hardwareDirectCost = partsCost + consumablesAndPackagingCost;
|
||||
const totalCostOfGoodsSold = hardwareDirectCost + laborCost + shippingAndPlatformCost;
|
||||
|
||||
const grossProfit = revenue - hardwareDirectCost;
|
||||
const netProfit = revenue - totalCostOfGoodsSold;
|
||||
const marginPercent = revenue > 0 ? (netProfit / revenue) * 100 : 0;
|
||||
|
||||
const laborHours = laborMinutes / 60;
|
||||
// Gain horaire effectif = (CA - Frais matériels et expédition) / Heures passées
|
||||
const netBeforeLabor = revenue - (hardwareDirectCost + shippingAndPlatformCost);
|
||||
const effectiveHourlyRate = laborHours > 0 ? netBeforeLabor / laborHours : 0;
|
||||
|
||||
return {
|
||||
partsCost,
|
||||
partsCostAllocated,
|
||||
partsCostPlanned,
|
||||
laborMinutes,
|
||||
laborCost,
|
||||
consumablesAndPackagingCost,
|
||||
shippingAndPlatformCost,
|
||||
totalCostOfGoodsSold,
|
||||
hardwareDirectCost,
|
||||
revenue,
|
||||
grossProfit,
|
||||
netProfit,
|
||||
marginPercent,
|
||||
effectiveHourlyRate,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Formate un montant en devise (€ par défaut)
|
||||
*/
|
||||
export function formatCurrency(amount: number, currency = '€'): string {
|
||||
if (isNaN(amount) || amount === null || amount === undefined) return `0,00 ${currency}`;
|
||||
return `${amount.toLocaleString('fr-FR', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})} ${currency}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formate un nombre de minutes en heures et minutes (ex: 135 min -> "2h 15m")
|
||||
*/
|
||||
export function formatMinutes(minutes: number): string {
|
||||
if (!minutes || minutes <= 0) return '0 min';
|
||||
const h = Math.floor(minutes / 60);
|
||||
const m = Math.round(minutes % 60);
|
||||
if (h === 0) return `${m} min`;
|
||||
if (m === 0) return `${h}h`;
|
||||
return `${h}h ${m.toString().padStart(2, '0')}m`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule la valeur totale du stock (pièces détachées + consoles de base)
|
||||
*/
|
||||
export function calculateTotalStockValue(items: InventoryItem[]): number {
|
||||
return items.reduce((sum, item) => {
|
||||
return sum + (item.quantityInStock * (item.unitCostWithFees || item.unitPurchasePrice || 0));
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtient le libellé lisible pour une catégorie de pièce
|
||||
*/
|
||||
export function getPartCategoryLabel(category: string): string {
|
||||
const map: Record<string, string> = {
|
||||
console_base: 'Console Donneuse / Brute',
|
||||
screen: 'Écran & Verre (IPS / OLED)',
|
||||
shell: 'Coque & Plasturgie',
|
||||
buttons_pads: 'Boutons & Silicones',
|
||||
motherboard_electronics: 'Électronique & Cartes Mères',
|
||||
cartridge_games: 'Cartouches & Flashcards',
|
||||
battery_power: 'Batterie & Alimentation LiPo / USB-C',
|
||||
sound_amp: 'Audio, Haut-parleur & Ampli',
|
||||
consumables: 'Consommables & Soudure',
|
||||
packaging_accessories: 'Packaging & Accessoires',
|
||||
other: 'Autre matériel',
|
||||
};
|
||||
return map[category] || category;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtient la couleur badge pour une catégorie de pièce
|
||||
*/
|
||||
export function getCategoryBadgeColor(category: string): { bg: string; text: string; border: string } {
|
||||
switch (category) {
|
||||
case 'console_base':
|
||||
return { bg: 'bg-amber-500/10', text: 'text-amber-400', border: 'border-amber-500/30' };
|
||||
case 'screen':
|
||||
return { bg: 'bg-cyan-500/10', text: 'text-cyan-400', border: 'border-cyan-500/30' };
|
||||
case 'shell':
|
||||
return { bg: 'bg-purple-500/10', text: 'text-purple-400', border: 'border-purple-500/30' };
|
||||
case 'battery_power':
|
||||
return { bg: 'bg-emerald-500/10', text: 'text-emerald-400', border: 'border-emerald-500/30' };
|
||||
case 'sound_amp':
|
||||
return { bg: 'bg-pink-500/10', text: 'text-pink-400', border: 'border-pink-500/30' };
|
||||
case 'buttons_pads':
|
||||
return { bg: 'bg-blue-500/10', text: 'text-blue-400', border: 'border-blue-500/30' };
|
||||
case 'motherboard_electronics':
|
||||
return { bg: 'bg-indigo-500/10', text: 'text-indigo-400', border: 'border-indigo-500/30' };
|
||||
case 'cartridge_games':
|
||||
return { bg: 'bg-rose-500/10', text: 'text-rose-400', border: 'border-rose-500/30' };
|
||||
default:
|
||||
return { bg: 'bg-slate-500/10', text: 'text-slate-400', border: 'border-slate-500/30' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtient le libellé pour un statut de projet
|
||||
*/
|
||||
export function getProjectStatusInfo(status: string): { label: string; bg: string; text: string; dot: string; color: string } {
|
||||
switch (status) {
|
||||
case 'draft':
|
||||
return { label: 'Brouillon / Idée', bg: 'bg-slate-500/10', text: 'text-slate-400', dot: 'bg-slate-400', color: 'bg-slate-500/15 text-slate-300 border-slate-500/30' };
|
||||
case 'in_progress':
|
||||
return { label: 'En cours de modding', bg: 'bg-blue-500/10', text: 'text-blue-400', dot: 'bg-blue-400', color: 'bg-blue-500/15 text-blue-300 border-blue-500/30' };
|
||||
case 'qc_testing':
|
||||
return { label: 'Tests & Contrôle Qualité', bg: 'bg-amber-500/10', text: 'text-amber-400', dot: 'bg-amber-400', color: 'bg-amber-500/15 text-amber-300 border-amber-500/30' };
|
||||
case 'in_stock_ready':
|
||||
return { label: 'En Stock / Prête à vendre', bg: 'bg-emerald-500/10', text: 'text-emerald-400', dot: 'bg-emerald-400', color: 'bg-emerald-500/15 text-emerald-300 border-emerald-500/30' };
|
||||
case 'reserved':
|
||||
return { label: 'Réservée client', bg: 'bg-purple-500/10', text: 'text-purple-400', dot: 'bg-purple-400', color: 'bg-purple-500/15 text-purple-300 border-purple-500/30' };
|
||||
case 'sold':
|
||||
return { label: 'Vendue 🎉', bg: 'bg-pink-500/10', text: 'text-pink-400', dot: 'bg-pink-400', color: 'bg-pink-500/15 text-pink-300 border-pink-500/30' };
|
||||
default:
|
||||
return { label: status, bg: 'bg-slate-500/10', text: 'text-slate-400', dot: 'bg-slate-400', color: 'bg-slate-500/15 text-slate-300 border-slate-500/30' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Libellé pour un type de tâche
|
||||
*/
|
||||
export function getTaskTypeLabel(taskType: string): string {
|
||||
const map: Record<string, string> = {
|
||||
diagnostic_demontage: 'Diagnostic & Démontage',
|
||||
nettoyage_recap: 'Nettoyage & Changement Condos',
|
||||
decoupe_coque: 'Découpe & Ajustement Coque',
|
||||
soudure_mods: 'Soudure des Mods & USB-C',
|
||||
assemblage: 'Assemblage & Calibrage',
|
||||
test_qc: 'Tests & Contrôle Qualité (Boutons, Son, Écran)',
|
||||
packaging_photo: 'Shooting Photo & Packaging',
|
||||
autre: 'Autre travail',
|
||||
};
|
||||
return map[taskType] || taskType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Libellé et style pour le statut d'un article en inventaire
|
||||
*/
|
||||
export function getInventoryStatusInfo(status: string): { label: string; bg: string; text: string; border: string; isPlanned?: boolean } {
|
||||
switch (status) {
|
||||
case 'in_stock':
|
||||
return { label: 'En Stock', bg: 'bg-emerald-500/10', text: 'text-emerald-400', border: 'border-emerald-500/30' };
|
||||
case 'planned_order':
|
||||
return { label: 'À Approvisionner (En Prévision)', bg: 'bg-amber-500/10', text: 'text-amber-400', border: 'border-amber-500/30', isPlanned: true };
|
||||
case 'ordered':
|
||||
return { label: 'En Commande Fournisseur', bg: 'bg-cyan-500/10', text: 'text-cyan-400', border: 'border-cyan-500/30' };
|
||||
case 'low_stock':
|
||||
return { label: 'Stock Faible', bg: 'bg-orange-500/10', text: 'text-orange-400', border: 'border-orange-500/30' };
|
||||
case 'out_of_stock':
|
||||
return { label: 'Rupture de Stock', bg: 'bg-rose-500/10', text: 'text-rose-400', border: 'border-rose-500/30' };
|
||||
default:
|
||||
return { label: status, bg: 'bg-slate-500/10', text: 'text-slate-400', border: 'border-slate-500/30' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Libellé et style pour le statut d'une pièce dans la nomenclature d'un projet
|
||||
*/
|
||||
export function getProjectPartStatusInfo(status?: string): { label: string; shortLabel: string; bg: string; text: string; border: string; isPlanned: boolean } {
|
||||
switch (status) {
|
||||
case 'to_order':
|
||||
return {
|
||||
label: 'À Approvisionner (Coût à prévoir)',
|
||||
shortLabel: 'À Prévoir',
|
||||
bg: 'bg-amber-500/15',
|
||||
text: 'text-amber-400',
|
||||
border: 'border-amber-500/30',
|
||||
isPlanned: true,
|
||||
};
|
||||
case 'ordered':
|
||||
return {
|
||||
label: 'En Commande Fournisseur',
|
||||
shortLabel: 'Commandé',
|
||||
bg: 'bg-cyan-500/15',
|
||||
text: 'text-cyan-400',
|
||||
border: 'border-cyan-500/30',
|
||||
isPlanned: true,
|
||||
};
|
||||
case 'allocated':
|
||||
case 'received':
|
||||
default:
|
||||
return {
|
||||
label: 'En Stock / Alloué',
|
||||
shortLabel: 'En Stock',
|
||||
bg: 'bg-emerald-500/15',
|
||||
text: 'text-emerald-400',
|
||||
border: 'border-emerald-500/30',
|
||||
isPlanned: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user