feat: uwuFinances app with themes, JWT auth and NoCodeBackend sync

This commit is contained in:
streaper2
2026-09-27 17:59:36 +02:00
commit 88f23caf3b
55 changed files with 20928 additions and 0 deletions

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

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

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

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

View 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>
</>
);
};

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

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

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

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

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

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

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

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

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

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

File diff suppressed because it is too large Load Diff

View 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é&#10;Port de charge USB-C Pro&#10;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>
);
};

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

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

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

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

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

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

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

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

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

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