feat: uwuFinances app with themes, JWT auth and NoCodeBackend sync
This commit is contained in:
5
.env
Normal file
5
.env
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# NoCodeBackend - uwuFinances Database
|
||||||
|
# VITE_ prefix needed for Vite to expose these to the proxy config
|
||||||
|
VITE_NCB_INSTANCE=54770_uwu_finances
|
||||||
|
VITE_NCB_SECRET_KEY=sk_live_0dydoaq1kib4f23ou6
|
||||||
|
VITE_NCB_DATA_API_URL=https://eu-api.nocodebackend.com
|
||||||
4
.env.example
Normal file
4
.env.example
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# NoCodeBackend - uwuFinances Configuration
|
||||||
|
VITE_NCB_INSTANCE=54770_uwu_finances
|
||||||
|
VITE_NCB_SECRET_KEY=your_secret_key_here
|
||||||
|
VITE_NCB_DATA_API_URL=https://eu-api.nocodebackend.com
|
||||||
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
8
.oxlintrc.json
Normal file
8
.oxlintrc.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["react", "typescript", "oxc"],
|
||||||
|
"rules": {
|
||||||
|
"react/rules-of-hooks": "error",
|
||||||
|
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||||
|
}
|
||||||
|
}
|
||||||
32
README.md
Normal file
32
README.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the Oxlint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["react", "typescript", "oxc"],
|
||||||
|
"options": {
|
||||||
|
"typeAware": true
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"react/rules-of-hooks": "error",
|
||||||
|
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
|
||||||
19
index.html
Normal file
19
index.html
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="fr" class="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🎮</text></svg>" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>uwuFinances | Gestionnaire Console Modding & Business Hub</title>
|
||||||
|
<meta name="description" content="Gestionnaire financier complet pour atelier de customisation et modding de consoles : Centre d'achat, Centre de dépenses par projet, Quantification du temps, et Dashboard de rentabilité." />
|
||||||
|
<!-- Google Fonts -->
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600;700&family=Fredoka:wght@400;600;700&display=swap" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body class="bg-slate-950 text-slate-100 font-sans antialiased min-h-screen selection:bg-pink-500 selection:text-white">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
2508
package-lock.json
generated
Normal file
2508
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
package.json
Normal file
37
package.json
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "uwufinances",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "oxlint",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
|
"canvas-confetti": "^1.9.4",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^1.48.0",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
|
"react": "^19.2.8",
|
||||||
|
"react-dom": "^19.2.8",
|
||||||
|
"recharts": "^3.10.1",
|
||||||
|
"tailwind-merge": "^3.7.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
|
"@types/node": "^24.13.3",
|
||||||
|
"@types/react": "^19.2.18",
|
||||||
|
"@types/react-dom": "^19.2.7",
|
||||||
|
"@vitejs/plugin-react": "^6.1.1",
|
||||||
|
"autoprefixer": "^10.6.1",
|
||||||
|
"oxlint": "^1.81.0",
|
||||||
|
"postcss": "^8.5.28",
|
||||||
|
"tailwindcss": "^4.3.3",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"vite": "^8.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
public/favicon.svg
Normal file
1
public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
public/icons.svg
Normal file
24
public/icons.svg
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
184
src/App.css
Normal file
184
src/App.css
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
.counter {
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--accent-bg);
|
||||||
|
border: 2px solid transparent;
|
||||||
|
transition: border-color 0.3s;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--accent-border);
|
||||||
|
}
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.base,
|
||||||
|
.framework,
|
||||||
|
.vite {
|
||||||
|
inset-inline: 0;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base {
|
||||||
|
width: 170px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.framework,
|
||||||
|
.vite {
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.framework {
|
||||||
|
z-index: 1;
|
||||||
|
top: 34px;
|
||||||
|
height: 28px;
|
||||||
|
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||||
|
scale(1.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vite {
|
||||||
|
z-index: 0;
|
||||||
|
top: 107px;
|
||||||
|
height: 26px;
|
||||||
|
width: auto;
|
||||||
|
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||||
|
scale(0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#center {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 25px;
|
||||||
|
place-content: center;
|
||||||
|
place-items: center;
|
||||||
|
flex-grow: 1;
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
padding: 32px 20px 24px;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#next-steps {
|
||||||
|
display: flex;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
|
& > div {
|
||||||
|
flex: 1 1 0;
|
||||||
|
padding: 32px;
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
padding: 24px 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#docs {
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#next-steps ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 32px 0 0;
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--text-h);
|
||||||
|
font-size: 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--social-bg);
|
||||||
|
display: flex;
|
||||||
|
padding: 6px 12px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: box-shadow 0.3s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.button-icon {
|
||||||
|
height: 18px;
|
||||||
|
width: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
margin-top: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
li {
|
||||||
|
flex: 1 1 calc(50% - 8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#spacer {
|
||||||
|
height: 88px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticks {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&::before,
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -4.5px;
|
||||||
|
border: 5px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
left: 0;
|
||||||
|
border-left-color: var(--border);
|
||||||
|
}
|
||||||
|
&::after {
|
||||||
|
right: 0;
|
||||||
|
border-right-color: var(--border);
|
||||||
|
}
|
||||||
|
}
|
||||||
110
src/App.tsx
Normal file
110
src/App.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { AppProvider, useApp } from './context/AppContext';
|
||||||
|
import { Sidebar } from './components/common/Sidebar';
|
||||||
|
import { TopHeader } from './components/common/TopHeader';
|
||||||
|
import { ToastContainer } from './components/common/ToastContainer';
|
||||||
|
import { DashboardView } from './components/dashboard/DashboardView';
|
||||||
|
import { PurchasesView } from './components/inventory/PurchasesView';
|
||||||
|
import { ProjectsView } from './components/projects/ProjectsView';
|
||||||
|
import { TimeTrackerView } from './components/timetracker/TimeTrackerView';
|
||||||
|
import { AnalyticsView } from './components/analytics/AnalyticsView';
|
||||||
|
import { ModsLibraryView } from './components/mods/ModsLibraryView';
|
||||||
|
import { SettingsView } from './components/settings/SettingsView';
|
||||||
|
import { ProjectModal } from './components/projects/ProjectModal';
|
||||||
|
import { InventoryModal } from './components/inventory/InventoryModal';
|
||||||
|
import { AuthModal } from './components/auth/AuthModal';
|
||||||
|
import { LoginGate } from './components/auth/LoginGate';
|
||||||
|
|
||||||
|
const MainContent: React.FC = () => {
|
||||||
|
const {
|
||||||
|
currentTab,
|
||||||
|
showNewProjectModal,
|
||||||
|
setShowNewProjectModal,
|
||||||
|
showNewInventoryModal,
|
||||||
|
setShowNewInventoryModal,
|
||||||
|
showAuthModal,
|
||||||
|
setShowAuthModal,
|
||||||
|
} = useApp();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="p-4 sm:p-6 lg:p-8 max-w-[1600px] mx-auto w-full">
|
||||||
|
{currentTab === 'dashboard' && <DashboardView />}
|
||||||
|
{currentTab === 'purchases' && <PurchasesView />}
|
||||||
|
{currentTab === 'projects' && <ProjectsView />}
|
||||||
|
{currentTab === 'mods' && <ModsLibraryView />}
|
||||||
|
{currentTab === 'timetracker' && <TimeTrackerView />}
|
||||||
|
{currentTab === 'analytics' && <AnalyticsView />}
|
||||||
|
{currentTab === 'settings' && <SettingsView />}
|
||||||
|
|
||||||
|
{/* Global Modals: Accessible from Sidebar, TopHeader, Dashboard & all tabs */}
|
||||||
|
<ProjectModal
|
||||||
|
isOpen={showNewProjectModal}
|
||||||
|
onClose={() => setShowNewProjectModal(false)}
|
||||||
|
/>
|
||||||
|
<InventoryModal
|
||||||
|
isOpen={showNewInventoryModal}
|
||||||
|
onClose={() => setShowNewInventoryModal(false)}
|
||||||
|
/>
|
||||||
|
<AuthModal
|
||||||
|
isOpen={showAuthModal}
|
||||||
|
onClose={() => setShowAuthModal(false)}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const AppLayout: React.FC = () => {
|
||||||
|
const { settings, authSession } = useApp();
|
||||||
|
const activeTheme = settings?.theme || 'uwu-pink';
|
||||||
|
|
||||||
|
// Full site lock: If user is not logged in, display the LoginGate screen
|
||||||
|
if (!authSession?.isAuthenticated) {
|
||||||
|
return (
|
||||||
|
<div className={`min-h-screen theme-root-container text-slate-100 theme-${activeTheme}`} data-theme={activeTheme}>
|
||||||
|
<LoginGate />
|
||||||
|
<ToastContainer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`min-h-screen theme-root-container text-slate-100 flex flex-col lg:flex-row theme-${activeTheme}`} data-theme={activeTheme}>
|
||||||
|
{/* Left Sidebar Navigation */}
|
||||||
|
<Sidebar />
|
||||||
|
|
||||||
|
{/* Main Content Area (Offset on desktop for fixed left sidebar) */}
|
||||||
|
<div className="flex-1 lg:pl-72 flex flex-col min-w-0 transition-all duration-300">
|
||||||
|
<TopHeader />
|
||||||
|
<div className="flex-1">
|
||||||
|
<MainContent />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Discreet Footer */}
|
||||||
|
<footer className="border-t border-slate-800/80 py-5 mt-12 text-center text-xs text-slate-500 no-print px-4">
|
||||||
|
<div className="max-w-7xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<span className="font-bold text-slate-400">uwuFinances</span> • Gestionnaire Financier & Atelier Console Modding
|
||||||
|
</div>
|
||||||
|
<div className="font-mono text-[11px] text-slate-600">
|
||||||
|
Données sécurisées • {settings.businessName || 'Atelier Console Modding'} • v1.2 Pro
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ToastContainer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
return (
|
||||||
|
<AppProvider>
|
||||||
|
<AppLayout />
|
||||||
|
</AppProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
|
|
||||||
|
|
||||||
BIN
src/assets/hero.png
Normal file
BIN
src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
1
src/assets/react.svg
Normal file
1
src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
1
src/assets/vite.svg
Normal file
1
src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
574
src/components/analytics/AnalyticsView.tsx
Normal file
574
src/components/analytics/AnalyticsView.tsx
Normal file
@@ -0,0 +1,574 @@
|
|||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
TrendingUp,
|
||||||
|
Calculator,
|
||||||
|
Clock,
|
||||||
|
Gamepad2,
|
||||||
|
Sliders,
|
||||||
|
BarChart2,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
ResponsiveContainer,
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
Bar,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
Tooltip,
|
||||||
|
ComposedChart,
|
||||||
|
ReferenceLine,
|
||||||
|
} from 'recharts';
|
||||||
|
import { useApp } from '../../context/AppContext';
|
||||||
|
|
||||||
|
import {
|
||||||
|
calculateProjectFinancials,
|
||||||
|
formatCurrency,
|
||||||
|
formatMinutes,
|
||||||
|
getTaskTypeLabel,
|
||||||
|
} from '../../utils/calculations';
|
||||||
|
import type { TaskType } from '../../types';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const AnalyticsView: React.FC = () => {
|
||||||
|
const { projects, settings } = useApp();
|
||||||
|
|
||||||
|
// 1. Simulator Interactive State
|
||||||
|
const [simBaseConsoleCost, setSimBaseConsoleCost] = useState<number>(35);
|
||||||
|
const [simScreenCost, setSimScreenCost] = useState<number>(45);
|
||||||
|
const [simShellCost, setSimShellCost] = useState<number>(16);
|
||||||
|
const [simModsCost, setSimModsCost] = useState<number>(14); // USB-C, battery, etc.
|
||||||
|
const [simConsumablesCost, setSimConsumablesCost] = useState<number>(5);
|
||||||
|
const [simLaborHours, setSimLaborHours] = useState<number>(2.5);
|
||||||
|
const [simHourlyRate, setSimHourlyRate] = useState<number>(settings.defaultHourlyRate || 25);
|
||||||
|
const [simPlatformFeePercent, setSimPlatformFeePercent] = useState<number>(settings.defaultPlatformFeePercent || 0);
|
||||||
|
const [simSellingPrice, setSimSellingPrice] = useState<number>(185);
|
||||||
|
|
||||||
|
// Simulator Calculations
|
||||||
|
const simTotalHardwareCost = simBaseConsoleCost + simScreenCost + simShellCost + simModsCost + simConsumablesCost;
|
||||||
|
const simTotalLaborCost = simLaborHours * simHourlyRate;
|
||||||
|
const simPlatformFee = (simSellingPrice * simPlatformFeePercent) / 100;
|
||||||
|
const simTotalCOGS = simTotalHardwareCost + simTotalLaborCost + simPlatformFee;
|
||||||
|
const simGrossProfit = simSellingPrice - simTotalHardwareCost;
|
||||||
|
const simNetProfit = simSellingPrice - simTotalCOGS;
|
||||||
|
const simMarginPercent = simSellingPrice > 0 ? (simNetProfit / simSellingPrice) * 100 : 0;
|
||||||
|
const simEffectiveHourlyRate = simLaborHours > 0 ? (simSellingPrice - simTotalHardwareCost - simPlatformFee) / simLaborHours : 0;
|
||||||
|
|
||||||
|
// 2. Model Performance Statistics
|
||||||
|
const modelStats = useMemo(() => {
|
||||||
|
const stats: Record<string, {
|
||||||
|
totalBuilds: number;
|
||||||
|
soldBuilds: number;
|
||||||
|
totalRevenue: number;
|
||||||
|
totalHardwareCost: number;
|
||||||
|
totalLaborMinutes: number;
|
||||||
|
totalNetProfit: number;
|
||||||
|
}> = {};
|
||||||
|
|
||||||
|
projects.forEach((p) => {
|
||||||
|
if (!stats[p.consoleModel]) {
|
||||||
|
stats[p.consoleModel] = {
|
||||||
|
totalBuilds: 0,
|
||||||
|
soldBuilds: 0,
|
||||||
|
totalRevenue: 0,
|
||||||
|
totalHardwareCost: 0,
|
||||||
|
totalLaborMinutes: 0,
|
||||||
|
totalNetProfit: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const fin = calculateProjectFinancials(p);
|
||||||
|
stats[p.consoleModel].totalBuilds += 1;
|
||||||
|
stats[p.consoleModel].totalHardwareCost += fin.hardwareDirectCost;
|
||||||
|
stats[p.consoleModel].totalLaborMinutes += fin.laborMinutes;
|
||||||
|
|
||||||
|
if (p.status === 'sold') {
|
||||||
|
stats[p.consoleModel].soldBuilds += 1;
|
||||||
|
stats[p.consoleModel].totalRevenue += fin.revenue;
|
||||||
|
stats[p.consoleModel].totalNetProfit += fin.netProfit;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.entries(stats).map(([model, data]) => ({
|
||||||
|
model,
|
||||||
|
...data,
|
||||||
|
avgLaborHours: data.totalBuilds > 0 ? (data.totalLaborMinutes / data.totalBuilds / 60) : 0,
|
||||||
|
marginPercent: data.totalRevenue > 0 ? (data.totalNetProfit / data.totalRevenue) * 100 : 0,
|
||||||
|
})).sort((a, b) => b.totalNetProfit - a.totalNetProfit);
|
||||||
|
}, [projects]);
|
||||||
|
|
||||||
|
// 3. Task Time Distribution
|
||||||
|
const taskDistribution = useMemo(() => {
|
||||||
|
const map: Record<string, number> = {};
|
||||||
|
projects.forEach((p) => {
|
||||||
|
p.timeSessions.forEach((s) => {
|
||||||
|
map[s.taskType] = (map[s.taskType] || 0) + s.durationMinutes;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const totalMinutes = Object.values(map).reduce((sum, m) => sum + m, 0);
|
||||||
|
|
||||||
|
return Object.entries(map).map(([taskType, minutes]) => ({
|
||||||
|
taskType: taskType as TaskType,
|
||||||
|
minutes,
|
||||||
|
percentage: totalMinutes > 0 ? (minutes / totalMinutes) * 100 : 0,
|
||||||
|
})).sort((a, b) => b.minutes - a.minutes);
|
||||||
|
}, [projects]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8 pb-12 animate-fadeIn">
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<div>
|
||||||
|
<div className="inline-flex items-center space-x-2 px-3 py-1 rounded-full text-xs font-semibold bg-emerald-500/10 text-emerald-400 border border-emerald-500/30 mb-2">
|
||||||
|
<TrendingUp className="w-3.5 h-3.5" />
|
||||||
|
<span>Analyses Financières & Rentabilité</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl sm:text-3xl font-black text-white font-display">
|
||||||
|
Statistiques de Marge & Simulateur de Prix
|
||||||
|
</h2>
|
||||||
|
<p className="text-slate-400 text-xs sm:text-sm">
|
||||||
|
Analysez la rentabilité par modèle de console et simulez vos prix de vente avant de lancer un build.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Interactive Price & Margin Simulator */}
|
||||||
|
<div className="glass-panel p-6 sm:p-8 rounded-3xl border border-emerald-500/30 space-y-6">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-emerald-500/10 border border-emerald-500/30 flex items-center justify-center text-emerald-400">
|
||||||
|
<Calculator className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold text-white">
|
||||||
|
Simulateur de Prix de Vente & Marge Pro
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
Ajustez les composants et le temps estimé pour trouver votre prix de vente optimal
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sliders & Inputs Grid */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 pt-2">
|
||||||
|
|
||||||
|
{/* Col 1: Hardware Costs Inputs */}
|
||||||
|
<div className="space-y-4 p-4 rounded-2xl bg-slate-950/80 border border-slate-800">
|
||||||
|
<h4 className="text-xs font-bold uppercase tracking-wider text-cyan-400 flex items-center space-x-1.5">
|
||||||
|
<Gamepad2 className="w-4 h-4" />
|
||||||
|
<span>Coûts Pièces Estimés</span>
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<div className="space-y-3 text-xs">
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between text-slate-300 mb-1">
|
||||||
|
<span>Console Donneuse :</span>
|
||||||
|
<span className="font-mono font-bold text-white">{simBaseConsoleCost} €</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="120"
|
||||||
|
step="5"
|
||||||
|
value={simBaseConsoleCost}
|
||||||
|
onChange={(e) => setSimBaseConsoleCost(Number(e.target.value))}
|
||||||
|
className="w-full accent-cyan-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between text-slate-300 mb-1">
|
||||||
|
<span>Écran IPS / OLED :</span>
|
||||||
|
<span className="font-mono font-bold text-white">{simScreenCost} €</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
step="2"
|
||||||
|
value={simScreenCost}
|
||||||
|
onChange={(e) => setSimScreenCost(Number(e.target.value))}
|
||||||
|
className="w-full accent-cyan-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between text-slate-300 mb-1">
|
||||||
|
<span>Coque & Boutons :</span>
|
||||||
|
<span className="font-mono font-bold text-white">{simShellCost} €</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="60"
|
||||||
|
step="2"
|
||||||
|
value={simShellCost}
|
||||||
|
onChange={(e) => setSimShellCost(Number(e.target.value))}
|
||||||
|
className="w-full accent-cyan-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between text-slate-300 mb-1">
|
||||||
|
<span>Mods USB-C / LiPo / Audio :</span>
|
||||||
|
<span className="font-mono font-bold text-white">{simModsCost} €</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="60"
|
||||||
|
step="2"
|
||||||
|
value={simModsCost}
|
||||||
|
onChange={(e) => setSimModsCost(Number(e.target.value))}
|
||||||
|
className="w-full accent-cyan-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between text-slate-300 mb-1">
|
||||||
|
<span>Consommables & Packaging :</span>
|
||||||
|
<span className="font-mono font-bold text-white">{simConsumablesCost} €</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="30"
|
||||||
|
step="1"
|
||||||
|
value={simConsumablesCost}
|
||||||
|
onChange={(e) => setSimConsumablesCost(Number(e.target.value))}
|
||||||
|
className="w-full accent-cyan-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-2 border-t border-slate-800 flex justify-between font-bold text-slate-200">
|
||||||
|
<span>Total Matériel :</span>
|
||||||
|
<span className="text-cyan-300 font-mono">{formatCurrency(simTotalHardwareCost, settings.currency)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Col 2: Labor & Platform Settings */}
|
||||||
|
<div className="space-y-4 p-4 rounded-2xl bg-slate-950/80 border border-slate-800">
|
||||||
|
<h4 className="text-xs font-bold uppercase tracking-wider text-amber-400 flex items-center space-x-1.5">
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
<span>Temps & Main d'Œuvre</span>
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<div className="space-y-3 text-xs">
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between text-slate-300 mb-1">
|
||||||
|
<span>Temps estimé sur établi :</span>
|
||||||
|
<span className="font-mono font-bold text-amber-300">{simLaborHours} heures ({Math.round(simLaborHours * 60)} min)</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0.5"
|
||||||
|
max="8"
|
||||||
|
step="0.25"
|
||||||
|
value={simLaborHours}
|
||||||
|
onChange={(e) => setSimLaborHours(Number(e.target.value))}
|
||||||
|
className="w-full accent-amber-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between text-slate-300 mb-1">
|
||||||
|
<span>Taux horaire souhaité :</span>
|
||||||
|
<span className="font-mono font-bold text-white">{simHourlyRate} €/h</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="10"
|
||||||
|
max="60"
|
||||||
|
step="5"
|
||||||
|
value={simHourlyRate}
|
||||||
|
onChange={(e) => setSimHourlyRate(Number(e.target.value))}
|
||||||
|
className="w-full accent-amber-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between text-slate-300 mb-1">
|
||||||
|
<span>Commission Plateforme (% Etsy/Vinted) :</span>
|
||||||
|
<span className="font-mono font-bold text-white">{simPlatformFeePercent}%</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="15"
|
||||||
|
step="1"
|
||||||
|
value={simPlatformFeePercent}
|
||||||
|
onChange={(e) => setSimPlatformFeePercent(Number(e.target.value))}
|
||||||
|
className="w-full accent-purple-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-2 border-t border-slate-800 flex justify-between font-bold text-slate-200">
|
||||||
|
<span>Coût Total de Revient (COGS) :</span>
|
||||||
|
<span className="text-amber-300 font-mono">{formatCurrency(simTotalCOGS, settings.currency)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Col 3: Selling Price Slider & Instant Profit Verdict */}
|
||||||
|
<div className="space-y-4 p-5 rounded-2xl bg-gradient-to-b from-slate-950 to-slate-900 border border-emerald-500/40 flex flex-col justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-bold uppercase tracking-wider text-emerald-400 flex items-center space-x-1.5">
|
||||||
|
<Sliders className="w-4 h-4" />
|
||||||
|
<span>Prix de Vente Simulé</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="flex justify-between items-baseline mb-2">
|
||||||
|
<span className="text-xs text-slate-400">Prix public :</span>
|
||||||
|
<span className="font-mono font-black text-3xl text-white">
|
||||||
|
{formatCurrency(simSellingPrice, settings.currency)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="60"
|
||||||
|
max="350"
|
||||||
|
step="5"
|
||||||
|
value={simSellingPrice}
|
||||||
|
onChange={(e) => setSimSellingPrice(Number(e.target.value))}
|
||||||
|
className="w-full accent-emerald-400 h-2 bg-slate-800 rounded-lg cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Verdict Box */}
|
||||||
|
<div className="p-4 rounded-xl bg-slate-950/90 border border-emerald-500/30 space-y-2.5 text-xs">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-slate-400">Marge Brute (hors temps) :</span>
|
||||||
|
<span className="font-mono font-bold text-white">
|
||||||
|
{formatCurrency(simGrossProfit, settings.currency)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-slate-400">Bénéfice Net Réel :</span>
|
||||||
|
<span className={`font-mono font-bold text-base ${simNetProfit >= 0 ? 'text-emerald-400' : 'text-rose-400'}`}>
|
||||||
|
{formatCurrency(simNetProfit, settings.currency)} ({simMarginPercent.toFixed(1)}%)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Real-time Dynamic Break-Even & Profit Simulation Curve Chart */}
|
||||||
|
<div className="pt-2">
|
||||||
|
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-1 flex items-center justify-between">
|
||||||
|
<span>Courbe de Rentabilité Simulée</span>
|
||||||
|
<span className="text-emerald-400">Point vert : {simSellingPrice}€</span>
|
||||||
|
</div>
|
||||||
|
{(() => {
|
||||||
|
const curveData = [];
|
||||||
|
for (let p = 70; p <= 290; p += 20) {
|
||||||
|
const fee = (p * simPlatformFeePercent) / 100;
|
||||||
|
const cogs = simTotalHardwareCost + simTotalLaborCost + fee;
|
||||||
|
const net = p - cogs;
|
||||||
|
curveData.push({
|
||||||
|
price: `${p}€`,
|
||||||
|
rawPrice: p,
|
||||||
|
'Bénéfice Net': Math.round(net),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="h-28 w-full">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart data={curveData} margin={{ top: 5, right: 5, left: -25, bottom: 0 }}>
|
||||||
|
<XAxis dataKey="price" stroke="#64748b" fontSize={9} tickLine={false} axisLine={{ stroke: '#334155' }} />
|
||||||
|
<YAxis stroke="#64748b" fontSize={9} tickLine={false} axisLine={{ stroke: '#334155' }} />
|
||||||
|
<Tooltip
|
||||||
|
content={({ active, payload }) => {
|
||||||
|
if (active && payload && payload.length) {
|
||||||
|
const d = payload[0].payload;
|
||||||
|
return (
|
||||||
|
<div className="bg-slate-900 border border-slate-700 p-2 rounded-xl text-[10px] font-mono shadow-xl">
|
||||||
|
<div className="text-slate-300">Prix : {d.price}</div>
|
||||||
|
<div className={d['Bénéfice Net'] >= 0 ? 'text-emerald-400 font-bold' : 'text-rose-400 font-bold'}>
|
||||||
|
Gain net : {d['Bénéfice Net']} €
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ReferenceLine y={0} stroke="#f43f5e" strokeDasharray="3 3" />
|
||||||
|
<Line type="monotone" dataKey="Bénéfice Net" stroke="#10b981" strokeWidth={2.5} dot={false} />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-2 border-t border-slate-800 flex justify-between font-bold">
|
||||||
|
<span className="text-slate-300">Rémunération horaire effective :</span>
|
||||||
|
<span className="text-amber-300 font-mono text-sm">
|
||||||
|
{formatCurrency(simEffectiveHourlyRate, settings.currency)}/h
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Model Performance Comparison Visual Chart & Table */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Gamepad2 className="w-5 h-5 text-pink-400" />
|
||||||
|
<h3 className="text-lg font-bold text-white">
|
||||||
|
Performance & Rentabilité par Modèle de Console
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Visual Composed Chart: Revenue, Hardware Costs & Net Profit by Model */}
|
||||||
|
<div className="glass-panel p-5 rounded-3xl border border-slate-800 space-y-3">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-400 flex items-center space-x-1.5">
|
||||||
|
<BarChart2 className="w-4 h-4 text-emerald-400" />
|
||||||
|
<span>Comparatif Chiffre d'Affaires & Bénéfices par Modèle</span>
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-3 text-xs font-mono">
|
||||||
|
<span className="flex items-center text-pink-400">
|
||||||
|
<span className="w-2.5 h-2.5 rounded-full bg-pink-500 mr-1.5" /> CA Total
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center text-cyan-400">
|
||||||
|
<span className="w-2.5 h-2.5 rounded-full bg-cyan-500 mr-1.5" /> Coût Matériel
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center text-emerald-400">
|
||||||
|
<span className="w-2.5 h-2.5 rounded-full bg-emerald-500 mr-1.5" /> Bénéfice Net
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-56 w-full pt-1">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<ComposedChart
|
||||||
|
data={modelStats.map((s) => ({
|
||||||
|
model: s.model,
|
||||||
|
'Chiffre d\'Affaires': s.totalRevenue,
|
||||||
|
'Coût Pièces': s.totalHardwareCost,
|
||||||
|
'Bénéfice Net': s.totalNetProfit,
|
||||||
|
marge: s.marginPercent,
|
||||||
|
}))}
|
||||||
|
margin={{ top: 10, right: 10, left: -20, bottom: 0 }}
|
||||||
|
>
|
||||||
|
<XAxis dataKey="model" stroke="#64748b" fontSize={11} tickLine={false} axisLine={{ stroke: '#334155' }} />
|
||||||
|
<YAxis stroke="#64748b" fontSize={11} tickLine={false} axisLine={{ stroke: '#334155' }} tickFormatter={(v) => `${v}€`} />
|
||||||
|
<Tooltip
|
||||||
|
content={({ active, payload }) => {
|
||||||
|
if (active && payload && payload.length) {
|
||||||
|
const d = payload[0].payload;
|
||||||
|
return (
|
||||||
|
<div className="bg-slate-900 border border-slate-700 p-3 rounded-2xl shadow-xl text-xs space-y-1 font-mono">
|
||||||
|
<div className="font-bold text-white font-sans text-xs pb-1 border-b border-slate-800">{d.model}</div>
|
||||||
|
<div className="text-pink-400">💰 CA : {formatCurrency(d['Chiffre d\'Affaires'], settings.currency)}</div>
|
||||||
|
<div className="text-cyan-300">📦 Coût Pièces : {formatCurrency(d['Coût Pièces'], settings.currency)}</div>
|
||||||
|
<div className="text-emerald-400 font-bold pt-1 border-t border-slate-800">
|
||||||
|
✨ Bénéfice Net : {formatCurrency(d['Bénéfice Net'], settings.currency)} ({d.marge.toFixed(1)}%)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="Chiffre d'Affaires" fill="#ec4899" radius={[4, 4, 0, 0]} />
|
||||||
|
<Bar dataKey="Coût Pièces" fill="#06b6d4" radius={[4, 4, 0, 0]} />
|
||||||
|
<Line type="monotone" dataKey="Bénéfice Net" stroke="#10b981" strokeWidth={3} dot={{ r: 4, fill: '#10b981' }} />
|
||||||
|
</ComposedChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="glass-panel rounded-3xl border border-slate-800 overflow-hidden">
|
||||||
|
<table className="w-full text-left border-collapse text-xs sm:text-sm">
|
||||||
|
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-800 bg-slate-950/60 text-slate-400 font-semibold uppercase text-[11px] tracking-wider">
|
||||||
|
<th className="py-3.5 px-4">Modèle Console</th>
|
||||||
|
<th className="py-3.5 px-3 text-center">Projets Totaux</th>
|
||||||
|
<th className="py-3.5 px-3 text-center">Consoles Vendues</th>
|
||||||
|
<th className="py-3.5 px-3 text-right">CA Réalisé</th>
|
||||||
|
<th className="py-3.5 px-3 text-center">Temps Moyen / Build</th>
|
||||||
|
<th className="py-3.5 px-3 text-right">Bénéfice Net</th>
|
||||||
|
<th className="py-3.5 px-4 text-right">Marge Nette %</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-800/60">
|
||||||
|
{modelStats.map((stat) => (
|
||||||
|
<tr key={stat.model} className="hover:bg-slate-900/40 transition-colors">
|
||||||
|
<td className="py-3 px-4 font-bold text-white">
|
||||||
|
{stat.model}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-3 text-center font-mono text-slate-300">
|
||||||
|
{stat.totalBuilds}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-3 text-center font-mono text-emerald-400 font-bold">
|
||||||
|
{stat.soldBuilds}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-3 text-right font-mono text-slate-200">
|
||||||
|
{formatCurrency(stat.totalRevenue, settings.currency)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-3 text-center font-mono text-amber-300">
|
||||||
|
{stat.avgLaborHours.toFixed(1)}h ({Math.round(stat.avgLaborHours * 60)}m)
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-3 text-right font-mono font-bold text-emerald-400">
|
||||||
|
{formatCurrency(stat.totalNetProfit, settings.currency)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-right font-mono font-bold text-slate-200">
|
||||||
|
{stat.marginPercent.toFixed(1)}%
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Task Time Distribution Breakdown */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Clock className="w-5 h-5 text-amber-400" />
|
||||||
|
<h3 className="text-lg font-bold text-white">
|
||||||
|
Répartition du Temps de Travail par Type d'Opération
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{taskDistribution.map((item) => (
|
||||||
|
<div key={item.taskType} className="glass-panel p-4 rounded-2xl border border-slate-800 space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-bold text-slate-300 truncate">
|
||||||
|
{getTaskTypeLabel(item.taskType)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-mono font-bold text-amber-300">
|
||||||
|
{item.percentage.toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-2 bg-slate-900 rounded-full overflow-hidden border border-slate-800">
|
||||||
|
<div
|
||||||
|
style={{ width: `${item.percentage}%` }}
|
||||||
|
className="bg-amber-400 h-full rounded-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] font-mono text-slate-400">
|
||||||
|
{formatMinutes(item.minutes)} cumulées
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
543
src/components/auth/AuthModal.tsx
Normal file
543
src/components/auth/AuthModal.tsx
Normal file
@@ -0,0 +1,543 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
X,
|
||||||
|
User,
|
||||||
|
KeyRound,
|
||||||
|
Database,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
RefreshCw,
|
||||||
|
LogOut,
|
||||||
|
Sparkles,
|
||||||
|
Wifi,
|
||||||
|
ShieldAlert,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
Lock,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useApp } from '../../context/AppContext';
|
||||||
|
import {
|
||||||
|
decodeJwtToken,
|
||||||
|
testNcbConnection,
|
||||||
|
authenticateWithDatabase,
|
||||||
|
changeUserPassword,
|
||||||
|
} from '../../services/authService';
|
||||||
|
|
||||||
|
interface AuthModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AuthModal: React.FC<AuthModalProps> = ({ isOpen, onClose }) => {
|
||||||
|
const {
|
||||||
|
authSession,
|
||||||
|
loginUser,
|
||||||
|
logoutUser,
|
||||||
|
addToast,
|
||||||
|
} = useApp();
|
||||||
|
|
||||||
|
// Login form state
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [loginError, setLoginError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Password change state
|
||||||
|
const [showChangePwd, setShowChangePwd] = useState(false);
|
||||||
|
const [oldPwd, setOldPwd] = useState('');
|
||||||
|
const [newPwd, setNewPwd] = useState('');
|
||||||
|
const [confirmPwd, setConfirmPwd] = useState('');
|
||||||
|
const [isChangingPwd, setIsChangingPwd] = useState(false);
|
||||||
|
const [pwdMsg, setPwdMsg] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
|
||||||
|
|
||||||
|
// Active view inside modal
|
||||||
|
const [activeTab, setActiveTab] = useState<'login' | 'jwt' | 'database'>(
|
||||||
|
authSession.isAuthenticated ? 'login' : 'login'
|
||||||
|
);
|
||||||
|
|
||||||
|
// JWT & NCB state
|
||||||
|
const [copiedToken, setCopiedToken] = useState(false);
|
||||||
|
const [isTestingNcb, setIsTestingNcb] = useState(false);
|
||||||
|
const [ncbTestResult, setNcbTestResult] = useState<{ success: boolean; latencyMs: number; message: string } | null>(null);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const currentDecoded = decodeJwtToken(authSession.token);
|
||||||
|
|
||||||
|
const handleLoginSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoginError(null);
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await authenticateWithDatabase(username, password);
|
||||||
|
if (result.success && result.user) {
|
||||||
|
loginUser(result.user, 720); // 30 days token
|
||||||
|
addToast({
|
||||||
|
type: 'success',
|
||||||
|
title: 'Connexion réussie',
|
||||||
|
message: `Bienvenue ${result.user.name} ! Jeton JWT émis et base NCB connectée.`,
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
setLoginError(result.error || 'Identifiants incorrects.');
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setLoginError(err.message || 'Erreur lors de la tentative de connexion.');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePasswordChange = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setPwdMsg(null);
|
||||||
|
|
||||||
|
if (!oldPwd.trim() || !newPwd.trim() || !confirmPwd.trim()) {
|
||||||
|
setPwdMsg({ type: 'error', message: 'Veuillez remplir tous les champs.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPwd !== confirmPwd) {
|
||||||
|
setPwdMsg({ type: 'error', message: 'Les nouveaux mots de passe ne correspondent pas.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPwd.length < 4) {
|
||||||
|
setPwdMsg({ type: 'error', message: 'Le mot de passe doit comporter au moins 4 caractères.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsChangingPwd(true);
|
||||||
|
try {
|
||||||
|
const usernameToUpdate = authSession.user.name || 'streaper2';
|
||||||
|
const result = await changeUserPassword(usernameToUpdate, oldPwd, newPwd);
|
||||||
|
if (result.success) {
|
||||||
|
setPwdMsg({ type: 'success', message: result.message || 'Mot de passe mis à jour !' });
|
||||||
|
setOldPwd('');
|
||||||
|
setNewPwd('');
|
||||||
|
setConfirmPwd('');
|
||||||
|
addToast({
|
||||||
|
type: 'success',
|
||||||
|
title: 'Mot de passe mis à jour',
|
||||||
|
message: 'Synchronisé avec la table users de NoCodeBackend.',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setPwdMsg({ type: 'error', message: result.error || 'Échec de modification.' });
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setPwdMsg({ type: 'error', message: err.message || 'Erreur réseau.' });
|
||||||
|
} finally {
|
||||||
|
setIsChangingPwd(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyToken = () => {
|
||||||
|
if (!authSession.token) return;
|
||||||
|
navigator.clipboard.writeText(authSession.token);
|
||||||
|
setCopiedToken(true);
|
||||||
|
setTimeout(() => setCopiedToken(false), 2000);
|
||||||
|
addToast({
|
||||||
|
type: 'info',
|
||||||
|
title: 'Jeton JWT Copié',
|
||||||
|
message: 'Token copié dans le presse-papier.',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTestNcb = async () => {
|
||||||
|
setIsTestingNcb(true);
|
||||||
|
setNcbTestResult(null);
|
||||||
|
try {
|
||||||
|
const result = await testNcbConnection('54770_uwu_finances');
|
||||||
|
setNcbTestResult(result);
|
||||||
|
if (result.success) {
|
||||||
|
addToast({
|
||||||
|
type: 'success',
|
||||||
|
title: 'Base Cloud NoCodeBackend OK',
|
||||||
|
message: result.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsTestingNcb(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-md animate-fadeIn">
|
||||||
|
<div
|
||||||
|
className="glass-panel w-full max-w-lg rounded-3xl border border-slate-700 bg-slate-950/95 shadow-2xl flex flex-col max-h-[90vh] overflow-hidden"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Modal Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-800">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="p-2.5 rounded-2xl bg-gradient-to-tr from-pink-500/20 to-purple-500/20 border border-pink-500/30 text-pink-400">
|
||||||
|
<Lock className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-black text-white font-display flex items-center space-x-2">
|
||||||
|
<span>Accès Atelier & Authentification</span>
|
||||||
|
{authSession.isAuthenticated && (
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold bg-emerald-500/15 text-emerald-400 border border-emerald-500/30">
|
||||||
|
Connecté ({authSession.user.name})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
Compte unique atelier & vérification en base NoCodeBackend
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 rounded-xl text-slate-400 hover:text-white hover:bg-slate-800 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sub Navigation */}
|
||||||
|
<div className="flex border-b border-slate-800 bg-slate-900/50 p-1.5 gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab('login')}
|
||||||
|
className={`flex-1 py-2 px-3 rounded-xl text-xs font-bold flex items-center justify-center space-x-2 transition-all ${
|
||||||
|
activeTab === 'login'
|
||||||
|
? 'bg-gradient-to-r from-pink-500 to-purple-600 text-white shadow-md shadow-pink-500/20'
|
||||||
|
: 'text-slate-400 hover:text-white hover:bg-slate-800/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<User className="w-3.5 h-3.5" />
|
||||||
|
<span>{authSession.isAuthenticated ? 'Mon Compte' : 'Connexion'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab('jwt')}
|
||||||
|
className={`flex-1 py-2 px-3 rounded-xl text-xs font-bold flex items-center justify-center space-x-2 transition-all ${
|
||||||
|
activeTab === 'jwt'
|
||||||
|
? 'bg-gradient-to-r from-cyan-500 to-blue-600 text-white shadow-md shadow-cyan-500/20'
|
||||||
|
: 'text-slate-400 hover:text-white hover:bg-slate-800/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<KeyRound className="w-3.5 h-3.5" />
|
||||||
|
<span>Jeton JWT</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab('database')}
|
||||||
|
className={`flex-1 py-2 px-3 rounded-xl text-xs font-bold flex items-center justify-center space-x-2 transition-all ${
|
||||||
|
activeTab === 'database'
|
||||||
|
? 'bg-gradient-to-r from-purple-500 to-pink-600 text-white shadow-md shadow-purple-500/20'
|
||||||
|
: 'text-slate-400 hover:text-white hover:bg-slate-800/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Database className="w-3.5 h-3.5" />
|
||||||
|
<span>Base NCB</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Body */}
|
||||||
|
<div className="p-6 overflow-y-auto space-y-5">
|
||||||
|
|
||||||
|
{/* TAB 1: LOGIN / ACCOUNT VIEW */}
|
||||||
|
{activeTab === 'login' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{authSession.isAuthenticated ? (
|
||||||
|
/* Authenticated User Card */
|
||||||
|
<div className="p-5 rounded-2xl bg-gradient-to-br from-slate-900 to-slate-950 border border-pink-500/30 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-3.5">
|
||||||
|
<div className="relative">
|
||||||
|
<img
|
||||||
|
src={authSession.user.avatarUrl || 'https://api.dicebear.com/7.x/bottts/svg?seed=streaper2'}
|
||||||
|
alt={authSession.user.name}
|
||||||
|
className="w-12 h-12 rounded-2xl border border-pink-500/40 bg-slate-800 object-cover"
|
||||||
|
/>
|
||||||
|
<span className="absolute -bottom-1 -right-1 w-3.5 h-3.5 rounded-full bg-emerald-500 border-2 border-slate-950" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<h3 className="font-bold text-base text-white">{authSession.user.name}</h3>
|
||||||
|
<span className="px-2 py-0.5 rounded-full text-[10px] font-extrabold uppercase bg-pink-500/20 text-pink-300 border border-pink-500/30">
|
||||||
|
{authSession.user.role}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400 font-mono mt-0.5">{authSession.user.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={logoutUser}
|
||||||
|
className="px-3 py-1.5 rounded-xl bg-slate-900 border border-rose-500/30 hover:bg-rose-500/10 text-rose-400 text-xs font-semibold flex items-center space-x-1.5 transition-colors"
|
||||||
|
>
|
||||||
|
<LogOut className="w-3.5 h-3.5" />
|
||||||
|
<span>Se Déconnecter</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2.5 pt-2 text-xs">
|
||||||
|
<div className="p-3 rounded-xl bg-slate-950/80 border border-slate-800">
|
||||||
|
<div className="text-[10px] uppercase font-bold text-slate-500">Instance Base</div>
|
||||||
|
<div className="font-mono text-cyan-300 font-bold mt-0.5">54770_uwu_finances</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 rounded-xl bg-slate-950/80 border border-slate-800">
|
||||||
|
<div className="text-[10px] uppercase font-bold text-slate-500">Statut JWT</div>
|
||||||
|
<div className="font-mono text-emerald-400 font-bold mt-0.5">{currentDecoded.timeLeftFormatted}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Password change toggle */}
|
||||||
|
<div className="pt-2 border-t border-slate-800">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowChangePwd(!showChangePwd)}
|
||||||
|
className="text-xs font-semibold text-pink-400 hover:text-pink-300 flex items-center space-x-1.5 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<KeyRound className="w-3.5 h-3.5" />
|
||||||
|
<span>{showChangePwd ? 'Masquer le changement de mot de passe' : 'Modifier le mot de passe'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showChangePwd && (
|
||||||
|
<form onSubmit={handlePasswordChange} className="mt-3 p-3.5 rounded-xl bg-slate-950 border border-slate-800 space-y-3">
|
||||||
|
{pwdMsg && (
|
||||||
|
<div className={`p-2.5 rounded-lg text-xs flex items-center space-x-2 ${
|
||||||
|
pwdMsg.type === 'success'
|
||||||
|
? 'bg-emerald-500/10 text-emerald-300 border border-emerald-500/30'
|
||||||
|
: 'bg-rose-500/10 text-rose-300 border border-rose-500/30'
|
||||||
|
}`}>
|
||||||
|
<span>{pwdMsg.message}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-[11px] font-semibold text-slate-400">Ancien Mot de Passe</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
value={oldPwd}
|
||||||
|
onChange={(e) => setOldPwd(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
className="w-full px-3 py-1.5 rounded-lg bg-slate-900 border border-slate-700 text-white text-xs font-mono focus:outline-none focus:border-pink-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-[11px] font-semibold text-slate-400">Nouveau</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
value={newPwd}
|
||||||
|
onChange={(e) => setNewPwd(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
className="w-full px-3 py-1.5 rounded-lg bg-slate-900 border border-slate-700 text-white text-xs font-mono focus:outline-none focus:border-pink-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-[11px] font-semibold text-slate-400">Confirmation</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
value={confirmPwd}
|
||||||
|
onChange={(e) => setConfirmPwd(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
className="w-full px-3 py-1.5 rounded-lg bg-slate-900 border border-slate-700 text-white text-xs font-mono focus:outline-none focus:border-pink-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end pt-1">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isChangingPwd}
|
||||||
|
className="px-3.5 py-1.5 rounded-lg bg-gradient-to-r from-pink-500 to-purple-600 text-white text-xs font-bold shadow-md hover:scale-[1.01] transition-all disabled:opacity-50 cursor-pointer"
|
||||||
|
>
|
||||||
|
{isChangingPwd ? 'Enregistrement...' : 'Valider Nouveau Mot de Passe'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Login Form */
|
||||||
|
<form onSubmit={handleLoginSubmit} className="space-y-4">
|
||||||
|
{loginError && (
|
||||||
|
<div className="p-3 rounded-xl bg-rose-500/10 border border-rose-500/30 text-rose-300 text-xs flex items-center space-x-2">
|
||||||
|
<ShieldAlert className="w-4 h-4 text-rose-400 shrink-0" />
|
||||||
|
<span>{loginError}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-semibold text-slate-300 flex items-center space-x-1.5">
|
||||||
|
<User className="w-3.5 h-3.5 text-pink-400" />
|
||||||
|
<span>Nom d'utilisateur</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
placeholder="Nom d'utilisateur"
|
||||||
|
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-900 border border-slate-700 text-white text-sm focus:outline-none focus:border-pink-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-semibold text-slate-300 flex items-center space-x-1.5">
|
||||||
|
<Lock className="w-3.5 h-3.5 text-pink-400" />
|
||||||
|
<span>Mot de passe</span>
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
required
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="Mot de passe"
|
||||||
|
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-900 border border-slate-700 text-white text-sm focus:outline-none focus:border-pink-500 pr-10 font-mono"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white"
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="w-full py-3 rounded-xl bg-gradient-to-r from-pink-500 via-purple-600 to-pink-600 text-white font-bold text-sm shadow-lg shadow-pink-500/25 hover:scale-[1.01] active:scale-[0.99] transition-all flex items-center justify-center space-x-2 disabled:opacity-50 cursor-pointer"
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Sparkles className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
<span>{isSubmitting ? 'Connexion en cours...' : 'Se Connecter à l\'Atelier'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="p-3 rounded-xl bg-slate-900/50 border border-slate-800 text-[11px] text-slate-400 flex items-center justify-between">
|
||||||
|
<span>Synchronisé avec la BDD</span>
|
||||||
|
<span className="font-mono text-cyan-300">NoCodeBackend</span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* TAB 2: JWT TOKEN */}
|
||||||
|
{activeTab === 'jwt' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-xs font-bold uppercase tracking-wider text-cyan-400 flex items-center space-x-1.5">
|
||||||
|
<KeyRound className="w-3.5 h-3.5" />
|
||||||
|
<span>Jeton JWT Actif</span>
|
||||||
|
</label>
|
||||||
|
<span className="text-[11px] text-slate-400 font-mono">
|
||||||
|
Validité : <span className="text-emerald-400 font-bold">{currentDecoded.timeLeftFormatted}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<textarea
|
||||||
|
rows={4}
|
||||||
|
readOnly
|
||||||
|
value={authSession.token || ''}
|
||||||
|
className="w-full px-3 py-2.5 rounded-xl bg-slate-950 border border-slate-800 text-slate-200 text-xs font-mono break-all focus:outline-none focus:border-cyan-400"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleCopyToken}
|
||||||
|
className="absolute right-2.5 bottom-3.5 px-2.5 py-1.5 rounded-lg bg-slate-900 border border-slate-700 hover:border-cyan-400 text-slate-200 text-xs font-semibold flex items-center space-x-1.5 transition-all cursor-pointer"
|
||||||
|
>
|
||||||
|
{copiedToken ? <Check className="w-3.5 h-3.5 text-emerald-400" /> : <Copy className="w-3.5 h-3.5" />}
|
||||||
|
<span>{copiedToken ? 'Copié !' : 'Copier'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* JWT Payload breakdown */}
|
||||||
|
{currentDecoded.payload && (
|
||||||
|
<div className="p-3.5 rounded-xl bg-slate-900/60 border border-slate-800 space-y-2">
|
||||||
|
<div className="text-[10px] uppercase font-bold text-slate-400">Claims Payload (JSON)</div>
|
||||||
|
<pre className="text-[11px] font-mono text-slate-300 overflow-x-auto p-2 rounded-lg bg-slate-950">
|
||||||
|
{JSON.stringify(currentDecoded.payload, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* TAB 3: NCB DATABASE */}
|
||||||
|
{activeTab === 'database' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-4 rounded-2xl bg-slate-900/60 border border-cyan-500/30 space-y-3">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="p-2.5 rounded-xl bg-cyan-500/10 border border-cyan-500/30 text-cyan-400">
|
||||||
|
<Database className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-sm text-white">Instance 54770_uwu_finances</h3>
|
||||||
|
<p className="text-xs text-slate-400">Table 'users' & données de l'atelier synchronisées</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-2 flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" />
|
||||||
|
<span className="text-xs font-semibold text-emerald-400">En Ligne & Connecté</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleTestNcb}
|
||||||
|
disabled={isTestingNcb}
|
||||||
|
className="px-3.5 py-1.5 rounded-xl bg-slate-900 border border-cyan-500/40 hover:bg-cyan-500/10 text-cyan-300 text-xs font-semibold flex items-center space-x-1.5 transition-all disabled:opacity-50 cursor-pointer"
|
||||||
|
>
|
||||||
|
<Wifi className={`w-3.5 h-3.5 ${isTestingNcb ? 'animate-spin' : ''}`} />
|
||||||
|
<span>{isTestingNcb ? 'Test...' : 'Tester le Ping NCB'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{ncbTestResult && (
|
||||||
|
<div className={`p-3 rounded-xl border text-xs flex items-center space-x-2 mt-2 ${
|
||||||
|
ncbTestResult.success
|
||||||
|
? 'bg-emerald-500/10 text-emerald-300 border-emerald-500/30'
|
||||||
|
: 'bg-rose-500/10 text-rose-300 border-rose-500/30'
|
||||||
|
}`}>
|
||||||
|
{ncbTestResult.success ? <Check className="w-4 h-4 text-emerald-400 shrink-0" /> : <ShieldAlert className="w-4 h-4 text-rose-400 shrink-0" />}
|
||||||
|
<span>{ncbTestResult.message}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Footer */}
|
||||||
|
<div className="px-6 py-4 border-t border-slate-800 bg-slate-900/30 flex items-center justify-between">
|
||||||
|
<span className="text-[11px] font-mono text-slate-500">
|
||||||
|
Auth v1.2 • NoCodeBackend Auth & JWT
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-5 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-white text-xs font-bold transition-all"
|
||||||
|
>
|
||||||
|
Fermer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
168
src/components/auth/LoginGate.tsx
Normal file
168
src/components/auth/LoginGate.tsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Gamepad2,
|
||||||
|
Lock,
|
||||||
|
User,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
Sparkles,
|
||||||
|
RefreshCw,
|
||||||
|
ShieldAlert,
|
||||||
|
ShieldCheck,
|
||||||
|
Database,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useApp } from '../../context/AppContext';
|
||||||
|
import { authenticateWithDatabase } from '../../services/authService';
|
||||||
|
|
||||||
|
export const LoginGate: React.FC = () => {
|
||||||
|
const { loginUser, addToast, settings } = useApp();
|
||||||
|
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setErrorMessage(null);
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await authenticateWithDatabase(username, password);
|
||||||
|
if (result.success && result.user) {
|
||||||
|
loginUser(result.user, 720); // 30 days session
|
||||||
|
addToast({
|
||||||
|
type: 'success',
|
||||||
|
title: 'Accès Autorisé',
|
||||||
|
message: `Bienvenue dans votre atelier, ${result.user.name} ! Jeton JWT émis.`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setErrorMessage(result.error || 'Identifiant ou mot de passe incorrect.');
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setErrorMessage(err.message || 'Erreur lors de la connexion au serveur.');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen theme-root-container flex items-center justify-center p-4 selection:bg-pink-500 selection:text-white relative overflow-hidden">
|
||||||
|
{/* Ambient background glows */}
|
||||||
|
<div className="absolute top-1/4 left-1/2 -translate-x-1/2 -translate-y-1/2 w-96 h-96 bg-pink-500/10 rounded-full blur-3xl pointer-events-none" />
|
||||||
|
<div className="absolute bottom-1/4 right-1/4 w-80 h-80 bg-purple-500/10 rounded-full blur-3xl pointer-events-none" />
|
||||||
|
|
||||||
|
<div className="w-full max-w-md relative z-10 animate-fadeIn">
|
||||||
|
{/* Main Card */}
|
||||||
|
<div className="glass-panel p-8 sm:p-10 rounded-3xl border border-slate-700/80 bg-slate-950/90 shadow-2xl shadow-pink-500/5 space-y-6">
|
||||||
|
|
||||||
|
{/* Logo & Header */}
|
||||||
|
<div className="text-center space-y-3">
|
||||||
|
<div className="inline-flex items-center justify-center w-16 h-16 rounded-3xl bg-gradient-to-tr from-pink-500 via-purple-600 to-cyan-400 p-[2px] shadow-xl shadow-pink-500/25 mb-1">
|
||||||
|
<div className="w-full h-full bg-slate-950 rounded-[22px] flex items-center justify-center">
|
||||||
|
<Gamepad2 className="w-8 h-8 text-pink-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-black tracking-tight bg-gradient-to-r from-pink-400 via-purple-300 to-cyan-400 bg-clip-text text-transparent font-display">
|
||||||
|
{settings.logoText || 'uwuFinances'}
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">
|
||||||
|
Atelier Console Modding • Gestion Financière & Projets
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{errorMessage && (
|
||||||
|
<div className="p-3 rounded-2xl bg-rose-500/10 border border-rose-500/30 text-rose-300 text-xs flex items-center space-x-2 animate-shake">
|
||||||
|
<ShieldAlert className="w-4 h-4 text-rose-400 shrink-0" />
|
||||||
|
<span>{errorMessage}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Username Input */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-semibold text-slate-300 flex items-center space-x-1.5">
|
||||||
|
<User className="w-3.5 h-3.5 text-pink-400" />
|
||||||
|
<span>Identifiant Utilisateur</span>
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
placeholder="Nom d'utilisateur"
|
||||||
|
className="w-full px-4 py-3 rounded-2xl bg-slate-900/90 border border-slate-750 text-white text-sm focus:outline-none focus:border-pink-500 focus:ring-2 focus:ring-pink-500/20 transition-all font-medium"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Password Input */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-semibold text-slate-300 flex items-center space-x-1.5">
|
||||||
|
<Lock className="w-3.5 h-3.5 text-pink-400" />
|
||||||
|
<span>Mot de passe</span>
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
required
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="Mot de passe"
|
||||||
|
className="w-full px-4 py-3 rounded-2xl bg-slate-900/90 border border-slate-750 text-white text-sm focus:outline-none focus:border-pink-500 focus:ring-2 focus:ring-pink-500/20 transition-all font-mono pr-11"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white transition-colors"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit Button */}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="w-full py-3.5 px-4 rounded-2xl bg-gradient-to-r from-pink-500 via-purple-600 to-pink-600 hover:from-pink-400 hover:to-purple-500 text-white font-bold text-sm shadow-xl shadow-pink-500/25 hover:shadow-pink-500/40 hover:scale-[1.01] active:scale-[0.99] transition-all duration-200 flex items-center justify-center space-x-2 disabled:opacity-50 cursor-pointer mt-2"
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Sparkles className="w-4 h-4 text-pink-200" />
|
||||||
|
)}
|
||||||
|
<span>{isSubmitting ? 'Vérification en BDD...' : 'Accéder à l\'Atelier'}</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Database info badge */}
|
||||||
|
<div className="pt-2 border-t border-slate-850 flex items-center justify-between text-[11px] text-slate-400">
|
||||||
|
<div className="flex items-center space-x-1.5">
|
||||||
|
<Database className="w-3.5 h-3.5 text-cyan-400" />
|
||||||
|
<span className="font-mono">Base de données Cloud NCB</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-1 text-emerald-400 font-semibold">
|
||||||
|
<ShieldCheck className="w-3.5 h-3.5" />
|
||||||
|
<span>JWT Chiffré</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Discreet bottom notice */}
|
||||||
|
<div className="text-center text-xs text-slate-500 mt-4">
|
||||||
|
Session sécurisée pour l'administrateur de l'atelier
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
284
src/components/common/Header.tsx
Normal file
284
src/components/common/Header.tsx
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Gamepad2,
|
||||||
|
LayoutDashboard,
|
||||||
|
ShoppingCart,
|
||||||
|
Layers,
|
||||||
|
Clock,
|
||||||
|
BarChart3,
|
||||||
|
Settings as SettingsIcon,
|
||||||
|
PackagePlus,
|
||||||
|
Play,
|
||||||
|
Pause,
|
||||||
|
Sparkles,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useApp } from '../../context/AppContext';
|
||||||
|
|
||||||
|
|
||||||
|
export const Header: React.FC = () => {
|
||||||
|
const {
|
||||||
|
currentTab,
|
||||||
|
setCurrentTab,
|
||||||
|
inventory,
|
||||||
|
projects,
|
||||||
|
activeTimer,
|
||||||
|
pauseTimer,
|
||||||
|
resumeTimer,
|
||||||
|
setShowNewProjectModal,
|
||||||
|
setShowNewInventoryModal,
|
||||||
|
settings,
|
||||||
|
} = useApp();
|
||||||
|
|
||||||
|
// Calculate alerts
|
||||||
|
const lowStockCount = inventory.filter(
|
||||||
|
(i) => i.quantityInStock <= i.minStockAlert
|
||||||
|
).length;
|
||||||
|
|
||||||
|
const activeProjectsCount = projects.filter(
|
||||||
|
(p) => p.status === 'in_progress' || p.status === 'qc_testing'
|
||||||
|
).length;
|
||||||
|
|
||||||
|
const readyToSellCount = projects.filter(
|
||||||
|
(p) => p.status === 'in_stock_ready'
|
||||||
|
).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-40 w-full border-b border-slate-800/80 bg-slate-950/85 backdrop-blur-xl shadow-lg shadow-black/20 no-print">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex items-center justify-between h-16 sm:h-20">
|
||||||
|
|
||||||
|
{/* Logo & Brand */}
|
||||||
|
<div className="flex items-center space-x-3 cursor-pointer" onClick={() => setCurrentTab('dashboard')}>
|
||||||
|
<div className="relative flex items-center justify-center w-11 h-11 rounded-2xl bg-gradient-to-tr from-pink-500 via-purple-600 to-cyan-400 p-[2px] shadow-lg shadow-pink-500/20 group">
|
||||||
|
<div className="w-full h-full bg-slate-950 rounded-[14px] flex items-center justify-center transition-all group-hover:bg-slate-900">
|
||||||
|
<Gamepad2 className="w-6 h-6 text-pink-400 group-hover:scale-110 transition-transform duration-300" />
|
||||||
|
</div>
|
||||||
|
<span className="absolute -top-1 -right-1 flex h-3 w-3">
|
||||||
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-cyan-400 opacity-75"></span>
|
||||||
|
<span className="relative inline-flex rounded-full h-3 w-3 bg-cyan-500"></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<h1 className="text-xl sm:text-2xl font-black tracking-tight bg-gradient-to-r from-pink-400 via-purple-300 to-cyan-400 bg-clip-text text-transparent font-display">
|
||||||
|
{settings.logoText || 'uwuFinances'}
|
||||||
|
</h1>
|
||||||
|
<span className="hidden md:inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold bg-pink-500/10 text-pink-400 border border-pink-500/30">
|
||||||
|
MODDING PRO
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400 hidden sm:block truncate max-w-[200px]">
|
||||||
|
{settings.businessName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation Tabs */}
|
||||||
|
<nav className="hidden lg:flex items-center space-x-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentTab('dashboard')}
|
||||||
|
className={`flex items-center space-x-2 px-3.5 py-2 rounded-xl text-sm font-semibold transition-all duration-200 ${
|
||||||
|
currentTab === 'dashboard'
|
||||||
|
? 'bg-gradient-to-r from-pink-500/15 to-purple-500/15 text-pink-400 border border-pink-500/30 shadow-sm'
|
||||||
|
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-900/60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<LayoutDashboard className="w-4 h-4" />
|
||||||
|
<span>Dashboard</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentTab('purchases')}
|
||||||
|
className={`flex items-center space-x-2 px-3.5 py-2 rounded-xl text-sm font-semibold transition-all duration-200 relative ${
|
||||||
|
currentTab === 'purchases'
|
||||||
|
? 'bg-gradient-to-r from-cyan-500/15 to-blue-500/15 text-cyan-400 border border-cyan-500/30 shadow-sm'
|
||||||
|
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-900/60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ShoppingCart className="w-4 h-4" />
|
||||||
|
<span>Achats & Stock</span>
|
||||||
|
{lowStockCount > 0 && (
|
||||||
|
<span className="ml-1.5 px-1.5 py-0.5 text-[10px] font-extrabold rounded-full bg-amber-500/20 text-amber-300 border border-amber-500/40 animate-pulse">
|
||||||
|
{lowStockCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentTab('projects')}
|
||||||
|
className={`flex items-center space-x-2 px-3.5 py-2 rounded-xl text-sm font-semibold transition-all duration-200 relative ${
|
||||||
|
currentTab === 'projects'
|
||||||
|
? 'bg-gradient-to-r from-purple-500/15 to-pink-500/15 text-purple-400 border border-purple-500/30 shadow-sm'
|
||||||
|
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-900/60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Layers className="w-4 h-4" />
|
||||||
|
<span>Projets & Dépenses</span>
|
||||||
|
{readyToSellCount > 0 ? (
|
||||||
|
<span className="ml-1.5 px-1.5 py-0.5 text-[10px] font-extrabold rounded-full bg-emerald-500/20 text-emerald-300 border border-emerald-500/40">
|
||||||
|
{readyToSellCount} en vente
|
||||||
|
</span>
|
||||||
|
) : activeProjectsCount > 0 ? (
|
||||||
|
<span className="ml-1.5 px-1.5 py-0.5 text-[10px] font-extrabold rounded-full bg-blue-500/20 text-blue-300 border border-blue-500/40">
|
||||||
|
{activeProjectsCount} en cours
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentTab('timetracker')}
|
||||||
|
className={`flex items-center space-x-2 px-3.5 py-2 rounded-xl text-sm font-semibold transition-all duration-200 relative ${
|
||||||
|
currentTab === 'timetracker'
|
||||||
|
? 'bg-gradient-to-r from-amber-500/15 to-orange-500/15 text-amber-400 border border-amber-500/30 shadow-sm'
|
||||||
|
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-900/60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
<span>Chronomètre Établi</span>
|
||||||
|
{activeTimer.isRunning && (
|
||||||
|
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-ping ml-1" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentTab('analytics')}
|
||||||
|
className={`flex items-center space-x-2 px-3.5 py-2 rounded-xl text-sm font-semibold transition-all duration-200 ${
|
||||||
|
currentTab === 'analytics'
|
||||||
|
? 'bg-gradient-to-r from-emerald-500/15 to-teal-500/15 text-emerald-400 border border-emerald-500/30 shadow-sm'
|
||||||
|
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-900/60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<BarChart3 className="w-4 h-4" />
|
||||||
|
<span>Rentabilité</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentTab('settings')}
|
||||||
|
className={`p-2 rounded-xl text-slate-400 hover:text-slate-200 hover:bg-slate-900/60 transition-all ${
|
||||||
|
currentTab === 'settings' ? 'text-pink-400 bg-pink-500/10 border border-pink-500/30' : ''
|
||||||
|
}`}
|
||||||
|
title="Paramètres & Sauvegardes"
|
||||||
|
>
|
||||||
|
<SettingsIcon className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Right Action Buttons */}
|
||||||
|
<div className="flex items-center space-x-2 sm:space-x-3">
|
||||||
|
{/* Live Stopwatch Mini Widget (if running or has elapsed time) */}
|
||||||
|
{activeTimer.elapsedSeconds > 0 && (
|
||||||
|
<div
|
||||||
|
onClick={() => setCurrentTab('timetracker')}
|
||||||
|
className="cursor-pointer hidden sm:flex items-center space-x-2 px-3 py-1.5 rounded-xl bg-slate-900 border border-amber-500/40 text-amber-300 text-xs font-mono shadow-md hover:border-amber-400 transition-all"
|
||||||
|
>
|
||||||
|
<div className={`w-2 h-2 rounded-full ${activeTimer.isRunning ? 'bg-amber-400 animate-pulse' : 'bg-slate-500'}`} />
|
||||||
|
<span>
|
||||||
|
{Math.floor(activeTimer.elapsedSeconds / 3600) > 0
|
||||||
|
? `${Math.floor(activeTimer.elapsedSeconds / 3600)}:`
|
||||||
|
: ''}
|
||||||
|
{Math.floor((activeTimer.elapsedSeconds % 3600) / 60).toString().padStart(2, '0')}:
|
||||||
|
{(activeTimer.elapsedSeconds % 60).toString().padStart(2, '0')}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
activeTimer.isRunning ? pauseTimer() : resumeTimer();
|
||||||
|
}}
|
||||||
|
className="p-1 hover:text-white"
|
||||||
|
>
|
||||||
|
{activeTimer.isRunning ? <Pause className="w-3 h-3" /> : <Play className="w-3 h-3" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Quick Add Part */}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowNewInventoryModal(true)}
|
||||||
|
className="flex items-center space-x-1.5 px-3 py-1.5 sm:px-3.5 sm:py-2 rounded-xl text-xs sm:text-sm font-semibold bg-slate-900 text-cyan-300 border border-cyan-500/30 hover:bg-cyan-500/10 hover:border-cyan-500/60 shadow-sm transition-all duration-200"
|
||||||
|
title="Ajouter un achat de pièces au stock"
|
||||||
|
>
|
||||||
|
<PackagePlus className="w-4 h-4 text-cyan-400" />
|
||||||
|
<span className="hidden sm:inline">Achat Pièce</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Quick New Project */}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowNewProjectModal(true)}
|
||||||
|
className="flex items-center space-x-1.5 px-3 py-1.5 sm:px-4 sm:py-2 rounded-xl text-xs sm:text-sm font-bold bg-gradient-to-r from-pink-500 via-purple-600 to-pink-600 text-white shadow-lg shadow-pink-500/25 hover:shadow-pink-500/40 hover:scale-[1.02] active:scale-[0.98] transition-all duration-200"
|
||||||
|
title="Créer une nouvelle console custom"
|
||||||
|
>
|
||||||
|
<Sparkles className="w-4 h-4" />
|
||||||
|
<span>Nouveau Projet</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Navigation Tabs */}
|
||||||
|
<div className="lg:hidden flex items-center justify-around py-2 border-t border-slate-900 overflow-x-auto space-x-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentTab('dashboard')}
|
||||||
|
className={`flex flex-col items-center p-1.5 rounded-lg text-xs ${
|
||||||
|
currentTab === 'dashboard' ? 'text-pink-400 font-bold' : 'text-slate-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<LayoutDashboard className="w-4 h-4" />
|
||||||
|
<span className="text-[10px] mt-0.5">Dashboard</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentTab('purchases')}
|
||||||
|
className={`flex flex-col items-center p-1.5 rounded-lg text-xs relative ${
|
||||||
|
currentTab === 'purchases' ? 'text-cyan-400 font-bold' : 'text-slate-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ShoppingCart className="w-4 h-4" />
|
||||||
|
<span className="text-[10px] mt-0.5">Stock</span>
|
||||||
|
{lowStockCount > 0 && (
|
||||||
|
<span className="absolute top-0 right-1 w-2 h-2 rounded-full bg-amber-400" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentTab('projects')}
|
||||||
|
className={`flex flex-col items-center p-1.5 rounded-lg text-xs relative ${
|
||||||
|
currentTab === 'projects' ? 'text-purple-400 font-bold' : 'text-slate-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Layers className="w-4 h-4" />
|
||||||
|
<span className="text-[10px] mt-0.5">Projets</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentTab('timetracker')}
|
||||||
|
className={`flex flex-col items-center p-1.5 rounded-lg text-xs ${
|
||||||
|
currentTab === 'timetracker' ? 'text-amber-400 font-bold' : 'text-slate-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
<span className="text-[10px] mt-0.5">Chrono</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentTab('analytics')}
|
||||||
|
className={`flex flex-col items-center p-1.5 rounded-lg text-xs ${
|
||||||
|
currentTab === 'analytics' ? 'text-emerald-400 font-bold' : 'text-slate-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<BarChart3 className="w-4 h-4" />
|
||||||
|
<span className="text-[10px] mt-0.5">Marge</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentTab('settings')}
|
||||||
|
className={`flex flex-col items-center p-1.5 rounded-lg text-xs ${
|
||||||
|
currentTab === 'settings' ? 'text-pink-400 font-bold' : 'text-slate-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<SettingsIcon className="w-4 h-4" />
|
||||||
|
<span className="text-[10px] mt-0.5">Options</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
};
|
||||||
470
src/components/common/Sidebar.tsx
Normal file
470
src/components/common/Sidebar.tsx
Normal file
@@ -0,0 +1,470 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Gamepad2,
|
||||||
|
LayoutDashboard,
|
||||||
|
ShoppingCart,
|
||||||
|
Layers,
|
||||||
|
Clock,
|
||||||
|
BarChart3,
|
||||||
|
Settings as SettingsIcon,
|
||||||
|
PackagePlus,
|
||||||
|
Play,
|
||||||
|
Pause,
|
||||||
|
Sparkles,
|
||||||
|
ChevronRight,
|
||||||
|
Menu,
|
||||||
|
X,
|
||||||
|
ShieldCheck,
|
||||||
|
AlertTriangle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useApp } from '../../context/AppContext';
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
id: 'dashboard' | 'purchases' | 'projects' | 'mods' | 'timetracker' | 'analytics' | 'settings';
|
||||||
|
label: string;
|
||||||
|
subtitle: string;
|
||||||
|
icon: React.ElementType;
|
||||||
|
colorClass: {
|
||||||
|
active: string;
|
||||||
|
text: string;
|
||||||
|
border: string;
|
||||||
|
iconColor: string;
|
||||||
|
bgHover: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const NAV_ITEMS: NavItem[] = [
|
||||||
|
{
|
||||||
|
id: 'dashboard',
|
||||||
|
label: 'Tableau de Bord',
|
||||||
|
subtitle: 'Vue globale & graphiques',
|
||||||
|
icon: LayoutDashboard,
|
||||||
|
colorClass: {
|
||||||
|
active: 'bg-gradient-to-r from-pink-500/15 via-purple-500/10 to-transparent text-pink-400 border-pink-500/40 shadow-sm shadow-pink-500/10',
|
||||||
|
text: 'text-pink-400',
|
||||||
|
border: 'border-pink-500/40',
|
||||||
|
iconColor: 'text-pink-400',
|
||||||
|
bgHover: 'hover:bg-pink-500/5 hover:text-pink-300',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'purchases',
|
||||||
|
label: 'Achats & Stocks',
|
||||||
|
subtitle: 'Composants & consoles',
|
||||||
|
icon: ShoppingCart,
|
||||||
|
colorClass: {
|
||||||
|
active: 'bg-gradient-to-r from-cyan-500/15 via-blue-500/10 to-transparent text-cyan-400 border-cyan-500/40 shadow-sm shadow-cyan-500/10',
|
||||||
|
text: 'text-cyan-400',
|
||||||
|
border: 'border-cyan-500/40',
|
||||||
|
iconColor: 'text-cyan-400',
|
||||||
|
bgHover: 'hover:bg-cyan-500/5 hover:text-cyan-300',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'projects',
|
||||||
|
label: 'Projets & Dépenses',
|
||||||
|
subtitle: 'Customisations & coûts',
|
||||||
|
icon: Layers,
|
||||||
|
colorClass: {
|
||||||
|
active: 'bg-gradient-to-r from-purple-500/15 via-pink-500/10 to-transparent text-purple-400 border-purple-500/40 shadow-sm shadow-purple-500/10',
|
||||||
|
text: 'text-purple-400',
|
||||||
|
border: 'border-purple-500/40',
|
||||||
|
iconColor: 'text-purple-400',
|
||||||
|
bgHover: 'hover:bg-purple-500/5 hover:text-purple-300',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mods',
|
||||||
|
label: 'Mods & Recettes',
|
||||||
|
subtitle: 'Kits & catalogue pièces',
|
||||||
|
icon: Sparkles,
|
||||||
|
colorClass: {
|
||||||
|
active: 'bg-gradient-to-r from-rose-500/15 via-pink-500/10 to-transparent text-rose-400 border-rose-500/40 shadow-sm shadow-rose-500/10',
|
||||||
|
text: 'text-rose-400',
|
||||||
|
border: 'border-rose-500/40',
|
||||||
|
iconColor: 'text-rose-400',
|
||||||
|
bgHover: 'hover:bg-rose-500/5 hover:text-rose-300',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'timetracker',
|
||||||
|
label: 'Chronomètre Établi',
|
||||||
|
subtitle: 'Main d\'œuvre & sessions',
|
||||||
|
icon: Clock,
|
||||||
|
colorClass: {
|
||||||
|
active: 'bg-gradient-to-r from-amber-500/15 via-orange-500/10 to-transparent text-amber-400 border-amber-500/40 shadow-sm shadow-amber-500/10',
|
||||||
|
text: 'text-amber-400',
|
||||||
|
border: 'border-amber-500/40',
|
||||||
|
iconColor: 'text-amber-400',
|
||||||
|
bgHover: 'hover:bg-amber-500/5 hover:text-amber-300',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'analytics',
|
||||||
|
label: 'Rentabilité & Marge',
|
||||||
|
subtitle: 'Simulateur & ROI',
|
||||||
|
icon: BarChart3,
|
||||||
|
colorClass: {
|
||||||
|
active: 'bg-gradient-to-r from-emerald-500/15 via-teal-500/10 to-transparent text-emerald-400 border-emerald-500/40 shadow-sm shadow-emerald-500/10',
|
||||||
|
text: 'text-emerald-400',
|
||||||
|
border: 'border-emerald-500/40',
|
||||||
|
iconColor: 'text-emerald-400',
|
||||||
|
bgHover: 'hover:bg-emerald-500/5 hover:text-emerald-300',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'settings',
|
||||||
|
label: 'Paramètres & Données',
|
||||||
|
subtitle: 'Taux horaire & exports',
|
||||||
|
icon: SettingsIcon,
|
||||||
|
colorClass: {
|
||||||
|
active: 'bg-gradient-to-r from-slate-700/30 via-slate-800/20 to-transparent text-slate-200 border-slate-600 shadow-sm',
|
||||||
|
text: 'text-slate-300',
|
||||||
|
border: 'border-slate-600',
|
||||||
|
iconColor: 'text-slate-300',
|
||||||
|
bgHover: 'hover:bg-slate-800/40 hover:text-slate-200',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const Sidebar: React.FC = () => {
|
||||||
|
const {
|
||||||
|
currentTab,
|
||||||
|
setCurrentTab,
|
||||||
|
inventory,
|
||||||
|
projects,
|
||||||
|
modsLibrary,
|
||||||
|
activeTimer,
|
||||||
|
pauseTimer,
|
||||||
|
resumeTimer,
|
||||||
|
setShowNewProjectModal,
|
||||||
|
setShowNewInventoryModal,
|
||||||
|
settings,
|
||||||
|
authSession,
|
||||||
|
setShowAuthModal,
|
||||||
|
} = useApp();
|
||||||
|
|
||||||
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
|
||||||
|
// Alerts
|
||||||
|
const lowStockCount = inventory.filter(
|
||||||
|
(i) => i.quantityInStock <= i.minStockAlert
|
||||||
|
).length;
|
||||||
|
|
||||||
|
const activeProjectsCount = projects.filter(
|
||||||
|
(p) => p.status === 'in_progress' || p.status === 'qc_testing'
|
||||||
|
).length;
|
||||||
|
|
||||||
|
const readyToSellCount = projects.filter(
|
||||||
|
(p) => p.status === 'in_stock_ready'
|
||||||
|
).length;
|
||||||
|
|
||||||
|
// Active project title if stopwatch is running
|
||||||
|
const activeTimerProject = projects.find((p) => p.id === activeTimer.projectId);
|
||||||
|
|
||||||
|
const formatTimerDigits = (totalSec: number) => {
|
||||||
|
const hours = Math.floor(totalSec / 3600);
|
||||||
|
const minutes = Math.floor((totalSec % 3600) / 60);
|
||||||
|
const seconds = totalSec % 60;
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}h ${minutes.toString().padStart(2, '0')}m ${seconds.toString().padStart(2, '0')}s`;
|
||||||
|
}
|
||||||
|
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNavClick = (tabId: NavItem['id']) => {
|
||||||
|
setCurrentTab(tabId);
|
||||||
|
setMobileOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Mobile Top Bar (Visible only on small screens < lg) */}
|
||||||
|
<div className="lg:hidden sticky top-0 z-40 flex items-center justify-between px-4 py-3 theme-header border-b border-slate-800/80 backdrop-blur-xl no-print">
|
||||||
|
<div
|
||||||
|
className="flex items-center space-x-3 cursor-pointer"
|
||||||
|
onClick={() => handleNavClick('dashboard')}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center w-9 h-9 rounded-xl bg-gradient-to-tr from-pink-500 to-purple-600 p-[2px]">
|
||||||
|
<div className="w-full h-full bg-slate-950 rounded-[10px] flex items-center justify-center">
|
||||||
|
<Gamepad2 className="w-5 h-5 text-pink-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-black tracking-tight bg-gradient-to-r from-pink-400 to-purple-400 bg-clip-text text-transparent">
|
||||||
|
{settings.logoText || 'uwuFinances'}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
{activeTimer.elapsedSeconds > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleNavClick('timetracker')}
|
||||||
|
className="flex items-center space-x-1 px-2.5 py-1 rounded-lg bg-slate-900 border border-amber-500/40 text-amber-300 text-xs font-mono"
|
||||||
|
>
|
||||||
|
<div className={`w-2 h-2 rounded-full ${activeTimer.isRunning ? 'bg-amber-400 animate-pulse' : 'bg-slate-500'}`} />
|
||||||
|
<span>{Math.floor(activeTimer.elapsedSeconds / 60)}m</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setMobileOpen(!mobileOpen)}
|
||||||
|
className="p-2 rounded-xl bg-slate-900 border border-slate-800 text-slate-300 hover:text-white"
|
||||||
|
aria-label="Toggle Menu"
|
||||||
|
>
|
||||||
|
{mobileOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Backdrop for Mobile Overlay */}
|
||||||
|
{mobileOpen && (
|
||||||
|
<div
|
||||||
|
onClick={() => setMobileOpen(false)}
|
||||||
|
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-40 lg:hidden"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sidebar Container */}
|
||||||
|
<aside
|
||||||
|
className={`fixed top-0 left-0 bottom-0 z-50 w-72 lg:w-72 theme-sidebar border-r border-slate-800 flex flex-col justify-between transition-transform duration-300 ease-in-out no-print ${
|
||||||
|
mobileOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Top Header & Branding */}
|
||||||
|
<div className="p-5 border-b border-slate-850">
|
||||||
|
<div
|
||||||
|
className="flex items-center space-x-3.5 cursor-pointer group"
|
||||||
|
onClick={() => handleNavClick('dashboard')}
|
||||||
|
>
|
||||||
|
<div className="relative flex items-center justify-center w-12 h-12 rounded-2xl bg-gradient-to-tr from-pink-500 via-purple-600 to-cyan-400 p-[2px] shadow-lg shadow-pink-500/20 group-hover:scale-105 transition-transform duration-300">
|
||||||
|
<div className="w-full h-full bg-slate-950 rounded-[14px] flex items-center justify-center">
|
||||||
|
<Gamepad2 className="w-6 h-6 text-pink-400 group-hover:rotate-12 transition-transform duration-300" />
|
||||||
|
</div>
|
||||||
|
<span className="absolute -top-1 -right-1 flex h-3.5 w-3.5">
|
||||||
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-cyan-400 opacity-75"></span>
|
||||||
|
<span className="relative inline-flex rounded-full h-3.5 w-3.5 bg-cyan-500 border-2 border-slate-950"></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<h1 className="text-xl font-black tracking-tight bg-gradient-to-r from-pink-400 via-purple-300 to-cyan-400 bg-clip-text text-transparent font-display truncate">
|
||||||
|
{settings.logoText || 'uwuFinances'}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400 truncate mt-0.5">
|
||||||
|
{settings.businessName || 'Atelier Console Modding'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Action Buttons (Full width, spacious & clear) */}
|
||||||
|
<div className="mt-5 space-y-2">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowNewProjectModal(true);
|
||||||
|
setMobileOpen(false);
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center justify-center space-x-2 px-3.5 py-2.5 rounded-xl font-bold text-sm bg-gradient-to-r from-pink-500 via-purple-600 to-pink-600 text-white shadow-lg shadow-pink-500/20 hover:shadow-pink-500/35 hover:scale-[1.02] active:scale-[0.98] transition-all duration-200"
|
||||||
|
>
|
||||||
|
<Sparkles className="w-4 h-4 text-pink-200" />
|
||||||
|
<span>Nouveau Projet</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowNewInventoryModal(true);
|
||||||
|
setMobileOpen(false);
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center justify-center space-x-2 px-3.5 py-2 rounded-xl font-semibold text-xs bg-slate-900/90 text-cyan-300 border border-cyan-500/30 hover:bg-cyan-500/10 hover:border-cyan-500/50 transition-all duration-200"
|
||||||
|
>
|
||||||
|
<PackagePlus className="w-4 h-4 text-cyan-400" />
|
||||||
|
<span>Achat Pièce / Stock</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation Items (Main List) */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-3.5 py-4 space-y-1.5 custom-scrollbar">
|
||||||
|
<div className="px-3 pb-2 text-[10px] font-bold uppercase tracking-wider text-slate-500">
|
||||||
|
Menu Principal
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{NAV_ITEMS.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const isActive = currentTab === item.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => handleNavClick(item.id)}
|
||||||
|
className={`w-full flex items-center justify-between px-3.5 py-3 rounded-2xl text-left transition-all duration-200 group border ${
|
||||||
|
isActive
|
||||||
|
? `${item.colorClass.active} font-bold`
|
||||||
|
: `text-slate-400 border-transparent hover:border-slate-800/80 hover:bg-slate-900/50 ${item.colorClass.bgHover}`
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-3.5 min-w-0">
|
||||||
|
<div
|
||||||
|
className={`p-2 rounded-xl transition-all ${
|
||||||
|
isActive
|
||||||
|
? 'bg-slate-900/80 shadow-inner'
|
||||||
|
: 'bg-slate-900/40 group-hover:bg-slate-800/60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
className={`w-5 h-5 transition-transform duration-200 group-hover:scale-110 ${
|
||||||
|
isActive ? item.colorClass.iconColor : 'text-slate-400 group-hover:text-slate-200'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="truncate">
|
||||||
|
<div
|
||||||
|
className={`text-sm tracking-tight ${
|
||||||
|
isActive ? 'text-white font-bold' : 'text-slate-300 font-medium group-hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-slate-500 truncate">
|
||||||
|
{item.subtitle}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Badges / Indicators */}
|
||||||
|
<div className="flex items-center space-x-1.5 ml-2 shrink-0">
|
||||||
|
{item.id === 'mods' && (
|
||||||
|
<span className="px-2 py-0.5 text-[10px] font-extrabold rounded-full bg-rose-500/20 text-rose-300 border border-rose-500/40">
|
||||||
|
{modsLibrary.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{item.id === 'purchases' && lowStockCount > 0 && (
|
||||||
|
<span className="px-2 py-0.5 text-[10px] font-extrabold rounded-full bg-amber-500/20 text-amber-300 border border-amber-500/40 flex items-center space-x-1">
|
||||||
|
<AlertTriangle className="w-2.5 h-2.5" />
|
||||||
|
<span>{lowStockCount}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{item.id === 'projects' && (
|
||||||
|
<>
|
||||||
|
{readyToSellCount > 0 ? (
|
||||||
|
<span className="px-2 py-0.5 text-[10px] font-extrabold rounded-full bg-emerald-500/20 text-emerald-300 border border-emerald-500/40">
|
||||||
|
{readyToSellCount} prêt
|
||||||
|
</span>
|
||||||
|
) : activeProjectsCount > 0 ? (
|
||||||
|
<span className="px-2 py-0.5 text-[10px] font-extrabold rounded-full bg-purple-500/20 text-purple-300 border border-purple-500/40">
|
||||||
|
{activeProjectsCount} actif
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{item.id === 'timetracker' && activeTimer.isRunning && (
|
||||||
|
<span className="flex h-2.5 w-2.5 relative">
|
||||||
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
|
||||||
|
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-emerald-500"></span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ChevronRight
|
||||||
|
className={`w-4 h-4 transition-transform ${
|
||||||
|
isActive ? 'text-white translate-x-0.5 opacity-100' : 'text-slate-600 opacity-0 group-hover:opacity-100'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom Section: Live Stopwatch Card (if timer active) + Status */}
|
||||||
|
<div className="p-3.5 border-t border-slate-850 space-y-2.5 bg-slate-950/60">
|
||||||
|
{/* Live Stopwatch Card */}
|
||||||
|
{activeTimer.elapsedSeconds > 0 && (
|
||||||
|
<div
|
||||||
|
onClick={() => handleNavClick('timetracker')}
|
||||||
|
className="cursor-pointer p-3 rounded-2xl bg-gradient-to-br from-slate-900 to-slate-950 border border-amber-500/40 shadow-lg shadow-amber-500/5 hover:border-amber-400 transition-all group"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
|
<div className="flex items-center space-x-1.5">
|
||||||
|
<span className={`w-2 h-2 rounded-full ${activeTimer.isRunning ? 'bg-amber-400 animate-pulse' : 'bg-slate-500'}`} />
|
||||||
|
<span className="text-[10px] uppercase font-bold tracking-wider text-amber-400">
|
||||||
|
{activeTimer.isRunning ? 'Chrono en cours' : 'Chrono en pause'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
activeTimer.isRunning ? pauseTimer() : resumeTimer();
|
||||||
|
}}
|
||||||
|
className="p-1 rounded-lg bg-amber-500/20 text-amber-300 hover:bg-amber-500/30 transition-all"
|
||||||
|
title={activeTimer.isRunning ? 'Mettre en pause' : 'Reprendre'}
|
||||||
|
>
|
||||||
|
{activeTimer.isRunning ? <Pause className="w-3.5 h-3.5" /> : <Play className="w-3.5 h-3.5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="font-mono text-lg font-black text-amber-300 tracking-tight">
|
||||||
|
{formatTimerDigits(activeTimer.elapsedSeconds)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-[11px] text-slate-400 truncate mt-0.5">
|
||||||
|
{activeTimerProject ? activeTimerProject.title : 'Session libre'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* User Profile & JWT Session Card */}
|
||||||
|
<div
|
||||||
|
onClick={() => {
|
||||||
|
setShowAuthModal(true);
|
||||||
|
setMobileOpen(false);
|
||||||
|
}}
|
||||||
|
className="p-2.5 rounded-2xl bg-slate-900/60 border border-slate-800 hover:border-pink-500/40 cursor-pointer flex items-center justify-between transition-all group"
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-2.5 min-w-0">
|
||||||
|
<div className="relative shrink-0">
|
||||||
|
<img
|
||||||
|
src={authSession.user?.avatarUrl || 'https://api.dicebear.com/7.x/bottts/svg?seed=uwu'}
|
||||||
|
alt={authSession.user?.name}
|
||||||
|
className="w-8 h-8 rounded-xl bg-slate-800 object-cover border border-slate-700"
|
||||||
|
/>
|
||||||
|
{authSession.isAuthenticated && (
|
||||||
|
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full bg-emerald-400 animate-pulse" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="font-bold text-xs text-white truncate group-hover:text-pink-300 transition-colors">
|
||||||
|
{authSession.user?.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-slate-400 uppercase font-mono flex items-center space-x-1 truncate">
|
||||||
|
<span>{authSession.user?.role}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span className="text-cyan-400">JWT Actif</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="text-[10px] font-mono font-bold px-1.5 py-0.5 rounded bg-pink-500/10 text-pink-300 border border-pink-500/20 shrink-0">
|
||||||
|
Session
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Local Security & Version Badge */}
|
||||||
|
<div className="px-2 py-1 rounded-xl bg-slate-900/40 border border-slate-800 flex items-center justify-between text-[11px] text-slate-400">
|
||||||
|
<div className="flex items-center space-x-1.5">
|
||||||
|
<ShieldCheck className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
|
<span>Base NCB EU</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] font-mono text-slate-500">v1.2 JWT</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
52
src/components/common/ToastContainer.tsx
Normal file
52
src/components/common/ToastContainer.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { CheckCircle2, AlertCircle, Info, AlertTriangle, X } from 'lucide-react';
|
||||||
|
import { useApp } from '../../context/AppContext';
|
||||||
|
|
||||||
|
export const ToastContainer: React.FC = () => {
|
||||||
|
const { toasts, removeToast } = useApp();
|
||||||
|
|
||||||
|
if (toasts.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-5 right-5 z-50 flex flex-col space-y-3 max-w-sm w-full pointer-events-none no-print">
|
||||||
|
{toasts.map((toast) => {
|
||||||
|
let icon = <Info className="w-5 h-5 text-cyan-400 shrink-0" />;
|
||||||
|
let borderColor = 'border-cyan-500/40 bg-slate-900/95';
|
||||||
|
|
||||||
|
if (toast.type === 'success') {
|
||||||
|
icon = <CheckCircle2 className="w-5 h-5 text-emerald-400 shrink-0" />;
|
||||||
|
borderColor = 'border-emerald-500/40 bg-slate-900/95';
|
||||||
|
} else if (toast.type === 'error') {
|
||||||
|
icon = <AlertCircle className="w-5 h-5 text-rose-400 shrink-0" />;
|
||||||
|
borderColor = 'border-rose-500/40 bg-slate-900/95';
|
||||||
|
} else if (toast.type === 'warning') {
|
||||||
|
icon = <AlertTriangle className="w-5 h-5 text-amber-400 shrink-0" />;
|
||||||
|
borderColor = 'border-amber-500/40 bg-slate-900/95';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={toast.id}
|
||||||
|
className={`pointer-events-auto flex items-start p-4 rounded-2xl border shadow-xl backdrop-blur-xl transition-all duration-300 transform translate-y-0 opacity-100 ${borderColor}`}
|
||||||
|
>
|
||||||
|
<div className="mr-3 mt-0.5">{icon}</div>
|
||||||
|
<div className="flex-1 mr-2">
|
||||||
|
<h4 className="text-sm font-bold text-white leading-tight">
|
||||||
|
{toast.title}
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-slate-300 mt-1 leading-relaxed">
|
||||||
|
{toast.message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => removeToast(toast.id)}
|
||||||
|
className="text-slate-400 hover:text-white p-1 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
212
src/components/common/TopHeader.tsx
Normal file
212
src/components/common/TopHeader.tsx
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
ShoppingCart,
|
||||||
|
Layers,
|
||||||
|
Clock,
|
||||||
|
BarChart3,
|
||||||
|
Settings as SettingsIcon,
|
||||||
|
Sparkles,
|
||||||
|
PackagePlus,
|
||||||
|
Coins,
|
||||||
|
Cpu,
|
||||||
|
Database,
|
||||||
|
RefreshCw,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useApp } from '../../context/AppContext';
|
||||||
|
import { formatCurrency } from '../../utils/calculations';
|
||||||
|
|
||||||
|
export const TopHeader: React.FC = () => {
|
||||||
|
const {
|
||||||
|
currentTab,
|
||||||
|
inventory,
|
||||||
|
projects,
|
||||||
|
dbSyncStatus,
|
||||||
|
setShowNewProjectModal,
|
||||||
|
setShowNewInventoryModal,
|
||||||
|
authSession,
|
||||||
|
setShowAuthModal,
|
||||||
|
} = useApp();
|
||||||
|
|
||||||
|
// Quick topbar calculations
|
||||||
|
const totalStockValuation = inventory.reduce(
|
||||||
|
(acc, item) =>
|
||||||
|
acc +
|
||||||
|
item.quantityInStock *
|
||||||
|
(item.unitPurchasePrice + (item.shippingCostShare || 0) + (item.taxImportShare || 0)),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeProjectsCount = projects.filter(
|
||||||
|
(p) => p.status === 'in_progress' || p.status === 'qc_testing'
|
||||||
|
).length;
|
||||||
|
|
||||||
|
const getPageInfo = () => {
|
||||||
|
switch (currentTab) {
|
||||||
|
case 'dashboard':
|
||||||
|
return {
|
||||||
|
title: 'Tableau de Bord & Finances',
|
||||||
|
subtitle: 'Vue d\'ensemble des performances, graphiques et pipeline d\'atelier',
|
||||||
|
icon: LayoutDashboard,
|
||||||
|
iconColor: 'text-pink-400',
|
||||||
|
};
|
||||||
|
case 'purchases':
|
||||||
|
return {
|
||||||
|
title: 'Achats & Gestion des Stocks',
|
||||||
|
subtitle: 'Consoles donneuses, écrans IPS, coques, composants et alertes de stock',
|
||||||
|
icon: ShoppingCart,
|
||||||
|
iconColor: 'text-cyan-400',
|
||||||
|
};
|
||||||
|
case 'projects':
|
||||||
|
return {
|
||||||
|
title: 'Centre de Dépenses Projets',
|
||||||
|
subtitle: 'Nomenclature pièces (BOM), temps d\'atelier, coût de revient et certificats',
|
||||||
|
icon: Layers,
|
||||||
|
iconColor: 'text-purple-400',
|
||||||
|
};
|
||||||
|
case 'timetracker':
|
||||||
|
return {
|
||||||
|
title: 'Chronomètre d\'Atelier & Main d\'Œuvre',
|
||||||
|
subtitle: 'Chronométrage des sessions de modding et valorisation horaire en direct',
|
||||||
|
icon: Clock,
|
||||||
|
iconColor: 'text-amber-400',
|
||||||
|
};
|
||||||
|
case 'analytics':
|
||||||
|
return {
|
||||||
|
title: 'Rentabilité & Simulateur de Prix',
|
||||||
|
subtitle: 'Calculateur de marge nette, point mort (break-even) et analyse par console',
|
||||||
|
icon: BarChart3,
|
||||||
|
iconColor: 'text-emerald-400',
|
||||||
|
};
|
||||||
|
case 'settings':
|
||||||
|
return {
|
||||||
|
title: 'Paramètres & Sauvegardes',
|
||||||
|
subtitle: 'Identité de l\'atelier, taux horaire par défaut, thèmes et connexion NoCodeBackend',
|
||||||
|
icon: SettingsIcon,
|
||||||
|
iconColor: 'text-slate-300',
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
title: 'uwuFinances',
|
||||||
|
subtitle: 'Console Modding Studio Hub',
|
||||||
|
icon: LayoutDashboard,
|
||||||
|
iconColor: 'text-pink-400',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pageInfo = getPageInfo();
|
||||||
|
const Icon = pageInfo.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-30 w-full theme-header backdrop-blur-xl border-b border-slate-800/80 px-4 sm:px-8 py-3.5 flex items-center justify-between no-print">
|
||||||
|
{/* Page Title & Breadcrumb */}
|
||||||
|
<div className="flex items-center space-x-3.5">
|
||||||
|
<div className="p-2 rounded-xl bg-slate-900 border border-slate-800 hidden sm:flex items-center justify-center shadow-inner">
|
||||||
|
<Icon className={`w-5 h-5 ${pageInfo.iconColor}`} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base sm:text-lg font-bold text-white tracking-tight flex items-center space-x-2">
|
||||||
|
<span>{pageInfo.title}</span>
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-slate-400 hidden md:block">
|
||||||
|
{pageInfo.subtitle}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Stats & Quick Actions */}
|
||||||
|
<div className="flex items-center space-x-2 sm:space-x-3">
|
||||||
|
{/* User Profile & JWT Button */}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAuthModal(true)}
|
||||||
|
className={`flex items-center space-x-2 px-2.5 py-1.5 rounded-xl text-xs border transition-all cursor-pointer ${
|
||||||
|
authSession.isAuthenticated
|
||||||
|
? 'bg-slate-900 border-pink-500/30 hover:border-pink-500/60 text-slate-200'
|
||||||
|
: 'bg-slate-900/60 border-slate-800 hover:border-slate-700 text-slate-400'
|
||||||
|
}`}
|
||||||
|
title="Gérer la session utilisateur, le jeton JWT et la liaison NCB"
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<img
|
||||||
|
src={authSession.user?.avatarUrl || 'https://api.dicebear.com/7.x/bottts/svg?seed=uwu'}
|
||||||
|
alt={authSession.user?.name}
|
||||||
|
className="w-5 h-5 rounded-full bg-slate-800 object-cover border border-slate-700"
|
||||||
|
/>
|
||||||
|
{authSession.isAuthenticated && (
|
||||||
|
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full bg-emerald-400 animate-pulse" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="font-semibold text-xs text-slate-200 hidden md:inline truncate max-w-[120px]">
|
||||||
|
{authSession.user?.name?.split(' ')[0] || 'Utilisateur'}
|
||||||
|
</span>
|
||||||
|
<span className="px-1.5 py-0.2 rounded text-[9px] font-mono font-bold uppercase bg-pink-500/15 text-pink-300 hidden lg:inline border border-pink-500/30">
|
||||||
|
JWT
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Cloud Database Status indicator */}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAuthModal(true)}
|
||||||
|
className={`hidden md:flex items-center space-x-1.5 px-2.5 py-1.5 rounded-xl text-xs border transition-all cursor-pointer ${
|
||||||
|
dbSyncStatus === 'connected'
|
||||||
|
? 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30 hover:bg-emerald-500/20'
|
||||||
|
: dbSyncStatus === 'syncing'
|
||||||
|
? 'bg-amber-500/10 text-amber-400 border-amber-500/30'
|
||||||
|
: 'bg-rose-500/10 text-rose-400 border-rose-500/30 hover:bg-rose-500/20'
|
||||||
|
}`}
|
||||||
|
title="Statut Cloud Database NoCodeBackend (54770_uwu_finances) - Cliquez pour gérer"
|
||||||
|
>
|
||||||
|
{dbSyncStatus === 'syncing' ? (
|
||||||
|
<RefreshCw className="w-3 h-3 animate-spin text-amber-400" />
|
||||||
|
) : (
|
||||||
|
<span className={`w-2 h-2 rounded-full ${
|
||||||
|
dbSyncStatus === 'connected' ? 'bg-emerald-400 animate-pulse' : 'bg-rose-400'
|
||||||
|
}`} />
|
||||||
|
)}
|
||||||
|
<Database className="w-3 h-3 text-slate-400" />
|
||||||
|
<span className="font-mono text-[11px] font-semibold">NCB EU</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Quick Inventory Stock Valuation badge */}
|
||||||
|
<div className="hidden xl:flex items-center space-x-2 px-3 py-1.5 rounded-xl bg-slate-900/80 border border-slate-800 text-xs">
|
||||||
|
<Coins className="w-3.5 h-3.5 text-cyan-400" />
|
||||||
|
<span className="text-slate-400">Stock valorisé:</span>
|
||||||
|
<span className="font-mono font-bold text-cyan-300">
|
||||||
|
{formatCurrency(totalStockValuation)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active Projects badge */}
|
||||||
|
<div className="hidden xl:flex items-center space-x-2 px-3 py-1.5 rounded-xl bg-slate-900/80 border border-slate-800 text-xs">
|
||||||
|
<Cpu className="w-3.5 h-3.5 text-purple-400" />
|
||||||
|
<span className="text-slate-400">En cours:</span>
|
||||||
|
<span className="font-mono font-bold text-purple-300">
|
||||||
|
{activeProjectsCount} projet{activeProjectsCount > 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Add Part */}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowNewInventoryModal(true)}
|
||||||
|
className="flex items-center space-x-1.5 px-3 py-1.5 sm:px-3.5 sm:py-2 rounded-xl text-xs sm:text-sm font-semibold bg-slate-900 text-cyan-300 border border-cyan-500/30 hover:bg-cyan-500/10 hover:border-cyan-500/60 shadow-sm transition-all duration-200"
|
||||||
|
title="Ajouter un achat de pièces au stock"
|
||||||
|
>
|
||||||
|
<PackagePlus className="w-4 h-4 text-cyan-400" />
|
||||||
|
<span className="hidden sm:inline">Achat Pièce</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Quick New Project */}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowNewProjectModal(true)}
|
||||||
|
className="flex items-center space-x-1.5 px-3 py-1.5 sm:px-3.5 sm:py-2 rounded-xl text-xs sm:text-sm font-bold bg-gradient-to-r from-pink-500 via-purple-600 to-pink-600 text-white shadow-lg shadow-pink-500/20 hover:shadow-pink-500/35 hover:scale-[1.02] active:scale-[0.98] transition-all duration-200"
|
||||||
|
title="Créer une nouvelle console custom"
|
||||||
|
>
|
||||||
|
<Sparkles className="w-4 h-4 text-pink-200" />
|
||||||
|
<span>Nouveau Projet</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
863
src/components/dashboard/DashboardView.tsx
Normal file
863
src/components/dashboard/DashboardView.tsx
Normal file
@@ -0,0 +1,863 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
TrendingUp,
|
||||||
|
DollarSign,
|
||||||
|
Package,
|
||||||
|
Clock,
|
||||||
|
AlertTriangle,
|
||||||
|
Sparkles,
|
||||||
|
Gamepad2,
|
||||||
|
ArrowUpRight,
|
||||||
|
ArrowRight,
|
||||||
|
Plus,
|
||||||
|
Play,
|
||||||
|
CheckCircle2,
|
||||||
|
Box,
|
||||||
|
Layers,
|
||||||
|
Wrench,
|
||||||
|
Percent,
|
||||||
|
PieChart as PieIcon,
|
||||||
|
BarChart2,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
ResponsiveContainer,
|
||||||
|
AreaChart,
|
||||||
|
Area,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
Tooltip,
|
||||||
|
PieChart,
|
||||||
|
Pie,
|
||||||
|
Cell,
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
Legend,
|
||||||
|
} from 'recharts';
|
||||||
|
import { useApp } from '../../context/AppContext';
|
||||||
|
import {
|
||||||
|
calculateProjectFinancials,
|
||||||
|
calculateTotalStockValue,
|
||||||
|
formatCurrency,
|
||||||
|
formatMinutes,
|
||||||
|
getProjectStatusInfo,
|
||||||
|
} from '../../utils/calculations';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const DashboardView: React.FC = () => {
|
||||||
|
const {
|
||||||
|
projects,
|
||||||
|
inventory,
|
||||||
|
settings,
|
||||||
|
setCurrentTab,
|
||||||
|
setSelectedProjectId,
|
||||||
|
setShowNewProjectModal,
|
||||||
|
setShowNewInventoryModal,
|
||||||
|
startTimer,
|
||||||
|
} = useApp();
|
||||||
|
|
||||||
|
// 1. Calculate All Financial Metrics
|
||||||
|
const soldProjects = projects.filter((p) => p.status === 'sold');
|
||||||
|
const inStockReadyProjects = projects.filter((p) => p.status === 'in_stock_ready');
|
||||||
|
const inProgressProjects = projects.filter(
|
||||||
|
(p) => p.status === 'in_progress' || p.status === 'qc_testing'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Total Revenue from sold builds
|
||||||
|
const totalRevenue = soldProjects.reduce((sum, p) => {
|
||||||
|
return sum + (p.salePriceActual !== undefined ? p.salePriceActual : p.salePriceTarget || 0);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
// Total Hardware/Direct Costs for sold builds
|
||||||
|
const totalSoldDirectCosts = soldProjects.reduce((sum, p) => {
|
||||||
|
const fin = calculateProjectFinancials(p);
|
||||||
|
return sum + fin.hardwareDirectCost + fin.shippingAndPlatformCost;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
// Total Net Profit for sold builds (CA - COGS)
|
||||||
|
const totalNetProfit = soldProjects.reduce((sum, p) => {
|
||||||
|
const fin = calculateProjectFinancials(p);
|
||||||
|
return sum + fin.netProfit;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
// Total Gross Profit for sold builds (CA - Hardware)
|
||||||
|
const totalGrossProfit = totalRevenue - totalSoldDirectCosts;
|
||||||
|
|
||||||
|
// Average Margin %
|
||||||
|
const averageMarginPercent =
|
||||||
|
soldProjects.length > 0
|
||||||
|
? soldProjects.reduce((sum, p) => sum + calculateProjectFinancials(p).marginPercent, 0) /
|
||||||
|
soldProjects.length
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// Total Labor time logged across all projects
|
||||||
|
const totalLaborMinutesAll = projects.reduce((sum, p) => {
|
||||||
|
return sum + p.timeSessions.reduce((s, t) => s + t.durationMinutes, 0);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
// Total Labor time for sold builds only
|
||||||
|
const totalLaborMinutesSold = soldProjects.reduce((sum, p) => {
|
||||||
|
return sum + p.timeSessions.reduce((s, t) => s + t.durationMinutes, 0);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
// Effective Hourly Rate on sold builds: Total Net Before Labor / Hours
|
||||||
|
const effectiveHourlyRateAverage =
|
||||||
|
totalLaborMinutesSold > 0
|
||||||
|
? totalGrossProfit / (totalLaborMinutesSold / 60)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// Total Inventory Value (Parts & Donor Consoles in stock)
|
||||||
|
const totalStockValue = calculateTotalStockValue(inventory);
|
||||||
|
|
||||||
|
// Total potential value of finished consoles currently in stock ready to sell
|
||||||
|
const potentialShowcaseRevenue = inStockReadyProjects.reduce(
|
||||||
|
(sum, p) => sum + (p.salePriceTarget || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
// Count donor consoles in stock (category === 'console_base')
|
||||||
|
const donorConsolesInStock = inventory
|
||||||
|
.filter((i) => i.category === 'console_base')
|
||||||
|
.reduce((sum, i) => sum + i.quantityInStock, 0);
|
||||||
|
|
||||||
|
// Low stock alerts
|
||||||
|
const lowStockItems = inventory.filter(
|
||||||
|
(item) => item.quantityInStock <= item.minStockAlert
|
||||||
|
);
|
||||||
|
|
||||||
|
// Group projects by Console Model for breakdown
|
||||||
|
const modelStats: Record<string, { count: number; soldCount: number; profit: number }> = {};
|
||||||
|
projects.forEach((proj) => {
|
||||||
|
if (!modelStats[proj.consoleModel]) {
|
||||||
|
modelStats[proj.consoleModel] = { count: 0, soldCount: 0, profit: 0 };
|
||||||
|
}
|
||||||
|
modelStats[proj.consoleModel].count += 1;
|
||||||
|
if (proj.status === 'sold') {
|
||||||
|
modelStats[proj.consoleModel].soldCount += 1;
|
||||||
|
modelStats[proj.consoleModel].profit += calculateProjectFinancials(proj).netProfit;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8 pb-12 animate-fadeIn">
|
||||||
|
|
||||||
|
{/* Welcome Banner */}
|
||||||
|
<div className="relative overflow-hidden rounded-3xl bg-gradient-to-r from-slate-900 via-purple-950/40 to-slate-900 border border-pink-500/20 p-6 sm:p-8 shadow-2xl">
|
||||||
|
<div className="absolute top-0 right-0 -mr-16 -mt-16 w-64 h-64 bg-pink-500/10 rounded-full blur-3xl pointer-events-none" />
|
||||||
|
<div className="absolute bottom-0 right-32 -mb-16 w-64 h-64 bg-cyan-500/10 rounded-full blur-3xl pointer-events-none" />
|
||||||
|
|
||||||
|
<div className="relative z-10 flex flex-col md:flex-row md:items-center justify-between gap-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="inline-flex items-center space-x-2 px-3 py-1 rounded-full text-xs font-semibold bg-pink-500/10 text-pink-400 border border-pink-500/30">
|
||||||
|
<Sparkles className="w-3.5 h-3.5" />
|
||||||
|
<span>Studio & Atelier de Customisation</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl sm:text-3xl font-black text-white font-display">
|
||||||
|
Tableau de bord financier & Atelier
|
||||||
|
</h2>
|
||||||
|
<p className="text-slate-400 text-sm max-w-2xl leading-relaxed">
|
||||||
|
Supervisez en direct vos achats de pièces, vos coûts de fabrication par console,
|
||||||
|
le temps passé sur chaque mod et la rentabilité nette de votre activité.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3 shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
startTimer();
|
||||||
|
setCurrentTab('timetracker');
|
||||||
|
}}
|
||||||
|
className="flex items-center space-x-2 px-4 py-2.5 rounded-2xl bg-amber-500/15 text-amber-300 border border-amber-500/30 hover:bg-amber-500/25 hover:border-amber-500/50 transition-all font-semibold text-sm shadow-md"
|
||||||
|
>
|
||||||
|
<Play className="w-4 h-4 fill-amber-300 text-amber-300" />
|
||||||
|
<span>Lancer Chrono Établi</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowNewProjectModal(true)}
|
||||||
|
className="flex items-center space-x-2 px-4 py-2.5 rounded-2xl bg-gradient-to-r from-pink-500 to-purple-600 text-white font-bold text-sm shadow-lg shadow-pink-500/20 hover:scale-[1.02] active:scale-[0.98] transition-all"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
<span>Nouvelle Console</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main KPI Cards Grid */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
|
||||||
|
|
||||||
|
{/* CA & Ventes */}
|
||||||
|
<div className="glass-panel p-5 rounded-2xl border border-slate-800 hover:border-pink-500/40 transition-all group">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wider text-slate-400">
|
||||||
|
Chiffre d'Affaires Réalisé
|
||||||
|
</span>
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-pink-500/10 flex items-center justify-center text-pink-400 group-hover:scale-110 transition-transform">
|
||||||
|
<DollarSign className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="text-2xl sm:text-3xl font-black text-white font-mono">
|
||||||
|
{formatCurrency(totalRevenue, settings.currency)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center mt-2 text-xs text-slate-400">
|
||||||
|
<span className="text-emerald-400 font-bold mr-1.5 flex items-center">
|
||||||
|
<ArrowUpRight className="w-3.5 h-3.5 mr-0.5" />
|
||||||
|
{soldProjects.length} vendue{soldProjects.length > 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
<span>sur {projects.length} projets totaux</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bénéfice Net Réalisé */}
|
||||||
|
<div className="glass-panel p-5 rounded-2xl border border-slate-800 hover:border-emerald-500/40 transition-all group">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wider text-slate-400">
|
||||||
|
Bénéfice Net Réalisé
|
||||||
|
</span>
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-emerald-500/10 flex items-center justify-center text-emerald-400 group-hover:scale-110 transition-transform">
|
||||||
|
<TrendingUp className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="text-2xl sm:text-3xl font-black text-emerald-400 font-mono">
|
||||||
|
{formatCurrency(totalNetProfit, settings.currency)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center mt-2 text-xs text-slate-400">
|
||||||
|
<span className="text-emerald-300 font-semibold mr-1">
|
||||||
|
Marge moy. : {averageMarginPercent.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
<span>(après main d'œuvre)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Valeur Stock Pièces & Consoles */}
|
||||||
|
<div className="glass-panel p-5 rounded-2xl border border-slate-800 hover:border-cyan-500/40 transition-all group cursor-pointer" onClick={() => setCurrentTab('purchases')}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wider text-slate-400">
|
||||||
|
Valeur Stock Pièces
|
||||||
|
</span>
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-cyan-500/10 flex items-center justify-center text-cyan-400 group-hover:scale-110 transition-transform">
|
||||||
|
<Package className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="text-2xl sm:text-3xl font-black text-cyan-300 font-mono">
|
||||||
|
{formatCurrency(totalStockValue, settings.currency)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center mt-2 text-xs text-slate-400">
|
||||||
|
<span className="text-cyan-400 font-bold mr-1">
|
||||||
|
{donorConsolesInStock} consoles brutes
|
||||||
|
</span>
|
||||||
|
<span>+ {inventory.length} réf. pièces</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Temps d'Atelier & Rémunération Réelle */}
|
||||||
|
<div className="glass-panel p-5 rounded-2xl border border-slate-800 hover:border-amber-500/40 transition-all group cursor-pointer" onClick={() => setCurrentTab('timetracker')}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wider text-slate-400">
|
||||||
|
Temps Total d'Atelier
|
||||||
|
</span>
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-amber-500/10 flex items-center justify-center text-amber-400 group-hover:scale-110 transition-transform">
|
||||||
|
<Clock className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="text-2xl sm:text-3xl font-black text-amber-300 font-mono">
|
||||||
|
{formatMinutes(totalLaborMinutesAll)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center mt-2 text-xs text-slate-400">
|
||||||
|
<span className="text-amber-400 font-bold mr-1">
|
||||||
|
{effectiveHourlyRateAverage > 0 ? `${formatCurrency(effectiveHourlyRateAverage, settings.currency)}/h` : 'Taux configuré : ' + settings.defaultHourlyRate + '€/h'}
|
||||||
|
</span>
|
||||||
|
<span>taux effectif</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Consoles Status Tracker Bar */}
|
||||||
|
<div className="glass-panel rounded-3xl p-6 border border-slate-800">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold text-white flex items-center space-x-2">
|
||||||
|
<Gamepad2 className="w-5 h-5 text-pink-400" />
|
||||||
|
<span>Parc & Pipeline de Consoles</span>
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5">
|
||||||
|
Suivi du cycle de vie complet : des consoles brutes au stock prêt à vendre
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-400 font-mono">
|
||||||
|
Valeur vitrine en stock : <span className="text-emerald-400 font-bold">{formatCurrency(potentialShowcaseRevenue, settings.currency)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||||
|
<div
|
||||||
|
onClick={() => setCurrentTab('purchases')}
|
||||||
|
className="bg-slate-900/80 rounded-2xl p-4 border border-amber-500/20 hover:border-amber-500/60 hover:bg-slate-900 cursor-pointer transition-all group"
|
||||||
|
title="Voir le stock de consoles brutes"
|
||||||
|
>
|
||||||
|
<div className="text-xs text-amber-400 font-semibold flex items-center group-hover:translate-x-0.5 transition-transform">
|
||||||
|
<Box className="w-3.5 h-3.5 mr-1.5" /> Consoles Brutes
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-black text-white font-mono mt-1">
|
||||||
|
{donorConsolesInStock} <span className="text-xs font-normal text-slate-400">en stock</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-slate-400 mt-1">Prêtes à être moddées →</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedProjectId(null);
|
||||||
|
setCurrentTab('projects');
|
||||||
|
}}
|
||||||
|
className="bg-slate-900/80 rounded-2xl p-4 border border-blue-500/20 hover:border-blue-500/60 hover:bg-slate-900 cursor-pointer transition-all group"
|
||||||
|
title="Gérer les builds en cours d'atelier"
|
||||||
|
>
|
||||||
|
<div className="text-xs text-blue-400 font-semibold flex items-center group-hover:translate-x-0.5 transition-transform">
|
||||||
|
<Wrench className="w-3.5 h-3.5 mr-1.5" /> En cours d'Atelier
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-black text-white font-mono mt-1">
|
||||||
|
{inProgressProjects.length} <span className="text-xs font-normal text-slate-400">builds</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-slate-400 mt-1">Soudure & Assemblage →</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedProjectId(null);
|
||||||
|
setCurrentTab('projects');
|
||||||
|
}}
|
||||||
|
className="bg-slate-900/80 rounded-2xl p-4 border border-emerald-500/20 hover:border-emerald-500/60 hover:bg-slate-900 cursor-pointer transition-all group"
|
||||||
|
title="Voir les consoles finies en stock prêtes à vendre"
|
||||||
|
>
|
||||||
|
<div className="text-xs text-emerald-400 font-semibold flex items-center group-hover:translate-x-0.5 transition-transform">
|
||||||
|
<Sparkles className="w-3.5 h-3.5 mr-1.5" /> En Vitrine / Prêtes
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-black text-emerald-400 font-mono mt-1">
|
||||||
|
{inStockReadyProjects.length} <span className="text-xs font-normal text-slate-400">consoles</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-slate-400 mt-1">Prêtes pour la vente →</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedProjectId(null);
|
||||||
|
setCurrentTab('projects');
|
||||||
|
}}
|
||||||
|
className="bg-slate-900/80 rounded-2xl p-4 border border-pink-500/20 hover:border-pink-500/60 hover:bg-slate-900 cursor-pointer transition-all group"
|
||||||
|
title="Voir l'historique des consoles vendues"
|
||||||
|
>
|
||||||
|
<div className="text-xs text-pink-400 font-semibold flex items-center group-hover:translate-x-0.5 transition-transform">
|
||||||
|
<CheckCircle2 className="w-3.5 h-3.5 mr-1.5" /> Consoles Vendues
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-black text-pink-400 font-mono mt-1">
|
||||||
|
{soldProjects.length} <span className="text-xs font-normal text-slate-400">livrées</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-slate-400 mt-1">Clients comblés 🎉 →</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Interactive Charts Section 1: Financial Area Trend & Cost Donut */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
|
||||||
|
{/* Left: Financial Performance Area Chart */}
|
||||||
|
<div className="lg:col-span-2 glass-panel p-6 rounded-3xl border border-slate-800 space-y-4">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-bold text-white flex items-center space-x-2">
|
||||||
|
<BarChart2 className="w-5 h-5 text-pink-400" />
|
||||||
|
<span>Performance Financière par Console</span>
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
Comparaison Chiffre d'Affaires, Coût Total de Revient et Bénéfice Net
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-3 text-xs font-mono">
|
||||||
|
<span className="flex items-center text-pink-400">
|
||||||
|
<span className="w-2.5 h-2.5 rounded-full bg-pink-500 mr-1.5" /> Prix Vente
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center text-cyan-400">
|
||||||
|
<span className="w-2.5 h-2.5 rounded-full bg-cyan-500 mr-1.5" /> Coûts (COGS)
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center text-emerald-400">
|
||||||
|
<span className="w-2.5 h-2.5 rounded-full bg-emerald-500 mr-1.5" /> Bénéfice
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-72 w-full pt-2">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart
|
||||||
|
data={projects.map((p) => {
|
||||||
|
const f = calculateProjectFinancials(p);
|
||||||
|
return {
|
||||||
|
name: p.serialNumber,
|
||||||
|
title: p.title,
|
||||||
|
revenue: f.revenue,
|
||||||
|
cogs: f.totalCostOfGoodsSold,
|
||||||
|
netProfit: f.netProfit,
|
||||||
|
hardwareCost: f.hardwareDirectCost,
|
||||||
|
laborCost: f.laborCost,
|
||||||
|
};
|
||||||
|
})}
|
||||||
|
margin={{ top: 10, right: 10, left: -20, bottom: 0 }}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="colorRevenue" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="#ec4899" stopOpacity={0.4} />
|
||||||
|
<stop offset="95%" stopColor="#ec4899" stopOpacity={0.0} />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="colorCogs" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="#06b6d4" stopOpacity={0.3} />
|
||||||
|
<stop offset="95%" stopColor="#06b6d4" stopOpacity={0.0} />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="colorProfit" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="#10b981" stopOpacity={0.4} />
|
||||||
|
<stop offset="95%" stopColor="#10b981" stopOpacity={0.0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<XAxis
|
||||||
|
dataKey="name"
|
||||||
|
stroke="#64748b"
|
||||||
|
fontSize={11}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={{ stroke: '#334155' }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
stroke="#64748b"
|
||||||
|
fontSize={11}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={{ stroke: '#334155' }}
|
||||||
|
tickFormatter={(val) => `${val}€`}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
content={({ active, payload }) => {
|
||||||
|
if (active && payload && payload.length) {
|
||||||
|
const data = payload[0].payload;
|
||||||
|
return (
|
||||||
|
<div className="bg-slate-900 border border-slate-700 p-3 rounded-2xl shadow-xl text-xs space-y-1.5 font-mono">
|
||||||
|
<div className="font-bold text-white font-sans text-xs pb-1 border-b border-slate-800">
|
||||||
|
{data.title} ({data.name})
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between space-x-4 text-pink-400">
|
||||||
|
<span>Prix Vente :</span>
|
||||||
|
<span className="font-bold">{formatCurrency(data.revenue, settings.currency)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between space-x-4 text-cyan-300">
|
||||||
|
<span>Coût Revient :</span>
|
||||||
|
<span>{formatCurrency(data.cogs, settings.currency)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between space-x-4 text-slate-400 text-[10px]">
|
||||||
|
<span>↳ Matériel : {formatCurrency(data.hardwareCost, settings.currency)}</span>
|
||||||
|
<span>↳ Mo : {formatCurrency(data.laborCost, settings.currency)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between space-x-4 text-emerald-400 font-bold pt-1 border-t border-slate-800">
|
||||||
|
<span>Bénéfice Net :</span>
|
||||||
|
<span>{formatCurrency(data.netProfit, settings.currency)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="revenue"
|
||||||
|
stroke="#ec4899"
|
||||||
|
strokeWidth={2.5}
|
||||||
|
fillOpacity={1}
|
||||||
|
fill="url(#colorRevenue)"
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="cogs"
|
||||||
|
stroke="#06b6d4"
|
||||||
|
strokeWidth={2}
|
||||||
|
fillOpacity={1}
|
||||||
|
fill="url(#colorCogs)"
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="netProfit"
|
||||||
|
stroke="#10b981"
|
||||||
|
strokeWidth={2.5}
|
||||||
|
fillOpacity={1}
|
||||||
|
fill="url(#colorProfit)"
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Cost Structure Donut Chart */}
|
||||||
|
<div className="glass-panel p-6 rounded-3xl border border-slate-800 space-y-4 flex flex-col justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-bold text-white flex items-center space-x-2">
|
||||||
|
<PieIcon className="w-5 h-5 text-purple-400" />
|
||||||
|
<span>Répartition des Dépenses</span>
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
Coûts engagés sur l'ensemble de vos builds
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(() => {
|
||||||
|
let screens = 0;
|
||||||
|
let consoles = 0;
|
||||||
|
let shells = 0;
|
||||||
|
let power = 0;
|
||||||
|
let audio = 0;
|
||||||
|
let labor = 0;
|
||||||
|
let fees = 0;
|
||||||
|
|
||||||
|
projects.forEach((p) => {
|
||||||
|
const fin = calculateProjectFinancials(p);
|
||||||
|
labor += fin.laborCost;
|
||||||
|
fees += fin.consumablesAndPackagingCost + fin.shippingAndPlatformCost;
|
||||||
|
p.parts.forEach((pt) => {
|
||||||
|
if (pt.category === 'screen') screens += pt.totalCost;
|
||||||
|
else if (pt.category === 'console_base') consoles += pt.totalCost;
|
||||||
|
else if (pt.category === 'shell' || pt.category === 'buttons_pads') shells += pt.totalCost;
|
||||||
|
else if (pt.category === 'battery_power') power += pt.totalCost;
|
||||||
|
else if (pt.category === 'sound_amp') audio += pt.totalCost;
|
||||||
|
else fees += pt.totalCost;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const pieData = [
|
||||||
|
{ name: 'Écrans IPS/OLED', value: Math.round(screens), color: '#06b6d4' },
|
||||||
|
{ name: 'Consoles Donneuses', value: Math.round(consoles), color: '#f59e0b' },
|
||||||
|
{ name: 'Coques & Boutons', value: Math.round(shells), color: '#a855f7' },
|
||||||
|
{ name: 'Main d\'œuvre', value: Math.round(labor), color: '#ec4899' },
|
||||||
|
{ name: 'Batteries & USB-C', value: Math.round(power), color: '#10b981' },
|
||||||
|
{ name: 'Audio & Amplis', value: Math.round(audio), color: '#3b82f6' },
|
||||||
|
{ name: 'Frais & Consommables', value: Math.round(fees), color: '#64748b' },
|
||||||
|
].filter((d) => d.value > 0);
|
||||||
|
|
||||||
|
const totalPieCost = pieData.reduce((s, d) => s + d.value, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="h-48 w-full relative flex items-center justify-center">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={pieData}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={55}
|
||||||
|
outerRadius={80}
|
||||||
|
paddingAngle={3}
|
||||||
|
dataKey="value"
|
||||||
|
>
|
||||||
|
{pieData.map((entry, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip
|
||||||
|
content={({ active, payload }) => {
|
||||||
|
if (active && payload && payload.length) {
|
||||||
|
const data = payload[0];
|
||||||
|
const percent = totalPieCost > 0 ? ((Number(data.value) / totalPieCost) * 100).toFixed(1) : 0;
|
||||||
|
return (
|
||||||
|
<div className="bg-slate-900 border border-slate-700 p-2.5 rounded-xl shadow-xl text-xs font-mono">
|
||||||
|
<div className="font-bold text-white">{data.name}</div>
|
||||||
|
<div className="text-cyan-400">{formatCurrency(Number(data.value), settings.currency)} ({percent}%)</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
|
||||||
|
<span className="text-[10px] text-slate-400 uppercase font-bold tracking-wider">Total</span>
|
||||||
|
<span className="text-base font-black text-white font-mono">{formatCurrency(totalPieCost, settings.currency)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-1.5 text-[11px] pt-2 border-t border-slate-800">
|
||||||
|
{pieData.slice(0, 6).map((item) => (
|
||||||
|
<div key={item.name} className="flex items-center space-x-1.5 truncate">
|
||||||
|
<span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: item.color }} />
|
||||||
|
<span className="text-slate-300 truncate">{item.name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Interactive Charts Section 2: Bar Chart Breakdown by Console Model */}
|
||||||
|
<div className="glass-panel p-6 rounded-3xl border border-slate-800 space-y-4">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-bold text-white flex items-center space-x-2">
|
||||||
|
<Gamepad2 className="w-5 h-5 text-cyan-400" />
|
||||||
|
<span>Volume & Statut de Production par Modèle</span>
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
Répartition des consoles en cours, en vitrine et déjà vendues
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-60 w-full pt-2">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart
|
||||||
|
data={Object.entries(modelStats).map(([model, data]) => {
|
||||||
|
const inProgress = projects.filter((p) => p.consoleModel === model && (p.status === 'in_progress' || p.status === 'qc_testing')).length;
|
||||||
|
const ready = projects.filter((p) => p.consoleModel === model && p.status === 'in_stock_ready').length;
|
||||||
|
const sold = projects.filter((p) => p.consoleModel === model && p.status === 'sold').length;
|
||||||
|
return {
|
||||||
|
model,
|
||||||
|
'En cours': inProgress,
|
||||||
|
'En Vitrine': ready,
|
||||||
|
'Vendues': sold,
|
||||||
|
totalProfit: data.profit,
|
||||||
|
};
|
||||||
|
})}
|
||||||
|
margin={{ top: 10, right: 10, left: -20, bottom: 0 }}
|
||||||
|
>
|
||||||
|
<XAxis dataKey="model" stroke="#64748b" fontSize={11} tickLine={false} axisLine={{ stroke: '#334155' }} />
|
||||||
|
<YAxis stroke="#64748b" fontSize={11} tickLine={false} axisLine={{ stroke: '#334155' }} allowDecimals={false} />
|
||||||
|
<Tooltip
|
||||||
|
content={({ active, payload }) => {
|
||||||
|
if (active && payload && payload.length) {
|
||||||
|
const d = payload[0].payload;
|
||||||
|
return (
|
||||||
|
<div className="bg-slate-900 border border-slate-700 p-3 rounded-2xl shadow-xl text-xs space-y-1 font-mono">
|
||||||
|
<div className="font-bold text-white font-sans text-xs pb-1 border-b border-slate-800">{d.model}</div>
|
||||||
|
<div className="text-blue-400">⚡ En cours : {d['En cours']} build(s)</div>
|
||||||
|
<div className="text-emerald-400">✨ En vitrine : {d['En Vitrine']} console(s)</div>
|
||||||
|
<div className="text-pink-400">🎉 Vendues : {d['Vendues']} console(s)</div>
|
||||||
|
{d.totalProfit > 0 && (
|
||||||
|
<div className="text-amber-300 font-bold pt-1 border-t border-slate-800">
|
||||||
|
Gain Net Total : {formatCurrency(d.totalProfit, settings.currency)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Legend wrapperStyle={{ fontSize: '11px', paddingTop: '8px' }} />
|
||||||
|
<Bar dataKey="En cours" fill="#3b82f6" radius={[4, 4, 0, 0]} />
|
||||||
|
<Bar dataKey="En Vitrine" fill="#10b981" radius={[4, 4, 0, 0]} />
|
||||||
|
<Bar dataKey="Vendues" fill="#ec4899" radius={[4, 4, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{/* Two Columns: Recent/Active Projects & Stock Alerts */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
|
|
||||||
|
{/* Left 2 Cols: Projects & Custom Builds Showcase */}
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold text-white flex items-center space-x-2">
|
||||||
|
<Layers className="w-5 h-5 text-purple-400" />
|
||||||
|
<span>Consoles Récents & Projets Actifs</span>
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
Accédez aux fiches de dépenses, temps et rentabilité par console
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentTab('projects')}
|
||||||
|
className="text-xs font-semibold text-pink-400 hover:text-pink-300 flex items-center space-x-1"
|
||||||
|
>
|
||||||
|
<span>Voir tout ({projects.length})</span>
|
||||||
|
<ArrowRight className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3.5">
|
||||||
|
{projects.slice(0, 4).map((project) => {
|
||||||
|
const fin = calculateProjectFinancials(project);
|
||||||
|
const statusInfo = getProjectStatusInfo(project.status);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={project.id}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedProjectId(project.id);
|
||||||
|
setCurrentTab('projects');
|
||||||
|
}}
|
||||||
|
className="glass-panel p-4 sm:p-5 rounded-2xl border border-slate-800/80 hover:border-purple-500/40 cursor-pointer transition-all duration-200 group"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||||
|
<div className="flex items-start space-x-3.5">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-slate-900 border border-slate-800 flex items-center justify-center shrink-0 text-pink-400 group-hover:scale-105 transition-transform">
|
||||||
|
<Gamepad2 className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<h4 className="text-sm font-bold text-white group-hover:text-pink-300 transition-colors">
|
||||||
|
{project.title}
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 mt-1">
|
||||||
|
<span className="text-[11px] font-mono text-slate-400">
|
||||||
|
{project.serialNumber}
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-600">•</span>
|
||||||
|
<span className="text-[11px] text-cyan-400 font-semibold">
|
||||||
|
{project.consoleModel}
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-600">•</span>
|
||||||
|
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold ${statusInfo.bg} ${statusInfo.text}`}>
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full ${statusInfo.dot} mr-1`} />
|
||||||
|
{statusInfo.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between sm:justify-end sm:space-x-6 border-t sm:border-t-0 pt-2 sm:pt-0 border-slate-800/80">
|
||||||
|
<div className="text-left sm:text-right">
|
||||||
|
<div className="text-xs text-slate-400">Prix Vente</div>
|
||||||
|
<div className="text-sm font-bold text-white font-mono">
|
||||||
|
{formatCurrency(fin.revenue, settings.currency)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-left sm:text-right">
|
||||||
|
<div className="text-xs text-slate-400">Bénéfice Net</div>
|
||||||
|
<div className={`text-sm font-bold font-mono ${fin.netProfit >= 0 ? 'text-emerald-400' : 'text-rose-400'}`}>
|
||||||
|
{formatCurrency(fin.netProfit, settings.currency)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-left sm:text-right hidden md:block">
|
||||||
|
<div className="text-xs text-slate-400">Temps</div>
|
||||||
|
<div className="text-xs font-semibold text-amber-300 font-mono">
|
||||||
|
{formatMinutes(fin.laborMinutes)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ArrowRight className="w-4 h-4 text-slate-500 group-hover:text-white group-hover:translate-x-1 transition-all" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right 1 Col: Stock Alerts & Inventory Quick-Look */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-bold text-white flex items-center space-x-2">
|
||||||
|
<AlertTriangle className="w-5 h-5 text-amber-400" />
|
||||||
|
<span>Alertes & Stock Pièces</span>
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentTab('purchases')}
|
||||||
|
className="text-xs font-semibold text-cyan-400 hover:text-cyan-300"
|
||||||
|
>
|
||||||
|
Gérer le stock
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="glass-panel p-5 rounded-3xl border border-slate-800 space-y-4">
|
||||||
|
{lowStockItems.length > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="p-3 rounded-2xl bg-amber-500/10 border border-amber-500/30 text-amber-300 text-xs flex items-start space-x-2">
|
||||||
|
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5 text-amber-400" />
|
||||||
|
<div>
|
||||||
|
<span className="font-bold">{lowStockItems.length} article(s)</span> en stock critique. Pensez à réapprovisionner pour ne pas bloquer vos futurs builds.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divide-y divide-slate-800/80">
|
||||||
|
{lowStockItems.slice(0, 4).map((item) => (
|
||||||
|
<div key={item.id} className="py-2.5 flex items-center justify-between text-xs">
|
||||||
|
<div className="pr-2">
|
||||||
|
<div className="font-semibold text-slate-200 truncate max-w-[170px]">
|
||||||
|
{item.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-slate-400">
|
||||||
|
{item.consoleModel} • Fournisseur: {item.supplier}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right shrink-0">
|
||||||
|
<span className="px-2 py-0.5 rounded-full font-bold bg-rose-500/15 text-rose-300 border border-rose-500/30 font-mono">
|
||||||
|
{item.quantityInStock} dispo
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="py-6 text-center text-slate-400 space-y-2">
|
||||||
|
<CheckCircle2 className="w-8 h-8 text-emerald-400 mx-auto" />
|
||||||
|
<div className="text-xs font-semibold text-slate-300">
|
||||||
|
Tous les stocks sont au vert !
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px]">
|
||||||
|
Aucun composant en dessous du seuil d'alerte.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setShowNewInventoryModal(true)}
|
||||||
|
className="w-full py-2.5 rounded-xl bg-slate-900 border border-cyan-500/30 text-cyan-300 hover:bg-cyan-500/10 transition-all text-xs font-bold flex items-center justify-center space-x-1.5"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" />
|
||||||
|
<span>Enregistrer un nouvel achat de pièces</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Console Breakdown Summary */}
|
||||||
|
<div className="glass-panel p-5 rounded-3xl border border-slate-800 space-y-3">
|
||||||
|
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-400 flex items-center space-x-1.5">
|
||||||
|
<Percent className="w-4 h-4 text-pink-400" />
|
||||||
|
<span>Répartition par Modèle</span>
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2 text-xs">
|
||||||
|
{Object.entries(modelStats).map(([model, data]) => (
|
||||||
|
<div key={model} className="flex items-center justify-between py-1 border-b border-slate-900/60 last:border-0">
|
||||||
|
<span className="text-slate-300 font-medium">{model}</span>
|
||||||
|
<div className="flex items-center space-x-2 font-mono">
|
||||||
|
<span className="text-slate-400">{data.count} projets</span>
|
||||||
|
{data.profit > 0 && (
|
||||||
|
<span className="text-emerald-400 font-bold">
|
||||||
|
+{formatCurrency(data.profit, settings.currency)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
383
src/components/inventory/InventoryModal.tsx
Normal file
383
src/components/inventory/InventoryModal.tsx
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { X, Package, Calculator, MapPin, Tag, Building2, Calendar, FileText } from 'lucide-react';
|
||||||
|
import { useApp } from '../../context/AppContext';
|
||||||
|
import type { InventoryItem, ConsoleModel, PartCategory } from '../../types';
|
||||||
|
|
||||||
|
import { formatCurrency } from '../../utils/calculations';
|
||||||
|
|
||||||
|
interface InventoryModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
editingItem?: InventoryItem | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InventoryModal: React.FC<InventoryModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
editingItem,
|
||||||
|
}) => {
|
||||||
|
const { addInventoryItem, updateInventoryItem, settings, consoleModels, partCategories } = useApp();
|
||||||
|
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [sku, setSku] = useState('');
|
||||||
|
const [category, setCategory] = useState<PartCategory>('screen');
|
||||||
|
const [consoleModel, setConsoleModel] = useState<ConsoleModel>('GBA SP');
|
||||||
|
const [supplier, setSupplier] = useState('FunnyPlaying');
|
||||||
|
const [orderDate, setOrderDate] = useState(new Date().toISOString().split('T')[0]);
|
||||||
|
const [unitPurchasePrice, setUnitPurchasePrice] = useState<number>(35);
|
||||||
|
const [shippingCostShare, setShippingCostShare] = useState<number>(2);
|
||||||
|
const [taxImportShare, setTaxImportShare] = useState<number>(0);
|
||||||
|
const [quantityInStock, setQuantityInStock] = useState<number>(3);
|
||||||
|
const [minStockAlert, setMinStockAlert] = useState<number>(1);
|
||||||
|
const [locationInWorkshop, setLocationInWorkshop] = useState('Tiroir B1');
|
||||||
|
const [notes, setNotes] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editingItem) {
|
||||||
|
setName(editingItem.name);
|
||||||
|
setSku(editingItem.sku);
|
||||||
|
setCategory(editingItem.category);
|
||||||
|
setConsoleModel(editingItem.consoleModel);
|
||||||
|
setSupplier(editingItem.supplier);
|
||||||
|
setOrderDate(editingItem.orderDate);
|
||||||
|
setUnitPurchasePrice(editingItem.unitPurchasePrice);
|
||||||
|
setShippingCostShare(editingItem.shippingCostShare || 0);
|
||||||
|
setTaxImportShare(editingItem.taxImportShare || 0);
|
||||||
|
setQuantityInStock(editingItem.quantityInStock);
|
||||||
|
setMinStockAlert(editingItem.minStockAlert || 1);
|
||||||
|
setLocationInWorkshop(editingItem.locationInWorkshop || '');
|
||||||
|
setNotes(editingItem.notes || '');
|
||||||
|
} else {
|
||||||
|
// Reset form
|
||||||
|
setName('');
|
||||||
|
setSku('SKU-' + Math.floor(1000 + Math.random() * 9000));
|
||||||
|
setCategory('screen');
|
||||||
|
setConsoleModel('GBA SP');
|
||||||
|
setSupplier('AliExpress / Fabricant');
|
||||||
|
setOrderDate(new Date().toISOString().split('T')[0]);
|
||||||
|
setUnitPurchasePrice(25);
|
||||||
|
setShippingCostShare(1.5);
|
||||||
|
setTaxImportShare(0);
|
||||||
|
setQuantityInStock(2);
|
||||||
|
setMinStockAlert(1);
|
||||||
|
setLocationInWorkshop('');
|
||||||
|
setNotes('');
|
||||||
|
}
|
||||||
|
}, [editingItem, isOpen]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
// Calcul du coût unitaire réel avec frais
|
||||||
|
const calculatedRealUnitCost =
|
||||||
|
Number(unitPurchasePrice || 0) +
|
||||||
|
Number(shippingCostShare || 0) +
|
||||||
|
Number(taxImportShare || 0);
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!name.trim()) return;
|
||||||
|
|
||||||
|
const itemData = {
|
||||||
|
name: name.trim(),
|
||||||
|
sku: sku.trim() || 'SKU-' + Date.now().toString().slice(-4),
|
||||||
|
category,
|
||||||
|
consoleModel,
|
||||||
|
supplier: supplier.trim() || 'Inconnu',
|
||||||
|
orderDate,
|
||||||
|
unitPurchasePrice: Number(unitPurchasePrice) || 0,
|
||||||
|
shippingCostShare: Number(shippingCostShare) || 0,
|
||||||
|
taxImportShare: Number(taxImportShare) || 0,
|
||||||
|
unitCostWithFees: calculatedRealUnitCost,
|
||||||
|
quantityInStock: Number(quantityInStock) || 0,
|
||||||
|
quantityReserved: editingItem?.quantityReserved || 0,
|
||||||
|
minStockAlert: Number(minStockAlert) || 1,
|
||||||
|
status: (Number(quantityInStock) <= 0
|
||||||
|
? 'out_of_stock'
|
||||||
|
: Number(quantityInStock) <= Number(minStockAlert)
|
||||||
|
? 'low_stock'
|
||||||
|
: 'in_stock') as 'in_stock' | 'low_stock' | 'out_of_stock',
|
||||||
|
locationInWorkshop: locationInWorkshop.trim(),
|
||||||
|
notes: notes.trim(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editingItem) {
|
||||||
|
updateInventoryItem(editingItem.id, itemData);
|
||||||
|
} else {
|
||||||
|
addInventoryItem(itemData);
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md overflow-y-auto no-print">
|
||||||
|
<div className="relative w-full max-w-2xl bg-slate-900 border border-slate-700/80 rounded-3xl shadow-2xl overflow-hidden my-8">
|
||||||
|
|
||||||
|
{/* Modal Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-5 border-b border-slate-800 bg-slate-950/50">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-cyan-500/10 border border-cyan-500/30 flex items-center justify-center text-cyan-400">
|
||||||
|
<Package className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold text-white">
|
||||||
|
{editingItem ? 'Modifier la pièce / stock' : 'Enregistrer un Achat de Pièces'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
Centre d'achat & approvisionnement pour l'atelier
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-slate-400 hover:text-white p-2 rounded-xl hover:bg-slate-800 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form Body */}
|
||||||
|
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
||||||
|
|
||||||
|
{/* Row 1: Designation & SKU */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="md:col-span-2 space-y-1.5">
|
||||||
|
<label className="text-xs font-semibold text-slate-300">
|
||||||
|
Nom / Désignation de la pièce *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="Ex: Écran IPS V3 Laminé OSD, Coque Atomic Purple..."
|
||||||
|
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-700 text-white placeholder-slate-500 focus:outline-none focus:border-cyan-500 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-semibold text-slate-300 flex items-center space-x-1">
|
||||||
|
<Tag className="w-3.5 h-3.5 text-cyan-400" />
|
||||||
|
<span>Référence / SKU</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={sku}
|
||||||
|
onChange={(e) => setSku(e.target.value)}
|
||||||
|
placeholder="Ex: SCR-GBA-01"
|
||||||
|
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-700 text-white placeholder-slate-500 focus:outline-none focus:border-cyan-500 text-sm font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 2: Console Model & Category */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-semibold text-slate-300">
|
||||||
|
Modèle de Console compatible *
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={consoleModel}
|
||||||
|
onChange={(e) => setConsoleModel(e.target.value as ConsoleModel)}
|
||||||
|
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-700 text-white focus:outline-none focus:border-cyan-500 text-sm"
|
||||||
|
>
|
||||||
|
{consoleModels.map((m) => (
|
||||||
|
<option key={m.id} value={m.slug}>
|
||||||
|
{m.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-semibold text-slate-300">
|
||||||
|
Catégorie de Composant *
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={category}
|
||||||
|
onChange={(e) => setCategory(e.target.value as PartCategory)}
|
||||||
|
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-700 text-white focus:outline-none focus:border-cyan-500 text-sm"
|
||||||
|
>
|
||||||
|
{partCategories.map((c) => (
|
||||||
|
<option key={c.id} value={c.slug}>
|
||||||
|
{c.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 3: Supplier & Date */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-semibold text-slate-300 flex items-center space-x-1">
|
||||||
|
<Building2 className="w-3.5 h-3.5 text-cyan-400" />
|
||||||
|
<span>Fournisseur / Provenance</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={supplier}
|
||||||
|
onChange={(e) => setSupplier(e.target.value)}
|
||||||
|
placeholder="Ex: FunnyPlaying, AliExpress, Vinted, Brocante..."
|
||||||
|
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-700 text-white placeholder-slate-500 focus:outline-none focus:border-cyan-500 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-semibold text-slate-300 flex items-center space-x-1">
|
||||||
|
<Calendar className="w-3.5 h-3.5 text-cyan-400" />
|
||||||
|
<span>Date d'achat / Commande</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={orderDate}
|
||||||
|
onChange={(e) => setOrderDate(e.target.value)}
|
||||||
|
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-700 text-white focus:outline-none focus:border-cyan-500 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pricing & Fees Breakdown Box */}
|
||||||
|
<div className="p-4 rounded-2xl bg-slate-950/70 border border-cyan-500/30 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wider text-cyan-400 flex items-center space-x-1.5">
|
||||||
|
<Calculator className="w-4 h-4" />
|
||||||
|
<span>Calcul du Coût d'Achat Réel Unitaire</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-400">
|
||||||
|
Coût pondéré avec frais
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-[11px] text-slate-300">
|
||||||
|
Prix d'achat unitaire ({settings.currency})
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
value={unitPurchasePrice}
|
||||||
|
onChange={(e) => setUnitPurchasePrice(parseFloat(e.target.value) || 0)}
|
||||||
|
className="w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 text-white text-sm font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-[11px] text-slate-300">
|
||||||
|
Part Frais de Port ({settings.currency})
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
value={shippingCostShare}
|
||||||
|
onChange={(e) => setShippingCostShare(parseFloat(e.target.value) || 0)}
|
||||||
|
className="w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 text-white text-sm font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-[11px] text-slate-300">
|
||||||
|
Part Douane / Taxes ({settings.currency})
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
value={taxImportShare}
|
||||||
|
onChange={(e) => setTaxImportShare(parseFloat(e.target.value) || 0)}
|
||||||
|
className="w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 text-white text-sm font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between pt-2 border-t border-slate-800 text-xs">
|
||||||
|
<span className="text-slate-300">Coût unitaire effectif à imputer aux projets :</span>
|
||||||
|
<span className="text-base font-black text-cyan-300 font-mono">
|
||||||
|
{formatCurrency(calculatedRealUnitCost, settings.currency)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 4: Quantity & Workshop Location */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-semibold text-slate-300">
|
||||||
|
Quantité en stock dispo
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={quantityInStock}
|
||||||
|
onChange={(e) => setQuantityInStock(parseInt(e.target.value) || 0)}
|
||||||
|
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-700 text-white text-sm font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-semibold text-slate-300">
|
||||||
|
Seuil alerte stock bas
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={minStockAlert}
|
||||||
|
onChange={(e) => setMinStockAlert(parseInt(e.target.value) || 1)}
|
||||||
|
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-700 text-white text-sm font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-semibold text-slate-300 flex items-center space-x-1">
|
||||||
|
<MapPin className="w-3.5 h-3.5 text-cyan-400" />
|
||||||
|
<span>Emplacement Atelier</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={locationInWorkshop}
|
||||||
|
onChange={(e) => setLocationInWorkshop(e.target.value)}
|
||||||
|
placeholder="Ex: Tiroir B1, Bac A2..."
|
||||||
|
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-700 text-white placeholder-slate-500 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-semibold text-slate-300 flex items-center space-x-1">
|
||||||
|
<FileText className="w-3.5 h-3.5 text-cyan-400" />
|
||||||
|
<span>Notes & Remarques techniques</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
rows={2}
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
placeholder="Ex: Révision de la nappe flex v3, réglage luminosité par capteur tactile..."
|
||||||
|
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-700 text-white placeholder-slate-500 text-sm focus:outline-none focus:border-cyan-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Footer */}
|
||||||
|
<div className="flex items-center justify-end space-x-3 pt-4 border-t border-slate-800">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2.5 rounded-xl bg-slate-800 text-slate-300 hover:text-white hover:bg-slate-700 text-sm font-semibold transition-colors"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="px-5 py-2.5 rounded-xl bg-gradient-to-r from-cyan-500 to-blue-600 text-white font-bold text-sm shadow-lg shadow-cyan-500/20 hover:scale-[1.02] active:scale-[0.98] transition-all"
|
||||||
|
>
|
||||||
|
{editingItem ? 'Enregistrer les modifications' : 'Ajouter au stock'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
830
src/components/inventory/PurchasesView.tsx
Normal file
830
src/components/inventory/PurchasesView.tsx
Normal file
@@ -0,0 +1,830 @@
|
|||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
Plus,
|
||||||
|
|
||||||
|
Package,
|
||||||
|
AlertTriangle,
|
||||||
|
Download,
|
||||||
|
Trash2,
|
||||||
|
Edit2,
|
||||||
|
MapPin,
|
||||||
|
PieChart as PieIcon,
|
||||||
|
BarChart2,
|
||||||
|
QrCode,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
ResponsiveContainer,
|
||||||
|
PieChart,
|
||||||
|
Pie,
|
||||||
|
Cell,
|
||||||
|
Tooltip,
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from 'recharts';
|
||||||
|
import { useApp } from '../../context/AppContext';
|
||||||
|
import type { InventoryItem, ConsoleModel, PartCategory } from '../../types';
|
||||||
|
|
||||||
|
import {
|
||||||
|
formatCurrency,
|
||||||
|
getPartCategoryLabel,
|
||||||
|
getCategoryBadgeColor,
|
||||||
|
calculateTotalStockValue,
|
||||||
|
} from '../../utils/calculations';
|
||||||
|
|
||||||
|
import { InventoryModal } from './InventoryModal';
|
||||||
|
import { QrLabelModal } from '../labels/QrLabelModal';
|
||||||
|
|
||||||
|
export const PurchasesView: React.FC = () => {
|
||||||
|
const {
|
||||||
|
inventory,
|
||||||
|
deleteInventoryItem,
|
||||||
|
updateInventoryItem,
|
||||||
|
receivePlannedItem,
|
||||||
|
adjustStockQuantity,
|
||||||
|
exportInventoryCSV,
|
||||||
|
showNewInventoryModal,
|
||||||
|
setShowNewInventoryModal,
|
||||||
|
settings,
|
||||||
|
consoleModels,
|
||||||
|
partCategories,
|
||||||
|
} = useApp();
|
||||||
|
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [selectedConsole, setSelectedConsole] = useState<ConsoleModel | 'ALL'>('ALL');
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState<PartCategory | 'ALL'>('ALL');
|
||||||
|
const [stockStatusFilter, setStockStatusFilter] = useState<'ALL' | 'in_stock' | 'planned_order' | 'ordered' | 'low_stock' | 'out_of_stock'>('ALL');
|
||||||
|
const [editingItem, setEditingItem] = useState<InventoryItem | null>(null);
|
||||||
|
const [itemToDelete, setItemToDelete] = useState<InventoryItem | null>(null);
|
||||||
|
const [labelModalItem, setLabelModalItem] = useState<InventoryItem | null>(null);
|
||||||
|
const [showBatchLabels, setShowBatchLabels] = useState(false);
|
||||||
|
|
||||||
|
// Filter items
|
||||||
|
const filteredItems = useMemo(() => {
|
||||||
|
return inventory.filter((item) => {
|
||||||
|
// Search
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
const matchesName = item.name.toLowerCase().includes(query);
|
||||||
|
const matchesSku = item.sku.toLowerCase().includes(query);
|
||||||
|
const matchesSupplier = item.supplier.toLowerCase().includes(query);
|
||||||
|
const matchesNotes = (item.notes || '').toLowerCase().includes(query);
|
||||||
|
const matchesLocation = (item.locationInWorkshop || '').toLowerCase().includes(query);
|
||||||
|
const matchesProject = (item.linkedProjectTitle || '').toLowerCase().includes(query);
|
||||||
|
if (!matchesName && !matchesSku && !matchesSupplier && !matchesNotes && !matchesLocation && !matchesProject) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Console Model
|
||||||
|
if (selectedConsole !== 'ALL' && item.consoleModel !== selectedConsole) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category
|
||||||
|
if (selectedCategory !== 'ALL' && item.category !== selectedCategory) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stock Status
|
||||||
|
if (stockStatusFilter === 'planned_order' && item.status !== 'planned_order') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (stockStatusFilter === 'ordered' && item.status !== 'ordered') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (stockStatusFilter === 'low_stock' && (item.quantityInStock > item.minStockAlert || item.status === 'planned_order')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (stockStatusFilter === 'out_of_stock' && (item.quantityInStock > 0 || item.status === 'planned_order')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (stockStatusFilter === 'in_stock' && (item.quantityInStock === 0 || item.status === 'planned_order')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [inventory, searchQuery, selectedConsole, selectedCategory, stockStatusFilter]);
|
||||||
|
|
||||||
|
// Overall Inventory Stats
|
||||||
|
const totalStockValuation = calculateTotalStockValue(inventory);
|
||||||
|
const inStockItems = inventory.filter((i) => i.status !== 'planned_order');
|
||||||
|
const totalUnitsInStock = inStockItems.reduce((sum, i) => sum + i.quantityInStock, 0);
|
||||||
|
const lowStockCount = inStockItems.filter((i) => i.quantityInStock <= i.minStockAlert).length;
|
||||||
|
const donorConsolesCount = inStockItems
|
||||||
|
.filter((i) => i.category === 'console_base')
|
||||||
|
.reduce((sum, i) => sum + i.quantityInStock, 0);
|
||||||
|
|
||||||
|
const plannedItems = inventory.filter((i) => i.status === 'planned_order');
|
||||||
|
const plannedProcurementTotalCost = plannedItems.reduce(
|
||||||
|
(sum, i) => sum + ((i.quantityReserved || 1) * (i.unitCostWithFees || i.unitPurchasePrice)),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const orderedItems = inventory.filter((i) => i.status === 'ordered');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 pb-12 animate-fadeIn">
|
||||||
|
|
||||||
|
{/* Top Banner & Title */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="inline-flex items-center space-x-2 px-3 py-1 rounded-full text-xs font-semibold bg-cyan-500/10 text-cyan-400 border border-cyan-500/30 mb-2">
|
||||||
|
<Package className="w-3.5 h-3.5" />
|
||||||
|
<span>Centre d'Achat & Logistique</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl sm:text-3xl font-black text-white font-display">
|
||||||
|
Gestion du Stock & Pièces Détachées
|
||||||
|
</h2>
|
||||||
|
<p className="text-slate-400 text-xs sm:text-sm">
|
||||||
|
Enregistrez vos commandes fournisseurs, suivez vos coûts unitaires réels et gérez l'état de votre inventaire.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2.5 shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowBatchLabels(true)}
|
||||||
|
className="flex items-center space-x-1.5 px-3.5 py-2 rounded-xl bg-purple-500/15 border border-purple-500/30 text-purple-300 hover:bg-purple-500/25 text-xs font-bold transition-all shadow-sm"
|
||||||
|
title="Générer une planche d'étiquettes QR pour tout le stock sélectionné"
|
||||||
|
>
|
||||||
|
<QrCode className="w-4 h-4" />
|
||||||
|
<span>Planche Étiquettes QR</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={exportInventoryCSV}
|
||||||
|
className="flex items-center space-x-1.5 px-3.5 py-2 rounded-xl bg-slate-900 border border-slate-700 text-slate-300 hover:text-white hover:border-slate-500 text-xs font-semibold transition-all shadow-sm"
|
||||||
|
title="Exporter l'inventaire au format CSV (Excel)"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
<span>Export CSV</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setEditingItem(null);
|
||||||
|
setShowNewInventoryModal(true);
|
||||||
|
}}
|
||||||
|
className="flex items-center space-x-2 px-4 py-2 rounded-xl bg-gradient-to-r from-cyan-500 to-blue-600 text-white font-bold text-sm shadow-lg shadow-cyan-500/20 hover:scale-[1.02] active:scale-[0.98] transition-all"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
<span>Nouvel Achat / Pièce</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* KPI Stats Strip */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-5 gap-3">
|
||||||
|
<div className="glass-panel p-4 rounded-2xl border border-slate-800">
|
||||||
|
<div className="text-xs text-slate-400 font-medium">Valeur Stock Immobilisé</div>
|
||||||
|
<div className="text-xl sm:text-2xl font-black text-cyan-300 font-mono mt-1">
|
||||||
|
{formatCurrency(totalStockValuation, settings.currency)}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-slate-400 mt-0.5">Calculé au coût réel</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="glass-panel p-4 rounded-2xl border border-slate-800">
|
||||||
|
<div className="text-xs text-slate-400 font-medium">Références & Pièces</div>
|
||||||
|
<div className="text-xl sm:text-2xl font-black text-white font-mono mt-1">
|
||||||
|
{inStockItems.length} <span className="text-xs font-normal text-slate-400">réf. ({totalUnitsInStock} pcs)</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-slate-400 mt-0.5">Composants en stock</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="glass-panel p-4 rounded-2xl border border-slate-800">
|
||||||
|
<div className="text-xs text-amber-400 font-medium">Consoles Brutes en Stock</div>
|
||||||
|
<div className="text-xl sm:text-2xl font-black text-amber-300 font-mono mt-1">
|
||||||
|
{donorConsolesCount} <span className="text-xs font-normal text-slate-400">consoles</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-slate-400 mt-0.5">Donneuses prêtes</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="glass-panel p-4 rounded-2xl border border-amber-500/30 bg-amber-500/5">
|
||||||
|
<div className="text-xs text-amber-300 font-semibold flex items-center justify-between">
|
||||||
|
<span>À Approvisionner</span>
|
||||||
|
<span className="px-1.5 py-0.5 rounded bg-amber-500/20 text-[10px] font-mono font-bold">{plannedItems.length} réf.</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xl sm:text-2xl font-black text-amber-300 font-mono mt-1">
|
||||||
|
{formatCurrency(plannedProcurementTotalCost, settings.currency)}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-amber-400/80 mt-0.5">Budget pièces à prévoir</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="glass-panel p-4 rounded-2xl border border-slate-800">
|
||||||
|
<div className="text-xs text-slate-400 font-medium">Alertes Stock Critique</div>
|
||||||
|
<div className={`text-xl sm:text-2xl font-black font-mono mt-1 ${lowStockCount > 0 ? 'text-rose-400' : 'text-emerald-400'}`}>
|
||||||
|
{lowStockCount} <span className="text-xs font-normal text-slate-400">articles</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-slate-400 mt-0.5">
|
||||||
|
{lowStockCount > 0 ? 'À recommander sous peu' : 'Aucune rupture'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Visual Stock Charts: Valuation by Category & Health Levels */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
|
||||||
|
{/* Left 1 Col: Donut Chart Valuation by Category */}
|
||||||
|
<div className="glass-panel p-5 rounded-3xl border border-slate-800 space-y-3 flex flex-col justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-bold text-white flex items-center space-x-2">
|
||||||
|
<PieIcon className="w-4 h-4 text-cyan-400" />
|
||||||
|
<span>Répartition de la Valeur Stock</span>
|
||||||
|
</h3>
|
||||||
|
<p className="text-[11px] text-slate-400">
|
||||||
|
Immobilisation financière par type de pièce
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(() => {
|
||||||
|
const catTotals: Record<string, number> = {};
|
||||||
|
inventory.forEach((i) => {
|
||||||
|
const val = i.quantityInStock * (i.unitCostWithFees || i.unitPurchasePrice || 0);
|
||||||
|
catTotals[i.category] = (catTotals[i.category] || 0) + val;
|
||||||
|
});
|
||||||
|
|
||||||
|
const catColors: Record<string, string> = {
|
||||||
|
screen: '#06b6d4',
|
||||||
|
console_base: '#f59e0b',
|
||||||
|
shell: '#a855f7',
|
||||||
|
buttons_pads: '#3b82f6',
|
||||||
|
battery_power: '#10b981',
|
||||||
|
sound_amp: '#ec4899',
|
||||||
|
cartridge_games: '#f43f5e',
|
||||||
|
packaging_accessories: '#8b5cf6',
|
||||||
|
consumables: '#64748b',
|
||||||
|
other: '#94a3b8',
|
||||||
|
};
|
||||||
|
|
||||||
|
const chartData = Object.entries(catTotals)
|
||||||
|
.map(([cat, val]) => ({
|
||||||
|
name: getPartCategoryLabel(cat),
|
||||||
|
value: Math.round(val),
|
||||||
|
color: catColors[cat] || '#06b6d4',
|
||||||
|
}))
|
||||||
|
.filter((d) => d.value > 0)
|
||||||
|
.sort((a, b) => b.value - a.value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="h-44 w-full relative flex items-center justify-center">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={chartData}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={50}
|
||||||
|
outerRadius={72}
|
||||||
|
paddingAngle={3}
|
||||||
|
dataKey="value"
|
||||||
|
>
|
||||||
|
{chartData.map((entry, index) => (
|
||||||
|
<Cell key={`cell-stock-${index}`} fill={entry.color} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip
|
||||||
|
content={({ active, payload }) => {
|
||||||
|
if (active && payload && payload.length) {
|
||||||
|
const d = payload[0];
|
||||||
|
const percent = totalStockValuation > 0 ? ((Number(d.value) / totalStockValuation) * 100).toFixed(1) : 0;
|
||||||
|
return (
|
||||||
|
<div className="bg-slate-900 border border-slate-700 p-2.5 rounded-xl shadow-xl text-xs font-mono">
|
||||||
|
<div className="font-bold text-white">{d.name}</div>
|
||||||
|
<div className="text-cyan-400">{formatCurrency(Number(d.value), settings.currency)} ({percent}%)</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
|
||||||
|
<span className="text-[10px] text-slate-400 uppercase font-bold">Total</span>
|
||||||
|
<span className="text-sm font-black text-cyan-300 font-mono">{formatCurrency(totalStockValuation, settings.currency)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-1 text-[10px] pt-1 border-t border-slate-800">
|
||||||
|
{chartData.slice(0, 4).map((item) => (
|
||||||
|
<div key={item.name} className="flex items-center space-x-1.5 truncate">
|
||||||
|
<span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: item.color }} />
|
||||||
|
<span className="text-slate-300 truncate">{item.name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right 2 Cols: Bar Chart of Stock Levels vs Min Thresholds */}
|
||||||
|
<div className="lg:col-span-2 glass-panel p-5 rounded-3xl border border-slate-800 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-bold text-white flex items-center space-x-2">
|
||||||
|
<BarChart2 className="w-4 h-4 text-emerald-400" />
|
||||||
|
<span>Niveaux de Disponibilité des Pièces</span>
|
||||||
|
</h3>
|
||||||
|
<p className="text-[11px] text-slate-400">
|
||||||
|
Quantité en stock physique vs seuil de réapprovisionnement
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-3 text-xs font-mono">
|
||||||
|
<span className="flex items-center text-cyan-400">
|
||||||
|
<span className="w-2.5 h-2.5 rounded-full bg-cyan-500 mr-1.5" /> Stock Actuel
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center text-rose-400">
|
||||||
|
<span className="w-2.5 h-2.5 rounded-full bg-rose-500 mr-1.5" /> Seuil Alerte
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-44 w-full pt-1">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart
|
||||||
|
data={inventory.slice(0, 8).map((item) => ({
|
||||||
|
name: item.name.length > 18 ? item.name.substring(0, 16) + '...' : item.name,
|
||||||
|
fullName: item.name,
|
||||||
|
'Stock Dispo': item.quantityInStock,
|
||||||
|
'Seuil Alerte': item.minStockAlert,
|
||||||
|
unitCost: item.unitCostWithFees,
|
||||||
|
}))}
|
||||||
|
margin={{ top: 10, right: 10, left: -20, bottom: 0 }}
|
||||||
|
>
|
||||||
|
<XAxis dataKey="name" stroke="#64748b" fontSize={10} tickLine={false} axisLine={{ stroke: '#334155' }} />
|
||||||
|
<YAxis stroke="#64748b" fontSize={10} tickLine={false} axisLine={{ stroke: '#334155' }} allowDecimals={false} />
|
||||||
|
<Tooltip
|
||||||
|
content={({ active, payload }) => {
|
||||||
|
if (active && payload && payload.length) {
|
||||||
|
const d = payload[0].payload;
|
||||||
|
return (
|
||||||
|
<div className="bg-slate-900 border border-slate-700 p-2.5 rounded-xl shadow-xl text-xs space-y-1 font-mono">
|
||||||
|
<div className="font-bold text-white font-sans text-xs pb-1 border-b border-slate-800">{d.fullName}</div>
|
||||||
|
<div className="text-cyan-400">📦 Stock physique : {d['Stock Dispo']} pcs</div>
|
||||||
|
<div className="text-rose-400">⚠️ Seuil alerte : {d['Seuil Alerte']} pcs</div>
|
||||||
|
<div className="text-slate-400 text-[10px]">Coût : {formatCurrency(d.unitCost, settings.currency)}/pc</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="Stock Dispo" fill="#06b6d4" radius={[3, 3, 0, 0]} />
|
||||||
|
<Bar dataKey="Seuil Alerte" fill="#f43f5e" radius={[3, 3, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
{/* Filters & Search Toolbar */}
|
||||||
|
<div className="glass-panel p-4 rounded-2xl border border-slate-800 space-y-3">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-12 gap-3">
|
||||||
|
|
||||||
|
{/* Search Input */}
|
||||||
|
<div className="sm:col-span-5 relative">
|
||||||
|
<Search className="w-4 h-4 text-slate-400 absolute left-3.5 top-3" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Rechercher par nom, SKU, fournisseur, emplacement..."
|
||||||
|
className="w-full pl-9 pr-3.5 py-2 rounded-xl bg-slate-950 border border-slate-700 text-white placeholder-slate-500 focus:outline-none focus:border-cyan-500 text-xs sm:text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Console Filter */}
|
||||||
|
<div className="sm:col-span-3">
|
||||||
|
<select
|
||||||
|
value={selectedConsole}
|
||||||
|
onChange={(e) => setSelectedConsole(e.target.value as ConsoleModel | 'ALL')}
|
||||||
|
className="w-full px-3 py-2 rounded-xl bg-slate-950 border border-slate-700 text-white focus:outline-none focus:border-cyan-500 text-xs sm:text-sm"
|
||||||
|
>
|
||||||
|
<option value="ALL">Toutes les consoles</option>
|
||||||
|
{consoleModels.map((c) => (
|
||||||
|
<option key={c.id} value={c.slug}>
|
||||||
|
{c.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category Filter */}
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<select
|
||||||
|
value={selectedCategory}
|
||||||
|
onChange={(e) => setSelectedCategory(e.target.value as PartCategory | 'ALL')}
|
||||||
|
className="w-full px-3 py-2 rounded-xl bg-slate-950 border border-slate-700 text-white focus:outline-none focus:border-cyan-500 text-xs sm:text-sm"
|
||||||
|
>
|
||||||
|
<option value="ALL">Toutes les catégories</option>
|
||||||
|
{partCategories.map((cat) => (
|
||||||
|
<option key={cat.id} value={cat.slug}>
|
||||||
|
{cat.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Filter */}
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<select
|
||||||
|
value={stockStatusFilter}
|
||||||
|
onChange={(e) => setStockStatusFilter(e.target.value as any)}
|
||||||
|
className="w-full px-3 py-2 rounded-xl bg-slate-950 border border-slate-700 text-white focus:outline-none focus:border-cyan-500 text-xs sm:text-sm"
|
||||||
|
>
|
||||||
|
<option value="ALL">Tous les statuts ({inventory.length})</option>
|
||||||
|
<option value="in_stock">🟢 En stock physique ({inStockItems.length})</option>
|
||||||
|
<option value="planned_order">📦 À Approvisionner (En Prévision) ({plannedItems.length})</option>
|
||||||
|
<option value="ordered">🚚 En Commande ({orderedItems.length})</option>
|
||||||
|
<option value="low_stock">⚠️ Stock Bas ({lowStockCount})</option>
|
||||||
|
<option value="out_of_stock">🚫 En Rupture (0)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Status Chips */}
|
||||||
|
<div className="flex flex-wrap gap-2 pt-1 border-t border-slate-850">
|
||||||
|
<button
|
||||||
|
onClick={() => setStockStatusFilter('ALL')}
|
||||||
|
className={`px-3 py-1 rounded-lg text-xs font-semibold transition-all ${
|
||||||
|
stockStatusFilter === 'ALL'
|
||||||
|
? 'bg-cyan-500 text-white shadow-sm shadow-cyan-500/30'
|
||||||
|
: 'bg-slate-950 text-slate-400 hover:text-white border border-slate-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Tous ({inventory.length})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setStockStatusFilter('in_stock')}
|
||||||
|
className={`px-3 py-1 rounded-lg text-xs font-semibold transition-all ${
|
||||||
|
stockStatusFilter === 'in_stock'
|
||||||
|
? 'bg-emerald-500 text-white shadow-sm shadow-emerald-500/30'
|
||||||
|
: 'bg-slate-950 text-slate-400 hover:text-emerald-300 border border-slate-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
🟢 En Stock ({inStockItems.length})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setStockStatusFilter('planned_order')}
|
||||||
|
className={`px-3 py-1 rounded-lg text-xs font-semibold transition-all flex items-center gap-1.5 ${
|
||||||
|
stockStatusFilter === 'planned_order'
|
||||||
|
? 'bg-amber-500 text-slate-950 shadow-sm shadow-amber-500/30 font-bold'
|
||||||
|
: 'bg-amber-500/10 text-amber-300 hover:bg-amber-500/20 border border-amber-500/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>📦 À Approvisionner (En Prévision)</span>
|
||||||
|
<span className="px-1.5 py-0.2 rounded-full bg-amber-500/30 text-[10px] font-mono font-bold">
|
||||||
|
{plannedItems.length}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setStockStatusFilter('ordered')}
|
||||||
|
className={`px-3 py-1 rounded-lg text-xs font-semibold transition-all ${
|
||||||
|
stockStatusFilter === 'ordered'
|
||||||
|
? 'bg-cyan-500 text-white shadow-sm shadow-cyan-500/30'
|
||||||
|
: 'bg-slate-950 text-slate-400 hover:text-cyan-300 border border-slate-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
🚚 En Commande ({orderedItems.length})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setStockStatusFilter('low_stock')}
|
||||||
|
className={`px-3 py-1 rounded-lg text-xs font-semibold transition-all ${
|
||||||
|
stockStatusFilter === 'low_stock'
|
||||||
|
? 'bg-rose-500 text-white shadow-sm shadow-rose-500/30'
|
||||||
|
: 'bg-slate-950 text-slate-400 hover:text-rose-300 border border-slate-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
⚠️ Stock Critique ({lowStockCount})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Inventory Items List */}
|
||||||
|
<div className="glass-panel rounded-3xl border border-slate-800 overflow-hidden">
|
||||||
|
{filteredItems.length === 0 ? (
|
||||||
|
<div className="p-12 text-center space-y-3">
|
||||||
|
<Package className="w-12 h-12 text-slate-600 mx-auto" />
|
||||||
|
<h3 className="text-base font-bold text-slate-300">Aucune pièce trouvée</h3>
|
||||||
|
<p className="text-xs text-slate-400 max-w-sm mx-auto">
|
||||||
|
Aucun composant ne correspond à vos critères de recherche ou de filtre.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSearchQuery('');
|
||||||
|
setSelectedConsole('ALL');
|
||||||
|
setSelectedCategory('ALL');
|
||||||
|
setStockStatusFilter('ALL');
|
||||||
|
}}
|
||||||
|
className="text-xs font-semibold text-cyan-400 hover:underline"
|
||||||
|
>
|
||||||
|
Réinitialiser les filtres
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left border-collapse text-xs sm:text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-800 bg-slate-950/60 text-slate-400 font-semibold uppercase text-[11px] tracking-wider">
|
||||||
|
<th className="py-3.5 px-4">Composant / Désignation</th>
|
||||||
|
<th className="py-3.5 px-3">Console & Catégorie</th>
|
||||||
|
<th className="py-3.5 px-3">Fournisseur & Date</th>
|
||||||
|
<th className="py-3.5 px-3 text-right">Prix Achat</th>
|
||||||
|
<th className="py-3.5 px-3 text-right">Coût Réel Unitaire</th>
|
||||||
|
<th className="py-3.5 px-4 text-center">Stock & Statut</th>
|
||||||
|
<th className="py-3.5 px-3 text-right">Valeur / Budget</th>
|
||||||
|
<th className="py-3.5 px-4 text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-800/60">
|
||||||
|
{filteredItems.map((item) => {
|
||||||
|
const badgeColor = getCategoryBadgeColor(item.category);
|
||||||
|
const isPlanned = item.status === 'planned_order';
|
||||||
|
const isOrdered = item.status === 'ordered';
|
||||||
|
const isLow = !isPlanned && item.quantityInStock <= item.minStockAlert && item.quantityInStock > 0;
|
||||||
|
const isOut = !isPlanned && item.quantityInStock <= 0;
|
||||||
|
const totalLineValuation = isPlanned
|
||||||
|
? (item.quantityReserved || 1) * item.unitCostWithFees
|
||||||
|
: item.quantityInStock * item.unitCostWithFees;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={item.id}
|
||||||
|
className={`transition-colors duration-150 group ${
|
||||||
|
isPlanned
|
||||||
|
? 'bg-amber-500/5 hover:bg-amber-500/10'
|
||||||
|
: isOrdered
|
||||||
|
? 'bg-cyan-500/5 hover:bg-cyan-500/10'
|
||||||
|
: 'hover:bg-slate-900/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Name & SKU */}
|
||||||
|
<td className="py-3 px-4">
|
||||||
|
<div className="font-bold text-white group-hover:text-cyan-300 transition-colors flex items-center gap-1.5 flex-wrap">
|
||||||
|
<span>{item.name}</span>
|
||||||
|
{isPlanned && (
|
||||||
|
<span className="px-2 py-0.5 rounded-md text-[10px] font-extrabold bg-amber-500/20 text-amber-300 border border-amber-500/40">
|
||||||
|
📦 À APPROVISIONNER (EN PRÉVISION)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isOrdered && (
|
||||||
|
<span className="px-2 py-0.5 rounded-md text-[10px] font-extrabold bg-cyan-500/20 text-cyan-300 border border-cyan-500/40">
|
||||||
|
🚚 EN COMMANDE
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2 mt-1 flex-wrap">
|
||||||
|
<span className="font-mono text-[11px] text-slate-400">
|
||||||
|
{item.sku}
|
||||||
|
</span>
|
||||||
|
{item.linkedProjectTitle && (
|
||||||
|
<span className="inline-flex items-center text-[10px] text-amber-300 bg-amber-500/10 px-2 py-0.5 rounded border border-amber-500/30">
|
||||||
|
🎯 Pour : {item.linkedProjectTitle}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{item.locationInWorkshop && !isPlanned && (
|
||||||
|
<span className="inline-flex items-center text-[10px] text-slate-400 bg-slate-900 px-1.5 py-0.5 rounded border border-slate-800">
|
||||||
|
<MapPin className="w-2.5 h-2.5 mr-1 text-cyan-400" />
|
||||||
|
{item.locationInWorkshop}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Console & Category */}
|
||||||
|
<td className="py-3 px-3">
|
||||||
|
<div className="font-semibold text-slate-200">
|
||||||
|
{item.consoleModel}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1">
|
||||||
|
<span
|
||||||
|
className={`inline-block px-2 py-0.5 rounded-md text-[10px] font-bold border ${badgeColor.bg} ${badgeColor.text} ${badgeColor.border}`}
|
||||||
|
>
|
||||||
|
{getPartCategoryLabel(item.category)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Supplier & Date */}
|
||||||
|
<td className="py-3 px-3">
|
||||||
|
<div className="text-slate-300 font-medium">
|
||||||
|
{item.supplier}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-slate-400 font-mono">
|
||||||
|
{item.orderDate}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Unit Base Purchase Price */}
|
||||||
|
<td className="py-3 px-3 text-right font-mono text-slate-300">
|
||||||
|
{formatCurrency(item.unitPurchasePrice, settings.currency)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Real Unit Cost */}
|
||||||
|
<td className="py-3 px-3 text-right">
|
||||||
|
<div className="font-mono font-bold text-cyan-300">
|
||||||
|
{formatCurrency(item.unitCostWithFees, settings.currency)}
|
||||||
|
</div>
|
||||||
|
{(item.shippingCostShare > 0 || item.taxImportShare > 0) && (
|
||||||
|
<div className="text-[10px] text-slate-400">
|
||||||
|
(+{(item.shippingCostShare + item.taxImportShare).toFixed(2)}€ frais)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Stock / Procurement Column */}
|
||||||
|
<td className="py-3 px-4">
|
||||||
|
{isPlanned ? (
|
||||||
|
<div className="text-center space-y-1">
|
||||||
|
<span className="inline-block font-mono font-bold text-xs px-2.5 py-0.5 rounded-full bg-amber-500/20 text-amber-300 border border-amber-500/40">
|
||||||
|
Qté requise : {item.quantityReserved || 1}
|
||||||
|
</span>
|
||||||
|
<div className="text-[10px] text-amber-400/80">
|
||||||
|
Stock actuel : 0
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-center space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={() => adjustStockQuantity(item.id, -1)}
|
||||||
|
disabled={item.quantityInStock <= 0}
|
||||||
|
className="w-6 h-6 rounded-lg bg-slate-800 hover:bg-slate-700 disabled:opacity-30 disabled:cursor-not-allowed flex items-center justify-center text-white font-bold text-xs"
|
||||||
|
title="Retirer 1 unité du stock"
|
||||||
|
>
|
||||||
|
-
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="min-w-[48px] text-center">
|
||||||
|
<span
|
||||||
|
className={`inline-block font-mono font-black text-sm px-2.5 py-0.5 rounded-full ${
|
||||||
|
isOut
|
||||||
|
? 'bg-rose-500/20 text-rose-400 border border-rose-500/40'
|
||||||
|
: isLow
|
||||||
|
? 'bg-amber-500/20 text-amber-300 border border-amber-500/40'
|
||||||
|
: 'bg-slate-900 text-white border border-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.quantityInStock}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => adjustStockQuantity(item.id, 1)}
|
||||||
|
className="w-6 h-6 rounded-lg bg-slate-800 hover:bg-slate-700 flex items-center justify-center text-white font-bold text-xs"
|
||||||
|
title="Ajouter 1 unité au stock"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{isLow && (
|
||||||
|
<div className="text-center text-[10px] text-amber-400 font-semibold mt-1">
|
||||||
|
Alerte seuil ({item.minStockAlert})
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Total Line Valuation / Budget */}
|
||||||
|
<td className="py-3 px-3 text-right font-mono font-bold">
|
||||||
|
<div className={isPlanned ? 'text-amber-300' : 'text-slate-200'}>
|
||||||
|
{formatCurrency(totalLineValuation, settings.currency)}
|
||||||
|
</div>
|
||||||
|
{isPlanned && (
|
||||||
|
<div className="text-[10px] text-amber-400/70 font-sans font-normal">
|
||||||
|
À prévoir
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<td className="py-3 px-4 text-right">
|
||||||
|
<div className="flex items-center justify-end space-x-1.5 flex-wrap">
|
||||||
|
{isPlanned && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => receivePlannedItem(item.id)}
|
||||||
|
className="px-2.5 py-1 text-[11px] font-bold bg-emerald-600 hover:bg-emerald-500 text-white rounded-lg shadow-sm flex items-center gap-1 transition-colors"
|
||||||
|
title="Réceptionner cette pièce en stock et l'allouer au projet"
|
||||||
|
>
|
||||||
|
📥 Réceptionner
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => updateInventoryItem(item.id, { status: 'ordered' })}
|
||||||
|
className="px-2.5 py-1 text-[11px] font-semibold bg-cyan-600 hover:bg-cyan-500 text-white rounded-lg transition-colors"
|
||||||
|
title="Passer en commande"
|
||||||
|
>
|
||||||
|
🚚 Commander
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isOrdered && (
|
||||||
|
<button
|
||||||
|
onClick={() => receivePlannedItem(item.id)}
|
||||||
|
className="px-2.5 py-1 text-[11px] font-bold bg-emerald-600 hover:bg-emerald-500 text-white rounded-lg shadow-sm flex items-center gap-1 transition-colors"
|
||||||
|
title="Réceptionner la commande"
|
||||||
|
>
|
||||||
|
📥 Réceptionner
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setLabelModalItem(item)}
|
||||||
|
className="p-1.5 text-slate-400 hover:text-purple-400 hover:bg-slate-800 rounded-lg transition-colors"
|
||||||
|
title="Imprimer l'étiquette QR pour ce composant"
|
||||||
|
>
|
||||||
|
<QrCode className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setEditingItem(item);
|
||||||
|
setShowNewInventoryModal(true);
|
||||||
|
}}
|
||||||
|
className="p-1.5 text-slate-400 hover:text-cyan-400 hover:bg-slate-800 rounded-lg transition-colors"
|
||||||
|
title="Modifier cette pièce"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setItemToDelete(item)}
|
||||||
|
className="p-1.5 text-slate-400 hover:text-rose-400 hover:bg-slate-800 rounded-lg transition-colors"
|
||||||
|
title="Supprimer du stock"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Inventory Modal for Create / Edit */}
|
||||||
|
<InventoryModal
|
||||||
|
isOpen={showNewInventoryModal}
|
||||||
|
onClose={() => {
|
||||||
|
setShowNewInventoryModal(false);
|
||||||
|
setEditingItem(null);
|
||||||
|
}}
|
||||||
|
editingItem={editingItem}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Confirmation Modal for Delete */}
|
||||||
|
{itemToDelete && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md">
|
||||||
|
<div className="bg-slate-900 border border-slate-700 rounded-3xl p-6 max-w-md w-full space-y-4 shadow-2xl">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-rose-500/10 border border-rose-500/30 flex items-center justify-center text-rose-400">
|
||||||
|
<AlertTriangle className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-lg font-bold text-white">Supprimer cette référence ?</h4>
|
||||||
|
<p className="text-xs text-slate-300 mt-1">
|
||||||
|
Êtes-vous certain de vouloir supprimer <strong>{itemToDelete.name}</strong> ({itemToDelete.quantityInStock} pcs en stock) ?
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-end space-x-3 pt-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setItemToDelete(null)}
|
||||||
|
className="px-4 py-2 rounded-xl bg-slate-800 text-slate-300 hover:text-white text-xs font-semibold"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
deleteInventoryItem(itemToDelete.id);
|
||||||
|
setItemToDelete(null);
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 rounded-xl bg-rose-600 hover:bg-rose-500 text-white text-xs font-bold shadow-lg shadow-rose-600/20"
|
||||||
|
>
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* QrLabelModal for single part or batch sheet */}
|
||||||
|
<QrLabelModal
|
||||||
|
isOpen={!!labelModalItem || showBatchLabels}
|
||||||
|
onClose={() => {
|
||||||
|
setLabelModalItem(null);
|
||||||
|
setShowBatchLabels(false);
|
||||||
|
}}
|
||||||
|
inventoryItem={labelModalItem}
|
||||||
|
batchItems={showBatchLabels ? filteredItems : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
521
src/components/labels/QrLabelModal.tsx
Normal file
521
src/components/labels/QrLabelModal.tsx
Normal file
@@ -0,0 +1,521 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
|
import {
|
||||||
|
X,
|
||||||
|
Printer,
|
||||||
|
QrCode,
|
||||||
|
Sliders,
|
||||||
|
Check,
|
||||||
|
Tag,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useApp } from '../../context/AppContext';
|
||||||
|
import type { Project, InventoryItem } from '../../types';
|
||||||
|
import { formatCurrency, getPartCategoryLabel } from '../../utils/calculations';
|
||||||
|
|
||||||
|
export type LabelType = 'project_box' | 'project_back_shell' | 'inventory_drawer' | 'inventory_pouch';
|
||||||
|
export type PageLayout = 'single_thermal' | 'a4_grid_2x4' | 'a4_grid_3x7';
|
||||||
|
|
||||||
|
interface QrLabelModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
project?: Project | null;
|
||||||
|
inventoryItem?: InventoryItem | null;
|
||||||
|
batchItems?: InventoryItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const QrLabelModal: React.FC<QrLabelModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
project,
|
||||||
|
inventoryItem,
|
||||||
|
batchItems,
|
||||||
|
}) => {
|
||||||
|
const { settings } = useApp();
|
||||||
|
|
||||||
|
const isProject = !!project;
|
||||||
|
const [labelType, setLabelType] = useState<LabelType>(
|
||||||
|
isProject ? 'project_box' : 'inventory_drawer'
|
||||||
|
);
|
||||||
|
const [pageLayout, setPageLayout] = useState<PageLayout>('single_thermal');
|
||||||
|
const [showPrice, setShowPrice] = useState(true);
|
||||||
|
const [showLocation, setShowLocation] = useState(true);
|
||||||
|
const [showMods, setShowMods] = useState(true);
|
||||||
|
const [showDate, setShowDate] = useState(true);
|
||||||
|
const [copiesCount, setCopiesCount] = useState(1);
|
||||||
|
const [themeInverted, setThemeInverted] = useState(false); // Dark sticker vs Standard White thermal paper
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
// Generate QR payload
|
||||||
|
const getQrValue = (p?: Project | null, item?: InventoryItem | null) => {
|
||||||
|
if (p) {
|
||||||
|
return JSON.stringify({
|
||||||
|
app: 'uwuFinances',
|
||||||
|
type: 'PROJECT',
|
||||||
|
serial: p.serialNumber,
|
||||||
|
model: p.consoleModel,
|
||||||
|
title: p.title,
|
||||||
|
date: p.completedDate || p.startDate,
|
||||||
|
warranty: `${p.warrantyMonths || 6}M`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (item) {
|
||||||
|
return JSON.stringify({
|
||||||
|
app: 'uwuFinances',
|
||||||
|
type: 'INVENTORY_PART',
|
||||||
|
sku: item.sku,
|
||||||
|
name: item.name,
|
||||||
|
category: item.category,
|
||||||
|
model: item.consoleModel,
|
||||||
|
loc: item.locationInWorkshop || 'Atelier',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return 'uwuFinances:ITEM';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrint = () => {
|
||||||
|
window.print();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render a single sticker label
|
||||||
|
const renderLabelCard = (
|
||||||
|
currentProject?: Project | null,
|
||||||
|
currentItem?: InventoryItem | null,
|
||||||
|
keyIndex = 0
|
||||||
|
) => {
|
||||||
|
const qrData = getQrValue(currentProject, currentItem);
|
||||||
|
|
||||||
|
// 1. BOX PACKAGING STICKER (Large format: ~80x50mm)
|
||||||
|
if (labelType === 'project_box' && currentProject) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={keyIndex}
|
||||||
|
className={`label-item relative w-[320px] h-[200px] p-3.5 rounded-xl border-2 transition-all flex flex-col justify-between overflow-hidden ${
|
||||||
|
themeInverted
|
||||||
|
? 'bg-slate-950 text-white border-pink-500'
|
||||||
|
: 'bg-white text-black border-black shadow-md'
|
||||||
|
}`}
|
||||||
|
style={{ width: '82mm', height: '52mm' }}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between border-b pb-1.5 border-current">
|
||||||
|
<div className="flex items-center space-x-1.5">
|
||||||
|
<span className="font-extrabold text-xs tracking-tight uppercase font-display">
|
||||||
|
{settings.businessName || 'uwu Custom Studio'}
|
||||||
|
</span>
|
||||||
|
<span className="text-[9px] px-1 py-0.2 rounded font-bold border border-current">
|
||||||
|
PRO MOD
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-mono text-[11px] font-black tracking-wider">
|
||||||
|
{currentProject.serialNumber}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body: Info + QR Code */}
|
||||||
|
<div className="flex items-center gap-2.5 my-auto">
|
||||||
|
<div className="p-1 rounded bg-white border border-slate-300 shrink-0">
|
||||||
|
<QRCodeSVG
|
||||||
|
value={qrData}
|
||||||
|
size={68}
|
||||||
|
level="M"
|
||||||
|
includeMargin={false}
|
||||||
|
fgColor="#000000"
|
||||||
|
bgColor="#ffffff"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1 space-y-1">
|
||||||
|
<div className="font-black text-xs leading-snug line-clamp-1">
|
||||||
|
{currentProject.title}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] font-bold flex items-center space-x-1 opacity-90">
|
||||||
|
<span>🎮 {currentProject.consoleModel}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showMods && currentProject.modsList && currentProject.modsList.length > 0 && (
|
||||||
|
<div className="text-[8.5px] leading-tight line-clamp-2 opacity-80">
|
||||||
|
{currentProject.modsList.slice(0, 3).join(' • ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex items-center justify-between text-[9px] font-mono border-t pt-1 border-current">
|
||||||
|
{showDate && (
|
||||||
|
<span>Date: {currentProject.completedDate || currentProject.startDate}</span>
|
||||||
|
)}
|
||||||
|
<span>QC: Validé ✓</span>
|
||||||
|
{showPrice && (
|
||||||
|
<span className="font-black font-sans text-[10px]">
|
||||||
|
{formatCurrency(currentProject.salePriceActual || currentProject.salePriceTarget, settings.currency)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. BACK SHELL / BATTERY COMPARTMENT STICKER (Compact: ~50x25mm)
|
||||||
|
if (labelType === 'project_back_shell' && currentProject) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={keyIndex}
|
||||||
|
className={`label-item relative w-[220px] h-[110px] p-2 rounded-lg border-2 flex items-center gap-2 overflow-hidden ${
|
||||||
|
themeInverted
|
||||||
|
? 'bg-slate-950 text-white border-pink-500'
|
||||||
|
: 'bg-white text-black border-black shadow-md'
|
||||||
|
}`}
|
||||||
|
style={{ width: '52mm', height: '28mm' }}
|
||||||
|
>
|
||||||
|
<div className="p-0.5 rounded bg-white border border-slate-300 shrink-0">
|
||||||
|
<QRCodeSVG
|
||||||
|
value={qrData}
|
||||||
|
size={48}
|
||||||
|
level="L"
|
||||||
|
includeMargin={false}
|
||||||
|
fgColor="#000000"
|
||||||
|
bgColor="#ffffff"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1 text-[9px] space-y-0.5 font-mono">
|
||||||
|
<div className="font-extrabold uppercase text-[9.5px] truncate font-display">
|
||||||
|
{settings.logoText || 'uwuFinances'}
|
||||||
|
</div>
|
||||||
|
<div className="font-black text-[10px] text-pink-600 truncate">
|
||||||
|
{currentProject.serialNumber}
|
||||||
|
</div>
|
||||||
|
<div className="truncate opacity-80">{currentProject.consoleModel}</div>
|
||||||
|
<div className="text-[8px] opacity-70">Garantie {currentProject.warrantyMonths || 6}M</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. INVENTORY DRAWER / STORAGE BOX STICKER (Medium: ~60x30mm)
|
||||||
|
if (currentItem) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={keyIndex}
|
||||||
|
className={`label-item relative w-[250px] h-[130px] p-2.5 rounded-xl border-2 flex flex-col justify-between overflow-hidden ${
|
||||||
|
themeInverted
|
||||||
|
? 'bg-slate-950 text-white border-cyan-500'
|
||||||
|
: 'bg-white text-black border-black shadow-md'
|
||||||
|
}`}
|
||||||
|
style={{ width: '60mm', height: '32mm' }}
|
||||||
|
>
|
||||||
|
{/* Top: SKU & Category */}
|
||||||
|
<div className="flex items-center justify-between border-b pb-1 border-current">
|
||||||
|
<span className="font-mono text-[10px] font-black">
|
||||||
|
{currentItem.sku}
|
||||||
|
</span>
|
||||||
|
<span className="text-[8.5px] font-bold uppercase truncate max-w-[120px]">
|
||||||
|
{getPartCategoryLabel(currentItem.category)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Middle: QR & Title */}
|
||||||
|
<div className="flex items-center gap-2 my-1">
|
||||||
|
<div className="p-0.5 rounded bg-white border border-slate-300 shrink-0">
|
||||||
|
<QRCodeSVG
|
||||||
|
value={qrData}
|
||||||
|
size={46}
|
||||||
|
level="M"
|
||||||
|
includeMargin={false}
|
||||||
|
fgColor="#000000"
|
||||||
|
bgColor="#ffffff"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="font-bold text-[10px] leading-tight line-clamp-2">
|
||||||
|
{currentItem.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-[9px] font-semibold opacity-80 mt-0.5">
|
||||||
|
🎮 {currentItem.consoleModel}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom: Location & Unit Cost */}
|
||||||
|
<div className="flex items-center justify-between text-[8.5px] font-mono border-t pt-1 border-current">
|
||||||
|
{showLocation && (
|
||||||
|
<span className="truncate max-w-[130px]">
|
||||||
|
📍 {currentItem.locationInWorkshop || 'Bac Stock'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{showPrice && (
|
||||||
|
<span className="font-bold font-sans text-[9.5px]">
|
||||||
|
Coût: {formatCurrency(currentItem.unitCostWithFees || currentItem.unitPurchasePrice, settings.currency)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Compile list of labels to render (support duplicates and batch items)
|
||||||
|
const itemsToRender: { project?: Project | null; item?: InventoryItem | null }[] = [];
|
||||||
|
if (batchItems && batchItems.length > 0) {
|
||||||
|
batchItems.forEach((it) => {
|
||||||
|
for (let c = 0; c < copiesCount; c++) {
|
||||||
|
itemsToRender.push({ item: it });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (project) {
|
||||||
|
for (let c = 0; c < copiesCount; c++) {
|
||||||
|
itemsToRender.push({ project });
|
||||||
|
}
|
||||||
|
} else if (inventoryItem) {
|
||||||
|
for (let c = 0; c < copiesCount; c++) {
|
||||||
|
itemsToRender.push({ item: inventoryItem });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/85 backdrop-blur-md overflow-y-auto">
|
||||||
|
<div className="relative w-full max-w-4xl bg-slate-900 border border-purple-500/40 rounded-3xl shadow-2xl overflow-hidden my-6 animate-scaleUp">
|
||||||
|
{/* Modal Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-800 bg-gradient-to-r from-purple-950/40 via-slate-900 to-pink-950/40 no-print">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="p-2.5 rounded-2xl bg-purple-500/15 border border-purple-500/30 text-purple-400">
|
||||||
|
<QrCode className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold text-white flex items-center space-x-2">
|
||||||
|
<span>Studio d'Impression d'Étiquettes & QR Codes</span>
|
||||||
|
<span className="px-2 py-0.5 rounded-full text-[10px] font-bold bg-pink-500/20 text-pink-300 border border-pink-500/40">
|
||||||
|
STICKERS PRO
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
Générez et imprimez des étiquettes autocollantes pour vos boîtes kraft, tiroirs de pièces et coques
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 rounded-xl text-slate-400 hover:text-white hover:bg-slate-800 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Body: Controls on Left, Live Preview on Right */}
|
||||||
|
<div className="p-6 grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
|
||||||
|
{/* Controls Column */}
|
||||||
|
<div className="space-y-4 no-print text-xs">
|
||||||
|
{/* 1. Label Format Choice */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="font-bold text-slate-300 flex items-center space-x-1.5">
|
||||||
|
<Tag className="w-3.5 h-3.5 text-pink-400" />
|
||||||
|
<span>Format & Type d'Étiquette :</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{isProject ? (
|
||||||
|
<div className="grid grid-cols-1 gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setLabelType('project_box')}
|
||||||
|
className={`p-2.5 rounded-xl border text-left flex items-center justify-between transition-all ${
|
||||||
|
labelType === 'project_box'
|
||||||
|
? 'bg-pink-500/15 border-pink-500 text-white font-bold'
|
||||||
|
: 'bg-slate-950 border-slate-800 text-slate-400 hover:text-slate-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs">📦 Étiquette Boîte Kraft Packaging</div>
|
||||||
|
<div className="text-[10px] text-slate-500 font-mono">Format 80x50 mm (Boîtes rigides)</div>
|
||||||
|
</div>
|
||||||
|
{labelType === 'project_box' && <Check className="w-4 h-4 text-pink-400" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setLabelType('project_back_shell')}
|
||||||
|
className={`p-2.5 rounded-xl border text-left flex items-center justify-between transition-all ${
|
||||||
|
labelType === 'project_back_shell'
|
||||||
|
? 'bg-pink-500/15 border-pink-500 text-white font-bold'
|
||||||
|
: 'bg-slate-950 border-slate-800 text-slate-400 hover:text-slate-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs">🎮 Sticker Arrière Console / Batterie</div>
|
||||||
|
<div className="text-[10px] text-slate-500 font-mono">Format Compact 50x28 mm</div>
|
||||||
|
</div>
|
||||||
|
{labelType === 'project_back_shell' && <Check className="w-4 h-4 text-pink-400" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setLabelType('inventory_drawer')}
|
||||||
|
className={`p-2.5 rounded-xl border text-left flex items-center justify-between transition-all ${
|
||||||
|
labelType === 'inventory_drawer'
|
||||||
|
? 'bg-cyan-500/15 border-cyan-500 text-white font-bold'
|
||||||
|
: 'bg-slate-950 border-slate-800 text-slate-400 hover:text-slate-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs">🗄️ Étiquette Tiroir & Bac de Stock</div>
|
||||||
|
<div className="text-[10px] text-slate-500 font-mono">Format 60x32 mm (Casier atelier)</div>
|
||||||
|
</div>
|
||||||
|
{labelType === 'inventory_drawer' && <Check className="w-4 h-4 text-cyan-400" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 2. Printer / Page Layout Format */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="font-bold text-slate-300 flex items-center space-x-1.5">
|
||||||
|
<Printer className="w-3.5 h-3.5 text-cyan-400" />
|
||||||
|
<span>Support d'Impression :</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={pageLayout}
|
||||||
|
onChange={(e) => setPageLayout(e.target.value as PageLayout)}
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white"
|
||||||
|
>
|
||||||
|
<option value="single_thermal">🖨️ Rouleau Thermique (Étiquette Unique)</option>
|
||||||
|
<option value="a4_grid_2x4">📄 Planche A4 (Grille 2x4 - 8 étiquettes/page)</option>
|
||||||
|
<option value="a4_grid_3x7">📄 Planche A4 (Grille 3x7 - 21 étiquettes/page)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 3. Number of Copies */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="font-bold text-slate-300">Nombre d'exemplaires :</label>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="50"
|
||||||
|
value={copiesCount}
|
||||||
|
onChange={(e) => setCopiesCount(Math.max(1, parseInt(e.target.value) || 1))}
|
||||||
|
className="w-20 bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white font-mono font-bold text-center"
|
||||||
|
/>
|
||||||
|
<span className="text-slate-400 text-xs">autocollant(s) à imprimer</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 4. Display Toggles */}
|
||||||
|
<div className="p-3.5 rounded-2xl bg-slate-950/70 border border-slate-850 space-y-2.5">
|
||||||
|
<span className="text-[11px] font-bold text-slate-400 uppercase tracking-wider block">
|
||||||
|
Champs Affichés sur l'Étiquette :
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<label className="flex items-center space-x-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showPrice}
|
||||||
|
onChange={(e) => setShowPrice(e.target.checked)}
|
||||||
|
className="rounded border-slate-700 text-pink-500 focus:ring-pink-500 bg-slate-900"
|
||||||
|
/>
|
||||||
|
<span className="text-slate-300">Afficher le prix / coût de revient</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{isProject && (
|
||||||
|
<label className="flex items-center space-x-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showMods}
|
||||||
|
onChange={(e) => setShowMods(e.target.checked)}
|
||||||
|
className="rounded border-slate-700 text-pink-500 focus:ring-pink-500 bg-slate-900"
|
||||||
|
/>
|
||||||
|
<span className="text-slate-300">Afficher la liste des mods installés</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isProject && (
|
||||||
|
<label className="flex items-center space-x-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showLocation}
|
||||||
|
onChange={(e) => setShowLocation(e.target.checked)}
|
||||||
|
className="rounded border-slate-700 text-cyan-500 focus:ring-cyan-500 bg-slate-900"
|
||||||
|
/>
|
||||||
|
<span className="text-slate-300">Afficher l'emplacement dans l'atelier</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<label className="flex items-center space-x-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showDate}
|
||||||
|
onChange={(e) => setShowDate(e.target.checked)}
|
||||||
|
className="rounded border-slate-700 text-pink-500 focus:ring-pink-500 bg-slate-900"
|
||||||
|
/>
|
||||||
|
<span className="text-slate-300">Afficher la date</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center space-x-2 cursor-pointer pt-1 border-t border-slate-850">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={themeInverted}
|
||||||
|
onChange={(e) => setThemeInverted(e.target.checked)}
|
||||||
|
className="rounded border-slate-700 text-purple-500 focus:ring-purple-500 bg-slate-900"
|
||||||
|
/>
|
||||||
|
<span className="text-slate-300">Style sombre / Négatif (Cyberpunk sticker)</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Print Button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handlePrint}
|
||||||
|
className="w-full flex items-center justify-center space-x-2 px-5 py-3 rounded-2xl font-black text-sm bg-gradient-to-r from-pink-500 via-purple-600 to-pink-600 hover:from-pink-400 hover:to-purple-500 text-white shadow-xl shadow-pink-500/25 hover:scale-[1.02] active:scale-[0.98] transition-all"
|
||||||
|
>
|
||||||
|
<Printer className="w-5 h-5" />
|
||||||
|
<span>Imprimer {itemsToRender.length} Étiquette(s)</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Live Preview Column (2 cols wide on LG) */}
|
||||||
|
<div className="lg:col-span-2 flex flex-col space-y-3">
|
||||||
|
<div className="flex items-center justify-between no-print">
|
||||||
|
<span className="text-xs font-bold text-slate-300 uppercase tracking-wider flex items-center space-x-1.5">
|
||||||
|
<Sliders className="w-3.5 h-3.5 text-purple-400" />
|
||||||
|
<span>Aperçu Réel Avant Impression :</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] font-mono text-slate-500">
|
||||||
|
{pageLayout === 'single_thermal' ? 'Format Rouleau 1x1' : 'Planche Plan A4'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Printable Container */}
|
||||||
|
<div className="flex-1 p-6 rounded-3xl bg-slate-950 border border-slate-800 flex items-center justify-center overflow-auto max-h-[500px] custom-scrollbar printable-label-sheet">
|
||||||
|
<div
|
||||||
|
className={`print-sheet flex flex-wrap gap-4 items-center justify-center ${
|
||||||
|
pageLayout === 'a4_grid_2x4'
|
||||||
|
? 'grid grid-cols-2 gap-3 max-w-[650px]'
|
||||||
|
: pageLayout === 'a4_grid_3x7'
|
||||||
|
? 'grid grid-cols-3 gap-2 max-w-[750px]'
|
||||||
|
: 'flex flex-col'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{itemsToRender.map((elem, idx) =>
|
||||||
|
renderLabelCard(elem.project, elem.item, idx)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center text-[11px] text-slate-500 no-print">
|
||||||
|
💡 Le QR code encodé est scannable avec n'importe quel smartphone ou douchette de code-barres.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
274
src/components/mods/ApplyModModal.tsx
Normal file
274
src/components/mods/ApplyModModal.tsx
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
X,
|
||||||
|
CheckCircle2,
|
||||||
|
Clock,
|
||||||
|
Wrench,
|
||||||
|
AlertTriangle,
|
||||||
|
Boxes,
|
||||||
|
ListTodo,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useApp } from '../../context/AppContext';
|
||||||
|
import type { ModRecipe } from '../../types';
|
||||||
|
|
||||||
|
interface ApplyModModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
modToApply: ModRecipe | null;
|
||||||
|
defaultProjectId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ApplyModModal: React.FC<ApplyModModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
modToApply,
|
||||||
|
defaultProjectId,
|
||||||
|
}) => {
|
||||||
|
const { projects, inventory, applyModToProject, setSelectedProjectId, setCurrentTab } = useApp();
|
||||||
|
|
||||||
|
// Active / eligible projects
|
||||||
|
const activeProjects = projects.filter(
|
||||||
|
(p) => p.status !== 'sold'
|
||||||
|
);
|
||||||
|
|
||||||
|
const [selectedProjectId, setLocalSelectedProjectId] = useState<string>(
|
||||||
|
defaultProjectId || (activeProjects[0]?.id || '')
|
||||||
|
);
|
||||||
|
const [deductStock, setDeductStock] = useState(true);
|
||||||
|
const [addTasks, setAddTasks] = useState(true);
|
||||||
|
|
||||||
|
if (!isOpen || !modToApply) return null;
|
||||||
|
|
||||||
|
const targetProject = projects.find((p) => p.id === selectedProjectId);
|
||||||
|
|
||||||
|
const totalPartsCost = (modToApply.requiredParts || []).reduce(
|
||||||
|
(s, p) => s + p.estimatedCost * p.quantity,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleApply = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!selectedProjectId) return;
|
||||||
|
|
||||||
|
applyModToProject(selectedProjectId, modToApply.id, {
|
||||||
|
deductStock,
|
||||||
|
addTasks,
|
||||||
|
});
|
||||||
|
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApplyAndOpenProject = () => {
|
||||||
|
if (!selectedProjectId) return;
|
||||||
|
applyModToProject(selectedProjectId, modToApply.id, {
|
||||||
|
deductStock,
|
||||||
|
addTasks,
|
||||||
|
});
|
||||||
|
setSelectedProjectId(selectedProjectId);
|
||||||
|
setCurrentTab('projects');
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-4 bg-slate-950/80 backdrop-blur-md overflow-y-auto">
|
||||||
|
<div className="bg-slate-900 border border-slate-700/80 rounded-2xl w-full max-w-xl shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-200">
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="p-5 border-b border-slate-800 flex items-center justify-between bg-gradient-to-r from-cyan-500/10 via-purple-500/10 to-transparent">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2.5 bg-cyan-500/20 text-cyan-400 border border-cyan-500/30 rounded-xl">
|
||||||
|
<Wrench className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-white">
|
||||||
|
Appliquer le Mod au Projet
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
Transférer les composants, la checklist et valoriser votre projet
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 text-slate-400 hover:text-white bg-slate-800 hover:bg-slate-700 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form Body */}
|
||||||
|
<form onSubmit={handleApply} className="p-5 space-y-5">
|
||||||
|
|
||||||
|
{/* Mod Summary Card */}
|
||||||
|
<div className="p-4 bg-slate-950 border border-slate-800 rounded-xl space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-semibold text-pink-400 uppercase tracking-wider">
|
||||||
|
Recette Sélectionnée
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-bold text-emerald-400 font-mono">
|
||||||
|
+{modToApply.suggestedAddedValue} € valeur
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-white text-base">{modToApply.name}</h3>
|
||||||
|
{modToApply.description && (
|
||||||
|
<p className="text-xs text-slate-400 line-clamp-2">{modToApply.description}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2 pt-1 text-[11px]">
|
||||||
|
<span className="px-2 py-0.5 bg-slate-800 text-slate-300 rounded-md font-mono flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3 text-purple-400" /> ~{modToApply.estimatedMinutes} min
|
||||||
|
</span>
|
||||||
|
<span className="px-2 py-0.5 bg-slate-800 text-cyan-300 rounded-md font-mono flex items-center gap-1">
|
||||||
|
<Boxes className="w-3 h-3 text-cyan-400" /> {modToApply.requiredParts?.length || 0} pièces (~{totalPartsCost.toFixed(2)} €)
|
||||||
|
</span>
|
||||||
|
<span className="px-2 py-0.5 bg-slate-800 text-amber-300 rounded-md font-mono flex items-center gap-1">
|
||||||
|
<ListTodo className="w-3 h-3 text-amber-400" /> {modToApply.checklistTasks?.length || 0} tâches
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Project Target Picker */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-300 mb-1.5">
|
||||||
|
Choisir le Projet Cible *
|
||||||
|
</label>
|
||||||
|
{activeProjects.length === 0 ? (
|
||||||
|
<div className="p-3 bg-amber-500/10 border border-amber-500/20 rounded-xl text-xs text-amber-300 flex items-center gap-2">
|
||||||
|
<AlertTriangle className="w-4 h-4 shrink-0" />
|
||||||
|
<span>Aucun projet en cours disponible. Veuillez d'abord créer un projet.</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<select
|
||||||
|
required
|
||||||
|
value={selectedProjectId}
|
||||||
|
onChange={(e) => setLocalSelectedProjectId(e.target.value)}
|
||||||
|
className="w-full bg-slate-950 border border-slate-700 rounded-xl px-3.5 py-2.5 text-white text-sm font-medium focus:outline-none focus:border-cyan-500"
|
||||||
|
>
|
||||||
|
{activeProjects.map((proj) => (
|
||||||
|
<option key={proj.id} value={proj.id}>
|
||||||
|
[{proj.serialNumber}] {proj.title} • {proj.consoleModel} ({proj.status})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{targetProject && (
|
||||||
|
<div className="mt-2 p-2.5 bg-slate-950/60 border border-slate-800/80 rounded-lg text-xs text-slate-400 flex items-center justify-between">
|
||||||
|
<span>Modèle : <strong className="text-white">{targetProject.consoleModel}</strong></span>
|
||||||
|
<span>Prix de vente cible actuel : <strong className="text-emerald-400 font-mono">{targetProject.salePriceTarget} €</strong></span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Parts preview & Stock availability */}
|
||||||
|
{modToApply.requiredParts && modToApply.requiredParts.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-300 mb-1.5">
|
||||||
|
Pièces qui seront ajoutées au projet ({modToApply.requiredParts.length}) :
|
||||||
|
</label>
|
||||||
|
<div className="space-y-1.5 max-h-36 overflow-y-auto pr-1">
|
||||||
|
{modToApply.requiredParts.map((part, idx) => {
|
||||||
|
const invItem = part.partId ? inventory.find((i) => i.id === part.partId) : null;
|
||||||
|
const hasStock = invItem ? invItem.quantityInStock >= part.quantity : true;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="p-2 bg-slate-950/80 border border-slate-800 rounded-lg flex items-center justify-between text-xs"
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0 pr-2">
|
||||||
|
<div className="text-white font-medium truncate">{part.partName}</div>
|
||||||
|
<div className="text-[10px] text-slate-400">
|
||||||
|
Qté : {part.quantity} • Coût estimé : {part.estimatedCost.toFixed(2)} €
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="shrink-0">
|
||||||
|
{invItem ? (
|
||||||
|
hasStock ? (
|
||||||
|
<span className="px-2 py-0.5 bg-emerald-500/10 text-emerald-400 border border-emerald-500/30 rounded text-[10px] font-mono">
|
||||||
|
En Stock ({invItem.quantityInStock})
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="px-2 py-0.5 bg-amber-500/15 text-amber-300 border border-amber-500/30 rounded text-[10px] font-mono">
|
||||||
|
À Prévoir (Stock : {invItem.quantityInStock})
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span className="px-2 py-0.5 bg-amber-500/15 text-amber-300 border border-amber-500/30 rounded text-[10px] font-mono">
|
||||||
|
À Prévoir (En Prévision)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Planned Items Info Banner */}
|
||||||
|
<div className="p-3 bg-slate-950/90 border border-amber-500/30 rounded-xl text-xs text-amber-300 flex items-start gap-2">
|
||||||
|
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5 text-amber-400" />
|
||||||
|
<span className="text-[11px] leading-relaxed text-amber-200/90">
|
||||||
|
Les pièces non disponibles en stock seront ajoutées au projet en mode <strong>« À approvisionner »</strong> (coût à prévoir) et enregistrées dans votre stock sous le statut <strong>« En Prévision »</strong>.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Options Toggles */}
|
||||||
|
<div className="space-y-2 pt-2 border-t border-slate-800">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer text-xs text-slate-300">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={deductStock}
|
||||||
|
onChange={(e) => setDeductStock(e.target.checked)}
|
||||||
|
className="rounded bg-slate-950 border-slate-700 text-cyan-500 focus:ring-cyan-500 w-4 h-4"
|
||||||
|
/>
|
||||||
|
<span>📦 Déduire automatiquement les pièces disponibles de l'inventaire</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer text-xs text-slate-300">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={addTasks}
|
||||||
|
onChange={(e) => setAddTasks(e.target.checked)}
|
||||||
|
className="rounded bg-slate-950 border-slate-700 text-purple-500 focus:ring-purple-500 w-4 h-4"
|
||||||
|
/>
|
||||||
|
<span>📋 Importer les {modToApply.checklistTasks?.length || 0} étapes du mod dans la checklist du projet</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="pt-3 border-t border-slate-800 flex flex-col sm:flex-row items-center justify-between gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="w-full sm:w-auto px-4 py-2.5 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-xl text-xs font-semibold transition-colors"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!selectedProjectId}
|
||||||
|
onClick={handleApplyAndOpenProject}
|
||||||
|
className="flex-1 sm:flex-initial px-4 py-2.5 bg-slate-800 hover:bg-slate-700 text-cyan-300 border border-cyan-500/30 rounded-xl text-xs font-semibold flex items-center justify-center gap-1.5 transition-colors"
|
||||||
|
>
|
||||||
|
Appliquer & Ouvrir
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!selectedProjectId}
|
||||||
|
className="flex-1 sm:flex-initial px-5 py-2.5 bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 disabled:opacity-50 text-white rounded-xl text-xs font-bold shadow-lg shadow-cyan-500/20 flex items-center justify-center gap-1.5 transition-all"
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="w-4 h-4" /> Appliquer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
591
src/components/mods/ModRecipeModal.tsx
Normal file
591
src/components/mods/ModRecipeModal.tsx
Normal file
@@ -0,0 +1,591 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
X,
|
||||||
|
Sparkles,
|
||||||
|
Layers,
|
||||||
|
Clock,
|
||||||
|
Euro,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
CheckCircle2,
|
||||||
|
Wrench,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useApp } from '../../context/AppContext';
|
||||||
|
import type {
|
||||||
|
ModRecipe,
|
||||||
|
ModCategory,
|
||||||
|
ConsoleModel,
|
||||||
|
ModRequiredPart,
|
||||||
|
} from '../../types';
|
||||||
|
|
||||||
|
interface ModRecipeModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
editingMod?: ModRecipe | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ModRecipeModal: React.FC<ModRecipeModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
editingMod,
|
||||||
|
}) => {
|
||||||
|
const { inventory, addModRecipe, updateModRecipe, modCategories, consoleModels } = useApp();
|
||||||
|
|
||||||
|
// Form State
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [category, setCategory] = useState<ModCategory>('screen');
|
||||||
|
const [selectedConsoles, setSelectedConsoles] = useState<ConsoleModel[]>(['Game Boy Advance']);
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [installationNotes, setInstallationNotes] = useState('');
|
||||||
|
const [difficulty, setDifficulty] = useState<'easy' | 'medium' | 'hard' | 'expert'>('medium');
|
||||||
|
const [estimatedMinutes, setEstimatedMinutes] = useState(45);
|
||||||
|
const [suggestedAddedValue, setSuggestedAddedValue] = useState(40);
|
||||||
|
const [isPopular, setIsPopular] = useState(false);
|
||||||
|
const [tagsInput, setTagsInput] = useState('');
|
||||||
|
|
||||||
|
// Required Parts
|
||||||
|
const [requiredParts, setRequiredParts] = useState<ModRequiredPart[]>([]);
|
||||||
|
const [selectedInventoryPartId, setSelectedInventoryPartId] = useState('');
|
||||||
|
|
||||||
|
// Checklist Tasks
|
||||||
|
const [checklistTasks, setChecklistTasks] = useState<string[]>([]);
|
||||||
|
const [newTaskInput, setNewTaskInput] = useState('');
|
||||||
|
|
||||||
|
// Initialize or Reset
|
||||||
|
useEffect(() => {
|
||||||
|
if (editingMod) {
|
||||||
|
setName(editingMod.name);
|
||||||
|
setCategory(editingMod.category);
|
||||||
|
setSelectedConsoles(editingMod.compatibleModels || []);
|
||||||
|
setDescription(editingMod.description || '');
|
||||||
|
setInstallationNotes(editingMod.installationNotes || '');
|
||||||
|
setDifficulty(editingMod.difficulty || 'medium');
|
||||||
|
setEstimatedMinutes(editingMod.estimatedMinutes || 45);
|
||||||
|
setSuggestedAddedValue(editingMod.suggestedAddedValue || 40);
|
||||||
|
setIsPopular(editingMod.isPopular || false);
|
||||||
|
setTagsInput(editingMod.tags ? editingMod.tags.join(', ') : '');
|
||||||
|
setRequiredParts(editingMod.requiredParts || []);
|
||||||
|
setChecklistTasks(editingMod.checklistTasks || []);
|
||||||
|
} else {
|
||||||
|
setName('');
|
||||||
|
setCategory('screen');
|
||||||
|
setSelectedConsoles(['Game Boy Advance']);
|
||||||
|
setDescription('');
|
||||||
|
setInstallationNotes('');
|
||||||
|
setDifficulty('medium');
|
||||||
|
setEstimatedMinutes(45);
|
||||||
|
setSuggestedAddedValue(40);
|
||||||
|
setIsPopular(false);
|
||||||
|
setTagsInput('');
|
||||||
|
setRequiredParts([]);
|
||||||
|
setChecklistTasks([
|
||||||
|
'Démonter la console et nettoyer la zone d\'intervention',
|
||||||
|
'Réaliser les soudures nécessaires avec étain de précision',
|
||||||
|
'Contrôler le bon fonctionnement avant remontage complet',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}, [editingMod, isOpen]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const toggleConsole = (consoleName: ConsoleModel) => {
|
||||||
|
if (selectedConsoles.includes(consoleName)) {
|
||||||
|
if (selectedConsoles.length > 1) {
|
||||||
|
setSelectedConsoles(selectedConsoles.filter((c) => c !== consoleName));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setSelectedConsoles([...selectedConsoles, consoleName]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddInventoryPart = () => {
|
||||||
|
if (!selectedInventoryPartId) return;
|
||||||
|
const invItem = inventory.find((i) => i.id === selectedInventoryPartId);
|
||||||
|
if (!invItem) return;
|
||||||
|
|
||||||
|
setRequiredParts([
|
||||||
|
...requiredParts,
|
||||||
|
{
|
||||||
|
partId: invItem.id,
|
||||||
|
partName: invItem.name,
|
||||||
|
category: invItem.category,
|
||||||
|
quantity: 1,
|
||||||
|
estimatedCost: invItem.unitCostWithFees,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
setSelectedInventoryPartId('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddCustomPart = () => {
|
||||||
|
setRequiredParts([
|
||||||
|
...requiredParts,
|
||||||
|
{
|
||||||
|
partName: 'Nouvelle pièce requise',
|
||||||
|
category: 'other',
|
||||||
|
quantity: 1,
|
||||||
|
estimatedCost: 10,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdatePart = (index: number, updates: Partial<ModRequiredPart>) => {
|
||||||
|
const updated = [...requiredParts];
|
||||||
|
updated[index] = { ...updated[index], ...updates };
|
||||||
|
setRequiredParts(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemovePart = (index: number) => {
|
||||||
|
setRequiredParts(requiredParts.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddTask = () => {
|
||||||
|
if (!newTaskInput.trim()) return;
|
||||||
|
setChecklistTasks([...checklistTasks, newTaskInput.trim()]);
|
||||||
|
setNewTaskInput('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveTask = (index: number) => {
|
||||||
|
setChecklistTasks(checklistTasks.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculateTotalPartsCost = () => {
|
||||||
|
return requiredParts.reduce((acc, p) => acc + (p.estimatedCost * p.quantity), 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!name.trim()) return;
|
||||||
|
|
||||||
|
const parsedTags = tagsInput
|
||||||
|
.split(',')
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const modData = {
|
||||||
|
name: name.trim(),
|
||||||
|
category,
|
||||||
|
compatibleModels: selectedConsoles,
|
||||||
|
description: description.trim(),
|
||||||
|
installationNotes: installationNotes.trim(),
|
||||||
|
difficulty,
|
||||||
|
estimatedMinutes: Number(estimatedMinutes) || 30,
|
||||||
|
suggestedAddedValue: Number(suggestedAddedValue) || 0,
|
||||||
|
requiredParts,
|
||||||
|
checklistTasks,
|
||||||
|
tags: parsedTags,
|
||||||
|
isPopular,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editingMod) {
|
||||||
|
updateModRecipe(editingMod.id, modData);
|
||||||
|
} else {
|
||||||
|
addModRecipe(modData);
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-4 md:p-6 bg-slate-950/80 backdrop-blur-md overflow-y-auto">
|
||||||
|
<div className="bg-slate-900 border border-slate-700/80 rounded-2xl w-full max-w-4xl max-h-[92vh] flex flex-col shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-200">
|
||||||
|
|
||||||
|
{/* Modal Header */}
|
||||||
|
<div className="p-5 sm:p-6 border-b border-slate-800 flex items-center justify-between bg-gradient-to-r from-pink-500/10 via-purple-500/10 to-transparent">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-3 bg-pink-500/20 text-pink-400 border border-pink-500/30 rounded-xl">
|
||||||
|
<Sparkles className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-white flex items-center gap-2">
|
||||||
|
{editingMod ? 'Modifier la Recette de Mod' : 'Créer une Nouvelle Recette de Mod'}
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
Définissez les pièces requises, le temps d'atelier et la valeur ajoutée pour réutiliser ce mod dans vos projets.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 text-slate-400 hover:text-white bg-slate-800/80 hover:bg-slate-700 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Body / Form */}
|
||||||
|
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto p-5 sm:p-6 space-y-6">
|
||||||
|
|
||||||
|
{/* Section 1: Informations Générales */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-sm font-semibold uppercase tracking-wider text-pink-400 flex items-center gap-2">
|
||||||
|
<Wrench className="w-4 h-4" /> 1. Informations Générales
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="block text-xs font-medium text-slate-300 mb-1">
|
||||||
|
Nom du Mod / Titre de la Recette *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="Ex: Écran IPS V5 Laminé Grand Angle avec Menu OSD"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="w-full bg-slate-950 border border-slate-700 rounded-xl px-4 py-2.5 text-white placeholder-slate-500 focus:outline-none focus:border-pink-500 text-sm font-medium"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-300 mb-1">
|
||||||
|
Catégorie de Modification
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={category}
|
||||||
|
onChange={(e) => setCategory(e.target.value as ModCategory)}
|
||||||
|
className="w-full bg-slate-950 border border-slate-700 rounded-xl px-3 py-2.5 text-white focus:outline-none focus:border-pink-500 text-sm"
|
||||||
|
>
|
||||||
|
{modCategories.map((cat) => (
|
||||||
|
<option key={cat.id} value={cat.slug}>
|
||||||
|
{cat.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-300 mb-1">
|
||||||
|
Niveau de Difficulté
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={difficulty}
|
||||||
|
onChange={(e) => setDifficulty(e.target.value as any)}
|
||||||
|
className="w-full bg-slate-950 border border-slate-700 rounded-xl px-3 py-2.5 text-white focus:outline-none focus:border-pink-500 text-sm font-medium"
|
||||||
|
>
|
||||||
|
<option value="easy">🟢 Facile (Sans soudure / Plug & Play)</option>
|
||||||
|
<option value="medium">🟡 Intermédiaire (3-4 points de soudure)</option>
|
||||||
|
<option value="hard">🟠 Avancé (Découpe coque & soudures de précision)</option>
|
||||||
|
<option value="expert">🔴 Expert (Full Recap CMS & Micro-soudure)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Consoles Compatibles (Multi-select Chips) */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-300 mb-2">
|
||||||
|
Consoles Compatibles (cliquez pour activer/désactiver)
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto p-2 bg-slate-950 border border-slate-800 rounded-xl">
|
||||||
|
{consoleModels.map((cm) => {
|
||||||
|
const isSelected = selectedConsoles.includes(cm.slug);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={cm.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleConsole(cm.slug)}
|
||||||
|
className={`px-3 py-1 rounded-lg text-xs font-medium transition-all ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-pink-500 text-white shadow-sm shadow-pink-500/30'
|
||||||
|
: 'bg-slate-800 text-slate-400 hover:bg-slate-700 hover:text-slate-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isSelected ? '✓ ' : '+ '}
|
||||||
|
{cm.name}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description & Notes */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-300 mb-1">
|
||||||
|
Description Commerciale & Caractéristiques
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
placeholder="Explication des fonctionnalités pour la fiche client..."
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
className="w-full bg-slate-950 border border-slate-700 rounded-xl px-3 py-2 text-white placeholder-slate-500 focus:outline-none focus:border-pink-500 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-300 mb-1">
|
||||||
|
Notes Techniques d'Installation & Précautions
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
placeholder="Ex: Points de test TP2 / TP3, isolation thermique du module..."
|
||||||
|
value={installationNotes}
|
||||||
|
onChange={(e) => setInstallationNotes(e.target.value)}
|
||||||
|
className="w-full bg-slate-950 border border-slate-700 rounded-xl px-3 py-2 text-white placeholder-slate-500 focus:outline-none focus:border-pink-500 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Section 2: Pièces & Matériel Requis (Lié à l'inventaire) */}
|
||||||
|
<div className="space-y-4 pt-4 border-t border-slate-800">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold uppercase tracking-wider text-cyan-400 flex items-center gap-2">
|
||||||
|
<Layers className="w-4 h-4" /> 2. Pièces & Composants Requis
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
Total matériel estimé : <strong className="text-cyan-300 font-mono">{calculateTotalPartsCost().toFixed(2)} €</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAddCustomPart}
|
||||||
|
className="px-2.5 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-200 rounded-lg text-xs font-medium flex items-center gap-1 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" /> Pièce Libre
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Link from Inventory Dropdown */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<select
|
||||||
|
value={selectedInventoryPartId}
|
||||||
|
onChange={(e) => setSelectedInventoryPartId(e.target.value)}
|
||||||
|
className="flex-1 bg-slate-950 border border-slate-700 rounded-xl px-3 py-2 text-white text-xs focus:outline-none focus:border-cyan-500"
|
||||||
|
>
|
||||||
|
<option value="">Sélectionner une pièce depuis votre stock actuel...</option>
|
||||||
|
{inventory.map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>
|
||||||
|
[{item.sku}] {item.name} ({item.unitCostWithFees.toFixed(2)} € - Dispo: {item.quantityInStock})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!selectedInventoryPartId}
|
||||||
|
onClick={handleAddInventoryPart}
|
||||||
|
className="px-4 py-2 bg-cyan-600 hover:bg-cyan-500 disabled:opacity-50 text-white rounded-xl text-xs font-semibold flex items-center gap-1 transition-colors shrink-0"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" /> Ajouter du Stock
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* List of Added Parts */}
|
||||||
|
{requiredParts.length === 0 ? (
|
||||||
|
<div className="p-4 rounded-xl border border-dashed border-slate-800 text-center text-xs text-slate-500">
|
||||||
|
Aucune pièce liée à cette recette. Vous pouvez sélectionner des pièces de votre stock ou en créer librement.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2 max-h-48 overflow-y-auto pr-1">
|
||||||
|
{requiredParts.map((part, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="p-2.5 bg-slate-950/80 border border-slate-800 rounded-xl flex flex-col sm:flex-row sm:items-center justify-between gap-2 text-xs"
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={part.partName}
|
||||||
|
onChange={(e) => handleUpdatePart(index, { partName: e.target.value })}
|
||||||
|
className="w-full bg-transparent border-b border-transparent hover:border-slate-700 focus:border-cyan-500 text-white font-medium focus:outline-none"
|
||||||
|
/>
|
||||||
|
<div className="text-[10px] text-slate-500">
|
||||||
|
{part.partId ? '🔗 Pièce liée au stock' : '📝 Pièce générique libre'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 shrink-0">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-slate-400">Qté:</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={part.quantity}
|
||||||
|
onChange={(e) => handleUpdatePart(index, { quantity: Math.max(1, parseInt(e.target.value) || 1) })}
|
||||||
|
className="w-12 bg-slate-900 border border-slate-700 rounded px-1.5 py-0.5 text-center text-white text-xs font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-slate-400">Coût:</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
min="0"
|
||||||
|
value={part.estimatedCost}
|
||||||
|
onChange={(e) => handleUpdatePart(index, { estimatedCost: parseFloat(e.target.value) || 0 })}
|
||||||
|
className="w-16 bg-slate-900 border border-slate-700 rounded px-1.5 py-0.5 text-right text-cyan-400 font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<span className="text-slate-400">€</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="font-mono text-cyan-300 font-semibold min-w-[50px] text-right">
|
||||||
|
{(part.estimatedCost * part.quantity).toFixed(2)} €
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRemovePart(index)}
|
||||||
|
className="p-1 text-slate-500 hover:text-red-400 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Section 3: Temps Atelier, Valeur Marchande & Checklist */}
|
||||||
|
<div className="space-y-4 pt-4 border-t border-slate-800">
|
||||||
|
<h3 className="text-sm font-semibold uppercase tracking-wider text-purple-400 flex items-center gap-2">
|
||||||
|
<Clock className="w-4 h-4" /> 3. Temps d'Atelier, Prix & Checklist d'Étapes
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-300 mb-1">
|
||||||
|
Temps d'Installation Estimé (Minutes)
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Clock className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="5"
|
||||||
|
step="5"
|
||||||
|
value={estimatedMinutes}
|
||||||
|
onChange={(e) => setEstimatedMinutes(parseInt(e.target.value) || 0)}
|
||||||
|
className="w-full bg-slate-950 border border-slate-700 rounded-xl pl-9 pr-4 py-2.5 text-white font-mono text-sm focus:outline-none focus:border-purple-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-300 mb-1">
|
||||||
|
Valeur Ajoutée Suggérée sur le Prix de Vente (€)
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Euro className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="5"
|
||||||
|
value={suggestedAddedValue}
|
||||||
|
onChange={(e) => setSuggestedAddedValue(parseFloat(e.target.value) || 0)}
|
||||||
|
className="w-full bg-slate-950 border border-slate-700 rounded-xl pl-9 pr-4 py-2.5 text-emerald-400 font-mono font-bold text-sm focus:outline-none focus:border-emerald-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Checklist Tasks Builder */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-300 mb-2">
|
||||||
|
Étapes de Travail / Checklist Recommandée pour ce Mod
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex gap-2 mb-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Ex: Découpe interne de la coque, Soudure des fils OSD..."
|
||||||
|
value={newTaskInput}
|
||||||
|
onChange={(e) => setNewTaskInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleAddTask();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="flex-1 bg-slate-950 border border-slate-700 rounded-xl px-3 py-2 text-white text-xs focus:outline-none focus:border-purple-500"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAddTask}
|
||||||
|
className="px-3 py-2 bg-purple-600 hover:bg-purple-500 text-white rounded-xl text-xs font-semibold flex items-center gap-1 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" /> Ajouter Étape
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5 max-h-36 overflow-y-auto">
|
||||||
|
{checklistTasks.map((task, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="flex items-center justify-between p-2 bg-slate-950/60 border border-slate-800/80 rounded-lg text-xs"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-5 h-5 rounded-full bg-purple-500/20 text-purple-300 flex items-center justify-center text-[10px] font-bold">
|
||||||
|
{idx + 1}
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-200">{task}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRemoveTask(idx)}
|
||||||
|
className="text-slate-500 hover:text-red-400 p-1"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tags & Badges */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 items-center">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-300 mb-1">
|
||||||
|
Tags & Mots-clés (séparés par des virgules)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Écran, IPS, OSD, Rétroéclairage..."
|
||||||
|
value={tagsInput}
|
||||||
|
onChange={(e) => setTagsInput(e.target.value)}
|
||||||
|
className="w-full bg-slate-950 border border-slate-700 rounded-xl px-3 py-2 text-white text-xs focus:outline-none focus:border-pink-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4 flex items-center gap-2">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isPopular}
|
||||||
|
onChange={(e) => setIsPopular(e.target.checked)}
|
||||||
|
className="rounded bg-slate-950 border-slate-700 text-pink-500 focus:ring-pink-500 w-4 h-4"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-slate-300 font-medium flex items-center gap-1">
|
||||||
|
<Sparkles className="w-3.5 h-3.5 text-pink-400" /> Marquer comme "Mod Populaire / Best Seller"
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Footer */}
|
||||||
|
<div className="pt-4 border-t border-slate-800 flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2.5 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-xl text-xs font-semibold transition-colors"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="px-6 py-2.5 bg-gradient-to-r from-pink-500 to-purple-600 hover:from-pink-400 hover:to-purple-500 text-white rounded-xl text-xs font-bold shadow-lg shadow-pink-500/20 flex items-center gap-2 transition-all transform hover:-translate-y-0.5"
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="w-4 h-4" />
|
||||||
|
{editingMod ? 'Enregistrer les Modifications' : 'Créer la Recette de Mod'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
615
src/components/mods/ModsLibraryView.tsx
Normal file
615
src/components/mods/ModsLibraryView.tsx
Normal file
@@ -0,0 +1,615 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Sparkles,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
Monitor,
|
||||||
|
BatteryCharging,
|
||||||
|
Volume2,
|
||||||
|
Palette,
|
||||||
|
Cpu,
|
||||||
|
Layers,
|
||||||
|
Wrench,
|
||||||
|
Clock,
|
||||||
|
Euro,
|
||||||
|
Copy,
|
||||||
|
Edit2,
|
||||||
|
Trash2,
|
||||||
|
Boxes,
|
||||||
|
ListTodo,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
Play,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useApp } from '../../context/AppContext';
|
||||||
|
import { ModRecipeModal } from './ModRecipeModal';
|
||||||
|
import { ApplyModModal } from './ApplyModModal';
|
||||||
|
import type { ModRecipe, ModCategory } from '../../types';
|
||||||
|
|
||||||
|
const CATEGORY_META: Record<ModCategory, { label: string; icon: React.ElementType; color: string; border: string; bg: string }> = {
|
||||||
|
screen: {
|
||||||
|
label: 'Écran & Affichage',
|
||||||
|
icon: Monitor,
|
||||||
|
color: 'text-cyan-400',
|
||||||
|
border: 'border-cyan-500/30',
|
||||||
|
bg: 'bg-cyan-500/10',
|
||||||
|
},
|
||||||
|
battery_power: {
|
||||||
|
label: 'Batterie & USB-C',
|
||||||
|
icon: BatteryCharging,
|
||||||
|
color: 'text-emerald-400',
|
||||||
|
border: 'border-emerald-500/30',
|
||||||
|
bg: 'bg-emerald-500/10',
|
||||||
|
},
|
||||||
|
audio_sound: {
|
||||||
|
label: 'Audio & Ampli',
|
||||||
|
icon: Volume2,
|
||||||
|
color: 'text-pink-400',
|
||||||
|
border: 'border-pink-500/30',
|
||||||
|
bg: 'bg-pink-500/10',
|
||||||
|
},
|
||||||
|
shell_aesthetics: {
|
||||||
|
label: 'Coque & Esthétique',
|
||||||
|
icon: Palette,
|
||||||
|
color: 'text-blue-400',
|
||||||
|
border: 'border-blue-500/30',
|
||||||
|
bg: 'bg-blue-500/10',
|
||||||
|
},
|
||||||
|
buttons_led: {
|
||||||
|
label: 'Boutons & LED',
|
||||||
|
icon: Sparkles,
|
||||||
|
color: 'text-purple-400',
|
||||||
|
border: 'border-purple-500/30',
|
||||||
|
bg: 'bg-purple-500/10',
|
||||||
|
},
|
||||||
|
motherboard_recap: {
|
||||||
|
label: 'Recap & Carte Mère',
|
||||||
|
icon: Cpu,
|
||||||
|
color: 'text-amber-400',
|
||||||
|
border: 'border-amber-500/30',
|
||||||
|
bg: 'bg-amber-500/10',
|
||||||
|
},
|
||||||
|
complete_kit: {
|
||||||
|
label: 'Pack Ultime All-in-One',
|
||||||
|
icon: Layers,
|
||||||
|
color: 'text-rose-400',
|
||||||
|
border: 'border-rose-500/30',
|
||||||
|
bg: 'bg-rose-500/10',
|
||||||
|
},
|
||||||
|
storage_expansion: {
|
||||||
|
label: 'Stockage & Mémoire',
|
||||||
|
icon: Boxes,
|
||||||
|
color: 'text-teal-400',
|
||||||
|
border: 'border-teal-500/30',
|
||||||
|
bg: 'bg-teal-500/10',
|
||||||
|
},
|
||||||
|
other: {
|
||||||
|
label: 'Autre Mod',
|
||||||
|
icon: Wrench,
|
||||||
|
color: 'text-slate-300',
|
||||||
|
border: 'border-slate-700',
|
||||||
|
bg: 'bg-slate-800',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const DIFFICULTY_META = {
|
||||||
|
easy: { label: 'Facile (Plug & Play)', color: 'text-emerald-400 bg-emerald-500/10 border-emerald-500/20' },
|
||||||
|
medium: { label: 'Intermédiaire', color: 'text-amber-400 bg-amber-500/10 border-amber-500/20' },
|
||||||
|
hard: { label: 'Avancé', color: 'text-orange-400 bg-orange-500/10 border-orange-500/20' },
|
||||||
|
expert: { label: 'Expert (Recap/CMS)', color: 'text-red-400 bg-red-500/10 border-red-500/20' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ModsLibraryView: React.FC = () => {
|
||||||
|
const { modsLibrary, deleteModRecipe, addModRecipe, modCategories, consoleModels } = useApp();
|
||||||
|
|
||||||
|
// Search & Filter State
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState<string>('all');
|
||||||
|
const [selectedConsole, setSelectedConsole] = useState<string>('all');
|
||||||
|
const [selectedDifficulty, setSelectedDifficulty] = useState<string>('all');
|
||||||
|
|
||||||
|
// Modals state
|
||||||
|
const [showRecipeModal, setShowRecipeModal] = useState(false);
|
||||||
|
const [editingMod, setEditingMod] = useState<ModRecipe | null>(null);
|
||||||
|
const [modToApply, setModToApply] = useState<ModRecipe | null>(null);
|
||||||
|
const [expandedCards, setExpandedCards] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
// Toggle card checklist expansion
|
||||||
|
const toggleCardExpand = (id: string) => {
|
||||||
|
setExpandedCards((prev) => ({ ...prev, [id]: !prev[id] }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Duplicate recipe
|
||||||
|
const handleDuplicate = (mod: ModRecipe) => {
|
||||||
|
const copyData = {
|
||||||
|
...mod,
|
||||||
|
name: `${mod.name} (Copie)`,
|
||||||
|
isPopular: false,
|
||||||
|
};
|
||||||
|
addModRecipe(copyData);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
const totalMods = modsLibrary.length;
|
||||||
|
const popularModsCount = modsLibrary.filter((m) => m.isPopular).length;
|
||||||
|
const avgAddedValue = totalMods > 0
|
||||||
|
? modsLibrary.reduce((s, m) => s + (m.suggestedAddedValue || 0), 0) / totalMods
|
||||||
|
: 0;
|
||||||
|
const avgMinutes = totalMods > 0
|
||||||
|
? modsLibrary.reduce((s, m) => s + (m.estimatedMinutes || 0), 0) / totalMods
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// Filtered mods
|
||||||
|
const filteredMods = modsLibrary.filter((mod) => {
|
||||||
|
// Search query
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
const q = searchQuery.toLowerCase();
|
||||||
|
const matchName = mod.name.toLowerCase().includes(q);
|
||||||
|
const matchDesc = (mod.description || '').toLowerCase().includes(q);
|
||||||
|
const matchTags = (mod.tags || []).some((t) => t.toLowerCase().includes(q));
|
||||||
|
const matchParts = (mod.requiredParts || []).some((p) => p.partName.toLowerCase().includes(q));
|
||||||
|
if (!matchName && !matchDesc && !matchTags && !matchParts) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category filter
|
||||||
|
if (selectedCategory !== 'all' && mod.category !== selectedCategory) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Console model filter
|
||||||
|
if (selectedConsole !== 'all') {
|
||||||
|
const isCompatible = (mod.compatibleModels || []).some(
|
||||||
|
(c) => c === selectedConsole || c === 'Autre / Custom'
|
||||||
|
);
|
||||||
|
if (!isCompatible) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Difficulty filter
|
||||||
|
if (selectedDifficulty !== 'all' && mod.difficulty !== selectedDifficulty) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 pb-16">
|
||||||
|
|
||||||
|
{/* Top Banner & Header */}
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 p-6 bg-gradient-to-r from-purple-900/30 via-slate-900 to-pink-900/20 border border-slate-800 rounded-3xl shadow-xl">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="px-2.5 py-0.5 bg-pink-500/20 text-pink-400 border border-pink-500/30 rounded-full text-xs font-semibold uppercase tracking-wider flex items-center gap-1">
|
||||||
|
<Sparkles className="w-3 h-3" /> Catalogue & Recettes de Modding
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-400 font-mono">
|
||||||
|
{totalMods} recettes prêtes à l'emploi
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-black text-white tracking-tight">
|
||||||
|
Bibliothèque de Mods & Kits Prédéfinis
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs sm:text-sm text-slate-300 max-w-2xl">
|
||||||
|
Créez vos recettes de modding réutilisables (écrans IPS, USB-C, CleanAmp, coques UV, recaps), associez les pièces requises et appliquez-les à vos projets en un clic.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setEditingMod(null);
|
||||||
|
setShowRecipeModal(true);
|
||||||
|
}}
|
||||||
|
className="px-5 py-3 bg-gradient-to-r from-pink-500 to-purple-600 hover:from-pink-400 hover:to-purple-500 text-white rounded-2xl text-xs sm:text-sm font-bold shadow-lg shadow-pink-500/25 flex items-center gap-2 transition-all transform hover:-translate-y-0.5 shrink-0"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" /> Nouvelle Recette de Mod
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* KPI Cards */}
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<div className="p-4 bg-slate-900/80 border border-slate-800/80 rounded-2xl flex items-center gap-3">
|
||||||
|
<div className="p-3 bg-purple-500/10 text-purple-400 border border-purple-500/20 rounded-xl shrink-0">
|
||||||
|
<Layers className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] text-slate-400 uppercase tracking-wider font-semibold">Total Recettes</div>
|
||||||
|
<div className="text-xl font-bold text-white font-mono">{totalMods}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 bg-slate-900/80 border border-slate-800/80 rounded-2xl flex items-center gap-3">
|
||||||
|
<div className="p-3 bg-pink-500/10 text-pink-400 border border-pink-500/20 rounded-xl shrink-0">
|
||||||
|
<Sparkles className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] text-slate-400 uppercase tracking-wider font-semibold">Best Sellers</div>
|
||||||
|
<div className="text-xl font-bold text-pink-400 font-mono">{popularModsCount}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 bg-slate-900/80 border border-slate-800/80 rounded-2xl flex items-center gap-3">
|
||||||
|
<div className="p-3 bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 rounded-xl shrink-0">
|
||||||
|
<Euro className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] text-slate-400 uppercase tracking-wider font-semibold">Valeur Ajoutée Moy.</div>
|
||||||
|
<div className="text-xl font-bold text-emerald-400 font-mono">+{avgAddedValue.toFixed(0)} €</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 bg-slate-900/80 border border-slate-800/80 rounded-2xl flex items-center gap-3">
|
||||||
|
<div className="p-3 bg-cyan-500/10 text-cyan-400 border border-cyan-500/20 rounded-xl shrink-0">
|
||||||
|
<Clock className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] text-slate-400 uppercase tracking-wider font-semibold">Temps Atelier Moyen</div>
|
||||||
|
<div className="text-xl font-bold text-cyan-400 font-mono">{avgMinutes.toFixed(0)} min</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter & Search Toolbar */}
|
||||||
|
<div className="p-4 bg-slate-900/90 border border-slate-800 rounded-2xl space-y-3">
|
||||||
|
<div className="flex flex-col md:flex-row gap-3 items-stretch md:items-center">
|
||||||
|
|
||||||
|
{/* Search Input */}
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="w-4 h-4 absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Rechercher un mod par nom, pièce requise, tag (ex: IPS, CleanAmp, USB-C, Recap)..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 focus:border-pink-500 rounded-xl pl-10 pr-4 py-2 text-xs sm:text-sm text-white placeholder-slate-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSearchQuery('')}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white text-xs"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Console filter dropdown */}
|
||||||
|
<div className="shrink-0">
|
||||||
|
<select
|
||||||
|
value={selectedConsole}
|
||||||
|
onChange={(e) => setSelectedConsole(e.target.value)}
|
||||||
|
className="w-full md:w-auto bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-xs text-slate-200 focus:outline-none focus:border-pink-500"
|
||||||
|
>
|
||||||
|
<option value="all">🎮 Toutes les Consoles</option>
|
||||||
|
{consoleModels.map((cm) => (
|
||||||
|
<option key={cm.id} value={cm.slug}>
|
||||||
|
{cm.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category filter dropdown */}
|
||||||
|
<div className="shrink-0">
|
||||||
|
<select
|
||||||
|
value={selectedCategory}
|
||||||
|
onChange={(e) => setSelectedCategory(e.target.value)}
|
||||||
|
className="w-full md:w-auto bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-xs text-slate-200 focus:outline-none focus:border-pink-500"
|
||||||
|
>
|
||||||
|
<option value="all">📁 Toutes Catégories</option>
|
||||||
|
{modCategories.map((mc) => (
|
||||||
|
<option key={mc.id} value={mc.slug}>
|
||||||
|
{mc.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Difficulty filter dropdown */}
|
||||||
|
<div className="shrink-0">
|
||||||
|
<select
|
||||||
|
value={selectedDifficulty}
|
||||||
|
onChange={(e) => setSelectedDifficulty(e.target.value)}
|
||||||
|
className="w-full md:w-auto bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-xs text-slate-200 focus:outline-none focus:border-pink-500"
|
||||||
|
>
|
||||||
|
<option value="all">⚡ Toute Difficulté</option>
|
||||||
|
<option value="easy">🟢 Facile</option>
|
||||||
|
<option value="medium">🟡 Intermédiaire</option>
|
||||||
|
<option value="hard">🟠 Avancé</option>
|
||||||
|
<option value="expert">🔴 Expert</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Category Chips */}
|
||||||
|
<div className="flex flex-wrap gap-1.5 pt-1 overflow-x-auto pb-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedCategory('all')}
|
||||||
|
className={`px-3 py-1 rounded-lg text-xs font-medium transition-all ${
|
||||||
|
selectedCategory === 'all'
|
||||||
|
? 'bg-pink-500 text-white shadow-sm shadow-pink-500/30'
|
||||||
|
: 'bg-slate-950 text-slate-400 hover:text-white border border-slate-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Tous ({modsLibrary.length})
|
||||||
|
</button>
|
||||||
|
{Object.entries(CATEGORY_META).map(([catKey, meta]) => {
|
||||||
|
const count = modsLibrary.filter((m) => m.category === catKey).length;
|
||||||
|
if (count === 0 && selectedCategory !== catKey) return null;
|
||||||
|
const Icon = meta.icon;
|
||||||
|
const isActive = selectedCategory === catKey;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={catKey}
|
||||||
|
onClick={() => setSelectedCategory(catKey)}
|
||||||
|
className={`px-2.5 py-1 rounded-lg text-xs font-medium flex items-center gap-1.5 transition-all border ${
|
||||||
|
isActive
|
||||||
|
? 'bg-slate-800 text-white border-pink-500 shadow-sm'
|
||||||
|
: 'bg-slate-950 text-slate-400 hover:text-slate-200 border-slate-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className={`w-3.5 h-3.5 ${meta.color}`} />
|
||||||
|
<span>{meta.label}</span>
|
||||||
|
<span className="text-[10px] font-mono opacity-70">({count})</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grid of Mod Recipe Cards */}
|
||||||
|
{filteredMods.length === 0 ? (
|
||||||
|
<div className="p-12 text-center bg-slate-900/60 border border-slate-800 rounded-3xl space-y-4">
|
||||||
|
<div className="p-4 bg-slate-800/80 text-slate-400 rounded-2xl w-fit mx-auto">
|
||||||
|
<Search className="w-8 h-8" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="text-base font-bold text-white">Aucune recette trouvée</h3>
|
||||||
|
<p className="text-xs text-slate-400 max-w-md mx-auto">
|
||||||
|
Aucun mod ne correspond à vos filtres actuels. Réinitialisez la recherche ou créez un nouveau mod personnalisé.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSearchQuery('');
|
||||||
|
setSelectedCategory('all');
|
||||||
|
setSelectedConsole('all');
|
||||||
|
setSelectedDifficulty('all');
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-200 rounded-xl text-xs font-semibold transition-colors"
|
||||||
|
>
|
||||||
|
Réinitialiser les filtres
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-5">
|
||||||
|
{filteredMods.map((mod) => {
|
||||||
|
const catMeta = CATEGORY_META[mod.category] || CATEGORY_META.other;
|
||||||
|
const CatIcon = catMeta.icon;
|
||||||
|
const diffMeta = DIFFICULTY_META[mod.difficulty] || DIFFICULTY_META.medium;
|
||||||
|
const totalPartsCost = (mod.requiredParts || []).reduce(
|
||||||
|
(acc, p) => acc + p.estimatedCost * p.quantity,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const isExpanded = expandedCards[mod.id] || false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={mod.id}
|
||||||
|
className="bg-slate-900 border border-slate-800 hover:border-slate-700/80 rounded-2xl p-5 flex flex-col justify-between space-y-4 shadow-lg hover:shadow-xl transition-all group relative overflow-hidden"
|
||||||
|
>
|
||||||
|
{/* Glow accent */}
|
||||||
|
<div
|
||||||
|
className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-bl from-pink-500/5 to-transparent rounded-bl-full pointer-events-none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Card Top: Badges & Actions */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
|
<span className={`px-2.5 py-1 rounded-lg text-[11px] font-semibold flex items-center gap-1 border ${catMeta.bg} ${catMeta.color} ${catMeta.border}`}>
|
||||||
|
<CatIcon className="w-3.5 h-3.5" />
|
||||||
|
{catMeta.label}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className={`px-2 py-0.5 rounded-md text-[10px] font-medium border ${diffMeta.color}`}>
|
||||||
|
{diffMeta.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mod.isPopular && (
|
||||||
|
<span className="px-2 py-0.5 bg-gradient-to-r from-amber-500/20 to-pink-500/20 text-amber-300 border border-amber-500/30 rounded-full text-[10px] font-bold flex items-center gap-1 shrink-0">
|
||||||
|
⭐ Best Seller
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title & Description */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-bold text-white group-hover:text-pink-300 transition-colors">
|
||||||
|
{mod.name}
|
||||||
|
</h3>
|
||||||
|
{mod.description && (
|
||||||
|
<p className="text-xs text-slate-400 mt-1 line-clamp-2">
|
||||||
|
{mod.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Compatible Consoles Chips */}
|
||||||
|
<div className="flex flex-wrap gap-1 pt-1">
|
||||||
|
{(mod.compatibleModels || []).map((cons) => (
|
||||||
|
<span
|
||||||
|
key={cons}
|
||||||
|
className="px-2 py-0.5 bg-slate-950 border border-slate-800 rounded text-[10px] text-slate-300 font-medium"
|
||||||
|
>
|
||||||
|
🎮 {cons}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Key Metrics: Time & Value */}
|
||||||
|
<div className="grid grid-cols-2 gap-2 p-2.5 bg-slate-950/80 border border-slate-800/80 rounded-xl text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="p-1.5 bg-purple-500/10 text-purple-400 rounded-lg">
|
||||||
|
<Clock className="w-3.5 h-3.5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] text-slate-400">Atelier</div>
|
||||||
|
<div className="font-bold text-white font-mono">~{mod.estimatedMinutes} min</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="p-1.5 bg-emerald-500/10 text-emerald-400 rounded-lg">
|
||||||
|
<Euro className="w-3.5 h-3.5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] text-slate-400">Valeur Vente</div>
|
||||||
|
<div className="font-bold text-emerald-400 font-mono">+{mod.suggestedAddedValue} €</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Required Parts Preview */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between text-[11px] text-slate-400 font-medium">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Boxes className="w-3.5 h-3.5 text-cyan-400" />
|
||||||
|
{mod.requiredParts?.length || 0} composant(s) requis :
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-cyan-300">
|
||||||
|
Total ~{totalPartsCost.toFixed(2)} €
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-2 bg-slate-950/50 border border-slate-800/60 rounded-lg text-[11px] space-y-1">
|
||||||
|
{mod.requiredParts && mod.requiredParts.length > 0 ? (
|
||||||
|
mod.requiredParts.map((rp, i) => (
|
||||||
|
<div key={i} className="flex items-center justify-between text-slate-300">
|
||||||
|
<span className="truncate pr-2">• {rp.quantity}x {rp.partName}</span>
|
||||||
|
<span className="font-mono text-slate-400 shrink-0">{(rp.estimatedCost * rp.quantity).toFixed(2)} €</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="text-slate-500 italic">Aucune pièce matérielle liée</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expandable Checklist Steps */}
|
||||||
|
{mod.checklistTasks && mod.checklistTasks.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleCardExpand(mod.id)}
|
||||||
|
className="w-full flex items-center justify-between py-1 text-[11px] text-slate-400 hover:text-slate-200 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<ListTodo className="w-3.5 h-3.5 text-amber-400" />
|
||||||
|
{mod.checklistTasks.length} étapes d'atelier recommandées
|
||||||
|
</span>
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronUp className="w-3.5 h-3.5" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="mt-1 p-2 bg-slate-950/60 border border-slate-800/60 rounded-lg text-[11px] space-y-1 animate-in fade-in duration-150">
|
||||||
|
{mod.checklistTasks.map((task, idx) => (
|
||||||
|
<div key={idx} className="flex items-start gap-1.5 text-slate-300">
|
||||||
|
<span className="text-purple-400 font-bold">{idx + 1}.</span>
|
||||||
|
<span>{task}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
{mod.tags && mod.tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{mod.tags.map((tag) => (
|
||||||
|
<span key={tag} className="text-[10px] text-slate-500 flex items-center gap-0.5">
|
||||||
|
#{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card Footer Actions */}
|
||||||
|
<div className="pt-3 border-t border-slate-800 flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Modifier cette recette"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingMod(mod);
|
||||||
|
setShowRecipeModal(true);
|
||||||
|
}}
|
||||||
|
className="p-2 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Dupliquer cette recette"
|
||||||
|
onClick={() => handleDuplicate(mod)}
|
||||||
|
className="p-2 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Copy className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Supprimer cette recette"
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(`Supprimer définitivement la recette "${mod.name}" ?`)) {
|
||||||
|
deleteModRecipe(mod.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="p-2 text-slate-400 hover:text-red-400 hover:bg-slate-800 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setModToApply(mod)}
|
||||||
|
className="px-3.5 py-2 bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-white rounded-xl text-xs font-bold shadow-md shadow-cyan-500/20 flex items-center gap-1.5 transition-all transform hover:scale-105"
|
||||||
|
>
|
||||||
|
<Play className="w-3.5 h-3.5 fill-current" /> Appliquer au Projet
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Modals */}
|
||||||
|
<ModRecipeModal
|
||||||
|
isOpen={showRecipeModal}
|
||||||
|
onClose={() => {
|
||||||
|
setShowRecipeModal(false);
|
||||||
|
setEditingMod(null);
|
||||||
|
}}
|
||||||
|
editingMod={editingMod}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ApplyModModal
|
||||||
|
isOpen={!!modToApply}
|
||||||
|
onClose={() => setModToApply(null)}
|
||||||
|
modToApply={modToApply}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
180
src/components/projects/MarkAsSoldModal.tsx
Normal file
180
src/components/projects/MarkAsSoldModal.tsx
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { X, Sparkles, DollarSign, Store, User, Calendar } from 'lucide-react';
|
||||||
|
import { useApp } from '../../context/AppContext';
|
||||||
|
import type { Project, SalesChannel } from '../../types';
|
||||||
|
import { formatCurrency } from '../../utils/calculations';
|
||||||
|
|
||||||
|
|
||||||
|
interface MarkAsSoldModalProps {
|
||||||
|
project: Project | null;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SALES_CHANNELS: { id: SalesChannel; label: string }[] = [
|
||||||
|
{ id: 'vinted', label: 'Vinted' },
|
||||||
|
{ id: 'leboncoin', label: 'Leboncoin' },
|
||||||
|
{ id: 'etsy', label: 'Etsy' },
|
||||||
|
{ id: 'ebay', label: 'eBay' },
|
||||||
|
{ id: 'direct_shop', label: 'Vente Directe / Boutique' },
|
||||||
|
{ id: 'convention', label: 'Convention / Événement' },
|
||||||
|
{ id: 'autre', label: 'Autre canal' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const MarkAsSoldModal: React.FC<MarkAsSoldModalProps> = ({
|
||||||
|
project,
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
const { markProjectAsSold, settings } = useApp();
|
||||||
|
|
||||||
|
const [soldPrice, setSoldPrice] = useState<number>(180);
|
||||||
|
const [salesChannel, setSalesChannel] = useState<SalesChannel>('vinted');
|
||||||
|
const [soldDate, setSoldDate] = useState<string>(
|
||||||
|
new Date().toISOString().split('T')[0]
|
||||||
|
);
|
||||||
|
const [customerName, setCustomerName] = useState<string>('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (project) {
|
||||||
|
setSoldPrice(project.salePriceActual || project.salePriceTarget || 150);
|
||||||
|
setSalesChannel(project.salesChannel || 'vinted');
|
||||||
|
setSoldDate(project.soldDate || new Date().toISOString().split('T')[0]);
|
||||||
|
setCustomerName(project.customerName || '');
|
||||||
|
}
|
||||||
|
}, [project, isOpen]);
|
||||||
|
|
||||||
|
if (!isOpen || !project) return null;
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
markProjectAsSold(
|
||||||
|
project.id,
|
||||||
|
Number(soldPrice) || 0,
|
||||||
|
salesChannel,
|
||||||
|
soldDate,
|
||||||
|
customerName.trim()
|
||||||
|
);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md no-print">
|
||||||
|
<div className="relative w-full max-w-md bg-slate-900 border border-pink-500/40 rounded-3xl shadow-2xl overflow-hidden animate-scaleUp">
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-5 border-b border-slate-800 bg-gradient-to-r from-pink-950/40 to-purple-950/40">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-pink-500/20 border border-pink-500/40 flex items-center justify-center text-pink-400">
|
||||||
|
<Sparkles className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-bold text-white">
|
||||||
|
Enregistrer la Vente 🎉
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-slate-400 truncate max-w-[240px]">
|
||||||
|
{project.title}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-slate-400 hover:text-white p-2 rounded-xl hover:bg-slate-800 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||||
|
|
||||||
|
{/* Price */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-semibold text-slate-300 flex items-center space-x-1">
|
||||||
|
<DollarSign className="w-3.5 h-3.5 text-pink-400" />
|
||||||
|
<span>Prix de vente final ({settings.currency}) *</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
required
|
||||||
|
value={soldPrice}
|
||||||
|
onChange={(e) => setSoldPrice(parseFloat(e.target.value) || 0)}
|
||||||
|
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-700 text-white font-mono font-bold text-lg focus:outline-none focus:border-pink-500"
|
||||||
|
/>
|
||||||
|
<div className="text-[11px] text-slate-400">
|
||||||
|
Prix affiché initialement : {formatCurrency(project.salePriceTarget, settings.currency)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sales Channel */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-semibold text-slate-300 flex items-center space-x-1">
|
||||||
|
<Store className="w-3.5 h-3.5 text-pink-400" />
|
||||||
|
<span>Canal / Plateforme de Vente *</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={salesChannel}
|
||||||
|
onChange={(e) => setSalesChannel(e.target.value as SalesChannel)}
|
||||||
|
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-700 text-white text-sm focus:outline-none focus:border-pink-500"
|
||||||
|
>
|
||||||
|
{SALES_CHANNELS.map((ch) => (
|
||||||
|
<option key={ch.id} value={ch.id}>
|
||||||
|
{ch.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Customer Name */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-semibold text-slate-300 flex items-center space-x-1">
|
||||||
|
<User className="w-3.5 h-3.5 text-pink-400" />
|
||||||
|
<span>Nom du Client / Pseudo</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={customerName}
|
||||||
|
onChange={(e) => setCustomerName(e.target.value)}
|
||||||
|
placeholder="Ex: Antoine M. (@retro_gamer)"
|
||||||
|
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-700 text-white text-sm placeholder-slate-500 focus:outline-none focus:border-pink-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sold Date */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-semibold text-slate-300 flex items-center space-x-1">
|
||||||
|
<Calendar className="w-3.5 h-3.5 text-pink-400" />
|
||||||
|
<span>Date de la vente</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={soldDate}
|
||||||
|
onChange={(e) => setSoldDate(e.target.value)}
|
||||||
|
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-700 text-white text-sm focus:outline-none focus:border-pink-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit */}
|
||||||
|
<div className="flex items-center justify-end space-x-3 pt-4 border-t border-slate-800">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 rounded-xl bg-slate-800 text-slate-300 hover:text-white text-xs font-semibold"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="px-5 py-2.5 rounded-xl bg-gradient-to-r from-pink-500 to-purple-600 text-white font-bold text-sm shadow-lg shadow-pink-500/30 hover:scale-[1.02] active:scale-[0.98] transition-all"
|
||||||
|
>
|
||||||
|
Valider la Vente 🎉
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
1952
src/components/projects/ProjectDetailView.tsx
Normal file
1952
src/components/projects/ProjectDetailView.tsx
Normal file
File diff suppressed because it is too large
Load Diff
577
src/components/projects/ProjectModal.tsx
Normal file
577
src/components/projects/ProjectModal.tsx
Normal file
@@ -0,0 +1,577 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { X, Gamepad2, Tag, Calendar, User, DollarSign, Wrench, Sparkles, FileText } from 'lucide-react';
|
||||||
|
import { useApp } from '../../context/AppContext';
|
||||||
|
import type { Project, ConsoleModel, ProjectType, ProjectStatus } from '../../types';
|
||||||
|
|
||||||
|
|
||||||
|
interface ProjectModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
editingProject?: Project | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProjectModal: React.FC<ProjectModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
editingProject,
|
||||||
|
}) => {
|
||||||
|
const { addProject, updateProject, modsLibrary, settings, consoleModels } = useApp();
|
||||||
|
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [serialNumber, setSerialNumber] = useState('');
|
||||||
|
const [consoleModel, setConsoleModel] = useState<ConsoleModel>('GBA SP');
|
||||||
|
const [type, setType] = useState<ProjectType>('showcase_sale');
|
||||||
|
const [status, setStatus] = useState<ProjectStatus>('in_progress');
|
||||||
|
const [customerName, setCustomerName] = useState('');
|
||||||
|
const [customerContact, setCustomerContact] = useState('');
|
||||||
|
const [startDate, setStartDate] = useState(new Date().toISOString().split('T')[0]);
|
||||||
|
const [salePriceTarget, setSalePriceTarget] = useState<number>(180);
|
||||||
|
const [hourlyRateDefault, setHourlyRateDefault] = useState<number>(settings.defaultHourlyRate || 25);
|
||||||
|
const [consumablesEst, setConsumablesEst] = useState<number>(3.5);
|
||||||
|
const [packagingCost, setPackagingCost] = useState<number>(4.0);
|
||||||
|
const [shippingCost, setShippingCost] = useState<number>(0);
|
||||||
|
const [platformFeePercent, setPlatformFeePercent] = useState<number>(settings.defaultPlatformFeePercent || 0);
|
||||||
|
const [warrantyMonths, setWarrantyMonths] = useState<number>(6);
|
||||||
|
const [notes, setNotes] = useState('');
|
||||||
|
const [modsText, setModsText] = useState('');
|
||||||
|
const [targetDeadlineDate, setTargetDeadlineDate] = useState('');
|
||||||
|
const [targetProfitMin, setTargetProfitMin] = useState<string>('60');
|
||||||
|
const [targetLaborHoursMax, setTargetLaborHoursMax] = useState<string>('3.5');
|
||||||
|
const [seedPresetTasks, setSeedPresetTasks] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editingProject) {
|
||||||
|
setTitle(editingProject.title);
|
||||||
|
setSerialNumber(editingProject.serialNumber);
|
||||||
|
setConsoleModel(editingProject.consoleModel);
|
||||||
|
setType(editingProject.type);
|
||||||
|
setStatus(editingProject.status);
|
||||||
|
setCustomerName(editingProject.customerName || '');
|
||||||
|
setCustomerContact(editingProject.customerContact || '');
|
||||||
|
setStartDate(editingProject.startDate);
|
||||||
|
setSalePriceTarget(editingProject.salePriceTarget || 0);
|
||||||
|
setHourlyRateDefault(editingProject.hourlyRateDefault || settings.defaultHourlyRate || 25);
|
||||||
|
setConsumablesEst(editingProject.additionalFees?.consumablesEst || 0);
|
||||||
|
setPackagingCost(editingProject.additionalFees?.packagingCost || 0);
|
||||||
|
setShippingCost(editingProject.additionalFees?.shippingCost || 0);
|
||||||
|
setPlatformFeePercent(editingProject.additionalFees?.platformFeePercent || 0);
|
||||||
|
setWarrantyMonths(editingProject.warrantyMonths || 6);
|
||||||
|
setNotes(editingProject.notes || '');
|
||||||
|
setModsText(editingProject.modsList ? editingProject.modsList.join('\n') : '');
|
||||||
|
setTargetDeadlineDate(editingProject.objectives?.targetDeadlineDate || '');
|
||||||
|
setTargetProfitMin(editingProject.objectives?.targetProfitMin?.toString() || '');
|
||||||
|
setTargetLaborHoursMax(editingProject.objectives?.targetLaborHoursMax?.toString() || '');
|
||||||
|
setSeedPresetTasks(false);
|
||||||
|
} else {
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
const randomId = Math.floor(100 + Math.random() * 900);
|
||||||
|
setTitle('');
|
||||||
|
setSerialNumber(`UWU-${year}-${randomId}`);
|
||||||
|
setConsoleModel('GBA SP');
|
||||||
|
setType('showcase_sale');
|
||||||
|
setStatus('in_progress');
|
||||||
|
setCustomerName('');
|
||||||
|
setCustomerContact('');
|
||||||
|
setStartDate(new Date().toISOString().split('T')[0]);
|
||||||
|
setSalePriceTarget(179);
|
||||||
|
setHourlyRateDefault(settings.defaultHourlyRate || 25);
|
||||||
|
setConsumablesEst(3.5);
|
||||||
|
setPackagingCost(4.0);
|
||||||
|
setShippingCost(0);
|
||||||
|
setPlatformFeePercent(settings.defaultPlatformFeePercent || 0);
|
||||||
|
setWarrantyMonths(6);
|
||||||
|
setNotes('');
|
||||||
|
setModsText('Écran IPS laminé rétroéclairé haute définition\nPort de charge USB-C\nBatterie LiPo haute autonomie\nCoque neuve de qualité supérieure');
|
||||||
|
|
||||||
|
// Default target date in 14 days
|
||||||
|
const targetDate = new Date();
|
||||||
|
targetDate.setDate(targetDate.getDate() + 14);
|
||||||
|
setTargetDeadlineDate(targetDate.toISOString().split('T')[0]);
|
||||||
|
setTargetProfitMin('65');
|
||||||
|
setTargetLaborHoursMax('3.5');
|
||||||
|
setSeedPresetTasks(true);
|
||||||
|
}
|
||||||
|
}, [editingProject, isOpen, settings]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!title.trim()) return;
|
||||||
|
|
||||||
|
const parsedMods = modsText
|
||||||
|
.split('\n')
|
||||||
|
.map((m) => m.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const initialPresetTasks = seedPresetTasks && !editingProject ? [
|
||||||
|
{ id: 't1', title: 'Diagnostic donneuse & nettoyage ultrasons', isCompleted: false, category: 'diagnostic' as const },
|
||||||
|
{ id: 't2', title: 'Pose & soudure écran IPS + câblage OSD', isCompleted: false, category: 'soudure' as const },
|
||||||
|
{ id: 't3', title: 'Installation module USB-C & filtre audio', isCompleted: false, category: 'soudure' as const },
|
||||||
|
{ id: 't4', title: 'Assemblage coque neuve & boutons', isCompleted: false, category: 'assemblage' as const },
|
||||||
|
{ id: 't5', title: 'Contrôle qualité QC & test autonomie LiPo', isCompleted: false, category: 'test_qc' as const },
|
||||||
|
{ id: 't6', title: 'Nettoyage microfibre & mise en boîte', isCompleted: false, category: 'packaging' as const },
|
||||||
|
] : (editingProject?.tasks || []);
|
||||||
|
|
||||||
|
const projectData = {
|
||||||
|
title: title.trim(),
|
||||||
|
serialNumber: serialNumber.trim() || `UWU-${Date.now().toString().slice(-4)}`,
|
||||||
|
consoleModel,
|
||||||
|
type,
|
||||||
|
status,
|
||||||
|
customerName: customerName.trim() || undefined,
|
||||||
|
customerContact: customerContact.trim() || undefined,
|
||||||
|
startDate,
|
||||||
|
salePriceTarget: Number(salePriceTarget) || 0,
|
||||||
|
hourlyRateDefault: Number(hourlyRateDefault) || 25,
|
||||||
|
additionalFees: {
|
||||||
|
consumablesEst: Number(consumablesEst) || 0,
|
||||||
|
packagingCost: Number(packagingCost) || 0,
|
||||||
|
shippingCost: Number(shippingCost) || 0,
|
||||||
|
platformFeePercent: Number(platformFeePercent) || 0,
|
||||||
|
platformFeeFixed: editingProject?.additionalFees?.platformFeeFixed || 0,
|
||||||
|
otherFees: editingProject?.additionalFees?.otherFees || 0,
|
||||||
|
},
|
||||||
|
warrantyMonths: Number(warrantyMonths) || 6,
|
||||||
|
notes: notes.trim(),
|
||||||
|
modsList: parsedMods,
|
||||||
|
parts: editingProject?.parts || [],
|
||||||
|
timeSessions: editingProject?.timeSessions || [],
|
||||||
|
tasks: initialPresetTasks,
|
||||||
|
objectives: {
|
||||||
|
targetDeadlineDate: targetDeadlineDate || undefined,
|
||||||
|
targetProfitMin: targetProfitMin ? parseFloat(targetProfitMin) : undefined,
|
||||||
|
targetLaborHoursMax: targetLaborHoursMax ? parseFloat(targetLaborHoursMax) : undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editingProject) {
|
||||||
|
updateProject(editingProject.id, projectData);
|
||||||
|
} else {
|
||||||
|
addProject(projectData);
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md overflow-y-auto no-print">
|
||||||
|
<div className="relative w-full max-w-2xl bg-slate-900 border border-purple-500/40 rounded-3xl shadow-2xl overflow-hidden my-8 animate-scaleUp">
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-5 border-b border-slate-800 bg-gradient-to-r from-purple-950/50 via-slate-900 to-pink-950/50">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-purple-500/20 border border-purple-500/40 flex items-center justify-center text-purple-300">
|
||||||
|
<Gamepad2 className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold text-white">
|
||||||
|
{editingProject ? 'Modifier la Console / Projet' : 'Créer une Nouvelle Console Custom'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
Centre de dépenses, nomenclature et rentabilité de build
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-slate-400 hover:text-white p-2 rounded-xl hover:bg-slate-800 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
||||||
|
|
||||||
|
{/* Row 1: Title & Serial */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="md:col-span-2 space-y-1.5">
|
||||||
|
<label className="text-xs font-semibold text-slate-300">
|
||||||
|
Titre / Nom du Projet *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="Ex: GBA SP Cyberpunk Neon IPS V3 & USB-C"
|
||||||
|
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-700 text-white text-sm focus:outline-none focus:border-purple-500 placeholder-slate-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-semibold text-slate-300 flex items-center space-x-1">
|
||||||
|
<Tag className="w-3.5 h-3.5 text-purple-400" />
|
||||||
|
<span>N° de Série Unique</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={serialNumber}
|
||||||
|
onChange={(e) => setSerialNumber(e.target.value)}
|
||||||
|
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-700 text-white font-mono text-sm focus:outline-none focus:border-purple-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 2: Console Model, Type & Status */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-semibold text-slate-300">
|
||||||
|
Modèle de Console *
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={consoleModel}
|
||||||
|
onChange={(e) => setConsoleModel(e.target.value as ConsoleModel)}
|
||||||
|
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-700 text-white text-sm focus:outline-none focus:border-purple-500"
|
||||||
|
>
|
||||||
|
{consoleModels.map((m) => (
|
||||||
|
<option key={m.id} value={m.slug}>
|
||||||
|
{m.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-semibold text-slate-300">
|
||||||
|
Type de Projet
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={type}
|
||||||
|
onChange={(e) => setType(e.target.value as ProjectType)}
|
||||||
|
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-700 text-white text-sm focus:outline-none focus:border-purple-500"
|
||||||
|
>
|
||||||
|
<option value="showcase_sale">Création pour Vente / Vitrine</option>
|
||||||
|
<option value="client_custom">Commande sur mesure Client</option>
|
||||||
|
<option value="repair_mod_only">Modding console fournie client</option>
|
||||||
|
<option value="personal_collection">Collection Personnelle</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-semibold text-slate-300">
|
||||||
|
Statut initial
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={status}
|
||||||
|
onChange={(e) => setStatus(e.target.value as ProjectStatus)}
|
||||||
|
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-700 text-white text-sm focus:outline-none focus:border-purple-500"
|
||||||
|
>
|
||||||
|
<option value="draft">Brouillon / En projet</option>
|
||||||
|
<option value="in_progress">En cours de modding</option>
|
||||||
|
<option value="qc_testing">Tests & Contrôle Qualité</option>
|
||||||
|
<option value="in_stock_ready">En Stock / Prête à vendre</option>
|
||||||
|
<option value="reserved">Réservée</option>
|
||||||
|
<option value="sold">Déjà Vendue</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 3: Target Sale Price, Hourly Rate & Date */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-semibold text-slate-300 flex items-center space-x-1">
|
||||||
|
<DollarSign className="w-3.5 h-3.5 text-pink-400" />
|
||||||
|
<span>Prix de Vente Cible ({settings.currency}) *</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
value={salePriceTarget}
|
||||||
|
onChange={(e) => setSalePriceTarget(parseFloat(e.target.value) || 0)}
|
||||||
|
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-700 text-white font-mono font-bold text-sm focus:outline-none focus:border-purple-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-semibold text-slate-300 flex items-center space-x-1">
|
||||||
|
<Wrench className="w-3.5 h-3.5 text-amber-400" />
|
||||||
|
<span>Taux Horaire Main d'Œuvre ({settings.currency}/h)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.5"
|
||||||
|
value={hourlyRateDefault}
|
||||||
|
onChange={(e) => setHourlyRateDefault(parseFloat(e.target.value) || 25)}
|
||||||
|
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-700 text-white font-mono text-sm focus:outline-none focus:border-purple-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-semibold text-slate-300 flex items-center space-x-1">
|
||||||
|
<Calendar className="w-3.5 h-3.5 text-purple-400" />
|
||||||
|
<span>Date de début</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={startDate}
|
||||||
|
onChange={(e) => setStartDate(e.target.value)}
|
||||||
|
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-700 text-white text-sm focus:outline-none focus:border-purple-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Section: Objectifs & Délais Cibles */}
|
||||||
|
<div className="p-4 rounded-2xl bg-gradient-to-br from-pink-950/20 to-purple-950/20 border border-pink-500/30 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wider text-pink-300 flex items-center space-x-1.5">
|
||||||
|
<Sparkles className="w-3.5 h-3.5 text-pink-400" />
|
||||||
|
<span>Objectifs de Rentabilité & Échéancier</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] text-slate-400 font-mono">Suivi automatique</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs text-slate-300">
|
||||||
|
📅 Échéance Cible (Date)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={targetDeadlineDate}
|
||||||
|
onChange={(e) => setTargetDeadlineDate(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-xl bg-slate-950 border border-slate-800 text-white text-xs font-mono focus:border-pink-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs text-slate-300">
|
||||||
|
💰 Objectif Gain Net (€)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="1"
|
||||||
|
value={targetProfitMin}
|
||||||
|
onChange={(e) => setTargetProfitMin(e.target.value)}
|
||||||
|
placeholder="ex: 65"
|
||||||
|
className="w-full px-3 py-2 rounded-xl bg-slate-950 border border-slate-800 text-white text-xs font-mono focus:border-pink-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs text-slate-300">
|
||||||
|
⏱️ Temps Atelier Max (Heures)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.5"
|
||||||
|
value={targetLaborHoursMax}
|
||||||
|
onChange={(e) => setTargetLaborHoursMax(e.target.value)}
|
||||||
|
placeholder="ex: 3.5"
|
||||||
|
className="w-full px-3 py-2 rounded-xl bg-slate-950 border border-slate-800 text-white text-xs font-mono focus:border-pink-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!editingProject && (
|
||||||
|
<label className="flex items-center space-x-2 pt-1 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={seedPresetTasks}
|
||||||
|
onChange={(e) => setSeedPresetTasks(e.target.checked)}
|
||||||
|
className="rounded border-slate-700 text-pink-500 focus:ring-pink-500 bg-slate-950"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-slate-300">
|
||||||
|
Générer automatiquement la checklist standard (Diagnostic, Soudure IPS/USB-C, QC, Packaging)
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 4: Customer Details (if applicable) */}
|
||||||
|
{(type === 'client_custom' || type === 'repair_mod_only') && (
|
||||||
|
<div className="p-4 rounded-2xl bg-purple-500/10 border border-purple-500/30 grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-semibold text-purple-300 flex items-center space-x-1">
|
||||||
|
<User className="w-3.5 h-3.5" />
|
||||||
|
<span>Nom du Client</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={customerName}
|
||||||
|
onChange={(e) => setCustomerName(e.target.value)}
|
||||||
|
placeholder="Ex: Thomas L."
|
||||||
|
className="w-full px-3.5 py-2 rounded-xl bg-slate-950 border border-purple-500/30 text-white text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-semibold text-purple-300">
|
||||||
|
Contact / Email / Instagram
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={customerContact}
|
||||||
|
onChange={(e) => setCustomerContact(e.target.value)}
|
||||||
|
placeholder="Ex: @thomas.gaming / contact@email.com"
|
||||||
|
className="w-full px-3.5 py-2 rounded-xl bg-slate-950 border border-purple-500/30 text-white text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Additional Fees Estimator Box */}
|
||||||
|
<div className="p-4 rounded-2xl bg-slate-950 border border-slate-800 space-y-3">
|
||||||
|
<div className="text-xs font-bold uppercase tracking-wider text-slate-400">
|
||||||
|
Frais Annexes & Consommables Estimés
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-[11px] text-slate-300">
|
||||||
|
Consommables (Étain, IPA)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
value={consumablesEst}
|
||||||
|
onChange={(e) => setConsumablesEst(parseFloat(e.target.value) || 0)}
|
||||||
|
className="w-full px-2.5 py-1.5 rounded-lg bg-slate-900 border border-slate-700 text-white text-xs font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-[11px] text-slate-300">
|
||||||
|
Packaging / Boîte
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
value={packagingCost}
|
||||||
|
onChange={(e) => setPackagingCost(parseFloat(e.target.value) || 0)}
|
||||||
|
className="w-full px-2.5 py-1.5 rounded-lg bg-slate-900 border border-slate-700 text-white text-xs font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-[11px] text-slate-300">
|
||||||
|
Frais d'envoi client
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
value={shippingCost}
|
||||||
|
onChange={(e) => setShippingCost(parseFloat(e.target.value) || 0)}
|
||||||
|
className="w-full px-2.5 py-1.5 rounded-lg bg-slate-900 border border-slate-700 text-white text-xs font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-[11px] text-slate-300">
|
||||||
|
% Plateforme (Etsy/Vinted)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.5"
|
||||||
|
value={platformFeePercent}
|
||||||
|
onChange={(e) => setPlatformFeePercent(parseFloat(e.target.value) || 0)}
|
||||||
|
className="w-full px-2.5 py-1.5 rounded-lg bg-slate-900 border border-slate-700 text-white text-xs font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Installed Mods list (with quick pick from Mod Catalogue) */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-xs font-semibold text-slate-300 flex items-center space-x-1">
|
||||||
|
<Sparkles className="w-3.5 h-3.5 text-pink-400" />
|
||||||
|
<span>Liste des Mods Installés (pour certificat & étiquettes)</span>
|
||||||
|
</label>
|
||||||
|
<span className="text-[11px] text-slate-400">
|
||||||
|
Cliquez sur un mod ci-dessous pour l'ajouter/retirer
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Compatible mods quick chips */}
|
||||||
|
{modsLibrary.length > 0 && (
|
||||||
|
<div className="p-2.5 bg-slate-950/80 border border-slate-800 rounded-xl space-y-1.5">
|
||||||
|
<div className="text-[10px] font-bold text-pink-400 uppercase tracking-wider flex items-center gap-1">
|
||||||
|
<Sparkles className="w-3 h-3" /> Mods Disponibles dans le Catalogue pour {consoleModel} :
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5 max-h-28 overflow-y-auto">
|
||||||
|
{modsLibrary
|
||||||
|
.filter(
|
||||||
|
(m) =>
|
||||||
|
m.compatibleModels?.includes(consoleModel) ||
|
||||||
|
m.compatibleModels?.includes('Autre / Custom') ||
|
||||||
|
!m.compatibleModels ||
|
||||||
|
m.compatibleModels.length === 0
|
||||||
|
)
|
||||||
|
.map((mod) => {
|
||||||
|
const isIncluded = modsText.includes(mod.name);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={mod.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (isIncluded) {
|
||||||
|
// remove
|
||||||
|
const lines = modsText.split('\n').filter((l) => l.trim() !== mod.name);
|
||||||
|
setModsText(lines.join('\n'));
|
||||||
|
} else {
|
||||||
|
// add
|
||||||
|
const lines = modsText ? modsText.split('\n').filter(Boolean) : [];
|
||||||
|
setModsText([...lines, mod.name].join('\n'));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`px-2.5 py-1 rounded-lg text-xs font-medium transition-all flex items-center gap-1 ${
|
||||||
|
isIncluded
|
||||||
|
? 'bg-pink-500 text-white shadow-sm shadow-pink-500/30'
|
||||||
|
: 'bg-slate-900 text-slate-300 hover:bg-slate-800 border border-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>{isIncluded ? '✓' : '+'}</span>
|
||||||
|
<span className="truncate max-w-[200px]">{mod.name}</span>
|
||||||
|
<span className="text-[10px] opacity-70 font-mono">+{mod.suggestedAddedValue}€</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
value={modsText}
|
||||||
|
onChange={(e) => setModsText(e.target.value)}
|
||||||
|
placeholder="Écran IPS V3 Drop-in Laminé Port de charge USB-C Pro Batterie LiPo 950mAh Makho"
|
||||||
|
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-700 text-white placeholder-slate-500 text-xs focus:outline-none focus:border-pink-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-semibold text-slate-300 flex items-center space-x-1">
|
||||||
|
<FileText className="w-3.5 h-3.5 text-purple-400" />
|
||||||
|
<span>Notes de fabrication & Spécificités</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
rows={2}
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
placeholder="Remarques particulières, soudure délicate, calibrage écran..."
|
||||||
|
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-700 text-white placeholder-slate-500 text-xs focus:outline-none focus:border-purple-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex items-center justify-end space-x-3 pt-4 border-t border-slate-800">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2.5 rounded-xl bg-slate-800 text-slate-300 hover:text-white text-xs font-semibold"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="px-5 py-2.5 rounded-xl bg-gradient-to-r from-purple-500 to-pink-600 text-white font-bold text-sm shadow-lg shadow-purple-500/20 hover:scale-[1.02] active:scale-[0.98] transition-all"
|
||||||
|
>
|
||||||
|
{editingProject ? 'Enregistrer les modifications' : 'Créer le projet'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
278
src/components/projects/ProjectObjectivesCard.tsx
Normal file
278
src/components/projects/ProjectObjectivesCard.tsx
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Target,
|
||||||
|
Calendar,
|
||||||
|
DollarSign,
|
||||||
|
Clock,
|
||||||
|
Percent,
|
||||||
|
CheckCircle,
|
||||||
|
AlertCircle,
|
||||||
|
Edit2,
|
||||||
|
Save,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useApp } from '../../context/AppContext';
|
||||||
|
import type { Project, ProjectObjectives } from '../../types';
|
||||||
|
import { calculateProjectFinancials, formatCurrency } from '../../utils/calculations';
|
||||||
|
|
||||||
|
interface ProjectObjectivesCardProps {
|
||||||
|
project: Project;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProjectObjectivesCard: React.FC<ProjectObjectivesCardProps> = ({ project }) => {
|
||||||
|
const { updateProjectObjectives } = useApp();
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
|
||||||
|
const objectives = project.objectives || {};
|
||||||
|
const financials = calculateProjectFinancials(project);
|
||||||
|
|
||||||
|
const [formDeadline, setFormDeadline] = useState(objectives.targetDeadlineDate || '');
|
||||||
|
const [formProfit, setFormProfit] = useState(objectives.targetProfitMin?.toString() || '');
|
||||||
|
const [formLaborHours, setFormLaborHours] = useState(objectives.targetLaborHoursMax?.toString() || '');
|
||||||
|
const [formMargin, setFormMargin] = useState(objectives.targetMarginPercentMin?.toString() || '');
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
const updated: Partial<ProjectObjectives> = {
|
||||||
|
targetDeadlineDate: formDeadline || undefined,
|
||||||
|
targetProfitMin: formProfit ? parseFloat(formProfit) : undefined,
|
||||||
|
targetLaborHoursMax: formLaborHours ? parseFloat(formLaborHours) : undefined,
|
||||||
|
targetMarginPercentMin: formMargin ? parseFloat(formMargin) : undefined,
|
||||||
|
};
|
||||||
|
updateProjectObjectives(project.id, updated);
|
||||||
|
setIsEditing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculations for objectives attainment
|
||||||
|
const actualLaborHours = financials.laborMinutes / 60;
|
||||||
|
const isProfitAchieved = objectives.targetProfitMin !== undefined
|
||||||
|
? financials.netProfit >= objectives.targetProfitMin
|
||||||
|
: true;
|
||||||
|
|
||||||
|
const isLaborOnTrack = objectives.targetLaborHoursMax !== undefined
|
||||||
|
? actualLaborHours <= objectives.targetLaborHoursMax
|
||||||
|
: true;
|
||||||
|
|
||||||
|
const isMarginAchieved = objectives.targetMarginPercentMin !== undefined
|
||||||
|
? financials.marginPercent >= objectives.targetMarginPercentMin
|
||||||
|
: true;
|
||||||
|
|
||||||
|
// Deadline countdown
|
||||||
|
let deadlineStatusText = '';
|
||||||
|
let deadlineBadgeColor = 'text-slate-400 bg-slate-800/60';
|
||||||
|
if (objectives.targetDeadlineDate) {
|
||||||
|
const targetDate = new Date(objectives.targetDeadlineDate);
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
const diffDays = Math.ceil((targetDate.getTime() - today.getTime()) / (1000 * 3600 * 24));
|
||||||
|
|
||||||
|
if (project.status === 'sold' || project.status === 'in_stock_ready') {
|
||||||
|
deadlineStatusText = 'Terminé dans les temps ✅';
|
||||||
|
deadlineBadgeColor = 'text-emerald-300 bg-emerald-500/20 border-emerald-500/40';
|
||||||
|
} else if (diffDays < 0) {
|
||||||
|
deadlineStatusText = `En retard de ${Math.abs(diffDays)} j ⚠️`;
|
||||||
|
deadlineBadgeColor = 'text-rose-300 bg-rose-500/20 border-rose-500/40';
|
||||||
|
} else if (diffDays === 0) {
|
||||||
|
deadlineStatusText = "Aujourd'hui ! 🚨";
|
||||||
|
deadlineBadgeColor = 'text-amber-300 bg-amber-500/20 border-amber-500/40';
|
||||||
|
} else {
|
||||||
|
deadlineStatusText = `J-${diffDays} (dans ${diffDays} j)`;
|
||||||
|
deadlineBadgeColor = 'text-cyan-300 bg-cyan-500/20 border-cyan-500/40';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-5 rounded-2xl bg-gradient-to-br from-slate-900/90 to-slate-950 border border-slate-800 shadow-xl space-y-4">
|
||||||
|
<div className="flex items-center justify-between pb-3 border-b border-slate-800">
|
||||||
|
<div className="flex items-center space-x-2.5">
|
||||||
|
<div className="p-2 rounded-xl bg-pink-500/10 border border-pink-500/30 text-pink-400">
|
||||||
|
<Target className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-white text-base">Objectifs & Jalons du Projet</h3>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
Cibles financières, délais et temps de travail
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{!isEditing ? (
|
||||||
|
<button
|
||||||
|
onClick={() => setIsEditing(true)}
|
||||||
|
className="flex items-center space-x-1.5 px-3 py-1.5 rounded-xl text-xs font-semibold bg-slate-900 text-slate-300 border border-slate-800 hover:text-white hover:border-pink-500/40 transition-all"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-3.5 h-3.5 text-pink-400" />
|
||||||
|
<span>Ajuster Objectifs</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
className="flex items-center space-x-1 px-3 py-1.5 rounded-xl text-xs font-bold bg-pink-600 hover:bg-pink-500 text-white transition-all shadow-md"
|
||||||
|
>
|
||||||
|
<Save className="w-3.5 h-3.5" />
|
||||||
|
<span>Enregistrer</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsEditing(false)}
|
||||||
|
className="p-1.5 rounded-xl bg-slate-800 text-slate-400 hover:text-white"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Edit Form Mode */}
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 pt-1">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-400 mb-1">
|
||||||
|
📅 Échéance Cible (Date)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={formDeadline}
|
||||||
|
onChange={(e) => setFormDeadline(e.target.value)}
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-pink-500 font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-400 mb-1">
|
||||||
|
💰 Objectif Gain Net (€)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="1"
|
||||||
|
value={formProfit}
|
||||||
|
onChange={(e) => setFormProfit(e.target.value)}
|
||||||
|
placeholder="ex: 60"
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-pink-500 font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-400 mb-1">
|
||||||
|
⏱️ Temps Atelier Max (Heures)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.5"
|
||||||
|
value={formLaborHours}
|
||||||
|
onChange={(e) => setFormLaborHours(e.target.value)}
|
||||||
|
placeholder="ex: 3.5"
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-pink-500 font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-400 mb-1">
|
||||||
|
📊 Objectif Marge Min (%)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="5"
|
||||||
|
value={formMargin}
|
||||||
|
onChange={(e) => setFormMargin(e.target.value)}
|
||||||
|
placeholder="ex: 35"
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-pink-500 font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Objectives Overview Cards */
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3.5">
|
||||||
|
{/* 1. Target Deadline */}
|
||||||
|
<div className="p-3.5 rounded-xl bg-slate-950/70 border border-slate-800/80 flex flex-col justify-between">
|
||||||
|
<div className="flex items-center justify-between text-xs text-slate-400 mb-1.5">
|
||||||
|
<span className="flex items-center space-x-1.5">
|
||||||
|
<Calendar className="w-3.5 h-3.5 text-cyan-400" />
|
||||||
|
<span>Date d'Échéance</span>
|
||||||
|
</span>
|
||||||
|
{objectives.targetDeadlineDate && (
|
||||||
|
<span className={`px-2 py-0.5 rounded-full text-[10px] font-bold border ${deadlineBadgeColor}`}>
|
||||||
|
{deadlineStatusText}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="font-mono text-base font-black text-white">
|
||||||
|
{objectives.targetDeadlineDate || 'Non définie'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 2. Target Profit */}
|
||||||
|
<div className="p-3.5 rounded-xl bg-slate-950/70 border border-slate-800/80 flex flex-col justify-between">
|
||||||
|
<div className="flex items-center justify-between text-xs text-slate-400 mb-1.5">
|
||||||
|
<span className="flex items-center space-x-1.5">
|
||||||
|
<DollarSign className="w-3.5 h-3.5 text-pink-400" />
|
||||||
|
<span>Objectif Gain Net</span>
|
||||||
|
</span>
|
||||||
|
{objectives.targetProfitMin && (
|
||||||
|
<span className={`flex items-center space-x-1 text-[11px] font-bold ${isProfitAchieved ? 'text-emerald-400' : 'text-amber-400'}`}>
|
||||||
|
{isProfitAchieved ? <CheckCircle className="w-3 h-3" /> : <AlertCircle className="w-3 h-3" />}
|
||||||
|
<span>{isProfitAchieved ? 'Atteint' : 'En cours'}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline space-x-2">
|
||||||
|
<span className="font-mono text-base font-black text-pink-400">
|
||||||
|
{objectives.targetProfitMin ? formatCurrency(objectives.targetProfitMin) : '—'}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-500 font-mono">
|
||||||
|
(Actuel: {formatCurrency(financials.netProfit)})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 3. Target Labor Hours */}
|
||||||
|
<div className="p-3.5 rounded-xl bg-slate-950/70 border border-slate-800/80 flex flex-col justify-between">
|
||||||
|
<div className="flex items-center justify-between text-xs text-slate-400 mb-1.5">
|
||||||
|
<span className="flex items-center space-x-1.5">
|
||||||
|
<Clock className="w-3.5 h-3.5 text-amber-400" />
|
||||||
|
<span>Plafond Temps Atelier</span>
|
||||||
|
</span>
|
||||||
|
{objectives.targetLaborHoursMax && (
|
||||||
|
<span className={`text-[11px] font-bold ${isLaborOnTrack ? 'text-emerald-400' : 'text-rose-400'}`}>
|
||||||
|
{isLaborOnTrack ? 'Dans les temps' : 'Dépassement'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline space-x-2">
|
||||||
|
<span className="font-mono text-base font-black text-amber-400">
|
||||||
|
{objectives.targetLaborHoursMax ? `${objectives.targetLaborHoursMax}h max` : '—'}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-500 font-mono">
|
||||||
|
(Passé: {actualLaborHours.toFixed(1)}h)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 4. Target Margin % */}
|
||||||
|
<div className="p-3.5 rounded-xl bg-slate-950/70 border border-slate-800/80 flex flex-col justify-between">
|
||||||
|
<div className="flex items-center justify-between text-xs text-slate-400 mb-1.5">
|
||||||
|
<span className="flex items-center space-x-1.5">
|
||||||
|
<Percent className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
|
<span>Objectif Marge Nette</span>
|
||||||
|
</span>
|
||||||
|
{objectives.targetMarginPercentMin && (
|
||||||
|
<span className={`text-[11px] font-bold ${isMarginAchieved ? 'text-emerald-400' : 'text-amber-400'}`}>
|
||||||
|
{isMarginAchieved ? 'Atteint' : 'En dessous'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline space-x-2">
|
||||||
|
<span className="font-mono text-base font-black text-emerald-400">
|
||||||
|
{objectives.targetMarginPercentMin ? `${objectives.targetMarginPercentMin}%` : '—'}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-500 font-mono">
|
||||||
|
(Actuel: {financials.marginPercent.toFixed(1)}%)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
313
src/components/projects/ProjectTasksChecklist.tsx
Normal file
313
src/components/projects/ProjectTasksChecklist.tsx
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
CheckCircle2,
|
||||||
|
Circle,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
ListTodo,
|
||||||
|
Calendar,
|
||||||
|
Sparkles,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useApp } from '../../context/AppContext';
|
||||||
|
import type { Project, ProjectTask } from '../../types';
|
||||||
|
|
||||||
|
interface ProjectTasksChecklistProps {
|
||||||
|
project: Project;
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProjectTasksChecklist: React.FC<ProjectTasksChecklistProps> = ({
|
||||||
|
project,
|
||||||
|
compact = false,
|
||||||
|
}) => {
|
||||||
|
const { toggleProjectTask, addProjectTask, deleteProjectTask } = useApp();
|
||||||
|
const [newTitle, setNewTitle] = useState('');
|
||||||
|
const [newCategory, setNewCategory] = useState<ProjectTask['category']>('soudure');
|
||||||
|
const [newDueDate, setNewDueDate] = useState('');
|
||||||
|
|
||||||
|
const tasks = project.tasks || [];
|
||||||
|
const completedCount = tasks.filter((t) => t.isCompleted).length;
|
||||||
|
const totalCount = tasks.length;
|
||||||
|
const progressPercent = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0;
|
||||||
|
|
||||||
|
const handleAddTask = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!newTitle.trim()) return;
|
||||||
|
|
||||||
|
addProjectTask(project.id, {
|
||||||
|
title: newTitle.trim(),
|
||||||
|
isCompleted: false,
|
||||||
|
category: newCategory,
|
||||||
|
dueDate: newDueDate || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
setNewTitle('');
|
||||||
|
setNewDueDate('');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Quick preset tasks templates
|
||||||
|
const addPresetTasks = (presetType: 'ips_mod' | 'cleaning' | 'audio_mod') => {
|
||||||
|
const presets: Record<string, Omit<ProjectTask, 'id'>[]> = {
|
||||||
|
ips_mod: [
|
||||||
|
{ title: 'Diagnostic initial & test boot donneuse', isCompleted: false, category: 'diagnostic' },
|
||||||
|
{ title: 'Démontage & nettoyage ultrasons carte mère', isCompleted: false, category: 'nettoyage' },
|
||||||
|
{ title: 'Découpe & ajustement coque pour écran IPS', isCompleted: false, category: 'assemblage' },
|
||||||
|
{ title: 'Soudure des fils OSD (L, R, Select)', isCompleted: false, category: 'soudure' },
|
||||||
|
{ title: 'Test d\'allumage & centrage de la vitre', isCompleted: false, category: 'test_qc' },
|
||||||
|
{ title: 'Test batterie & calibration charge', isCompleted: false, category: 'test_qc' },
|
||||||
|
{ title: 'Nettoyage final & mise en boîte kraft', isCompleted: false, category: 'packaging' },
|
||||||
|
],
|
||||||
|
cleaning: [
|
||||||
|
{ title: 'Démontage complet vis tri-wing', isCompleted: false, category: 'diagnostic' },
|
||||||
|
{ title: 'Bain IPA ultrasons contacts boutons et switch', isCompleted: false, category: 'nettoyage' },
|
||||||
|
{ title: 'Remplacement des condensateurs (Cap Kit)', isCompleted: false, category: 'soudure' },
|
||||||
|
{ title: 'Remontage & test audio / vidéo', isCompleted: false, category: 'test_qc' },
|
||||||
|
],
|
||||||
|
audio_mod: [
|
||||||
|
{ title: 'Dessoudage de l\'ancien haut-parleur', isCompleted: false, category: 'soudure' },
|
||||||
|
{ title: 'Pose de l\'amplificateur audio CleanAmp Pro', isCompleted: false, category: 'soudure' },
|
||||||
|
{ title: 'Câblage haut-parleur 2W néodyme', isCompleted: false, category: 'assemblage' },
|
||||||
|
{ title: 'Test absence de parasites et souffle', isCompleted: false, category: 'test_qc' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const toAdd = presets[presetType];
|
||||||
|
if (toAdd) {
|
||||||
|
toAdd.forEach((t) => addProjectTask(project.id, t));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<div className="flex items-center space-x-1.5 text-slate-300 font-medium">
|
||||||
|
<ListTodo className="w-3.5 h-3.5 text-purple-400" />
|
||||||
|
<span>Checklist Tâches :</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-mono text-xs font-bold text-purple-300">
|
||||||
|
{completedCount}/{totalCount} ({progressPercent}%)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mini progress bar */}
|
||||||
|
<div className="w-full h-1.5 bg-slate-800 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-gradient-to-r from-pink-500 to-purple-500 transition-all duration-300"
|
||||||
|
style={{ width: `${progressPercent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Task list compact */}
|
||||||
|
<div className="space-y-1.5 max-h-40 overflow-y-auto pr-1 custom-scrollbar">
|
||||||
|
{tasks.map((task) => (
|
||||||
|
<div
|
||||||
|
key={task.id}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleProjectTask(project.id, task.id);
|
||||||
|
}}
|
||||||
|
className="flex items-center justify-between p-1.5 rounded-lg bg-slate-900/60 hover:bg-slate-800/80 cursor-pointer text-xs group transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-2 min-w-0">
|
||||||
|
{task.isCompleted ? (
|
||||||
|
<CheckCircle2 className="w-3.5 h-3.5 text-emerald-400 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<Circle className="w-3.5 h-3.5 text-slate-500 shrink-0 group-hover:text-purple-400" />
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={`truncate ${
|
||||||
|
task.isCompleted
|
||||||
|
? 'line-through text-slate-500'
|
||||||
|
: 'text-slate-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{task.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{task.dueDate && (
|
||||||
|
<span className="text-[10px] text-slate-500 shrink-0 font-mono">
|
||||||
|
{task.dueDate}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{tasks.length === 0 && (
|
||||||
|
<div className="text-[11px] text-slate-500 italic py-1">
|
||||||
|
Aucune tâche définie.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-5 rounded-2xl bg-slate-900/60 border border-slate-800 backdrop-blur-md space-y-4">
|
||||||
|
{/* Header with progress */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 pb-3 border-b border-slate-800">
|
||||||
|
<div className="flex items-center space-x-2.5">
|
||||||
|
<div className="p-2 rounded-xl bg-purple-500/10 border border-purple-500/30 text-purple-400">
|
||||||
|
<ListTodo className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-white text-base">Checklist & Tâches Atelier</h3>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
Suivi étape par étape de la customisation
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-xs text-slate-400">Progression</div>
|
||||||
|
<div className="text-sm font-black font-mono text-purple-300">
|
||||||
|
{completedCount} / {totalCount} terminées ({progressPercent}%)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-24 h-2 bg-slate-800 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-gradient-to-r from-pink-500 to-purple-500 transition-all duration-500"
|
||||||
|
style={{ width: `${progressPercent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preset Generators if empty */}
|
||||||
|
{tasks.length === 0 && (
|
||||||
|
<div className="p-4 rounded-xl bg-slate-950/70 border border-purple-500/20 text-center space-y-2.5">
|
||||||
|
<p className="text-xs text-slate-300">
|
||||||
|
Gagnez du temps avec un modèle de tâches prédéfini :
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => addPresetTasks('ips_mod')}
|
||||||
|
className="flex items-center space-x-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold bg-purple-500/20 text-purple-300 border border-purple-500/40 hover:bg-purple-500/30 transition-all"
|
||||||
|
>
|
||||||
|
<Sparkles className="w-3.5 h-3.5 text-purple-400" />
|
||||||
|
<span>+ Pack Mod IPS & USB-C</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => addPresetTasks('cleaning')}
|
||||||
|
className="flex items-center space-x-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold bg-cyan-500/20 text-cyan-300 border border-cyan-500/40 hover:bg-cyan-500/30 transition-all"
|
||||||
|
>
|
||||||
|
<Sparkles className="w-3.5 h-3.5 text-cyan-400" />
|
||||||
|
<span>+ Pack Restauration & CapKit</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => addPresetTasks('audio_mod')}
|
||||||
|
className="flex items-center space-x-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold bg-pink-500/20 text-pink-300 border border-pink-500/40 hover:bg-pink-500/30 transition-all"
|
||||||
|
>
|
||||||
|
<Sparkles className="w-3.5 h-3.5 text-pink-400" />
|
||||||
|
<span>+ Pack Mod Audio Amp</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Task List */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{tasks.map((task) => (
|
||||||
|
<div
|
||||||
|
key={task.id}
|
||||||
|
className={`flex items-center justify-between p-3 rounded-xl border transition-all duration-200 group ${
|
||||||
|
task.isCompleted
|
||||||
|
? 'bg-slate-950/40 border-slate-850/60 opacity-80'
|
||||||
|
: 'bg-slate-950/80 border-slate-800 hover:border-purple-500/40'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={() => toggleProjectTask(project.id, task.id)}
|
||||||
|
className="flex items-center space-x-3 cursor-pointer flex-1 min-w-0"
|
||||||
|
>
|
||||||
|
{task.isCompleted ? (
|
||||||
|
<CheckCircle2 className="w-5 h-5 text-emerald-400 shrink-0 transition-transform group-hover:scale-110" />
|
||||||
|
) : (
|
||||||
|
<Circle className="w-5 h-5 text-slate-500 shrink-0 group-hover:text-purple-400 transition-colors" />
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<span
|
||||||
|
className={`text-sm tracking-tight block truncate ${
|
||||||
|
task.isCompleted
|
||||||
|
? 'line-through text-slate-500 font-normal'
|
||||||
|
: 'text-slate-100 font-medium'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{task.title}
|
||||||
|
</span>
|
||||||
|
{task.category && (
|
||||||
|
<span className="text-[10px] text-purple-400 uppercase tracking-wider font-bold">
|
||||||
|
{task.category}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2 ml-3 shrink-0">
|
||||||
|
{task.dueDate && (
|
||||||
|
<div className="flex items-center space-x-1 px-2 py-1 rounded-lg bg-slate-900 border border-slate-800 text-[11px] font-mono text-slate-400">
|
||||||
|
<Calendar className="w-3 h-3 text-slate-500" />
|
||||||
|
<span>{task.dueDate}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => deleteProjectTask(project.id, task.id)}
|
||||||
|
className="p-1.5 rounded-lg text-slate-500 hover:text-rose-400 hover:bg-rose-500/10 opacity-0 group-hover:opacity-100 transition-all"
|
||||||
|
title="Supprimer la tâche"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add New Task Form */}
|
||||||
|
<form onSubmit={handleAddTask} className="flex flex-col sm:flex-row items-center gap-2 pt-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newTitle}
|
||||||
|
onChange={(e) => setNewTitle(e.target.value)}
|
||||||
|
placeholder="Ajouter une tâche (ex: Pose dissipateur thermique, Test charge...)"
|
||||||
|
className="flex-1 w-full bg-slate-950 border border-slate-800 rounded-xl px-3.5 py-2 text-sm text-white placeholder-slate-500 focus:outline-none focus:border-purple-500"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2 w-full sm:w-auto">
|
||||||
|
<select
|
||||||
|
value={newCategory}
|
||||||
|
onChange={(e) => setNewCategory(e.target.value as ProjectTask['category'])}
|
||||||
|
className="bg-slate-950 border border-slate-800 rounded-xl px-2.5 py-2 text-xs text-purple-300 focus:outline-none focus:border-purple-500"
|
||||||
|
>
|
||||||
|
<option value="diagnostic">Diagnostic</option>
|
||||||
|
<option value="nettoyage">Nettoyage</option>
|
||||||
|
<option value="soudure">Soudure</option>
|
||||||
|
<option value="assemblage">Assemblage</option>
|
||||||
|
<option value="test_qc">Test QC</option>
|
||||||
|
<option value="packaging">Packaging</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={newDueDate}
|
||||||
|
onChange={(e) => setNewDueDate(e.target.value)}
|
||||||
|
className="bg-slate-950 border border-slate-800 rounded-xl px-2.5 py-2 text-xs text-slate-300 focus:outline-none focus:border-purple-500 font-mono"
|
||||||
|
title="Date d'échéance optionnelle"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="flex items-center space-x-1.5 px-3.5 py-2 rounded-xl text-xs font-bold bg-purple-600 hover:bg-purple-500 text-white transition-all shadow-md shrink-0"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
<span>Ajouter</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
327
src/components/projects/ProjectsView.tsx
Normal file
327
src/components/projects/ProjectsView.tsx
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
Layers,
|
||||||
|
Search,
|
||||||
|
Plus,
|
||||||
|
LayoutGrid,
|
||||||
|
List as ListIcon,
|
||||||
|
Columns3,
|
||||||
|
Calendar as CalendarIcon,
|
||||||
|
Download,
|
||||||
|
Filter,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useApp } from '../../context/AppContext';
|
||||||
|
import type { Project, ConsoleModel, ProjectStatus } from '../../types';
|
||||||
|
import { ProjectModal } from './ProjectModal';
|
||||||
|
import { ProjectDetailView } from './ProjectDetailView';
|
||||||
|
import { MarkAsSoldModal } from './MarkAsSoldModal';
|
||||||
|
import { ProjectsTileView } from './views/ProjectsTileView';
|
||||||
|
import { ProjectsListView } from './views/ProjectsListView';
|
||||||
|
import { ProjectsKanbanView } from './views/ProjectsKanbanView';
|
||||||
|
import { ProjectsCalendarView } from './views/ProjectsCalendarView';
|
||||||
|
|
||||||
|
export type ProjectsViewMode = 'tile' | 'list' | 'kanban' | 'calendar';
|
||||||
|
|
||||||
|
const STATUS_FILTERS: { id: ProjectStatus | 'ALL'; label: string }[] = [
|
||||||
|
{ id: 'ALL', label: 'Tous les statuts' },
|
||||||
|
{ id: 'in_progress', label: '⚡ En cours d\'atelier' },
|
||||||
|
{ id: 'in_stock_ready', label: '✨ En stock / Prêtes' },
|
||||||
|
{ id: 'sold', label: '🎉 Vendues' },
|
||||||
|
{ id: 'qc_testing', label: '🔍 Tests & QC' },
|
||||||
|
{ id: 'draft', label: '📝 Brouillons' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const CONSOLE_MODELS: (ConsoleModel | 'ALL')[] = [
|
||||||
|
'ALL',
|
||||||
|
'Game Boy DMG',
|
||||||
|
'Game Boy Color',
|
||||||
|
'Game Boy Advance',
|
||||||
|
'GBA SP',
|
||||||
|
'Game Boy Pocket',
|
||||||
|
'Game Boy Micro',
|
||||||
|
'Nintendo DS Lite',
|
||||||
|
'PSP 1000 / 2000 / 3000',
|
||||||
|
'PS Vita',
|
||||||
|
'Nintendo Switch / Lite / OLED',
|
||||||
|
'Autre / Custom',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ProjectsView: React.FC = () => {
|
||||||
|
const {
|
||||||
|
projects,
|
||||||
|
selectedProjectId,
|
||||||
|
setSelectedProjectId,
|
||||||
|
showNewProjectModal,
|
||||||
|
setShowNewProjectModal,
|
||||||
|
exportProjectsCSV,
|
||||||
|
} = useApp();
|
||||||
|
|
||||||
|
const [viewMode, setViewMode] = useState<ProjectsViewMode>('tile');
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [selectedStatus, setSelectedStatus] = useState<ProjectStatus | 'ALL'>('ALL');
|
||||||
|
const [selectedConsole, setSelectedConsole] = useState<ConsoleModel | 'ALL'>('ALL');
|
||||||
|
const [soldModalProject, setSoldModalProject] = useState<Project | null>(null);
|
||||||
|
|
||||||
|
// Filtered projects
|
||||||
|
const filteredProjects = useMemo(() => {
|
||||||
|
return projects.filter((p) => {
|
||||||
|
// Search
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
const matchTitle = p.title.toLowerCase().includes(query);
|
||||||
|
const matchSerial = p.serialNumber.toLowerCase().includes(query);
|
||||||
|
const matchCustomer = (p.customerName || '').toLowerCase().includes(query);
|
||||||
|
const matchNotes = (p.notes || '').toLowerCase().includes(query);
|
||||||
|
if (!matchTitle && !matchSerial && !matchCustomer && !matchNotes) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status
|
||||||
|
if (selectedStatus !== 'ALL' && p.status !== selectedStatus) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Console
|
||||||
|
if (selectedConsole !== 'ALL' && p.consoleModel !== selectedConsole) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [projects, searchQuery, selectedStatus, selectedConsole]);
|
||||||
|
|
||||||
|
// If a specific project is selected, render its detailed view!
|
||||||
|
if (selectedProjectId) {
|
||||||
|
return (
|
||||||
|
<ProjectDetailView
|
||||||
|
projectId={selectedProjectId}
|
||||||
|
onBack={() => setSelectedProjectId(null)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary counts
|
||||||
|
const inStockCount = projects.filter((p) => p.status === 'in_stock_ready').length;
|
||||||
|
const inProgressCount = projects.filter(
|
||||||
|
(p) => p.status === 'in_progress' || p.status === 'qc_testing'
|
||||||
|
).length;
|
||||||
|
const soldCount = projects.filter((p) => p.status === 'sold').length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 pb-12 animate-fadeIn">
|
||||||
|
{/* Header & Quick stats */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="inline-flex items-center space-x-2 px-3 py-1 rounded-full text-xs font-semibold bg-purple-500/10 text-purple-400 border border-purple-500/30 mb-2">
|
||||||
|
<Layers className="w-3.5 h-3.5" />
|
||||||
|
<span>Centre de Dépenses & Modding</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl sm:text-3xl font-black text-white font-display">
|
||||||
|
Gestion des Projets & Objectifs
|
||||||
|
</h2>
|
||||||
|
<p className="text-slate-400 text-sm mt-1">
|
||||||
|
{projects.length} projets au total • {inProgressCount} en atelier • {inStockCount} en vitrine • {soldCount} vendus
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* View Mode Switcher & Export */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2.5">
|
||||||
|
{/* Display View Mode Switcher */}
|
||||||
|
<div className="flex items-center bg-slate-900/90 rounded-2xl p-1 border border-slate-800 shadow-inner">
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('tile')}
|
||||||
|
className={`flex items-center space-x-1.5 px-3 py-1.5 rounded-xl text-xs font-bold transition-all ${
|
||||||
|
viewMode === 'tile'
|
||||||
|
? 'bg-gradient-to-r from-pink-500 to-purple-600 text-white shadow-md'
|
||||||
|
: 'text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
title="Affichage en Tuiles / Cartes"
|
||||||
|
>
|
||||||
|
<LayoutGrid className="w-4 h-4" />
|
||||||
|
<span className="hidden sm:inline">Tuiles</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('list')}
|
||||||
|
className={`flex items-center space-x-1.5 px-3 py-1.5 rounded-xl text-xs font-bold transition-all ${
|
||||||
|
viewMode === 'list'
|
||||||
|
? 'bg-gradient-to-r from-pink-500 to-purple-600 text-white shadow-md'
|
||||||
|
: 'text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
title="Affichage en Lignes / Tableau"
|
||||||
|
>
|
||||||
|
<ListIcon className="w-4 h-4" />
|
||||||
|
<span className="hidden sm:inline">Lignes</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('kanban')}
|
||||||
|
className={`flex items-center space-x-1.5 px-3 py-1.5 rounded-xl text-xs font-bold transition-all ${
|
||||||
|
viewMode === 'kanban'
|
||||||
|
? 'bg-gradient-to-r from-pink-500 to-purple-600 text-white shadow-md'
|
||||||
|
: 'text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
title="Tableau Kanban"
|
||||||
|
>
|
||||||
|
<Columns3 className="w-4 h-4" />
|
||||||
|
<span className="hidden sm:inline">Kanban</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('calendar')}
|
||||||
|
className={`flex items-center space-x-1.5 px-3 py-1.5 rounded-xl text-xs font-bold transition-all ${
|
||||||
|
viewMode === 'calendar'
|
||||||
|
? 'bg-gradient-to-r from-pink-500 to-purple-600 text-white shadow-md'
|
||||||
|
: 'text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
title="Vue Calendrier & Échéances"
|
||||||
|
>
|
||||||
|
<CalendarIcon className="w-4 h-4" />
|
||||||
|
<span className="hidden sm:inline">Calendrier</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={exportProjectsCSV}
|
||||||
|
className="p-2 sm:px-3 sm:py-2 rounded-xl text-xs font-semibold bg-slate-900 border border-slate-800 text-slate-300 hover:text-white hover:border-slate-700 transition-all flex items-center space-x-1.5"
|
||||||
|
title="Exporter la liste en CSV"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 text-purple-400" />
|
||||||
|
<span className="hidden md:inline">CSV</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setShowNewProjectModal(true)}
|
||||||
|
className="flex items-center space-x-2 px-4 py-2 rounded-xl text-xs sm:text-sm font-bold bg-gradient-to-r from-pink-500 via-purple-600 to-pink-600 text-white shadow-lg shadow-pink-500/25 hover:shadow-pink-500/40 hover:scale-[1.02] active:scale-[0.98] transition-all"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
<span>Nouveau Projet</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter & Search Toolbar (shown for Tile, List, Kanban) */}
|
||||||
|
{viewMode !== 'calendar' && (
|
||||||
|
<div className="flex flex-col md:flex-row items-center gap-3 p-3 rounded-2xl bg-slate-900/60 border border-slate-800 backdrop-blur-md">
|
||||||
|
{/* Search bar */}
|
||||||
|
<div className="relative flex-1 w-full">
|
||||||
|
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Rechercher par titre, n° série, client..."
|
||||||
|
className="w-full pl-10 pr-4 py-2 bg-slate-950 border border-slate-800 rounded-xl text-xs sm:text-sm text-white placeholder-slate-500 focus:outline-none focus:border-purple-500 transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status filter */}
|
||||||
|
<div className="flex items-center space-x-2 w-full md:w-auto">
|
||||||
|
<Filter className="w-4 h-4 text-slate-400 shrink-0 hidden sm:block" />
|
||||||
|
<select
|
||||||
|
value={selectedStatus}
|
||||||
|
onChange={(e) => setSelectedStatus(e.target.value as ProjectStatus | 'ALL')}
|
||||||
|
className="w-full md:w-auto bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-xs sm:text-sm text-slate-200 focus:outline-none focus:border-purple-500"
|
||||||
|
>
|
||||||
|
{STATUS_FILTERS.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>
|
||||||
|
{s.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Console filter */}
|
||||||
|
<select
|
||||||
|
value={selectedConsole}
|
||||||
|
onChange={(e) => setSelectedConsole(e.target.value as ConsoleModel | 'ALL')}
|
||||||
|
className="w-full md:w-auto bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-xs sm:text-sm text-slate-200 focus:outline-none focus:border-purple-500"
|
||||||
|
>
|
||||||
|
<option value="ALL">Toutes les consoles</option>
|
||||||
|
{CONSOLE_MODELS.filter((c) => c !== 'ALL').map((c) => (
|
||||||
|
<option key={c} value={c}>
|
||||||
|
{c}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dynamic Views Rendering */}
|
||||||
|
{viewMode === 'tile' && (
|
||||||
|
<ProjectsTileView
|
||||||
|
projects={filteredProjects}
|
||||||
|
onSelectProject={(id) => setSelectedProjectId(id)}
|
||||||
|
onOpenSoldModal={(p) => setSoldModalProject(p)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{viewMode === 'list' && (
|
||||||
|
<ProjectsListView
|
||||||
|
projects={filteredProjects}
|
||||||
|
onSelectProject={(id) => setSelectedProjectId(id)}
|
||||||
|
onOpenSoldModal={(p) => setSoldModalProject(p)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{viewMode === 'kanban' && (
|
||||||
|
<ProjectsKanbanView
|
||||||
|
projects={filteredProjects}
|
||||||
|
onSelectProject={(id) => setSelectedProjectId(id)}
|
||||||
|
onOpenSoldModal={(p) => setSoldModalProject(p)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{viewMode === 'calendar' && (
|
||||||
|
<ProjectsCalendarView
|
||||||
|
projects={projects}
|
||||||
|
onSelectProject={(id) => setSelectedProjectId(id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
|
{viewMode !== 'calendar' && filteredProjects.length === 0 && (
|
||||||
|
<div className="p-12 rounded-3xl bg-slate-900/40 border border-slate-800 text-center space-y-4">
|
||||||
|
<div className="w-16 h-16 mx-auto rounded-2xl bg-purple-500/10 border border-purple-500/30 flex items-center justify-center text-purple-400">
|
||||||
|
<Layers className="w-8 h-8" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold text-white">Aucun projet trouvé</h3>
|
||||||
|
<p className="text-sm text-slate-400 mt-1 max-w-md mx-auto">
|
||||||
|
{searchQuery || selectedStatus !== 'ALL' || selectedConsole !== 'ALL'
|
||||||
|
? 'Aucun projet ne correspond à vos filtres actuels. Essayez de réinitialiser la recherche.'
|
||||||
|
: 'Commencez dès maintenant en créant votre premier projet de customisation de console !'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSearchQuery('');
|
||||||
|
setSelectedStatus('ALL');
|
||||||
|
setSelectedConsole('ALL');
|
||||||
|
setShowNewProjectModal(true);
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center space-x-2 px-5 py-2.5 rounded-xl font-bold text-sm bg-gradient-to-r from-pink-500 to-purple-600 text-white shadow-lg shadow-pink-500/25"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
<span>Créer un Projet</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Modals */}
|
||||||
|
<ProjectModal
|
||||||
|
isOpen={showNewProjectModal}
|
||||||
|
onClose={() => setShowNewProjectModal(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{soldModalProject && (
|
||||||
|
<MarkAsSoldModal
|
||||||
|
project={soldModalProject}
|
||||||
|
isOpen={true}
|
||||||
|
onClose={() => setSoldModalProject(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
373
src/components/projects/views/ProjectsCalendarView.tsx
Normal file
373
src/components/projects/views/ProjectsCalendarView.tsx
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
Calendar as CalendarIcon,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Target,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import type { Project } from '../../../types';
|
||||||
|
import {
|
||||||
|
calculateProjectFinancials,
|
||||||
|
formatCurrency,
|
||||||
|
getProjectStatusInfo,
|
||||||
|
} from '../../../utils/calculations';
|
||||||
|
|
||||||
|
interface ProjectsCalendarViewProps {
|
||||||
|
projects: Project[];
|
||||||
|
onSelectProject: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CalendarDayEvent {
|
||||||
|
project: Project;
|
||||||
|
type: 'start' | 'deadline' | 'completed' | 'sold';
|
||||||
|
label: string;
|
||||||
|
colorClass: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProjectsCalendarView: React.FC<ProjectsCalendarViewProps> = ({
|
||||||
|
projects,
|
||||||
|
onSelectProject,
|
||||||
|
}) => {
|
||||||
|
// Current calendar viewing month
|
||||||
|
const [currentDate, setCurrentDate] = useState(() => new Date());
|
||||||
|
|
||||||
|
const year = currentDate.getFullYear();
|
||||||
|
const month = currentDate.getMonth(); // 0-indexed
|
||||||
|
|
||||||
|
// Month navigation
|
||||||
|
const prevMonth = () => {
|
||||||
|
setCurrentDate(new Date(year, month - 1, 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextMonth = () => {
|
||||||
|
setCurrentDate(new Date(year, month + 1, 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const setToday = () => {
|
||||||
|
setCurrentDate(new Date());
|
||||||
|
};
|
||||||
|
|
||||||
|
// Month name in French
|
||||||
|
const monthName = new Intl.DateTimeFormat('fr-FR', {
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
}).format(currentDate);
|
||||||
|
|
||||||
|
// Generate calendar grid days
|
||||||
|
const calendarDays = useMemo(() => {
|
||||||
|
const firstDayOfMonth = new Date(year, month, 1);
|
||||||
|
// JS getDay(): 0 is Sunday, 1 is Monday... convert to Monday=0
|
||||||
|
let startDayOfWeek = firstDayOfMonth.getDay() - 1;
|
||||||
|
if (startDayOfWeek === -1) startDayOfWeek = 6;
|
||||||
|
|
||||||
|
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||||
|
const daysInPrevMonth = new Date(year, month, 0).getDate();
|
||||||
|
|
||||||
|
const days: {
|
||||||
|
dayNumber: number;
|
||||||
|
dateString: string;
|
||||||
|
isCurrentMonth: boolean;
|
||||||
|
isToday: boolean;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
const todayStr = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
// Previous month padding days
|
||||||
|
for (let i = startDayOfWeek - 1; i >= 0; i--) {
|
||||||
|
const d = daysInPrevMonth - i;
|
||||||
|
const prevMonthIdx = month === 0 ? 11 : month - 1;
|
||||||
|
const prevYear = month === 0 ? year - 1 : year;
|
||||||
|
const dateStr = `${prevYear}-${(prevMonthIdx + 1).toString().padStart(2, '0')}-${d.toString().padStart(2, '0')}`;
|
||||||
|
days.push({
|
||||||
|
dayNumber: d,
|
||||||
|
dateString: dateStr,
|
||||||
|
isCurrentMonth: false,
|
||||||
|
isToday: dateStr === todayStr,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Current month days
|
||||||
|
for (let d = 1; d <= daysInMonth; d++) {
|
||||||
|
const dateStr = `${year}-${(month + 1).toString().padStart(2, '0')}-${d.toString().padStart(2, '0')}`;
|
||||||
|
days.push({
|
||||||
|
dayNumber: d,
|
||||||
|
dateString: dateStr,
|
||||||
|
isCurrentMonth: true,
|
||||||
|
isToday: dateStr === todayStr,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next month padding days to complete grid (multiples of 7)
|
||||||
|
const remainingDays = (7 - (days.length % 7)) % 7;
|
||||||
|
for (let d = 1; d <= remainingDays; d++) {
|
||||||
|
const nextMonthIdx = month === 11 ? 0 : month + 1;
|
||||||
|
const nextYear = month === 11 ? year + 1 : year;
|
||||||
|
const dateStr = `${nextYear}-${(nextMonthIdx + 1).toString().padStart(2, '0')}-${d.toString().padStart(2, '0')}`;
|
||||||
|
days.push({
|
||||||
|
dayNumber: d,
|
||||||
|
dateString: dateStr,
|
||||||
|
isCurrentMonth: false,
|
||||||
|
isToday: dateStr === todayStr,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return days;
|
||||||
|
}, [year, month]);
|
||||||
|
|
||||||
|
// Map events to date strings
|
||||||
|
const eventsByDate = useMemo(() => {
|
||||||
|
const map = new Map<string, CalendarDayEvent[]>();
|
||||||
|
|
||||||
|
projects.forEach((proj) => {
|
||||||
|
// 1. Target Deadline
|
||||||
|
if (proj.objectives?.targetDeadlineDate) {
|
||||||
|
const d = proj.objectives.targetDeadlineDate;
|
||||||
|
const list = map.get(d) || [];
|
||||||
|
list.push({
|
||||||
|
project: proj,
|
||||||
|
type: 'deadline',
|
||||||
|
label: `🎯 Échéance: ${proj.serialNumber}`,
|
||||||
|
colorClass: 'bg-amber-500/20 text-amber-300 border-amber-500/40 hover:bg-amber-500/30',
|
||||||
|
});
|
||||||
|
map.set(d, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Start Date
|
||||||
|
if (proj.startDate) {
|
||||||
|
const d = proj.startDate;
|
||||||
|
const list = map.get(d) || [];
|
||||||
|
list.push({
|
||||||
|
project: proj,
|
||||||
|
type: 'start',
|
||||||
|
label: `⚡ Lancement: ${proj.serialNumber}`,
|
||||||
|
colorClass: 'bg-purple-500/20 text-purple-300 border-purple-500/40 hover:bg-purple-500/30',
|
||||||
|
});
|
||||||
|
map.set(d, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Completed Date
|
||||||
|
if (proj.completedDate && proj.status !== 'sold') {
|
||||||
|
const d = proj.completedDate;
|
||||||
|
const list = map.get(d) || [];
|
||||||
|
list.push({
|
||||||
|
project: proj,
|
||||||
|
type: 'completed',
|
||||||
|
label: `✨ Prête: ${proj.serialNumber}`,
|
||||||
|
colorClass: 'bg-cyan-500/20 text-cyan-300 border-cyan-500/40 hover:bg-cyan-500/30',
|
||||||
|
});
|
||||||
|
map.set(d, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Sold Date
|
||||||
|
if (proj.soldDate) {
|
||||||
|
const d = proj.soldDate;
|
||||||
|
const list = map.get(d) || [];
|
||||||
|
list.push({
|
||||||
|
project: proj,
|
||||||
|
type: 'sold',
|
||||||
|
label: `🎉 Vendu: ${proj.serialNumber}`,
|
||||||
|
colorClass: 'bg-emerald-500/20 text-emerald-300 border-emerald-500/40 hover:bg-emerald-500/30',
|
||||||
|
});
|
||||||
|
map.set(d, list);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}, [projects]);
|
||||||
|
|
||||||
|
// Upcoming deadlines summary
|
||||||
|
const upcomingDeadlines = useMemo(() => {
|
||||||
|
return projects
|
||||||
|
.filter((p) => p.objectives?.targetDeadlineDate && p.status !== 'sold')
|
||||||
|
.sort((a, b) => (a.objectives?.targetDeadlineDate || '').localeCompare(b.objectives?.targetDeadlineDate || ''));
|
||||||
|
}, [projects]);
|
||||||
|
|
||||||
|
const WEEK_DAYS = ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim'];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Calendar Header Toolbar */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 p-4 rounded-2xl bg-slate-900/80 border border-slate-800 backdrop-blur-md shadow-lg">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="p-2.5 rounded-xl bg-pink-500/10 border border-pink-500/30 text-pink-400">
|
||||||
|
<CalendarIcon className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-extrabold text-lg sm:text-xl text-white capitalize">
|
||||||
|
{monthName}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
Planning des jalons, lancements et échéances de modding
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Month switcher */}
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={setToday}
|
||||||
|
className="px-3 py-1.5 rounded-xl text-xs font-bold bg-slate-950 hover:bg-slate-800 text-slate-300 border border-slate-800 transition-all"
|
||||||
|
>
|
||||||
|
Aujourd'hui
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center bg-slate-950 rounded-xl border border-slate-800 p-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={prevMonth}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-slate-800 text-slate-400 hover:text-white transition-all"
|
||||||
|
title="Mois précédent"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={nextMonth}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-slate-800 text-slate-400 hover:text-white transition-all"
|
||||||
|
title="Mois suivant"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Grid: Calendar on Left, Upcoming Deadlines Card on Right */}
|
||||||
|
<div className="grid grid-cols-1 xl:grid-cols-4 gap-6">
|
||||||
|
{/* Calendar Board (3 Cols on XL) */}
|
||||||
|
<div className="xl:col-span-3 rounded-2xl bg-slate-900/60 border border-slate-800 overflow-hidden shadow-xl">
|
||||||
|
{/* Day of Week Headers */}
|
||||||
|
<div className="grid grid-cols-7 border-b border-slate-800 bg-slate-950/80 text-center py-2.5 text-xs font-bold text-slate-400 uppercase tracking-wider">
|
||||||
|
{WEEK_DAYS.map((d) => (
|
||||||
|
<div key={d}>{d}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Day Cells Grid */}
|
||||||
|
<div className="grid grid-cols-7 divide-x divide-y divide-slate-850 bg-slate-950/40">
|
||||||
|
{calendarDays.map((day) => {
|
||||||
|
const events = eventsByDate.get(day.dateString) || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={day.dateString}
|
||||||
|
className={`min-h-[105px] sm:min-h-[120px] p-1.5 sm:p-2 transition-all flex flex-col justify-between ${
|
||||||
|
day.isCurrentMonth
|
||||||
|
? 'bg-slate-900/20 hover:bg-slate-800/30'
|
||||||
|
: 'bg-slate-950/80 text-slate-600 opacity-60'
|
||||||
|
} ${day.isToday ? 'ring-2 ring-pink-500/40 bg-pink-500/5' : ''}`}
|
||||||
|
>
|
||||||
|
{/* Day Header */}
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span
|
||||||
|
className={`font-mono text-xs font-bold px-1.5 py-0.5 rounded ${
|
||||||
|
day.isToday
|
||||||
|
? 'bg-pink-500 text-white font-extrabold shadow-sm'
|
||||||
|
: day.isCurrentMonth
|
||||||
|
? 'text-slate-300'
|
||||||
|
: 'text-slate-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{day.dayNumber}
|
||||||
|
</span>
|
||||||
|
{events.length > 0 && (
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-pink-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Events list inside day */}
|
||||||
|
<div className="space-y-1 overflow-y-auto max-h-20 custom-scrollbar">
|
||||||
|
{events.map((ev, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
onClick={() => onSelectProject(ev.project.id)}
|
||||||
|
className={`text-[10px] font-mono px-1.5 py-0.5 rounded border truncate cursor-pointer transition-all ${ev.colorClass}`}
|
||||||
|
title={`${ev.label} - ${ev.project.title}`}
|
||||||
|
>
|
||||||
|
{ev.label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Sidebar: Upcoming Deadlines & Milestones */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-4 rounded-2xl bg-slate-900/60 border border-slate-800 backdrop-blur-md space-y-3">
|
||||||
|
<div className="flex items-center space-x-2 pb-2 border-b border-slate-800">
|
||||||
|
<Target className="w-4 h-4 text-amber-400" />
|
||||||
|
<h4 className="font-bold text-sm text-white">Échéances & Objectifs Cibles</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
{upcomingDeadlines.map((proj) => {
|
||||||
|
const fin = calculateProjectFinancials(proj);
|
||||||
|
const statusInfo = getProjectStatusInfo(proj.status);
|
||||||
|
const deadline = proj.objectives?.targetDeadlineDate;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={proj.id}
|
||||||
|
onClick={() => onSelectProject(proj.id)}
|
||||||
|
className="p-3 rounded-xl bg-slate-950/80 hover:bg-slate-950 border border-slate-800 hover:border-amber-500/40 cursor-pointer transition-all group"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between text-xs mb-1">
|
||||||
|
<span className="font-mono text-[10px] font-bold text-amber-300 bg-amber-500/10 px-1.5 py-0.5 rounded border border-amber-500/20">
|
||||||
|
📅 {deadline}
|
||||||
|
</span>
|
||||||
|
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded-full border ${statusInfo.color}`}>
|
||||||
|
{statusInfo.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="font-bold text-xs text-slate-200 group-hover:text-pink-300 transition-colors truncate">
|
||||||
|
{proj.title}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between mt-2 pt-2 border-t border-slate-850 text-[10px] font-mono text-slate-400">
|
||||||
|
<span>{proj.serialNumber}</span>
|
||||||
|
<span className="font-bold text-emerald-400">
|
||||||
|
{formatCurrency(fin.revenue)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{upcomingDeadlines.length === 0 && (
|
||||||
|
<div className="py-6 text-center text-xs text-slate-500 italic">
|
||||||
|
Aucune échéance fixée sur les projets en cours.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend Card */}
|
||||||
|
<div className="p-4 rounded-2xl bg-slate-900/60 border border-slate-800 text-xs space-y-2">
|
||||||
|
<div className="font-bold text-slate-300 mb-1">Légende du Calendrier</div>
|
||||||
|
<div className="flex items-center space-x-2 text-slate-400">
|
||||||
|
<span className="w-2.5 h-2.5 rounded-full bg-amber-400" />
|
||||||
|
<span>🎯 Échéances cibles (Objectifs)</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2 text-slate-400">
|
||||||
|
<span className="w-2.5 h-2.5 rounded-full bg-purple-400" />
|
||||||
|
<span>⚡ Date de lancement projet</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2 text-slate-400">
|
||||||
|
<span className="w-2.5 h-2.5 rounded-full bg-cyan-400" />
|
||||||
|
<span>✨ Finition & Prêt en stock</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2 text-slate-400">
|
||||||
|
<span className="w-2.5 h-2.5 rounded-full bg-emerald-400" />
|
||||||
|
<span>🎉 Date de vente & expédition</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
286
src/components/projects/views/ProjectsKanbanView.tsx
Normal file
286
src/components/projects/views/ProjectsKanbanView.tsx
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Gamepad2,
|
||||||
|
Clock,
|
||||||
|
ArrowRight,
|
||||||
|
ArrowLeft,
|
||||||
|
ShoppingBag,
|
||||||
|
ListTodo,
|
||||||
|
Calendar,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useApp } from '../../../context/AppContext';
|
||||||
|
import type { Project, ProjectStatus } from '../../../types';
|
||||||
|
import {
|
||||||
|
calculateProjectFinancials,
|
||||||
|
formatCurrency,
|
||||||
|
formatMinutes,
|
||||||
|
} from '../../../utils/calculations';
|
||||||
|
|
||||||
|
interface KanbanColumn {
|
||||||
|
id: ProjectStatus;
|
||||||
|
title: string;
|
||||||
|
badgeColor: string;
|
||||||
|
borderColor: string;
|
||||||
|
headerBg: string;
|
||||||
|
icon: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const KANBAN_COLUMNS: KanbanColumn[] = [
|
||||||
|
{
|
||||||
|
id: 'draft',
|
||||||
|
title: 'Planifié / En attente',
|
||||||
|
badgeColor: 'bg-slate-500/20 text-slate-300 border-slate-500/40',
|
||||||
|
borderColor: 'border-slate-800',
|
||||||
|
headerBg: 'from-slate-800/40 to-slate-900/20',
|
||||||
|
icon: '📝',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'in_progress',
|
||||||
|
title: 'En cours d\'Atelier',
|
||||||
|
badgeColor: 'bg-purple-500/20 text-purple-300 border-purple-500/40',
|
||||||
|
borderColor: 'border-purple-500/30',
|
||||||
|
headerBg: 'from-purple-950/40 to-slate-900/20',
|
||||||
|
icon: '⚡',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'qc_testing',
|
||||||
|
title: 'Tests & Contrôle QC',
|
||||||
|
badgeColor: 'bg-cyan-500/20 text-cyan-300 border-cyan-500/40',
|
||||||
|
borderColor: 'border-cyan-500/30',
|
||||||
|
headerBg: 'from-cyan-950/40 to-slate-900/20',
|
||||||
|
icon: '🔍',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'in_stock_ready',
|
||||||
|
title: 'Prêt en Stock / Vitrine',
|
||||||
|
badgeColor: 'bg-emerald-500/20 text-emerald-300 border-emerald-500/40',
|
||||||
|
borderColor: 'border-emerald-500/30',
|
||||||
|
headerBg: 'from-emerald-950/40 to-slate-900/20',
|
||||||
|
icon: '✨',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sold',
|
||||||
|
title: 'Vendu & Livré',
|
||||||
|
badgeColor: 'bg-pink-500/20 text-pink-300 border-pink-500/40',
|
||||||
|
borderColor: 'border-pink-500/30',
|
||||||
|
headerBg: 'from-pink-950/40 to-slate-900/20',
|
||||||
|
icon: '🎉',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
interface ProjectsKanbanViewProps {
|
||||||
|
projects: Project[];
|
||||||
|
onSelectProject: (id: string) => void;
|
||||||
|
onOpenSoldModal: (project: Project) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProjectsKanbanView: React.FC<ProjectsKanbanViewProps> = ({
|
||||||
|
projects,
|
||||||
|
onSelectProject,
|
||||||
|
onOpenSoldModal,
|
||||||
|
}) => {
|
||||||
|
const { updateProjectStatus, startTimer, activeTimer } = useApp();
|
||||||
|
|
||||||
|
const getPreviousStatus = (status: ProjectStatus): ProjectStatus | null => {
|
||||||
|
switch (status) {
|
||||||
|
case 'in_progress':
|
||||||
|
return 'draft';
|
||||||
|
case 'qc_testing':
|
||||||
|
return 'in_progress';
|
||||||
|
case 'in_stock_ready':
|
||||||
|
return 'qc_testing';
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getNextStatus = (status: ProjectStatus): ProjectStatus | null => {
|
||||||
|
switch (status) {
|
||||||
|
case 'draft':
|
||||||
|
return 'in_progress';
|
||||||
|
case 'in_progress':
|
||||||
|
return 'qc_testing';
|
||||||
|
case 'qc_testing':
|
||||||
|
return 'in_stock_ready';
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-5 gap-4.5 overflow-x-auto pb-4">
|
||||||
|
{KANBAN_COLUMNS.map((col) => {
|
||||||
|
const columnProjects = projects.filter((p) => {
|
||||||
|
if (col.id === 'sold') {
|
||||||
|
return p.status === 'sold' || p.status === 'reserved';
|
||||||
|
}
|
||||||
|
return p.status === col.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalPotentialRevenue = columnProjects.reduce((sum, p) => {
|
||||||
|
const fin = calculateProjectFinancials(p);
|
||||||
|
return sum + fin.revenue;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={col.id}
|
||||||
|
className="flex flex-col rounded-2xl bg-slate-950/60 border border-slate-850 overflow-hidden min-w-[270px]"
|
||||||
|
>
|
||||||
|
{/* Column Header */}
|
||||||
|
<div
|
||||||
|
className={`p-3.5 border-b border-slate-800 bg-gradient-to-b ${col.headerBg} flex items-center justify-between`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<span className="text-base">{col.icon}</span>
|
||||||
|
<span className="font-bold text-xs text-white tracking-tight">
|
||||||
|
{col.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`px-2 py-0.5 rounded-full text-xs font-mono font-bold border ${col.badgeColor}`}
|
||||||
|
>
|
||||||
|
{columnProjects.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Subheader: Potential revenue in this column */}
|
||||||
|
<div className="px-3.5 py-1.5 bg-slate-950/40 border-b border-slate-850/60 flex items-center justify-between text-[10px] text-slate-400 font-mono">
|
||||||
|
<span>Valeur :</span>
|
||||||
|
<span className="font-bold text-pink-400">
|
||||||
|
{formatCurrency(totalPotentialRevenue)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Column Project Cards */}
|
||||||
|
<div className="p-3 space-y-3 flex-1 overflow-y-auto max-h-[calc(100vh-280px)] custom-scrollbar">
|
||||||
|
{columnProjects.map((project) => {
|
||||||
|
const fin = calculateProjectFinancials(project);
|
||||||
|
const isTimerRunning =
|
||||||
|
activeTimer.isRunning && activeTimer.projectId === project.id;
|
||||||
|
const prev = getPreviousStatus(project.status);
|
||||||
|
const next = getNextStatus(project.status);
|
||||||
|
const tasks = project.tasks || [];
|
||||||
|
const completedTasks = tasks.filter((t) => t.isCompleted).length;
|
||||||
|
const objectives = project.objectives || {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={project.id}
|
||||||
|
onClick={() => onSelectProject(project.id)}
|
||||||
|
className="p-3.5 rounded-xl bg-slate-900/80 hover:bg-slate-900 border border-slate-800 hover:border-pink-500/40 transition-all shadow-md hover:shadow-xl cursor-pointer group space-y-2.5"
|
||||||
|
>
|
||||||
|
{/* Top row: Serial & Model */}
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="font-mono text-[10px] font-bold px-1.5 py-0.5 rounded bg-slate-950 text-slate-300 border border-slate-800">
|
||||||
|
{project.serialNumber}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] font-semibold text-cyan-300 flex items-center space-x-1">
|
||||||
|
<Gamepad2 className="w-3 h-3" />
|
||||||
|
<span>{project.consoleModel}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<h4 className="font-bold text-sm text-slate-100 group-hover:text-pink-300 transition-colors line-clamp-2">
|
||||||
|
{project.title}
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
{/* Tasks mini pill */}
|
||||||
|
{tasks.length > 0 && (
|
||||||
|
<div className="flex items-center space-x-1 text-[11px] text-purple-300 bg-purple-500/10 px-2 py-0.5 rounded-md border border-purple-500/20">
|
||||||
|
<ListTodo className="w-3 h-3 text-purple-400" />
|
||||||
|
<span>Checklist: {completedTasks}/{tasks.length}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Deadline target if set */}
|
||||||
|
{objectives.targetDeadlineDate && (
|
||||||
|
<div className="flex items-center space-x-1 text-[10px] text-amber-300 font-mono">
|
||||||
|
<Calendar className="w-3 h-3" />
|
||||||
|
<span>Cible : {objectives.targetDeadlineDate}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Financial stats row */}
|
||||||
|
<div className="flex items-center justify-between pt-2 border-t border-slate-800/80 text-[11px] font-mono">
|
||||||
|
<span className="text-slate-400">
|
||||||
|
{formatMinutes(fin.laborMinutes)}
|
||||||
|
</span>
|
||||||
|
<span className="font-bold text-emerald-400">
|
||||||
|
{formatCurrency(fin.netProfit)} net
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Kanban Movement Actions */}
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between pt-1 gap-1"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{prev ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => updateProjectStatus(project.id, prev)}
|
||||||
|
className="p-1 rounded-lg bg-slate-950 border border-slate-800 text-slate-400 hover:text-white hover:border-slate-600 transition-all text-[10px]"
|
||||||
|
title="Reculer d'une étape"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
) : <div />}
|
||||||
|
|
||||||
|
{/* Middle action button */}
|
||||||
|
{project.status === 'in_progress' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
startTimer(project.id, 'soudure_mods', `Atelier sur ${project.title}`)
|
||||||
|
}
|
||||||
|
className={`px-2 py-0.5 rounded-lg text-[10px] font-bold flex items-center space-x-1 ${
|
||||||
|
isTimerRunning
|
||||||
|
? 'bg-amber-500 text-slate-950 animate-pulse'
|
||||||
|
: 'bg-slate-950 border border-amber-500/30 text-amber-300 hover:bg-amber-500/20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Clock className="w-2.5 h-2.5" />
|
||||||
|
<span>{isTimerRunning ? 'Chrono actif' : 'Chrono'}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{project.status === 'in_stock_ready' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpenSoldModal(project)}
|
||||||
|
className="px-2 py-0.5 rounded-lg text-[10px] font-bold bg-pink-600 hover:bg-pink-500 text-white flex items-center space-x-1 shadow-sm"
|
||||||
|
>
|
||||||
|
<ShoppingBag className="w-2.5 h-2.5" />
|
||||||
|
<span>Vendre</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{next ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => updateProjectStatus(project.id, next)}
|
||||||
|
className="p-1 rounded-lg bg-slate-950 border border-slate-800 text-slate-400 hover:text-cyan-300 hover:border-cyan-500/40 transition-all text-[10px]"
|
||||||
|
title="Avancer à l'étape suivante"
|
||||||
|
>
|
||||||
|
<ArrowRight className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
) : <div />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{columnProjects.length === 0 && (
|
||||||
|
<div className="py-8 text-center text-xs text-slate-600 italic">
|
||||||
|
Aucun projet
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
200
src/components/projects/views/ProjectsListView.tsx
Normal file
200
src/components/projects/views/ProjectsListView.tsx
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Gamepad2,
|
||||||
|
Calendar,
|
||||||
|
ArrowRight,
|
||||||
|
ShoppingBag,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useApp } from '../../../context/AppContext';
|
||||||
|
import type { Project } from '../../../types';
|
||||||
|
import {
|
||||||
|
calculateProjectFinancials,
|
||||||
|
formatCurrency,
|
||||||
|
formatMinutes,
|
||||||
|
getProjectStatusInfo,
|
||||||
|
} from '../../../utils/calculations';
|
||||||
|
|
||||||
|
interface ProjectsListViewProps {
|
||||||
|
projects: Project[];
|
||||||
|
onSelectProject: (id: string) => void;
|
||||||
|
onOpenSoldModal: (project: Project) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProjectsListView: React.FC<ProjectsListViewProps> = ({
|
||||||
|
projects,
|
||||||
|
onSelectProject,
|
||||||
|
onOpenSoldModal,
|
||||||
|
}) => {
|
||||||
|
const { updateProjectStatus } = useApp();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-slate-900/60 rounded-2xl border border-slate-800 overflow-hidden shadow-xl">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-800 bg-slate-950/80 text-[11px] font-bold text-slate-400 uppercase tracking-wider">
|
||||||
|
<th className="py-3.5 px-4">Projet & Réf</th>
|
||||||
|
<th className="py-3.5 px-3">Modèle</th>
|
||||||
|
<th className="py-3.5 px-3">Statut</th>
|
||||||
|
<th className="py-3.5 px-3">Tâches</th>
|
||||||
|
<th className="py-3.5 px-3">Échéance 🎯</th>
|
||||||
|
<th className="py-3.5 px-3 text-right">Temps Atelier</th>
|
||||||
|
<th className="py-3.5 px-3 text-right">Coût Pièces</th>
|
||||||
|
<th className="py-3.5 px-3 text-right">Prix Vente</th>
|
||||||
|
<th className="py-3.5 px-3 text-right">Bénéfice Net</th>
|
||||||
|
<th className="py-3.5 px-4 text-center">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-850 text-xs">
|
||||||
|
{projects.map((project) => {
|
||||||
|
const fin = calculateProjectFinancials(project);
|
||||||
|
const statusInfo = getProjectStatusInfo(project.status);
|
||||||
|
const tasks = project.tasks || [];
|
||||||
|
const completedTasks = tasks.filter((t) => t.isCompleted).length;
|
||||||
|
const totalTasks = tasks.length;
|
||||||
|
const taskPercent = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0;
|
||||||
|
const objectives = project.objectives || {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={project.id}
|
||||||
|
onClick={() => onSelectProject(project.id)}
|
||||||
|
className="hover:bg-slate-800/50 cursor-pointer transition-colors group"
|
||||||
|
>
|
||||||
|
{/* Title & Ref */}
|
||||||
|
<td className="py-3.5 px-4">
|
||||||
|
<div className="flex items-center space-x-2.5">
|
||||||
|
<span className="font-mono font-bold text-[11px] text-pink-400 px-2 py-0.5 rounded bg-slate-950 border border-slate-800">
|
||||||
|
{project.serialNumber}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<div className="font-bold text-slate-100 group-hover:text-pink-300 transition-colors line-clamp-1">
|
||||||
|
{project.title}
|
||||||
|
</div>
|
||||||
|
{project.customerName && (
|
||||||
|
<div className="text-[11px] text-slate-500">
|
||||||
|
Client : {project.customerName}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Model */}
|
||||||
|
<td className="py-3.5 px-3">
|
||||||
|
<span className="inline-flex items-center space-x-1 px-2 py-0.5 rounded-md text-[11px] font-semibold bg-slate-950 text-cyan-300 border border-cyan-500/20">
|
||||||
|
<Gamepad2 className="w-3 h-3" />
|
||||||
|
<span>{project.consoleModel}</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
<td className="py-3.5 px-3">
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold border ${statusInfo.color}`}
|
||||||
|
>
|
||||||
|
{statusInfo.label}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Tasks Progress */}
|
||||||
|
<td className="py-3.5 px-3">
|
||||||
|
{totalTasks > 0 ? (
|
||||||
|
<div className="w-24">
|
||||||
|
<div className="flex items-center justify-between text-[10px] font-mono text-slate-400 mb-0.5">
|
||||||
|
<span>{completedTasks}/{totalTasks}</span>
|
||||||
|
<span className="text-purple-300 font-bold">{taskPercent}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-1 bg-slate-800 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-gradient-to-r from-pink-500 to-purple-500"
|
||||||
|
style={{ width: `${taskPercent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-slate-600 text-[11px] italic">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Deadline & Target Date */}
|
||||||
|
<td className="py-3.5 px-3 font-mono text-[11px]">
|
||||||
|
{objectives.targetDeadlineDate ? (
|
||||||
|
<span className="inline-flex items-center space-x-1 text-amber-300 bg-amber-500/10 px-1.5 py-0.5 rounded border border-amber-500/20">
|
||||||
|
<Calendar className="w-3 h-3" />
|
||||||
|
<span>{objectives.targetDeadlineDate}</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-slate-600">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Labor Time */}
|
||||||
|
<td className="py-3.5 px-3 text-right font-mono font-bold text-amber-300">
|
||||||
|
{formatMinutes(fin.laborMinutes)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Parts Cost */}
|
||||||
|
<td className="py-3.5 px-3 text-right font-mono text-slate-300">
|
||||||
|
{formatCurrency(fin.hardwareDirectCost)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Revenue / Target */}
|
||||||
|
<td className="py-3.5 px-3 text-right font-mono font-bold text-pink-400">
|
||||||
|
{formatCurrency(fin.revenue)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Net Profit & Margin */}
|
||||||
|
<td className="py-3.5 px-3 text-right">
|
||||||
|
<div className="font-mono font-extrabold text-emerald-400">
|
||||||
|
{formatCurrency(fin.netProfit)}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-emerald-500 font-mono">
|
||||||
|
{fin.marginPercent.toFixed(0)}% marge
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<td
|
||||||
|
className="py-3.5 px-4 text-center"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center space-x-1.5">
|
||||||
|
{project.status === 'in_stock_ready' ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpenSoldModal(project)}
|
||||||
|
className="px-2.5 py-1 rounded-lg text-xs font-bold bg-pink-600 hover:bg-pink-500 text-white shadow-sm flex items-center space-x-1"
|
||||||
|
>
|
||||||
|
<ShoppingBag className="w-3 h-3" />
|
||||||
|
<span>Vendre</span>
|
||||||
|
</button>
|
||||||
|
) : project.status === 'in_progress' ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => updateProjectStatus(project.id, 'qc_testing')}
|
||||||
|
className="px-2 py-1 rounded-lg text-xs font-semibold bg-cyan-600/30 hover:bg-cyan-600/50 text-cyan-300 border border-cyan-500/30"
|
||||||
|
>
|
||||||
|
QC
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelectProject(project.id)}
|
||||||
|
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800"
|
||||||
|
title="Voir la fiche détaillée"
|
||||||
|
>
|
||||||
|
<ArrowRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
313
src/components/projects/views/ProjectsTileView.tsx
Normal file
313
src/components/projects/views/ProjectsTileView.tsx
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Gamepad2,
|
||||||
|
Clock,
|
||||||
|
Wrench,
|
||||||
|
Sparkles,
|
||||||
|
ArrowRight,
|
||||||
|
CheckCircle2,
|
||||||
|
Calendar,
|
||||||
|
DollarSign,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
ShoppingBag,
|
||||||
|
ListTodo,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useApp } from '../../../context/AppContext';
|
||||||
|
import type { Project } from '../../../types';
|
||||||
|
import {
|
||||||
|
calculateProjectFinancials,
|
||||||
|
formatCurrency,
|
||||||
|
formatMinutes,
|
||||||
|
getProjectStatusInfo,
|
||||||
|
} from '../../../utils/calculations';
|
||||||
|
import { ProjectTasksChecklist } from '../ProjectTasksChecklist';
|
||||||
|
|
||||||
|
interface ProjectsTileViewProps {
|
||||||
|
projects: Project[];
|
||||||
|
onSelectProject: (id: string) => void;
|
||||||
|
onOpenSoldModal: (project: Project) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProjectsTileView: React.FC<ProjectsTileViewProps> = ({
|
||||||
|
projects,
|
||||||
|
onSelectProject,
|
||||||
|
onOpenSoldModal,
|
||||||
|
}) => {
|
||||||
|
const { updateProjectStatus, startTimer, activeTimer } = useApp();
|
||||||
|
const [expandedChecklistId, setExpandedChecklistId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const toggleChecklist = (id: string, e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setExpandedChecklistId(expandedChecklistId === id ? null : id);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||||
|
{projects.map((project) => {
|
||||||
|
const fin = calculateProjectFinancials(project);
|
||||||
|
const statusInfo = getProjectStatusInfo(project.status);
|
||||||
|
const isTimerRunningOnThis =
|
||||||
|
activeTimer.isRunning && activeTimer.projectId === project.id;
|
||||||
|
|
||||||
|
const tasks = project.tasks || [];
|
||||||
|
const completedTasks = tasks.filter((t) => t.isCompleted).length;
|
||||||
|
const totalTasks = tasks.length;
|
||||||
|
const taskPercent = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0;
|
||||||
|
const objectives = project.objectives || {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={project.id}
|
||||||
|
onClick={() => onSelectProject(project.id)}
|
||||||
|
className="group relative bg-slate-900/60 hover:bg-slate-900/90 rounded-3xl border border-slate-800 hover:border-pink-500/40 p-5 transition-all duration-300 shadow-lg hover:shadow-2xl hover:shadow-pink-500/10 cursor-pointer flex flex-col justify-between"
|
||||||
|
>
|
||||||
|
{/* Top Row: Serial & Status Badges */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between gap-2 mb-3">
|
||||||
|
<span className="font-mono text-xs font-extrabold px-2.5 py-1 rounded-lg bg-slate-950 text-slate-300 border border-slate-800">
|
||||||
|
{project.serialNumber}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-1.5">
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-bold border ${statusInfo.color}`}
|
||||||
|
>
|
||||||
|
{statusInfo.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title & Console Model */}
|
||||||
|
<h3 className="font-bold text-lg text-white group-hover:text-pink-400 transition-colors line-clamp-1">
|
||||||
|
{project.title}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2 mt-2">
|
||||||
|
<span className="inline-flex items-center space-x-1 px-2.5 py-0.5 rounded-md text-xs font-semibold bg-slate-950 text-cyan-300 border border-cyan-500/20">
|
||||||
|
<Gamepad2 className="w-3.5 h-3.5" />
|
||||||
|
<span>{project.consoleModel}</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{project.type === 'client_custom' && (
|
||||||
|
<span className="inline-flex items-center space-x-1 px-2 py-0.5 rounded-md text-[11px] font-medium bg-purple-500/10 text-purple-300 border border-purple-500/20">
|
||||||
|
<span>Client: {project.customerName || 'Privé'}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{objectives.targetDeadlineDate && (
|
||||||
|
<span className="inline-flex items-center space-x-1 px-2 py-0.5 rounded-md text-[11px] font-mono text-amber-300 bg-amber-500/10 border border-amber-500/20">
|
||||||
|
<Calendar className="w-3 h-3" />
|
||||||
|
<span>Cible: {objectives.targetDeadlineDate}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Objectives Mini Gauges */}
|
||||||
|
{(objectives.targetProfitMin || objectives.targetLaborHoursMax || totalTasks > 0) && (
|
||||||
|
<div className="mt-3.5 p-2.5 rounded-xl bg-slate-950/60 border border-slate-850 space-y-2">
|
||||||
|
{/* Task progress bar */}
|
||||||
|
{totalTasks > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between text-[11px] mb-1">
|
||||||
|
<span className="text-slate-400 flex items-center space-x-1">
|
||||||
|
<ListTodo className="w-3 h-3 text-purple-400" />
|
||||||
|
<span>Tâches ({completedTasks}/{totalTasks})</span>
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-purple-300 font-bold">{taskPercent}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-1.5 bg-slate-800 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-gradient-to-r from-pink-500 to-purple-500"
|
||||||
|
style={{ width: `${taskPercent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Objective targets */}
|
||||||
|
<div className="flex items-center justify-between text-[11px] font-mono text-slate-400">
|
||||||
|
{objectives.targetProfitMin && (
|
||||||
|
<span className="flex items-center space-x-1 text-pink-300">
|
||||||
|
<DollarSign className="w-3 h-3" />
|
||||||
|
<span>Cible: {formatCurrency(objectives.targetProfitMin)}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{objectives.targetLaborHoursMax && (
|
||||||
|
<span className="flex items-center space-x-1 text-amber-300">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
<span>Max: {objectives.targetLaborHoursMax}h</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Financial & Time Metrics Summary */}
|
||||||
|
<div className="grid grid-cols-3 gap-2 mt-4 pt-4 border-t border-slate-800/80">
|
||||||
|
<div className="p-2 rounded-xl bg-slate-950/60 border border-slate-850 text-center">
|
||||||
|
<span className="text-[10px] text-slate-400 block">Pièces & Frais</span>
|
||||||
|
<span className="font-mono text-xs font-bold text-slate-200">
|
||||||
|
{formatCurrency(fin.hardwareDirectCost)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-2 rounded-xl bg-slate-950/60 border border-slate-850 text-center">
|
||||||
|
<span className="text-[10px] text-slate-400 block">Temps Passé</span>
|
||||||
|
<span className="font-mono text-xs font-bold text-amber-300">
|
||||||
|
{formatMinutes(fin.laborMinutes)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-2 rounded-xl bg-slate-950/60 border border-slate-850 text-center">
|
||||||
|
<span className="text-[10px] text-slate-400 block">
|
||||||
|
{project.status === 'sold' ? 'Prix Vendu' : 'Prix Cible'}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-xs font-extrabold text-pink-400">
|
||||||
|
{formatCurrency(fin.revenue)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Net Profit & Margin Bar */}
|
||||||
|
<div className="mt-3 flex items-center justify-between px-3 py-2 rounded-xl bg-emerald-500/10 border border-emerald-500/20 text-xs">
|
||||||
|
<span className="text-emerald-300 font-medium">Bénéfice Net Réel :</span>
|
||||||
|
<div className="flex items-center space-x-2 font-mono">
|
||||||
|
<span className="font-bold text-emerald-400 text-sm">
|
||||||
|
{formatCurrency(fin.netProfit)}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-500/20 text-emerald-300 font-extrabold">
|
||||||
|
{fin.marginPercent.toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Checklist Expander in Card */}
|
||||||
|
{totalTasks > 0 && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => toggleChecklist(project.id, e)}
|
||||||
|
className="w-full flex items-center justify-between text-xs text-slate-400 hover:text-white py-1 px-1 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="flex items-center space-x-1.5">
|
||||||
|
<ListTodo className="w-3.5 h-3.5 text-purple-400" />
|
||||||
|
<span>Checklist ({completedTasks}/{totalTasks})</span>
|
||||||
|
</span>
|
||||||
|
{expandedChecklistId === project.id ? (
|
||||||
|
<ChevronUp className="w-3.5 h-3.5" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{expandedChecklistId === project.id && (
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="mt-2 p-2.5 rounded-xl bg-slate-950/90 border border-purple-500/30"
|
||||||
|
>
|
||||||
|
<ProjectTasksChecklist project={project} compact={true} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom Actions Toolbar */}
|
||||||
|
<div className="mt-5 pt-3 border-t border-slate-800 flex items-center justify-between gap-2">
|
||||||
|
{/* Quick Workflow Action Button */}
|
||||||
|
{project.status === 'draft' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
updateProjectStatus(project.id, 'in_progress');
|
||||||
|
}}
|
||||||
|
className="flex items-center space-x-1 px-3 py-1.5 rounded-xl text-xs font-bold bg-purple-600 hover:bg-purple-500 text-white shadow-md transition-all"
|
||||||
|
>
|
||||||
|
<Wrench className="w-3.5 h-3.5" />
|
||||||
|
<span>Démarrer</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{project.status === 'in_progress' && (
|
||||||
|
<div className="flex items-center space-x-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
startTimer(project.id, 'soudure_mods', `Atelier sur ${project.title}`);
|
||||||
|
}}
|
||||||
|
className={`flex items-center space-x-1 px-2.5 py-1.5 rounded-xl text-xs font-bold transition-all ${
|
||||||
|
isTimerRunningOnThis
|
||||||
|
? 'bg-amber-500 text-slate-950 animate-pulse'
|
||||||
|
: 'bg-slate-800 hover:bg-amber-500/20 text-amber-300 border border-amber-500/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Clock className="w-3.5 h-3.5" />
|
||||||
|
<span>{isTimerRunningOnThis ? 'Chrono actif' : 'Chrono'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
updateProjectStatus(project.id, 'qc_testing');
|
||||||
|
}}
|
||||||
|
className="flex items-center space-x-1 px-2.5 py-1.5 rounded-xl text-xs font-bold bg-cyan-600 hover:bg-cyan-500 text-white transition-all"
|
||||||
|
>
|
||||||
|
<span>Tests QC</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{project.status === 'qc_testing' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
updateProjectStatus(project.id, 'in_stock_ready');
|
||||||
|
}}
|
||||||
|
className="flex items-center space-x-1 px-3 py-1.5 rounded-xl text-xs font-bold bg-emerald-600 hover:bg-emerald-500 text-white shadow-md transition-all"
|
||||||
|
>
|
||||||
|
<Sparkles className="w-3.5 h-3.5" />
|
||||||
|
<span>Prête en stock</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{project.status === 'in_stock_ready' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onOpenSoldModal(project);
|
||||||
|
}}
|
||||||
|
className="flex items-center space-x-1.5 px-3 py-1.5 rounded-xl text-xs font-black bg-gradient-to-r from-pink-500 to-purple-600 hover:from-pink-400 hover:to-purple-500 text-white shadow-lg shadow-pink-500/20 transition-all"
|
||||||
|
>
|
||||||
|
<ShoppingBag className="w-3.5 h-3.5" />
|
||||||
|
<span>Vendre</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{project.status === 'sold' && (
|
||||||
|
<span className="flex items-center space-x-1 text-xs font-bold text-emerald-400">
|
||||||
|
<CheckCircle2 className="w-4 h-4" />
|
||||||
|
<span>Vendue ({project.soldDate || 'OK'})</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* View details arrow */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center space-x-1 text-xs font-semibold text-slate-400 group-hover:text-pink-400 transition-colors ml-auto"
|
||||||
|
>
|
||||||
|
<span>Fiche & Coûts</span>
|
||||||
|
<ArrowRight className="w-3.5 h-3.5 group-hover:translate-x-1 transition-transform" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
539
src/components/settings/CategoryManager.tsx
Normal file
539
src/components/settings/CategoryManager.tsx
Normal file
@@ -0,0 +1,539 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Tag,
|
||||||
|
Gamepad2,
|
||||||
|
Sparkles,
|
||||||
|
Plus,
|
||||||
|
Edit2,
|
||||||
|
Trash2,
|
||||||
|
Check,
|
||||||
|
X,
|
||||||
|
Layers,
|
||||||
|
Palette,
|
||||||
|
Eye,
|
||||||
|
Search,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useApp } from '../../context/AppContext';
|
||||||
|
import type { AppCategory, CategoryType } from '../../types';
|
||||||
|
|
||||||
|
const COLOR_PRESETS = [
|
||||||
|
{ name: 'Cyan Néon', color: '#06b6d4', badge: 'bg-cyan-500/10 text-cyan-400 border-cyan-500/30' },
|
||||||
|
{ name: 'Violet Cyber', color: '#a855f7', badge: 'bg-purple-500/10 text-purple-400 border-purple-500/30' },
|
||||||
|
{ name: 'Rose UwU', color: '#ec4899', badge: 'bg-pink-500/10 text-pink-400 border-pink-500/30' },
|
||||||
|
{ name: 'Emeraude LiPo', color: '#10b981', badge: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30' },
|
||||||
|
{ name: 'Ambre Vintage', color: '#f59e0b', badge: 'bg-amber-500/10 text-amber-400 border-amber-500/30' },
|
||||||
|
{ name: 'Bleu Royal', color: '#3b82f6', badge: 'bg-blue-500/10 text-blue-400 border-blue-500/30' },
|
||||||
|
{ name: 'Indigo Pro', color: '#6366f1', badge: 'bg-indigo-500/10 text-indigo-400 border-indigo-500/30' },
|
||||||
|
{ name: 'Rouge Flash', color: '#f43f5e', badge: 'bg-rose-500/10 text-rose-400 border-rose-500/30' },
|
||||||
|
{ name: 'Lime Rétro', color: '#84cc16', badge: 'bg-lime-500/10 text-lime-400 border-lime-500/30' },
|
||||||
|
{ name: 'Slate Neutre', color: '#64748b', badge: 'bg-slate-500/10 text-slate-400 border-slate-500/30' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const CategoryManager: React.FC = () => {
|
||||||
|
const {
|
||||||
|
categories,
|
||||||
|
addCategory,
|
||||||
|
updateCategory,
|
||||||
|
deleteCategory,
|
||||||
|
inventory,
|
||||||
|
projects,
|
||||||
|
modsLibrary,
|
||||||
|
} = useApp();
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<CategoryType>('part_category');
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [editingCategory, setEditingCategory] = useState<AppCategory | null>(null);
|
||||||
|
|
||||||
|
// Form State
|
||||||
|
const [formName, setFormName] = useState('');
|
||||||
|
const [formSlug, setFormSlug] = useState('');
|
||||||
|
const [formType, setFormType] = useState<CategoryType>('part_category');
|
||||||
|
const [formColor, setFormColor] = useState('#06b6d4');
|
||||||
|
const [formBadgeColor, setFormBadgeColor] = useState('bg-cyan-500/10 text-cyan-400 border-cyan-500/30');
|
||||||
|
const [formDescription, setFormDescription] = useState('');
|
||||||
|
const [formSortOrder, setFormSortOrder] = useState<number>(1);
|
||||||
|
const [formIsActive, setFormIsActive] = useState(true);
|
||||||
|
|
||||||
|
// Filter categories for the current tab and search query
|
||||||
|
const filteredCategories = categories
|
||||||
|
.filter((c) => c.type === activeTab)
|
||||||
|
.filter((c) => {
|
||||||
|
if (!searchQuery.trim()) return true;
|
||||||
|
const q = searchQuery.toLowerCase();
|
||||||
|
return (
|
||||||
|
c.name.toLowerCase().includes(q) ||
|
||||||
|
c.slug.toLowerCase().includes(q) ||
|
||||||
|
(c.description && c.description.toLowerCase().includes(q))
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.sortOrder - b.sortOrder);
|
||||||
|
|
||||||
|
// Stats calculation
|
||||||
|
const partCategoriesCount = categories.filter((c) => c.type === 'part_category').length;
|
||||||
|
const consoleModelsCount = categories.filter((c) => c.type === 'console_model').length;
|
||||||
|
const modCategoriesCount = categories.filter((c) => c.type === 'mod_category').length;
|
||||||
|
|
||||||
|
const countUsage = (cat: AppCategory): number => {
|
||||||
|
if (cat.type === 'part_category') {
|
||||||
|
const invCount = inventory.filter((i) => i.category === cat.slug).length;
|
||||||
|
const projPartCount = projects.reduce(
|
||||||
|
(sum, p) => sum + p.parts.filter((pt) => pt.category === cat.slug).length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
return invCount + projPartCount;
|
||||||
|
} else if (cat.type === 'console_model') {
|
||||||
|
const invCount = inventory.filter((i) => i.consoleModel === cat.slug).length;
|
||||||
|
const projCount = projects.filter((p) => p.consoleModel === cat.slug).length;
|
||||||
|
const modCount = modsLibrary.filter((m) => m.compatibleModels?.includes(cat.slug)).length;
|
||||||
|
return invCount + projCount + modCount;
|
||||||
|
} else if (cat.type === 'mod_category') {
|
||||||
|
return modsLibrary.filter((m) => m.category === cat.slug).length;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenAdd = () => {
|
||||||
|
setEditingCategory(null);
|
||||||
|
setFormName('');
|
||||||
|
setFormSlug('');
|
||||||
|
setFormType(activeTab);
|
||||||
|
setFormColor('#06b6d4');
|
||||||
|
setFormBadgeColor('bg-cyan-500/10 text-cyan-400 border-cyan-500/30');
|
||||||
|
setFormDescription('');
|
||||||
|
setFormSortOrder(filteredCategories.length + 1);
|
||||||
|
setFormIsActive(true);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenEdit = (cat: AppCategory) => {
|
||||||
|
setEditingCategory(cat);
|
||||||
|
setFormName(cat.name);
|
||||||
|
setFormSlug(cat.slug);
|
||||||
|
setFormType(cat.type);
|
||||||
|
setFormColor(cat.color || '#06b6d4');
|
||||||
|
setFormBadgeColor(cat.badgeColor || 'bg-cyan-500/10 text-cyan-400 border-cyan-500/30');
|
||||||
|
setFormDescription(cat.description || '');
|
||||||
|
setFormSortOrder(cat.sortOrder || 1);
|
||||||
|
setFormIsActive(cat.isActive);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNameChange = (name: string) => {
|
||||||
|
setFormName(name);
|
||||||
|
if (!editingCategory) {
|
||||||
|
if (formType === 'console_model') {
|
||||||
|
setFormSlug(name);
|
||||||
|
} else {
|
||||||
|
const slug = name
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.replace(/[^a-z0-9]+/g, '_')
|
||||||
|
.replace(/^_+|_+$/g, '');
|
||||||
|
setFormSlug(slug);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectPreset = (preset: typeof COLOR_PRESETS[0]) => {
|
||||||
|
setFormColor(preset.color);
|
||||||
|
setFormBadgeColor(preset.badge);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!formName.trim() || !formSlug.trim()) return;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
name: formName.trim(),
|
||||||
|
slug: formSlug.trim(),
|
||||||
|
type: formType,
|
||||||
|
color: formColor,
|
||||||
|
badgeColor: formBadgeColor,
|
||||||
|
description: formDescription.trim(),
|
||||||
|
sortOrder: Number(formSortOrder) || 1,
|
||||||
|
isActive: formIsActive,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editingCategory) {
|
||||||
|
updateCategory(editingCategory.id, payload);
|
||||||
|
} else {
|
||||||
|
addCategory(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsModalOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (cat: AppCategory) => {
|
||||||
|
const usage = countUsage(cat);
|
||||||
|
const warning =
|
||||||
|
usage > 0
|
||||||
|
? `Attention : Cet élément est utilisé par ${usage} pièce(s) / projet(s) existant(s). Êtes-vous sûr de vouloir le supprimer ?`
|
||||||
|
: `Confirmer la suppression de "${cat.name}" ?`;
|
||||||
|
|
||||||
|
if (confirm(warning)) {
|
||||||
|
deleteCategory(cat.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
|
||||||
|
{/* Header section with Stats & Add Button */}
|
||||||
|
<div className="glass-panel p-6 rounded-3xl border border-slate-800 space-y-4">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="inline-flex items-center space-x-2 px-3 py-1 rounded-full text-xs font-semibold bg-cyan-500/10 text-cyan-400 border border-cyan-500/30">
|
||||||
|
<Layers className="w-3.5 h-3.5" />
|
||||||
|
<span>Gestionnaire d'Énumérations & Modèles</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-black text-white font-display">
|
||||||
|
Catégories & Modèles de Consoles
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
Ajoutez, personnalisez et réorganisez les catégories de pièces, modèles de consoles et types de mods de votre atelier.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleOpenAdd}
|
||||||
|
className="px-4 py-2.5 rounded-xl bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-white text-xs font-bold flex items-center justify-center space-x-1.5 shadow-lg shadow-cyan-500/20 transition-all shrink-0"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
<span>
|
||||||
|
{activeTab === 'part_category'
|
||||||
|
? 'Nouvelle Catégorie Pièce'
|
||||||
|
: activeTab === 'console_model'
|
||||||
|
? 'Nouveau Modèle Console'
|
||||||
|
: 'Nouvelle Catégorie Mod'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab Switcher & Search Bar */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 pt-2 border-t border-slate-800/80">
|
||||||
|
<div className="flex items-center space-x-1.5 p-1 rounded-2xl bg-slate-950 border border-slate-800 overflow-x-auto">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('part_category')}
|
||||||
|
className={`px-3.5 py-2 rounded-xl text-xs font-bold flex items-center space-x-2 transition-all shrink-0 ${
|
||||||
|
activeTab === 'part_category'
|
||||||
|
? 'bg-cyan-500 text-slate-950 shadow-md shadow-cyan-500/20'
|
||||||
|
: 'text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Tag className="w-3.5 h-3.5" />
|
||||||
|
<span>Pièces & Matériel</span>
|
||||||
|
<span className={`px-1.5 py-0.5 rounded-md text-[10px] font-mono ${
|
||||||
|
activeTab === 'part_category' ? 'bg-slate-950/30 text-slate-950' : 'bg-slate-800 text-slate-400'
|
||||||
|
}`}>
|
||||||
|
{partCategoriesCount}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('console_model')}
|
||||||
|
className={`px-3.5 py-2 rounded-xl text-xs font-bold flex items-center space-x-2 transition-all shrink-0 ${
|
||||||
|
activeTab === 'console_model'
|
||||||
|
? 'bg-purple-500 text-white shadow-md shadow-purple-500/20'
|
||||||
|
: 'text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Gamepad2 className="w-3.5 h-3.5" />
|
||||||
|
<span>Modèles de Consoles</span>
|
||||||
|
<span className={`px-1.5 py-0.5 rounded-md text-[10px] font-mono ${
|
||||||
|
activeTab === 'console_model' ? 'bg-white/20 text-white' : 'bg-slate-800 text-slate-400'
|
||||||
|
}`}>
|
||||||
|
{consoleModelsCount}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('mod_category')}
|
||||||
|
className={`px-3.5 py-2 rounded-xl text-xs font-bold flex items-center space-x-2 transition-all shrink-0 ${
|
||||||
|
activeTab === 'mod_category'
|
||||||
|
? 'bg-pink-500 text-white shadow-md shadow-pink-500/20'
|
||||||
|
: 'text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Sparkles className="w-3.5 h-3.5" />
|
||||||
|
<span>Catégories de Mods</span>
|
||||||
|
<span className={`px-1.5 py-0.5 rounded-md text-[10px] font-mono ${
|
||||||
|
activeTab === 'mod_category' ? 'bg-white/20 text-white' : 'bg-slate-800 text-slate-400'
|
||||||
|
}`}>
|
||||||
|
{modCategoriesCount}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="w-3.5 h-3.5 text-slate-500 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Rechercher un modèle / catégorie..."
|
||||||
|
className="w-full sm:w-64 pl-8 pr-3 py-2 rounded-xl bg-slate-950 border border-slate-800 text-white text-xs placeholder-slate-500 focus:outline-none focus:border-cyan-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category List Grid */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
{filteredCategories.length === 0 ? (
|
||||||
|
<div className="md:col-span-2 glass-panel p-8 rounded-3xl border border-slate-800 text-center space-y-2">
|
||||||
|
<Layers className="w-8 h-8 text-slate-600 mx-auto" />
|
||||||
|
<div className="text-sm font-bold text-slate-300">Aucun élément trouvé</div>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
{searchQuery ? 'Aucun résultat pour cette recherche.' : 'Cliquez sur Ajouter pour créer votre premier élément.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredCategories.map((cat) => {
|
||||||
|
const usage = countUsage(cat);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={cat.id}
|
||||||
|
className="glass-panel p-4 rounded-2xl border border-slate-800/90 hover:border-slate-700 bg-slate-950/60 flex items-center justify-between gap-3 transition-all group"
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-3.5 min-w-0">
|
||||||
|
<div
|
||||||
|
className="w-4 h-4 rounded-full shrink-0 shadow-sm"
|
||||||
|
style={{ backgroundColor: cat.color || '#06b6d4' }}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<span className="text-sm font-bold text-white truncate">{cat.name}</span>
|
||||||
|
<span className={`px-2 py-0.5 rounded-full text-[10px] font-semibold border ${cat.badgeColor || 'bg-slate-500/10 text-slate-400 border-slate-500/30'}`}>
|
||||||
|
{cat.slug}
|
||||||
|
</span>
|
||||||
|
{!cat.isActive && (
|
||||||
|
<span className="px-1.5 py-0.5 rounded text-[9px] font-bold bg-rose-500/10 text-rose-400 border border-rose-500/30">
|
||||||
|
Désactivé
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{cat.description && (
|
||||||
|
<p className="text-[11px] text-slate-400 truncate mt-0.5">
|
||||||
|
{cat.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="text-[10px] text-slate-500 mt-1 flex items-center space-x-2">
|
||||||
|
<span>Ordre : #{cat.sortOrder}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{usage} utilisation(s)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-1 shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => handleOpenEdit(cat)}
|
||||||
|
className="p-2 rounded-lg text-slate-400 hover:text-cyan-400 hover:bg-slate-800/80 transition-colors"
|
||||||
|
title="Modifier"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(cat)}
|
||||||
|
className="p-2 rounded-lg text-slate-400 hover:text-rose-400 hover:bg-rose-500/10 transition-colors"
|
||||||
|
title="Supprimer"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Add / Edit Category */}
|
||||||
|
{isModalOpen && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md overflow-y-auto">
|
||||||
|
<div className="relative w-full max-w-lg bg-slate-900 border border-slate-700/80 rounded-3xl shadow-2xl overflow-hidden my-8 animate-fadeIn">
|
||||||
|
|
||||||
|
{/* Modal Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-800 bg-slate-950/50">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="w-9 h-9 rounded-xl bg-cyan-500/10 border border-cyan-500/30 flex items-center justify-center text-cyan-400">
|
||||||
|
<Layers className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-bold text-white">
|
||||||
|
{editingCategory ? `Modifier "${editingCategory.name}"` : 'Ajouter un Élément'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-[11px] text-slate-400">
|
||||||
|
Configuration de la table d'énumération NoCodeBackend
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsModalOpen(false)}
|
||||||
|
className="text-slate-400 hover:text-white p-2 rounded-xl hover:bg-slate-800 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Form */}
|
||||||
|
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||||
|
|
||||||
|
{/* Type Selector */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-semibold text-slate-300">Type de modèle / énumération</label>
|
||||||
|
<select
|
||||||
|
value={formType}
|
||||||
|
onChange={(e) => setFormType(e.target.value as CategoryType)}
|
||||||
|
className="w-full px-3.5 py-2 rounded-xl bg-slate-950 border border-slate-700 text-white text-xs focus:outline-none focus:border-cyan-500"
|
||||||
|
>
|
||||||
|
<option value="part_category">Catégorie de Pièce / Matériel (part_category)</option>
|
||||||
|
<option value="console_model">Modèle de Console Supporté (console_model)</option>
|
||||||
|
<option value="mod_category">Catégorie de Recette de Mod (mod_category)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Name & Slug */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-semibold text-slate-300">Nom lisible / Libellé *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={formName}
|
||||||
|
onChange={(e) => handleNameChange(e.target.value)}
|
||||||
|
placeholder="Ex: Écran IPS Laminé, PS Vita..."
|
||||||
|
className="w-full px-3.5 py-2 rounded-xl bg-slate-950 border border-slate-700 text-white text-xs focus:outline-none focus:border-cyan-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-semibold text-slate-300">Code Slug / Identifiant *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={formSlug}
|
||||||
|
onChange={(e) => setFormSlug(e.target.value)}
|
||||||
|
placeholder="Ex: screen, ps_vita..."
|
||||||
|
className="w-full px-3.5 py-2 rounded-xl bg-slate-950 border border-slate-700 text-white text-xs font-mono focus:outline-none focus:border-cyan-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-semibold text-slate-300">Description / Remarques (Optionnel)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formDescription}
|
||||||
|
onChange={(e) => setFormDescription(e.target.value)}
|
||||||
|
placeholder="Ex: Kits d'écran rétroéclairés IPS, AMOLED..."
|
||||||
|
className="w-full px-3.5 py-2 rounded-xl bg-slate-950 border border-slate-700 text-white text-xs focus:outline-none focus:border-cyan-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Color Presets Picker */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-semibold text-slate-300 flex items-center justify-between">
|
||||||
|
<span className="flex items-center space-x-1.5">
|
||||||
|
<Palette className="w-3.5 h-3.5 text-cyan-400" />
|
||||||
|
<span>Palette de Couleur & Style de Badge</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] font-mono text-slate-400">{formColor}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2 overflow-x-auto pb-1">
|
||||||
|
{COLOR_PRESETS.map((preset) => (
|
||||||
|
<button
|
||||||
|
key={preset.color}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSelectPreset(preset)}
|
||||||
|
className={`w-7 h-7 rounded-full shrink-0 border-2 transition-transform ${
|
||||||
|
formColor === preset.color
|
||||||
|
? 'border-white scale-110 shadow-lg'
|
||||||
|
: 'border-transparent hover:scale-105'
|
||||||
|
}`}
|
||||||
|
style={{ backgroundColor: preset.color }}
|
||||||
|
title={preset.name}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Live Preview Box */}
|
||||||
|
<div className="p-3.5 rounded-2xl bg-slate-950/80 border border-slate-800 space-y-2">
|
||||||
|
<div className="text-[10px] uppercase font-bold text-slate-400 flex items-center space-x-1">
|
||||||
|
<Eye className="w-3 h-3" />
|
||||||
|
<span>Aperçu du Rendu dans l'Application</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div
|
||||||
|
className="w-3.5 h-3.5 rounded-full shrink-0"
|
||||||
|
style={{ backgroundColor: formColor }}
|
||||||
|
/>
|
||||||
|
<span className="text-xs font-bold text-white">
|
||||||
|
{formName || 'Nom de la catégorie'}
|
||||||
|
</span>
|
||||||
|
<span className={`px-2.5 py-0.5 rounded-full text-xs font-semibold border ${formBadgeColor}`}>
|
||||||
|
{formName || 'Badge'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row: Sort Order & Active */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-semibold text-slate-300">Ordre d'affichage</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={formSortOrder}
|
||||||
|
onChange={(e) => setFormSortOrder(parseInt(e.target.value, 10) || 1)}
|
||||||
|
className="w-full px-3 py-2 rounded-xl bg-slate-950 border border-slate-700 text-white text-xs font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-semibold text-slate-300">Statut</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFormIsActive(!formIsActive)}
|
||||||
|
className={`w-full py-2 px-3 rounded-xl border text-xs font-bold flex items-center justify-center space-x-1.5 transition-all ${
|
||||||
|
formIsActive
|
||||||
|
? 'bg-emerald-500/10 border-emerald-500/40 text-emerald-400'
|
||||||
|
: 'bg-slate-800 border-slate-700 text-slate-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{formIsActive ? <Check className="w-3.5 h-3.5" /> : <X className="w-3.5 h-3.5" />}
|
||||||
|
<span>{formIsActive ? 'Actif (Visible)' : 'Désactivé'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Actions */}
|
||||||
|
<div className="flex items-center justify-end space-x-2.5 pt-3 border-t border-slate-800">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsModalOpen(false)}
|
||||||
|
className="px-4 py-2 rounded-xl bg-slate-800 text-slate-300 hover:text-white text-xs font-semibold transition-colors"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="px-5 py-2 rounded-xl bg-gradient-to-r from-cyan-500 to-blue-600 text-white text-xs font-bold shadow-lg shadow-cyan-500/20 hover:scale-[1.02] active:scale-[0.98] transition-all"
|
||||||
|
>
|
||||||
|
{editingCategory ? 'Sauvegarder' : 'Ajouter au catalogue'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
700
src/components/settings/SettingsView.tsx
Normal file
700
src/components/settings/SettingsView.tsx
Normal file
@@ -0,0 +1,700 @@
|
|||||||
|
import React, { useState, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
Settings as SettingsIcon,
|
||||||
|
Palette,
|
||||||
|
DollarSign,
|
||||||
|
Download,
|
||||||
|
Upload,
|
||||||
|
RotateCcw,
|
||||||
|
Building,
|
||||||
|
Save,
|
||||||
|
ShieldCheck,
|
||||||
|
FileSpreadsheet,
|
||||||
|
FileCode,
|
||||||
|
Database,
|
||||||
|
Cloud,
|
||||||
|
RefreshCw,
|
||||||
|
CheckCircle2,
|
||||||
|
AlertCircle,
|
||||||
|
KeyRound,
|
||||||
|
Layers,
|
||||||
|
Sliders,
|
||||||
|
Lock,
|
||||||
|
ShieldAlert,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useApp } from '../../context/AppContext';
|
||||||
|
import { CategoryManager } from './CategoryManager';
|
||||||
|
import { changeUserPassword } from '../../services/authService';
|
||||||
|
|
||||||
|
export const SettingsView: React.FC = () => {
|
||||||
|
const {
|
||||||
|
settings,
|
||||||
|
categories,
|
||||||
|
inventory,
|
||||||
|
projects,
|
||||||
|
modsLibrary,
|
||||||
|
updateSettings,
|
||||||
|
exportJSON,
|
||||||
|
importJSON,
|
||||||
|
exportInventoryCSV,
|
||||||
|
exportProjectsCSV,
|
||||||
|
resetToSampleData,
|
||||||
|
dbSyncStatus,
|
||||||
|
lastSyncedAt,
|
||||||
|
refreshFromDatabase,
|
||||||
|
syncAllToDatabase,
|
||||||
|
setShowAuthModal,
|
||||||
|
authSession,
|
||||||
|
addToast,
|
||||||
|
} = useApp();
|
||||||
|
|
||||||
|
const [settingsSection, setSettingsSection] = useState<'general' | 'categories' | 'database'>('categories');
|
||||||
|
const [businessName, setBusinessName] = useState(settings.businessName);
|
||||||
|
const [workshopLocation, setWorkshopLocation] = useState(settings.workshopLocation || '');
|
||||||
|
const [currency, setCurrency] = useState(settings.currency);
|
||||||
|
const [defaultHourlyRate, setDefaultHourlyRate] = useState(settings.defaultHourlyRate);
|
||||||
|
const [defaultPlatformFeePercent, setDefaultPlatformFeePercent] = useState(settings.defaultPlatformFeePercent);
|
||||||
|
const [theme, setTheme] = useState(settings.theme);
|
||||||
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
const [isPushing, setIsPushing] = useState(false);
|
||||||
|
|
||||||
|
// Password change state
|
||||||
|
const [oldPassword, setOldPassword] = useState('');
|
||||||
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [isChangingPassword, setIsChangingPassword] = useState(false);
|
||||||
|
const [passwordStatus, setPasswordStatus] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
|
||||||
|
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleSaveSettings = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
updateSettings({
|
||||||
|
businessName: businessName.trim(),
|
||||||
|
workshopLocation: workshopLocation.trim(),
|
||||||
|
currency,
|
||||||
|
defaultHourlyRate: Number(defaultHourlyRate) || 25,
|
||||||
|
defaultPlatformFeePercent: Number(defaultPlatformFeePercent) || 0,
|
||||||
|
theme,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (event) => {
|
||||||
|
const content = event.target?.result as string;
|
||||||
|
if (content) {
|
||||||
|
importJSON(content);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleManualRefresh = async () => {
|
||||||
|
setIsRefreshing(true);
|
||||||
|
try {
|
||||||
|
await refreshFromDatabase();
|
||||||
|
} finally {
|
||||||
|
setIsRefreshing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleManualPush = async () => {
|
||||||
|
setIsPushing(true);
|
||||||
|
try {
|
||||||
|
await syncAllToDatabase();
|
||||||
|
} finally {
|
||||||
|
setIsPushing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePasswordSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setPasswordStatus(null);
|
||||||
|
|
||||||
|
if (!oldPassword.trim() || !newPassword.trim() || !confirmPassword.trim()) {
|
||||||
|
setPasswordStatus({ type: 'error', message: 'Veuillez remplir tous les champs de mot de passe.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
setPasswordStatus({ type: 'error', message: 'Le nouveau mot de passe et la confirmation ne correspondent pas.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword.length < 4) {
|
||||||
|
setPasswordStatus({ type: 'error', message: 'Le nouveau mot de passe doit comporter au moins 4 caractères.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsChangingPassword(true);
|
||||||
|
try {
|
||||||
|
const usernameToUpdate = authSession?.user?.name || 'streaper2';
|
||||||
|
const result = await changeUserPassword(usernameToUpdate, oldPassword, newPassword);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
setPasswordStatus({ type: 'success', message: result.message || 'Mot de passe mis à jour avec succès !' });
|
||||||
|
setOldPassword('');
|
||||||
|
setNewPassword('');
|
||||||
|
setConfirmPassword('');
|
||||||
|
addToast({
|
||||||
|
type: 'success',
|
||||||
|
title: 'Sécurité mise à jour',
|
||||||
|
message: 'Votre nouveau mot de passe a été enregistré en base de données.',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setPasswordStatus({ type: 'error', message: result.error || 'Échec de modification du mot de passe.' });
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setPasswordStatus({ type: 'error', message: err.message || 'Erreur lors de la mise à jour.' });
|
||||||
|
} finally {
|
||||||
|
setIsChangingPassword(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8 pb-12 animate-fadeIn max-w-4xl mx-auto">
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<div>
|
||||||
|
<div className="inline-flex items-center space-x-2 px-3 py-1 rounded-full text-xs font-semibold bg-pink-500/10 text-pink-400 border border-pink-500/30 mb-2">
|
||||||
|
<SettingsIcon className="w-3.5 h-3.5" />
|
||||||
|
<span>Paramètres & Préférences</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl sm:text-3xl font-black text-white font-display">
|
||||||
|
Configuration de l'Atelier uwuFinances
|
||||||
|
</h2>
|
||||||
|
<p className="text-slate-400 text-xs sm:text-sm">
|
||||||
|
Personnalisez votre identité d'atelier, gérez les tables d'énumérations (catégories & modèles) et synchronisez avec le Cloud NoCodeBackend.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation Sub-Tabs */}
|
||||||
|
<div className="flex items-center space-x-2 p-1.5 rounded-2xl bg-slate-950 border border-slate-800">
|
||||||
|
<button
|
||||||
|
onClick={() => setSettingsSection('categories')}
|
||||||
|
className={`flex-1 py-2.5 px-4 rounded-xl text-xs font-bold flex items-center justify-center space-x-2 transition-all ${
|
||||||
|
settingsSection === 'categories'
|
||||||
|
? 'bg-gradient-to-r from-cyan-500 to-blue-600 text-white shadow-lg shadow-cyan-500/20'
|
||||||
|
: 'text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Layers className="w-4 h-4" />
|
||||||
|
<span>Catégories & Modèles ({categories.length})</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setSettingsSection('general')}
|
||||||
|
className={`flex-1 py-2.5 px-4 rounded-xl text-xs font-bold flex items-center justify-center space-x-2 transition-all ${
|
||||||
|
settingsSection === 'general'
|
||||||
|
? 'bg-gradient-to-r from-pink-500 to-purple-600 text-white shadow-lg shadow-pink-500/20'
|
||||||
|
: 'text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Sliders className="w-4 h-4" />
|
||||||
|
<span>Atelier, Taux & Thème</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setSettingsSection('database')}
|
||||||
|
className={`flex-1 py-2.5 px-4 rounded-xl text-xs font-bold flex items-center justify-center space-x-2 transition-all ${
|
||||||
|
settingsSection === 'database'
|
||||||
|
? 'bg-slate-800 text-cyan-400 border border-cyan-500/30 shadow-md'
|
||||||
|
: 'text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Database className="w-4 h-4" />
|
||||||
|
<span>Base Cloud & Sauvegardes</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cloud Database (NoCodeBackend) Card */}
|
||||||
|
<div className="glass-panel p-6 sm:p-8 rounded-3xl border border-pink-500/30 bg-gradient-to-br from-slate-950 via-slate-900/90 to-pink-950/20 space-y-6 relative overflow-hidden shadow-xl shadow-pink-500/5">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center space-x-3.5">
|
||||||
|
<div className="p-3 rounded-2xl bg-pink-500/15 border border-pink-500/30 text-pink-400">
|
||||||
|
<Database className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<h3 className="text-base font-bold text-white">Base de Données Cloud (NoCodeBackend)</h3>
|
||||||
|
<span className={`inline-flex items-center space-x-1 px-2.5 py-0.5 rounded-full text-[11px] font-semibold border ${
|
||||||
|
dbSyncStatus === 'connected'
|
||||||
|
? 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30'
|
||||||
|
: dbSyncStatus === 'syncing'
|
||||||
|
? 'bg-amber-500/10 text-amber-400 border-amber-500/30'
|
||||||
|
: 'bg-rose-500/10 text-rose-400 border-rose-500/30'
|
||||||
|
}`}>
|
||||||
|
{dbSyncStatus === 'connected' && <CheckCircle2 className="w-3 h-3" />}
|
||||||
|
{dbSyncStatus === 'syncing' && <RefreshCw className="w-3 h-3 animate-spin" />}
|
||||||
|
{dbSyncStatus === 'offline' && <AlertCircle className="w-3 h-3" />}
|
||||||
|
{dbSyncStatus === 'error' && <AlertCircle className="w-3 h-3" />}
|
||||||
|
<span>
|
||||||
|
{dbSyncStatus === 'connected' ? 'En ligne & Synchronisé' : dbSyncStatus === 'syncing' ? 'Synchronisation...' : 'Hors-ligne'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5">
|
||||||
|
Instance : <span className="font-mono text-pink-300 font-semibold">54770_uwu_finances</span> • Région : <span className="text-slate-300 font-semibold">EU (Frankfurt)</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowAuthModal(true)}
|
||||||
|
className="px-3.5 py-2 rounded-xl bg-slate-900 border border-cyan-500/40 hover:bg-cyan-500/10 text-cyan-300 text-xs font-semibold flex items-center space-x-1.5 transition-all cursor-pointer"
|
||||||
|
title="Gérer la session utilisateur et le jeton JWT"
|
||||||
|
>
|
||||||
|
<KeyRound className="w-3.5 h-3.5 text-cyan-400" />
|
||||||
|
<span>Session & Jeton JWT</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleManualRefresh}
|
||||||
|
disabled={isRefreshing || isPushing}
|
||||||
|
className="px-3.5 py-2 rounded-xl bg-slate-900 border border-slate-700 hover:border-pink-500/50 text-slate-200 hover:text-white text-xs font-semibold flex items-center space-x-1.5 transition-all disabled:opacity-50 cursor-pointer"
|
||||||
|
title="Recharger toutes les données depuis NoCodeBackend"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-3.5 h-3.5 ${isRefreshing ? 'animate-spin text-pink-400' : ''}`} />
|
||||||
|
<span>Recharger du Cloud</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleManualPush}
|
||||||
|
disabled={isRefreshing || isPushing}
|
||||||
|
className="px-3.5 py-2 rounded-xl bg-gradient-to-r from-pink-500 to-purple-600 hover:from-pink-400 hover:to-purple-500 text-white text-xs font-bold flex items-center space-x-1.5 shadow-lg shadow-pink-500/20 transition-all disabled:opacity-50 cursor-pointer"
|
||||||
|
title="Pousser l'état local actuel vers la base cloud"
|
||||||
|
>
|
||||||
|
<Cloud className="w-3.5 h-3.5" />
|
||||||
|
<span>Forcer Synchro Cloud</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Database Metrics Grid */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-5 gap-3 pt-2">
|
||||||
|
<div className="p-3 rounded-2xl bg-slate-950/80 border border-slate-800">
|
||||||
|
<div className="text-[10px] uppercase font-bold text-slate-400">Énumérations / Catégories</div>
|
||||||
|
<div className="text-lg font-black font-mono text-cyan-400 mt-1">{categories.length}</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 rounded-2xl bg-slate-950/80 border border-slate-800">
|
||||||
|
<div className="text-[10px] uppercase font-bold text-slate-400">Pièces en Stock (DB)</div>
|
||||||
|
<div className="text-lg font-black font-mono text-cyan-300 mt-1">{inventory.length}</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 rounded-2xl bg-slate-950/80 border border-slate-800">
|
||||||
|
<div className="text-[10px] uppercase font-bold text-slate-400">Projets Modding (DB)</div>
|
||||||
|
<div className="text-lg font-black font-mono text-purple-300 mt-1">{projects.length}</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 rounded-2xl bg-slate-950/80 border border-slate-800">
|
||||||
|
<div className="text-[10px] uppercase font-bold text-slate-400">Recettes de Mods (DB)</div>
|
||||||
|
<div className="text-lg font-black font-mono text-pink-300 mt-1">{modsLibrary.length}</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 rounded-2xl bg-slate-950/80 border border-slate-800 col-span-2 sm:col-span-1">
|
||||||
|
<div className="text-[10px] uppercase font-bold text-slate-400">Dernière Synchro</div>
|
||||||
|
<div className="text-xs font-mono text-slate-300 mt-2 font-medium truncate">
|
||||||
|
{lastSyncedAt ? lastSyncedAt.toLocaleTimeString('fr-FR') : 'En attente...'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab 1: Category & Enum Manager */}
|
||||||
|
{settingsSection === 'categories' && (
|
||||||
|
<CategoryManager />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tab 2: Studio Preferences & General Settings */}
|
||||||
|
{settingsSection === 'general' && (
|
||||||
|
<form onSubmit={handleSaveSettings} className="space-y-6">
|
||||||
|
{/* Studio Identity Card */}
|
||||||
|
<div className="glass-panel p-6 rounded-3xl border border-slate-800 space-y-4">
|
||||||
|
<h3 className="text-sm font-bold uppercase tracking-wider text-pink-400 flex items-center space-x-2">
|
||||||
|
<Building className="w-4 h-4" />
|
||||||
|
<span>Identité de l'Atelier & Fiches Clients</span>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-semibold text-slate-300">
|
||||||
|
Nom Commercial de l'Atelier / Studio
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={businessName}
|
||||||
|
onChange={(e) => setBusinessName(e.target.value)}
|
||||||
|
placeholder="Ex: uwu Custom Studio 🎮"
|
||||||
|
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-700 text-white text-sm focus:outline-none focus:border-pink-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-semibold text-slate-300">
|
||||||
|
Emplacement / Ville de l'Atelier
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={workshopLocation}
|
||||||
|
onChange={(e) => setWorkshopLocation(e.target.value)}
|
||||||
|
placeholder="Ex: Atelier Console Modding - Lyon"
|
||||||
|
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-700 text-white text-sm focus:outline-none focus:border-pink-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hourly Rates & Commissions */}
|
||||||
|
<div className="glass-panel p-6 rounded-3xl border border-slate-800 space-y-4">
|
||||||
|
<h3 className="text-sm font-bold uppercase tracking-wider text-amber-400 flex items-center space-x-2">
|
||||||
|
<DollarSign className="w-4 h-4" />
|
||||||
|
<span>Taux Horaires & Frais Plateformes</span>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-semibold text-slate-300">
|
||||||
|
Devise Principale
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={currency}
|
||||||
|
onChange={(e) => setCurrency(e.target.value)}
|
||||||
|
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-700 text-white text-sm focus:outline-none focus:border-amber-400"
|
||||||
|
>
|
||||||
|
<option value="€">EUR (€)</option>
|
||||||
|
<option value="$">USD ($)</option>
|
||||||
|
<option value="£">GBP (£)</option>
|
||||||
|
<option value="CHF">CHF</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-semibold text-slate-300">
|
||||||
|
Taux Horaire par Défaut (€/heure)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.5"
|
||||||
|
min="0"
|
||||||
|
value={defaultHourlyRate}
|
||||||
|
onChange={(e) => setDefaultHourlyRate(parseFloat(e.target.value) || 0)}
|
||||||
|
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-700 text-white text-sm font-mono focus:outline-none focus:border-amber-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-semibold text-slate-300">
|
||||||
|
Commission Plateforme par Défaut (%)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.5"
|
||||||
|
min="0"
|
||||||
|
value={defaultPlatformFeePercent}
|
||||||
|
onChange={(e) => setDefaultPlatformFeePercent(parseFloat(e.target.value) || 0)}
|
||||||
|
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-700 text-white text-sm font-mono focus:outline-none focus:border-amber-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Theme Customization */}
|
||||||
|
<div className="glass-panel p-6 rounded-3xl border border-slate-800 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-bold uppercase tracking-wider text-purple-400 flex items-center space-x-2">
|
||||||
|
<Palette className="w-4 h-4" />
|
||||||
|
<span>Thème Visuel & Ambiance</span>
|
||||||
|
</h3>
|
||||||
|
<span className="text-[11px] text-slate-400">
|
||||||
|
Changement appliqué instantanément
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3.5">
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
id: 'uwu-pink',
|
||||||
|
name: '🌸 UwU Pink Neo',
|
||||||
|
desc: 'Pastel, néons & retrogaming',
|
||||||
|
colors: ['#ec4899', '#a855f7', '#06b6d4'],
|
||||||
|
activeBorder: 'border-pink-500 bg-pink-500/10 shadow-lg shadow-pink-500/20',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cyberpunk',
|
||||||
|
name: '⚡ Cyberpunk Dark',
|
||||||
|
desc: 'Néon violet, cyan & high-tech',
|
||||||
|
colors: ['#00f0ff', '#9d00ff', '#fcee0a'],
|
||||||
|
activeBorder: 'border-cyan-400 bg-cyan-500/10 shadow-lg shadow-cyan-500/20',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'retro-gameboy',
|
||||||
|
name: '🎮 Rétro GameBoy',
|
||||||
|
desc: 'Ambiance DMG vintage & dot-matrix',
|
||||||
|
colors: ['#8bac0f', '#9bbc0f', '#9b2242'],
|
||||||
|
activeBorder: 'border-lime-500 bg-lime-500/10 shadow-lg shadow-lime-500/20',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'modern-dark',
|
||||||
|
name: '🌑 Modern Dark',
|
||||||
|
desc: 'Minimaliste titane & ardoise pro',
|
||||||
|
colors: ['#38bdf8', '#818cf8', '#f8fafc'],
|
||||||
|
activeBorder: 'border-sky-400 bg-sky-500/10 shadow-lg shadow-sky-500/20',
|
||||||
|
},
|
||||||
|
].map((t) => {
|
||||||
|
const isSelected = (theme === t.id) || (settings.theme === t.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setTheme(t.id as any);
|
||||||
|
updateSettings({ theme: t.id as any });
|
||||||
|
}}
|
||||||
|
className={`p-4 rounded-2xl border text-left flex flex-col justify-between space-y-3 transition-all cursor-pointer ${
|
||||||
|
isSelected
|
||||||
|
? t.activeBorder
|
||||||
|
: 'border-slate-800 bg-slate-950/60 hover:border-slate-700 hover:bg-slate-900/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="font-bold text-xs text-white">{t.name}</div>
|
||||||
|
{isSelected && (
|
||||||
|
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-slate-400 mt-1">{t.desc}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Color swatches */}
|
||||||
|
<div className="flex items-center space-x-1.5 pt-1">
|
||||||
|
{t.colors.map((c, idx) => (
|
||||||
|
<span
|
||||||
|
key={idx}
|
||||||
|
className="w-4 h-4 rounded-full border border-black/30 shadow-inner"
|
||||||
|
style={{ backgroundColor: c }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Security & User Password Management */}
|
||||||
|
<div className="glass-panel p-6 rounded-3xl border border-slate-800 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-bold uppercase tracking-wider text-rose-400 flex items-center space-x-2">
|
||||||
|
<Lock className="w-4 h-4" />
|
||||||
|
<span>Sécurité & Mot de Passe de l'Atelier</span>
|
||||||
|
</h3>
|
||||||
|
<span className="text-[11px] font-mono text-slate-400">
|
||||||
|
Compte : <span className="text-pink-300 font-bold">{authSession?.user?.name || 'streaper2'}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
Modifiez votre mot de passe d'accès. La modification sera immédiatement synchronisée dans la table <span className="font-mono text-cyan-300">users</span> de votre base NoCodeBackend.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{passwordStatus && (
|
||||||
|
<div className={`p-3.5 rounded-2xl border text-xs flex items-center space-x-2 ${
|
||||||
|
passwordStatus.type === 'success'
|
||||||
|
? 'bg-emerald-500/10 text-emerald-300 border-emerald-500/30'
|
||||||
|
: 'bg-rose-500/10 text-rose-300 border-rose-500/30'
|
||||||
|
}`}>
|
||||||
|
{passwordStatus.type === 'success' ? (
|
||||||
|
<CheckCircle2 className="w-4 h-4 text-emerald-400 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<ShieldAlert className="w-4 h-4 text-rose-400 shrink-0" />
|
||||||
|
)}
|
||||||
|
<span>{passwordStatus.message}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3.5 pt-1">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-semibold text-slate-300">
|
||||||
|
Ancien Mot de Passe
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={oldPassword}
|
||||||
|
onChange={(e) => setOldPassword(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-700 text-white text-sm font-mono focus:outline-none focus:border-rose-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-semibold text-slate-300">
|
||||||
|
Nouveau Mot de Passe
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-700 text-white text-sm font-mono focus:outline-none focus:border-rose-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-semibold text-slate-300">
|
||||||
|
Confirmer le Nouveau
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-slate-700 text-white text-sm font-mono focus:outline-none focus:border-rose-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end pt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handlePasswordSubmit}
|
||||||
|
disabled={isChangingPassword}
|
||||||
|
className="px-4 py-2 rounded-xl bg-slate-900 border border-rose-500/40 hover:bg-rose-500/10 text-rose-300 text-xs font-bold flex items-center space-x-1.5 transition-all disabled:opacity-50 cursor-pointer"
|
||||||
|
>
|
||||||
|
{isChangingPassword ? (
|
||||||
|
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<KeyRound className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
|
<span>{isChangingPassword ? 'Mise à jour...' : 'Mettre à jour le Mot de Passe'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Save Button */}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="flex items-center space-x-2 px-6 py-3 rounded-2xl bg-gradient-to-r from-pink-500 to-purple-600 text-white font-bold text-sm shadow-xl shadow-pink-500/25 hover:scale-[1.02] active:scale-[0.98] transition-all cursor-pointer"
|
||||||
|
>
|
||||||
|
<Save className="w-4 h-4" />
|
||||||
|
<span>Enregistrer les Préférences</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tab 3: Data Management & Backup Section */}
|
||||||
|
{settingsSection === 'database' && (
|
||||||
|
<div className="glass-panel p-6 sm:p-8 rounded-3xl border border-slate-800 space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold text-white flex items-center space-x-2">
|
||||||
|
<ShieldCheck className="w-5 h-5 text-cyan-400" />
|
||||||
|
<span>Gestion des Données & Sauvegardes</span>
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">
|
||||||
|
Vos données sont automatiquement synchronisées en temps réel avec NoCodeBackend (54770_uwu_finances) et sauvegardées localement en cache hors-ligne.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
{/* JSON Full Backup */}
|
||||||
|
<div className="p-4 rounded-2xl bg-slate-950 border border-slate-800 space-y-3 flex flex-col justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="font-bold text-xs text-cyan-400 flex items-center space-x-1.5">
|
||||||
|
<FileCode className="w-4 h-4" />
|
||||||
|
<span>Sauvegarde Complète JSON</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-slate-400">
|
||||||
|
Exporte toutes les catégories, pièces, stocks, projets, nomenclatures et logs de temps en 1 seul fichier.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2 pt-2">
|
||||||
|
<button
|
||||||
|
onClick={exportJSON}
|
||||||
|
className="flex-1 py-2 px-3 rounded-xl bg-slate-900 border border-cyan-500/40 text-cyan-300 hover:bg-cyan-500/10 text-xs font-semibold flex items-center justify-center space-x-1.5 transition-all"
|
||||||
|
>
|
||||||
|
<Download className="w-3.5 h-3.5" />
|
||||||
|
<span>Exporter JSON</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<label className="flex-1 py-2 px-3 rounded-xl bg-slate-900 border border-slate-700 text-slate-300 hover:text-white hover:border-slate-500 text-xs font-semibold flex items-center justify-center space-x-1.5 cursor-pointer transition-all">
|
||||||
|
<Upload className="w-3.5 h-3.5" />
|
||||||
|
<span>Restaurer JSON</span>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".json"
|
||||||
|
onChange={handleFileUpload}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CSV Exports */}
|
||||||
|
<div className="p-4 rounded-2xl bg-slate-950 border border-slate-800 space-y-3 flex flex-col justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="font-bold text-xs text-emerald-400 flex items-center space-x-1.5">
|
||||||
|
<FileSpreadsheet className="w-4 h-4" />
|
||||||
|
<span>Exports Tableur CSV (Excel)</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-slate-400">
|
||||||
|
Téléchargez les feuilles de calcul pour votre comptabilité ou analyse externe.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2 pt-2">
|
||||||
|
<button
|
||||||
|
onClick={exportInventoryCSV}
|
||||||
|
className="flex-1 py-2 px-2.5 rounded-xl bg-slate-900 border border-emerald-500/40 text-emerald-300 hover:bg-emerald-500/10 text-[11px] font-semibold flex items-center justify-center space-x-1 transition-all"
|
||||||
|
>
|
||||||
|
<Download className="w-3 h-3" />
|
||||||
|
<span>CSV Stock</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={exportProjectsCSV}
|
||||||
|
className="flex-1 py-2 px-2.5 rounded-xl bg-slate-900 border border-emerald-500/40 text-emerald-300 hover:bg-emerald-500/10 text-[11px] font-semibold flex items-center justify-center space-x-1 transition-all"
|
||||||
|
>
|
||||||
|
<Download className="w-3 h-3" />
|
||||||
|
<span>CSV Projets</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reset to Demo Data */}
|
||||||
|
<div className="pt-4 border-t border-slate-800 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-bold text-slate-300">
|
||||||
|
Réinitialiser avec le catalogue d'exemples
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-slate-500">
|
||||||
|
Rétablit les projets, catégories et pièces de démonstration GBA SP, GBC et DMG et synchronise la base cloud.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm('Attention, cette action va réinitialiser vos données actuelles avec le jeu d\'exemples de démonstration sur le Cloud et en local. Continuer ?')) {
|
||||||
|
resetToSampleData();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="flex items-center space-x-1.5 px-4 py-2 rounded-xl bg-slate-900 border border-rose-500/40 text-rose-400 hover:bg-rose-500/10 text-xs font-bold transition-all shrink-0"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-3.5 h-3.5" />
|
||||||
|
<span>Réinitialiser Données Démo</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
291
src/components/timetracker/TimeTrackerView.tsx
Normal file
291
src/components/timetracker/TimeTrackerView.tsx
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Clock,
|
||||||
|
Play,
|
||||||
|
Pause,
|
||||||
|
RotateCcw,
|
||||||
|
Check,
|
||||||
|
Gamepad2,
|
||||||
|
History,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useApp } from '../../context/AppContext';
|
||||||
|
import type { TaskType } from '../../types';
|
||||||
|
|
||||||
|
import {
|
||||||
|
formatCurrency,
|
||||||
|
formatMinutes,
|
||||||
|
getTaskTypeLabel,
|
||||||
|
} from '../../utils/calculations';
|
||||||
|
|
||||||
|
const TASK_TYPES_LIST: { id: TaskType; label: string; icon: string }[] = [
|
||||||
|
{ id: 'diagnostic_demontage', label: 'Diagnostic & Démontage', icon: '🔍' },
|
||||||
|
{ id: 'nettoyage_recap', label: 'Nettoyage & Condensateurs', icon: '🧼' },
|
||||||
|
{ id: 'decoupe_coque', label: 'Découpe Coque & Ajustement', icon: '✂️' },
|
||||||
|
{ id: 'soudure_mods', label: 'Soudure Mods & USB-C', icon: '🔥' },
|
||||||
|
{ id: 'assemblage', label: 'Assemblage & Calibrage', icon: '🪛' },
|
||||||
|
{ id: 'test_qc', label: 'Tests & Contrôle Qualité', icon: '🎮' },
|
||||||
|
{ id: 'packaging_photo', label: 'Photos & Packaging', icon: '📦' },
|
||||||
|
{ id: 'autre', label: 'Autre Travail', icon: '⚙️' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const TimeTrackerView: React.FC = () => {
|
||||||
|
const {
|
||||||
|
projects,
|
||||||
|
activeTimer,
|
||||||
|
startTimer,
|
||||||
|
pauseTimer,
|
||||||
|
resumeTimer,
|
||||||
|
resetTimer,
|
||||||
|
saveTimerSession,
|
||||||
|
setTimerDescription,
|
||||||
|
setTimerTaskType,
|
||||||
|
setTimerProject,
|
||||||
|
settings,
|
||||||
|
} = useApp();
|
||||||
|
|
||||||
|
// Active project object
|
||||||
|
const activeProject = projects.find((p) => p.id === activeTimer.projectId);
|
||||||
|
|
||||||
|
// Time format calculations
|
||||||
|
const hours = Math.floor(activeTimer.elapsedSeconds / 3600);
|
||||||
|
const minutes = Math.floor((activeTimer.elapsedSeconds % 3600) / 60);
|
||||||
|
const seconds = activeTimer.elapsedSeconds % 60;
|
||||||
|
|
||||||
|
// Live real-time cost accumulated
|
||||||
|
const liveCost = (activeTimer.elapsedSeconds / 3600) * (activeTimer.hourlyRate || settings.defaultHourlyRate || 25);
|
||||||
|
|
||||||
|
// All logged sessions across all projects for history
|
||||||
|
const allSessions = projects.flatMap((p) =>
|
||||||
|
p.timeSessions.map((s) => ({
|
||||||
|
...s,
|
||||||
|
projectTitle: p.title,
|
||||||
|
projectSerial: p.serialNumber,
|
||||||
|
consoleModel: p.consoleModel,
|
||||||
|
}))
|
||||||
|
).sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
||||||
|
|
||||||
|
const totalLoggedMinutes = allSessions.reduce((sum, s) => sum + s.durationMinutes, 0);
|
||||||
|
const totalLoggedCost = allSessions.reduce((sum, s) => sum + s.cost, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8 pb-12 animate-fadeIn">
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<div>
|
||||||
|
<div className="inline-flex items-center space-x-2 px-3 py-1 rounded-full text-xs font-semibold bg-amber-500/10 text-amber-400 border border-amber-500/30 mb-2">
|
||||||
|
<Clock className="w-3.5 h-3.5" />
|
||||||
|
<span>Chronomètre d'Établi & Main d'Œuvre</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl sm:text-3xl font-black text-white font-display">
|
||||||
|
Quantification du Temps Passé par Console
|
||||||
|
</h2>
|
||||||
|
<p className="text-slate-400 text-xs sm:text-sm">
|
||||||
|
Chronométrez en direct vos sessions de soudure et d'assemblage pour valoriser avec précision votre artisanat.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Big Stopwatch Display Card */}
|
||||||
|
<div className="glass-panel p-6 sm:p-10 rounded-3xl border border-amber-500/30 relative overflow-hidden shadow-2xl">
|
||||||
|
<div className="absolute top-0 right-0 -mr-20 -mt-20 w-80 h-80 bg-amber-500/10 rounded-full blur-3xl pointer-events-none" />
|
||||||
|
|
||||||
|
<div className="relative z-10 flex flex-col items-center text-center space-y-6">
|
||||||
|
|
||||||
|
{/* Project Picker */}
|
||||||
|
<div className="w-full max-w-md space-y-1.5 text-left">
|
||||||
|
<label className="text-xs font-bold text-slate-300 uppercase tracking-wider flex items-center space-x-1.5">
|
||||||
|
<Gamepad2 className="w-4 h-4 text-amber-400" />
|
||||||
|
<span>Console / Projet Assigné :</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={activeTimer.projectId || ''}
|
||||||
|
onChange={(e) => setTimerProject(e.target.value || null)}
|
||||||
|
className="w-full px-4 py-3 rounded-2xl bg-slate-950 border border-slate-700 text-white font-bold text-sm focus:outline-none focus:border-amber-400 shadow-inner"
|
||||||
|
>
|
||||||
|
<option value="">-- Choisir une console en cours --</option>
|
||||||
|
{projects.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.title} ({p.consoleModel} - {p.serialNumber})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{activeProject && (
|
||||||
|
<div className="text-[11px] text-amber-400 font-mono text-center pt-1">
|
||||||
|
Affectation : {activeProject.title} ({activeProject.serialNumber})
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
{/* Big Digital Numbers */}
|
||||||
|
<div className="py-4">
|
||||||
|
<div className="font-mono text-6xl sm:text-8xl font-black text-amber-300 tracking-tight drop-shadow-[0_0_20px_rgba(245,158,11,0.3)]">
|
||||||
|
{hours > 0 ? `${hours.toString().padStart(2, '0')}:` : ''}
|
||||||
|
{minutes.toString().padStart(2, '0')}:
|
||||||
|
{seconds.toString().padStart(2, '0')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Live Real-time Cost Counter */}
|
||||||
|
<div className="mt-3 flex items-center justify-center space-x-2 text-sm text-slate-300">
|
||||||
|
<span className="font-semibold">Coût main d'œuvre accumulé :</span>
|
||||||
|
<span className="font-mono font-black text-lg text-emerald-400">
|
||||||
|
{formatCurrency(liveCost, settings.currency)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-400">
|
||||||
|
(taux : {activeTimer.hourlyRate || settings.defaultHourlyRate || 25}€/h)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Task Type Grid */}
|
||||||
|
<div className="w-full max-w-2xl space-y-2 text-left">
|
||||||
|
<label className="text-xs font-bold text-slate-300 uppercase tracking-wider">
|
||||||
|
Type de tâche en cours :
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2.5">
|
||||||
|
{TASK_TYPES_LIST.map((task) => (
|
||||||
|
<button
|
||||||
|
key={task.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTimerTaskType(task.id)}
|
||||||
|
className={`flex items-center space-x-2 p-2.5 rounded-xl border text-xs font-semibold transition-all ${
|
||||||
|
activeTimer.taskType === task.id
|
||||||
|
? 'bg-amber-500/20 text-amber-300 border-amber-500 shadow-md shadow-amber-500/10'
|
||||||
|
: 'bg-slate-950/60 text-slate-400 border-slate-800 hover:border-slate-700 hover:text-slate-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-base">{task.icon}</span>
|
||||||
|
<span className="truncate">{task.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description Input */}
|
||||||
|
<div className="w-full max-w-2xl space-y-1.5 text-left">
|
||||||
|
<label className="text-xs font-bold text-slate-300 uppercase tracking-wider">
|
||||||
|
Note d'intervention :
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={activeTimer.description}
|
||||||
|
onChange={(e) => setTimerDescription(e.target.value)}
|
||||||
|
placeholder="Ex: Soudure du connecteur USB-C, test sous microscope..."
|
||||||
|
className="w-full px-4 py-2.5 rounded-xl bg-slate-950 border border-slate-700 text-white placeholder-slate-500 text-xs focus:outline-none focus:border-amber-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stopwatch Controls */}
|
||||||
|
<div className="flex flex-wrap items-center justify-center gap-4 pt-4">
|
||||||
|
{!activeTimer.isRunning ? (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (activeTimer.elapsedSeconds > 0) resumeTimer();
|
||||||
|
else startTimer(activeTimer.projectId || undefined, activeTimer.taskType, activeTimer.description);
|
||||||
|
}}
|
||||||
|
className="flex items-center space-x-2 px-8 py-4 rounded-2xl bg-gradient-to-r from-amber-500 to-orange-500 text-slate-950 font-black text-base shadow-xl shadow-amber-500/25 hover:scale-105 active:scale-95 transition-all"
|
||||||
|
>
|
||||||
|
<Play className="w-5 h-5 fill-current" />
|
||||||
|
<span>{activeTimer.elapsedSeconds > 0 ? 'Reprendre' : 'Démarrer le Chrono'}</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={pauseTimer}
|
||||||
|
className="flex items-center space-x-2 px-8 py-4 rounded-2xl bg-amber-500 text-slate-950 font-black text-base shadow-xl shadow-amber-500/25 hover:scale-105 active:scale-95 transition-all"
|
||||||
|
>
|
||||||
|
<Pause className="w-5 h-5 fill-current" />
|
||||||
|
<span>Mettre en Pause</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTimer.elapsedSeconds > 0 && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={saveTimerSession}
|
||||||
|
disabled={!activeTimer.projectId}
|
||||||
|
className="flex items-center space-x-2 px-6 py-4 rounded-2xl bg-emerald-500 text-slate-950 font-black text-base shadow-lg shadow-emerald-500/20 hover:scale-105 active:scale-95 disabled:opacity-40 disabled:cursor-not-allowed transition-all"
|
||||||
|
title={!activeTimer.projectId ? 'Sélectionnez un projet pour enregistrer' : 'Enregistrer ce temps sur le projet'}
|
||||||
|
>
|
||||||
|
<Check className="w-5 h-5" />
|
||||||
|
<span>Enregistrer sur le projet</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={resetTimer}
|
||||||
|
className="p-4 rounded-2xl bg-slate-900 border border-slate-700 text-slate-400 hover:text-white hover:bg-slate-800 transition-colors"
|
||||||
|
title="Réinitialiser le chronomètre"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* History of Recent Logged Sessions across all consoles */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<History className="w-5 h-5 text-amber-400" />
|
||||||
|
<h3 className="text-lg font-bold text-white">
|
||||||
|
Historique Global des Sessions d'Établi
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-400 font-mono">
|
||||||
|
Total cumulé : <strong className="text-amber-300">{formatMinutes(totalLoggedMinutes)}</strong> ({formatCurrency(totalLoggedCost, settings.currency)})
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="glass-panel rounded-3xl border border-slate-800 overflow-hidden">
|
||||||
|
{allSessions.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-xs text-slate-400">
|
||||||
|
Aucune session enregistrée pour le moment.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-slate-800/80">
|
||||||
|
{allSessions.slice(0, 8).map((session) => (
|
||||||
|
<div
|
||||||
|
key={session.id}
|
||||||
|
className="p-4 flex flex-col sm:flex-row sm:items-center justify-between gap-3 hover:bg-slate-900/40 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<span className="font-bold text-sm text-white">
|
||||||
|
{session.projectTitle}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] font-mono text-cyan-400">
|
||||||
|
({session.consoleModel})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2 text-xs text-slate-300">
|
||||||
|
<span className="font-semibold text-amber-400">
|
||||||
|
{getTaskTypeLabel(session.taskType)} :
|
||||||
|
</span>
|
||||||
|
<span>{session.description}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-6 text-right shrink-0">
|
||||||
|
<div className="text-[11px] text-slate-400 font-mono">
|
||||||
|
{session.date}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-mono font-bold text-amber-300 text-sm">
|
||||||
|
{formatMinutes(session.durationMinutes)}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-slate-400 font-mono">
|
||||||
|
{formatCurrency(session.cost, settings.currency)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
1796
src/context/AppContext.tsx
Normal file
1796
src/context/AppContext.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1348
src/data/initialData.ts
Normal file
1348
src/data/initialData.ts
Normal file
File diff suppressed because it is too large
Load Diff
327
src/index.css
Normal file
327
src/index.css
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root,
|
||||||
|
.theme-uwu-pink,
|
||||||
|
[data-theme="uwu-pink"] {
|
||||||
|
--font-sans: 'Plus Jakarta Sans', system-ui, -apple-system, sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', monospace;
|
||||||
|
--font-display: 'Fredoka', cursive, sans-serif;
|
||||||
|
|
||||||
|
/* Theme colors */
|
||||||
|
--app-bg: #090d16;
|
||||||
|
--app-bg-radial: radial-gradient(ellipse 80% 70% at 50% -10%, rgba(236, 72, 153, 0.15), rgba(168, 85, 247, 0.08) 40%, rgba(9, 13, 22, 0) 80%), #090d16;
|
||||||
|
--panel-bg: rgba(15, 23, 42, 0.75);
|
||||||
|
--panel-border: rgba(236, 72, 153, 0.18);
|
||||||
|
--panel-border-hover: rgba(236, 72, 153, 0.45);
|
||||||
|
--panel-hover-shadow: 0 8px 32px 0 rgba(236, 72, 153, 0.15);
|
||||||
|
--sidebar-bg: #0b0f19;
|
||||||
|
--sidebar-border: rgba(255, 255, 255, 0.08);
|
||||||
|
--header-bg: rgba(11, 15, 25, 0.85);
|
||||||
|
--accent-primary: #ec4899;
|
||||||
|
--accent-secondary: #a855f7;
|
||||||
|
--accent-tertiary: #06b6d4;
|
||||||
|
--accent-gradient: linear-gradient(135deg, #ec4899 0%, #a855f7 50%, #ec4899 100%);
|
||||||
|
--glow-color: rgba(236, 72, 153, 0.35);
|
||||||
|
--selection-bg: #ec4899;
|
||||||
|
--selection-text: #ffffff;
|
||||||
|
--text-heading: #fbcfe8;
|
||||||
|
--scrollbar-track: rgba(15, 23, 42, 0.6);
|
||||||
|
--scrollbar-thumb: rgba(236, 72, 153, 0.4);
|
||||||
|
--scrollbar-thumb-hover: rgba(236, 72, 153, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-cyberpunk,
|
||||||
|
[data-theme="cyberpunk"] {
|
||||||
|
--app-bg: #05060f;
|
||||||
|
--app-bg-radial: radial-gradient(ellipse 80% 60% at 50% -10%, rgba(0, 240, 255, 0.14), rgba(157, 0, 255, 0.1) 40%, rgba(5, 6, 15, 0) 80%), #05060f;
|
||||||
|
--panel-bg: rgba(10, 11, 26, 0.82);
|
||||||
|
--panel-border: rgba(0, 240, 255, 0.22);
|
||||||
|
--panel-border-hover: rgba(0, 240, 255, 0.65);
|
||||||
|
--panel-hover-shadow: 0 8px 32px 0 rgba(0, 240, 255, 0.25);
|
||||||
|
--sidebar-bg: #070814;
|
||||||
|
--sidebar-border: rgba(0, 240, 255, 0.18);
|
||||||
|
--header-bg: rgba(7, 8, 20, 0.9);
|
||||||
|
--accent-primary: #00f0ff;
|
||||||
|
--accent-secondary: #9d00ff;
|
||||||
|
--accent-tertiary: #fcee0a;
|
||||||
|
--accent-gradient: linear-gradient(135deg, #00f0ff 0%, #9d00ff 50%, #ff0055 100%);
|
||||||
|
--glow-color: rgba(0, 240, 255, 0.45);
|
||||||
|
--selection-bg: #00f0ff;
|
||||||
|
--selection-text: #05060f;
|
||||||
|
--text-heading: #00f0ff;
|
||||||
|
--scrollbar-track: rgba(7, 8, 20, 0.7);
|
||||||
|
--scrollbar-thumb: rgba(0, 240, 255, 0.4);
|
||||||
|
--scrollbar-thumb-hover: rgba(0, 240, 255, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-retro-gameboy,
|
||||||
|
[data-theme="retro-gameboy"] {
|
||||||
|
--app-bg: #0e150e;
|
||||||
|
--app-bg-radial: radial-gradient(ellipse 80% 60% at 50% -10%, rgba(139, 172, 15, 0.14), rgba(48, 98, 48, 0.1) 50%, rgba(14, 21, 14, 0) 80%), #0e150e;
|
||||||
|
--panel-bg: rgba(18, 26, 18, 0.88);
|
||||||
|
--panel-border: rgba(139, 172, 15, 0.25);
|
||||||
|
--panel-border-hover: rgba(155, 188, 15, 0.6);
|
||||||
|
--panel-hover-shadow: 0 8px 32px 0 rgba(139, 172, 15, 0.22);
|
||||||
|
--sidebar-bg: #101810;
|
||||||
|
--sidebar-border: rgba(139, 172, 15, 0.2);
|
||||||
|
--header-bg: rgba(16, 24, 16, 0.92);
|
||||||
|
--accent-primary: #8bac0f;
|
||||||
|
--accent-secondary: #9bbc0f;
|
||||||
|
--accent-tertiary: #9b2242;
|
||||||
|
--accent-gradient: linear-gradient(135deg, #8bac0f 0%, #9bbc0f 50%, #306230 100%);
|
||||||
|
--glow-color: rgba(139, 172, 15, 0.4);
|
||||||
|
--selection-bg: #8bac0f;
|
||||||
|
--selection-text: #0f380f;
|
||||||
|
--text-heading: #9bbc0f;
|
||||||
|
--scrollbar-track: rgba(16, 24, 16, 0.7);
|
||||||
|
--scrollbar-thumb: rgba(139, 172, 15, 0.4);
|
||||||
|
--scrollbar-thumb-hover: rgba(155, 188, 15, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-modern-dark,
|
||||||
|
[data-theme="modern-dark"] {
|
||||||
|
--app-bg: #08090d;
|
||||||
|
--app-bg-radial: radial-gradient(ellipse 80% 60% at 50% -10%, rgba(56, 189, 248, 0.08), rgba(99, 102, 241, 0.04) 50%, rgba(8, 9, 13, 0) 80%), #08090d;
|
||||||
|
--panel-bg: rgba(15, 18, 26, 0.88);
|
||||||
|
--panel-border: rgba(255, 255, 255, 0.1);
|
||||||
|
--panel-border-hover: rgba(56, 189, 248, 0.45);
|
||||||
|
--panel-hover-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.4);
|
||||||
|
--sidebar-bg: #0d0f17;
|
||||||
|
--sidebar-border: rgba(255, 255, 255, 0.08);
|
||||||
|
--header-bg: rgba(13, 15, 23, 0.94);
|
||||||
|
--accent-primary: #38bdf8;
|
||||||
|
--accent-secondary: #818cf8;
|
||||||
|
--accent-tertiary: #f8fafc;
|
||||||
|
--accent-gradient: linear-gradient(135deg, #38bdf8 0%, #818cf8 50%, #6366f1 100%);
|
||||||
|
--glow-color: rgba(56, 189, 248, 0.3);
|
||||||
|
--selection-bg: #38bdf8;
|
||||||
|
--selection-text: #08090d;
|
||||||
|
--text-heading: #f8fafc;
|
||||||
|
--scrollbar-track: rgba(13, 15, 23, 0.7);
|
||||||
|
--scrollbar-thumb: rgba(148, 163, 184, 0.3);
|
||||||
|
--scrollbar-thumb-hover: rgba(56, 189, 248, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
background-color: var(--app-bg);
|
||||||
|
background-image: var(--app-bg-radial);
|
||||||
|
background-attachment: fixed;
|
||||||
|
color: #f1f5f9;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Text selections themed */
|
||||||
|
::selection {
|
||||||
|
background-color: var(--selection-bg);
|
||||||
|
color: var(--selection-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom styles and themes */
|
||||||
|
.font-display {
|
||||||
|
font-family: 'Fredoka', cursive, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-mono {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom themed scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--scrollbar-track);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--scrollbar-thumb);
|
||||||
|
border-radius: 9999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--scrollbar-thumb-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dynamic Glassmorphism helpers using CSS vars */
|
||||||
|
.glass-panel {
|
||||||
|
background: var(--panel-bg);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
-webkit-backdrop-filter: blur(16px);
|
||||||
|
border: 1px solid var(--panel-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-panel-hover:hover {
|
||||||
|
border-color: var(--panel-border-hover);
|
||||||
|
box-shadow: var(--panel-hover-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Theme scoped app shell helpers */
|
||||||
|
.theme-root-container {
|
||||||
|
background-color: var(--app-bg);
|
||||||
|
background-image: var(--app-bg-radial);
|
||||||
|
background-attachment: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-sidebar {
|
||||||
|
background-color: var(--sidebar-bg) !important;
|
||||||
|
border-color: var(--sidebar-border) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-header {
|
||||||
|
background-color: var(--header-bg) !important;
|
||||||
|
border-color: var(--sidebar-border) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cyberpunk theme specific enhancements */
|
||||||
|
.theme-cyberpunk .bg-slate-950 {
|
||||||
|
background-color: #05060f !important;
|
||||||
|
}
|
||||||
|
.theme-cyberpunk .border-slate-800,
|
||||||
|
.theme-cyberpunk .border-slate-850 {
|
||||||
|
border-color: rgba(0, 240, 255, 0.18) !important;
|
||||||
|
}
|
||||||
|
.theme-cyberpunk .text-pink-400 {
|
||||||
|
color: #00f0ff !important;
|
||||||
|
}
|
||||||
|
.theme-cyberpunk .bg-pink-500\/10,
|
||||||
|
.theme-cyberpunk .bg-pink-500\/15 {
|
||||||
|
background-color: rgba(0, 240, 255, 0.12) !important;
|
||||||
|
}
|
||||||
|
.theme-cyberpunk .border-pink-500\/30,
|
||||||
|
.theme-cyberpunk .border-pink-500\/40 {
|
||||||
|
border-color: rgba(0, 240, 255, 0.35) !important;
|
||||||
|
}
|
||||||
|
.theme-cyberpunk .from-pink-500 {
|
||||||
|
--tw-gradient-from: #00f0ff var(--tw-gradient-from-position) !important;
|
||||||
|
}
|
||||||
|
.theme-cyberpunk .to-purple-600,
|
||||||
|
.theme-cyberpunk .to-purple-500 {
|
||||||
|
--tw-gradient-to: #9d00ff var(--tw-gradient-to-position) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Retro GameBoy theme specific enhancements */
|
||||||
|
.theme-retro-gameboy .bg-slate-950 {
|
||||||
|
background-color: #0e150e !important;
|
||||||
|
}
|
||||||
|
.theme-retro-gameboy .border-slate-800,
|
||||||
|
.theme-retro-gameboy .border-slate-850 {
|
||||||
|
border-color: rgba(139, 172, 15, 0.22) !important;
|
||||||
|
}
|
||||||
|
.theme-retro-gameboy .text-pink-400,
|
||||||
|
.theme-retro-gameboy .text-cyan-400 {
|
||||||
|
color: #9bbc0f !important;
|
||||||
|
}
|
||||||
|
.theme-retro-gameboy .text-pink-300,
|
||||||
|
.theme-retro-gameboy .text-cyan-300 {
|
||||||
|
color: #cadc9f !important;
|
||||||
|
}
|
||||||
|
.theme-retro-gameboy .bg-pink-500\/10,
|
||||||
|
.theme-retro-gameboy .bg-cyan-500\/10 {
|
||||||
|
background-color: rgba(139, 172, 15, 0.15) !important;
|
||||||
|
}
|
||||||
|
.theme-retro-gameboy .border-pink-500\/30,
|
||||||
|
.theme-retro-gameboy .border-cyan-500\/30 {
|
||||||
|
border-color: rgba(139, 172, 15, 0.35) !important;
|
||||||
|
}
|
||||||
|
.theme-retro-gameboy .from-pink-500,
|
||||||
|
.theme-retro-gameboy .from-cyan-500 {
|
||||||
|
--tw-gradient-from: #8bac0f var(--tw-gradient-from-position) !important;
|
||||||
|
}
|
||||||
|
.theme-retro-gameboy .to-purple-600,
|
||||||
|
.theme-retro-gameboy .to-blue-600 {
|
||||||
|
--tw-gradient-to: #306230 var(--tw-gradient-to-position) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modern Dark theme specific enhancements */
|
||||||
|
.theme-modern-dark .bg-slate-950 {
|
||||||
|
background-color: #08090d !important;
|
||||||
|
}
|
||||||
|
.theme-modern-dark .border-slate-800,
|
||||||
|
.theme-modern-dark .border-slate-850 {
|
||||||
|
border-color: rgba(255, 255, 255, 0.08) !important;
|
||||||
|
}
|
||||||
|
.theme-modern-dark .text-pink-400 {
|
||||||
|
color: #38bdf8 !important;
|
||||||
|
}
|
||||||
|
.theme-modern-dark .bg-pink-500\/10 {
|
||||||
|
background-color: rgba(56, 189, 248, 0.1) !important;
|
||||||
|
}
|
||||||
|
.theme-modern-dark .border-pink-500\/30 {
|
||||||
|
border-color: rgba(56, 189, 248, 0.3) !important;
|
||||||
|
}
|
||||||
|
.theme-modern-dark .from-pink-500 {
|
||||||
|
--tw-gradient-from: #38bdf8 var(--tw-gradient-from-position) !important;
|
||||||
|
}
|
||||||
|
.theme-modern-dark .to-purple-600 {
|
||||||
|
--tw-gradient-to: #6366f1 var(--tw-gradient-to-position) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glow effects */
|
||||||
|
.glow-pink {
|
||||||
|
box-shadow: 0 0 25px -5px var(--glow-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow-cyan {
|
||||||
|
box-shadow: 0 0 25px -5px rgba(6, 182, 212, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow-purple {
|
||||||
|
box-shadow: 0 0 25px -5px rgba(168, 85, 247, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pulse animation for active timer */
|
||||||
|
@keyframes pulse-ring {
|
||||||
|
0% {
|
||||||
|
transform: scale(0.95);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.08);
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(0.95);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-pulse-ring {
|
||||||
|
animation: pulse-ring 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Print formatting for build certificate / build sheet */
|
||||||
|
@media print {
|
||||||
|
body {
|
||||||
|
background: white !important;
|
||||||
|
color: black !important;
|
||||||
|
}
|
||||||
|
.no-print {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
.print-only {
|
||||||
|
display: block !important;
|
||||||
|
}
|
||||||
|
.glass-panel {
|
||||||
|
background: white !important;
|
||||||
|
border: 1px solid #ccc !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
color: black !important;
|
||||||
|
}
|
||||||
|
.printable-label-sheet {
|
||||||
|
background: white !important;
|
||||||
|
border: none !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
max-height: none !important;
|
||||||
|
overflow: visible !important;
|
||||||
|
}
|
||||||
|
.label-item {
|
||||||
|
page-break-inside: avoid;
|
||||||
|
break-inside: avoid;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
375
src/services/authService.ts
Normal file
375
src/services/authService.ts
Normal file
@@ -0,0 +1,375 @@
|
|||||||
|
import type { AuthUser, AuthSession, JwtPayload } from '../types';
|
||||||
|
import { ncbUsers } from './ncbApi';
|
||||||
|
|
||||||
|
const STORAGE_KEY_AUTH = 'uwu_auth_session_v1';
|
||||||
|
const JWT_SECRET_SIGNATURE_KEY = 'uwuFinances_jwt_modding_secret_2026';
|
||||||
|
|
||||||
|
// Primary registered user credentials in Database (NoCodeBackend)
|
||||||
|
export const DEFAULT_ADMIN_USER: AuthUser = {
|
||||||
|
id: 'user_streaper2',
|
||||||
|
name: 'streaper2',
|
||||||
|
email: 'streaper2@uwufinances.studio',
|
||||||
|
role: 'admin',
|
||||||
|
workshopName: 'uwu Custom Studio 🎮',
|
||||||
|
ncbInstance: '54770_uwu_finances',
|
||||||
|
avatarUrl: 'https://api.dicebear.com/7.x/bottts/svg?seed=streaper2',
|
||||||
|
lastLoginAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GUEST_UNAUTHENTICATED_SESSION: AuthSession = {
|
||||||
|
user: {
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
role: 'viewer',
|
||||||
|
lastLoginAt: '',
|
||||||
|
},
|
||||||
|
token: '',
|
||||||
|
expiresAt: 0,
|
||||||
|
isAuthenticated: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper for Base64 URL Encoding / Decoding
|
||||||
|
export function base64UrlEncode(str: string): string {
|
||||||
|
try {
|
||||||
|
const utf8Bytes = new TextEncoder().encode(str);
|
||||||
|
let binary = '';
|
||||||
|
utf8Bytes.forEach((byte) => (binary += String.fromCharCode(byte)));
|
||||||
|
return btoa(binary)
|
||||||
|
.replace(/\+/g, '-')
|
||||||
|
.replace(/\//g, '_')
|
||||||
|
.replace(/=+$/, '');
|
||||||
|
} catch {
|
||||||
|
return btoa(unescape(encodeURIComponent(str)))
|
||||||
|
.replace(/\+/g, '-')
|
||||||
|
.replace(/\//g, '_')
|
||||||
|
.replace(/=+$/, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function base64UrlDecode(str: string): string {
|
||||||
|
try {
|
||||||
|
let base64 = str.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
while (base64.length % 4) {
|
||||||
|
base64 += '=';
|
||||||
|
}
|
||||||
|
const binary = atob(base64);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) {
|
||||||
|
bytes[i] = binary.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return new TextDecoder().decode(bytes);
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple deterministic signature mock (for browser client-side JWTs)
|
||||||
|
function generateMockSignature(headerB64: string, payloadB64: string, secret: string): string {
|
||||||
|
const data = `${headerB64}.${payloadB64}.${secret}`;
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < data.length; i++) {
|
||||||
|
const char = data.charCodeAt(i);
|
||||||
|
hash = ((hash << 5) - hash) + char;
|
||||||
|
hash |= 0;
|
||||||
|
}
|
||||||
|
return base64UrlEncode(`sig_${Math.abs(hash).toString(36)}_${Date.now().toString(36)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a standard RFC 7519 compliant JWT string
|
||||||
|
*/
|
||||||
|
export function createJwtToken(
|
||||||
|
user: AuthUser,
|
||||||
|
instance: string = '54770_uwu_finances',
|
||||||
|
expiresInHours: number = 720 // Default 30 days
|
||||||
|
): { token: string; payload: JwtPayload } {
|
||||||
|
const header = {
|
||||||
|
alg: 'HS256',
|
||||||
|
typ: 'JWT',
|
||||||
|
};
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const exp = now + expiresInHours * 3600;
|
||||||
|
|
||||||
|
const payload: JwtPayload = {
|
||||||
|
sub: user.id,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
instance: instance || '54770_uwu_finances',
|
||||||
|
iat: now,
|
||||||
|
exp,
|
||||||
|
iss: 'uwuFinances-ncb-auth',
|
||||||
|
};
|
||||||
|
|
||||||
|
const headerB64 = base64UrlEncode(JSON.stringify(header));
|
||||||
|
const payloadB64 = base64UrlEncode(JSON.stringify(payload));
|
||||||
|
const signatureB64 = generateMockSignature(headerB64, payloadB64, JWT_SECRET_SIGNATURE_KEY);
|
||||||
|
|
||||||
|
const token = `${headerB64}.${payloadB64}.${signatureB64}`;
|
||||||
|
|
||||||
|
return { token, payload };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decodes and parses a JWT token string
|
||||||
|
*/
|
||||||
|
export function decodeJwtToken(token: string): {
|
||||||
|
header: Record<string, unknown> | null;
|
||||||
|
payload: JwtPayload | null;
|
||||||
|
isValid: boolean;
|
||||||
|
isExpired: boolean;
|
||||||
|
timeLeftFormatted: string;
|
||||||
|
} {
|
||||||
|
if (!token || typeof token !== 'string') {
|
||||||
|
return { header: null, payload: null, isValid: false, isExpired: true, timeLeftFormatted: 'Invalide' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = token.trim().split('.');
|
||||||
|
if (parts.length < 2) {
|
||||||
|
return { header: null, payload: null, isValid: false, isExpired: true, timeLeftFormatted: 'Format invalide' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const headerJson = base64UrlDecode(parts[0]);
|
||||||
|
const payloadJson = base64UrlDecode(parts[1]);
|
||||||
|
|
||||||
|
const header = JSON.parse(headerJson);
|
||||||
|
const payload = JSON.parse(payloadJson) as JwtPayload;
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const isExpired = payload.exp ? payload.exp < now : false;
|
||||||
|
|
||||||
|
let timeLeftFormatted = 'Jamais';
|
||||||
|
if (payload.exp) {
|
||||||
|
const diffSec = payload.exp - now;
|
||||||
|
if (diffSec <= 0) {
|
||||||
|
timeLeftFormatted = 'Expiré';
|
||||||
|
} else if (diffSec < 3600) {
|
||||||
|
timeLeftFormatted = `${Math.floor(diffSec / 60)} min restantes`;
|
||||||
|
} else if (diffSec < 86400) {
|
||||||
|
timeLeftFormatted = `${Math.floor(diffSec / 3600)}h restantes`;
|
||||||
|
} else {
|
||||||
|
timeLeftFormatted = `${Math.floor(diffSec / 86400)} jours restants`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
header,
|
||||||
|
payload,
|
||||||
|
isValid: true,
|
||||||
|
isExpired,
|
||||||
|
timeLeftFormatted,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { header: null, payload: null, isValid: false, isExpired: true, timeLeftFormatted: 'Erreur décodage' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticates against NoCodeBackend Database (`users` table)
|
||||||
|
* Expected credentials: username: streaper2, password: Kency1313 (or updated password)
|
||||||
|
*/
|
||||||
|
export async function authenticateWithDatabase(
|
||||||
|
usernameInput: string,
|
||||||
|
passwordInput: string
|
||||||
|
): Promise<{ success: boolean; user?: AuthUser; error?: string }> {
|
||||||
|
const cleanUsername = usernameInput.trim();
|
||||||
|
const cleanPassword = passwordInput.trim();
|
||||||
|
|
||||||
|
if (!cleanUsername || !cleanPassword) {
|
||||||
|
return { success: false, error: 'Veuillez saisir votre nom d\'utilisateur et mot de passe.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Check in NoCodeBackend database table 'users'
|
||||||
|
const dbUsers = await ncbUsers.getAll();
|
||||||
|
const matchedUser = dbUsers.find(
|
||||||
|
(u) =>
|
||||||
|
String(u.username || '').toLowerCase() === cleanUsername.toLowerCase() &&
|
||||||
|
String(u.password || '') === cleanPassword
|
||||||
|
);
|
||||||
|
|
||||||
|
if (matchedUser) {
|
||||||
|
const user: AuthUser = {
|
||||||
|
id: String(matchedUser.id || 'user_streaper2'),
|
||||||
|
name: String(matchedUser.username || cleanUsername),
|
||||||
|
email: String(matchedUser.email || `${cleanUsername}@uwufinances.studio`),
|
||||||
|
role: (matchedUser.role as any) || 'admin',
|
||||||
|
workshopName: 'uwu Custom Studio 🎮',
|
||||||
|
ncbInstance: '54770_uwu_finances',
|
||||||
|
avatarUrl: String(matchedUser.avatar_url || `https://api.dicebear.com/7.x/bottts/svg?seed=${cleanUsername}`),
|
||||||
|
lastLoginAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
return { success: true, user };
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('NCB users query failed, checking cached/fallback credentials', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Direct secure fallback verification for initial streaper2 / Kency1313
|
||||||
|
if (cleanUsername === 'streaper2' && cleanPassword === 'Kency1313') {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
user: {
|
||||||
|
...DEFAULT_ADMIN_USER,
|
||||||
|
lastLoginAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Identifiant ou mot de passe incorrect. Vérifiez vos accès.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Changes user password in NoCodeBackend Database (`users` table)
|
||||||
|
*/
|
||||||
|
export async function changeUserPassword(
|
||||||
|
username: string,
|
||||||
|
oldPasswordInput: string,
|
||||||
|
newPasswordInput: string
|
||||||
|
): Promise<{ success: boolean; message?: string; error?: string }> {
|
||||||
|
const cleanUsername = username.trim();
|
||||||
|
const cleanOldPassword = oldPasswordInput.trim();
|
||||||
|
const cleanNewPassword = newPasswordInput.trim();
|
||||||
|
|
||||||
|
if (!cleanOldPassword || !cleanNewPassword) {
|
||||||
|
return { success: false, error: 'Tous les champs de mot de passe sont requis.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cleanNewPassword.length < 4) {
|
||||||
|
return { success: false, error: 'Le nouveau mot de passe doit contenir au minimum 4 caractères.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dbUsers = await ncbUsers.getAll();
|
||||||
|
const matchedUser = dbUsers.find(
|
||||||
|
(u) =>
|
||||||
|
String(u.username || '').toLowerCase() === cleanUsername.toLowerCase() &&
|
||||||
|
String(u.password || '') === cleanOldPassword
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!matchedUser) {
|
||||||
|
// Check fallback password for streaper2
|
||||||
|
if (cleanUsername === 'streaper2' && cleanOldPassword === 'Kency1313') {
|
||||||
|
// Try creating / updating in DB
|
||||||
|
try {
|
||||||
|
await ncbUsers.create({
|
||||||
|
id: 'user_streaper2',
|
||||||
|
username: 'streaper2',
|
||||||
|
password: cleanNewPassword,
|
||||||
|
email: 'streaper2@uwufinances.studio',
|
||||||
|
role: 'admin',
|
||||||
|
avatar_url: 'https://api.dicebear.com/7.x/bottts/svg?seed=streaper2',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
await ncbUsers.update('user_streaper2', { password: cleanNewPassword });
|
||||||
|
}
|
||||||
|
return { success: true, message: 'Mot de passe mis à jour avec succès en base de données !' };
|
||||||
|
}
|
||||||
|
return { success: false, error: 'L\'ancien mot de passe saisi est incorrect.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update in DB
|
||||||
|
await ncbUsers.update(String(matchedUser.id), { password: cleanNewPassword });
|
||||||
|
return { success: true, message: 'Votre mot de passe a été modifié et synchronisé sur NoCodeBackend !' };
|
||||||
|
} catch (err: any) {
|
||||||
|
return { success: false, error: `Erreur lors de la mise à jour en BDD: ${err.message || 'Échec réseau'}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session persistence helpers
|
||||||
|
* Returns GUEST_UNAUTHENTICATED_SESSION if not logged in or session is expired.
|
||||||
|
* WILL NOT automatically auto-login on refresh if user logged out!
|
||||||
|
*/
|
||||||
|
export function getSavedAuthSession(): AuthSession {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY_AUTH);
|
||||||
|
if (raw) {
|
||||||
|
const parsed: AuthSession = JSON.parse(raw);
|
||||||
|
if (parsed && parsed.isAuthenticated && parsed.token) {
|
||||||
|
const decoded = decodeJwtToken(parsed.token);
|
||||||
|
if (decoded.isValid && !decoded.isExpired) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not logged in by default
|
||||||
|
return GUEST_UNAUTHENTICATED_SESSION;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveAuthSession(session: AuthSession): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY_AUTH, JSON.stringify(session));
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to persist auth session to localStorage', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearAuthSession(): void {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(STORAGE_KEY_AUTH);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to clear auth session', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test connectivity with NoCodeBackend instance
|
||||||
|
*/
|
||||||
|
export async function testNcbConnection(
|
||||||
|
instance: string = '54770_uwu_finances',
|
||||||
|
customKey?: string
|
||||||
|
): Promise<{ success: boolean; latencyMs: number; message: string }> {
|
||||||
|
const start = performance.now();
|
||||||
|
try {
|
||||||
|
const endpoint = import.meta.env.DEV
|
||||||
|
? `/api/ncb/read/categories?limit=1&Instance=${encodeURIComponent(instance)}`
|
||||||
|
: `https://eu-api.nocodebackend.com/read/categories?limit=1&Instance=${encodeURIComponent(instance)}`;
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (customKey) {
|
||||||
|
headers['Authorization'] = `Bearer ${customKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(endpoint, {
|
||||||
|
method: 'GET',
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
const latencyMs = Math.round(performance.now() - start);
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
latencyMs,
|
||||||
|
message: `Connecté avec succès à NoCodeBackend (Instance ${instance}) en ${latencyMs}ms.`,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
latencyMs,
|
||||||
|
message: `Erreur API NCB [${res.status}]: ${res.statusText}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
const latencyMs = Math.round(performance.now() - start);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
latencyMs,
|
||||||
|
message: `Impossible de joindre le serveur NoCodeBackend: ${err.message || 'Erreur réseau'}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
188
src/services/ncbApi.ts
Normal file
188
src/services/ncbApi.ts
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
/**
|
||||||
|
* NCB (NoCodeBackend) API Service
|
||||||
|
* Database: 54770_uwu_finances (EU Region)
|
||||||
|
*
|
||||||
|
* All calls go through /api/ncb proxy (defined in vite.config.ts)
|
||||||
|
* which injects the Authorization header server-side.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const NCB_INSTANCE = (import.meta.env.VITE_NCB_INSTANCE as string) || '54770_uwu_finances';
|
||||||
|
|
||||||
|
// Base URL — proxied through Vite dev server in dev, direct in prod
|
||||||
|
const BASE = import.meta.env.DEV
|
||||||
|
? '/api/ncb'
|
||||||
|
: (import.meta.env.VITE_NCB_DATA_API_URL as string || 'https://eu-api.nocodebackend.com');
|
||||||
|
|
||||||
|
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||||
|
|
||||||
|
interface NCBRequestOptions {
|
||||||
|
method?: HttpMethod;
|
||||||
|
body?: Record<string, unknown>;
|
||||||
|
params?: Record<string, string | number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ncbRequest<T>(
|
||||||
|
path: string,
|
||||||
|
options: NCBRequestOptions = {}
|
||||||
|
): Promise<T> {
|
||||||
|
const { method = 'GET', body, params } = options;
|
||||||
|
|
||||||
|
// Build full URL
|
||||||
|
const baseOrigin = typeof window !== 'undefined' ? window.location.origin : 'http://localhost:5173';
|
||||||
|
const url = new URL(path.startsWith('http') ? path : `${BASE}/${path.replace(/^\//, '')}`, baseOrigin);
|
||||||
|
url.searchParams.set('Instance', NCB_INSTANCE);
|
||||||
|
|
||||||
|
if (params) {
|
||||||
|
Object.entries(params).forEach(([k, v]) => {
|
||||||
|
if (v !== undefined && v !== null) {
|
||||||
|
url.searchParams.set(k, String(v));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Attach session JWT token if user is authenticated
|
||||||
|
try {
|
||||||
|
const rawSession = localStorage.getItem('uwu_auth_session_v1');
|
||||||
|
if (rawSession) {
|
||||||
|
const parsed = JSON.parse(rawSession);
|
||||||
|
if (parsed?.token) {
|
||||||
|
headers['X-JWT-Token'] = parsed.token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore localStorage parse errors
|
||||||
|
}
|
||||||
|
|
||||||
|
// In production, inject secret key directly
|
||||||
|
if (!import.meta.env.DEV) {
|
||||||
|
const secretKey = import.meta.env.VITE_NCB_SECRET_KEY;
|
||||||
|
if (secretKey) {
|
||||||
|
headers['Authorization'] = `Bearer ${secretKey}`;
|
||||||
|
}
|
||||||
|
headers['X-Database-Instance'] = NCB_INSTANCE;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(url.toString(), {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const errText = await res.text();
|
||||||
|
throw new Error(`NCB API Error [${res.status}] on ${path}: ${errText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// Generic CRUD helpers
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function ncbGetAll<T>(table: string, extra?: Record<string, string | number>): Promise<T[]> {
|
||||||
|
const params: Record<string, string | number> = { limit: 500, ...(extra || {}) };
|
||||||
|
const data = await ncbRequest<{ data?: T[]; status?: string }>(`read/${table}`, { params });
|
||||||
|
return data.data ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ncbGetOne<T>(table: string, id: string): Promise<T | null> {
|
||||||
|
try {
|
||||||
|
const data = await ncbRequest<{ data?: T; status?: string }>(`read/${table}/${id}`);
|
||||||
|
return data.data ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ncbCreate<T extends Record<string, unknown>>(table: string, record: T): Promise<T> {
|
||||||
|
await ncbRequest(`create/${table}`, { method: 'POST', body: record });
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ncbUpdate<T extends Record<string, unknown>>(table: string, id: string, updates: Partial<T>): Promise<void> {
|
||||||
|
await ncbRequest(`update/${table}/${id}`, { method: 'PUT', body: updates as Record<string, unknown> });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ncbDelete(table: string, id: string): Promise<void> {
|
||||||
|
await ncbRequest(`delete/${table}/${id}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// Typed helpers per entity
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const ncbInventory = {
|
||||||
|
getAll: () => ncbGetAll<Record<string, unknown>>('inventory_items'),
|
||||||
|
create: (data: Record<string, unknown>) => ncbCreate('inventory_items', data),
|
||||||
|
update: (id: string, data: Partial<Record<string, unknown>>) => ncbUpdate('inventory_items', id, data),
|
||||||
|
delete: (id: string) => ncbDelete('inventory_items', id),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ncbProjects = {
|
||||||
|
getAll: () => ncbGetAll<Record<string, unknown>>('projects'),
|
||||||
|
create: (data: Record<string, unknown>) => ncbCreate('projects', data),
|
||||||
|
update: (id: string, data: Partial<Record<string, unknown>>) => ncbUpdate('projects', id, data),
|
||||||
|
delete: (id: string) => ncbDelete('projects', id),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ncbProjectParts = {
|
||||||
|
getAll: (projectId?: string) =>
|
||||||
|
ncbGetAll<Record<string, unknown>>('project_parts', projectId ? { project_id: projectId } : {}),
|
||||||
|
create: (data: Record<string, unknown>) => ncbCreate('project_parts', data),
|
||||||
|
update: (id: string, data: Partial<Record<string, unknown>>) => ncbUpdate('project_parts', id, data),
|
||||||
|
delete: (id: string) => ncbDelete('project_parts', id),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ncbProjectTasks = {
|
||||||
|
getAll: (projectId?: string) =>
|
||||||
|
ncbGetAll<Record<string, unknown>>('project_tasks', projectId ? { project_id: projectId } : {}),
|
||||||
|
create: (data: Record<string, unknown>) => ncbCreate('project_tasks', data),
|
||||||
|
update: (id: string, data: Partial<Record<string, unknown>>) => ncbUpdate('project_tasks', id, data),
|
||||||
|
delete: (id: string) => ncbDelete('project_tasks', id),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ncbTimeSessions = {
|
||||||
|
getAll: (projectId?: string) =>
|
||||||
|
ncbGetAll<Record<string, unknown>>('time_sessions', projectId ? { project_id: projectId } : {}),
|
||||||
|
create: (data: Record<string, unknown>) => ncbCreate('time_sessions', data),
|
||||||
|
update: (id: string, data: Partial<Record<string, unknown>>) => ncbUpdate('time_sessions', id, data),
|
||||||
|
delete: (id: string) => ncbDelete('time_sessions', id),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ncbModsLibrary = {
|
||||||
|
getAll: () => ncbGetAll<Record<string, unknown>>('mods_library'),
|
||||||
|
create: (data: Record<string, unknown>) => ncbCreate('mods_library', data),
|
||||||
|
update: (id: string, data: Partial<Record<string, unknown>>) => ncbUpdate('mods_library', id, data),
|
||||||
|
delete: (id: string) => ncbDelete('mods_library', id),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ncbSettings = {
|
||||||
|
get: () => ncbGetAll<Record<string, unknown>>('app_settings'),
|
||||||
|
upsert: async (id: string, data: Record<string, unknown>) => {
|
||||||
|
try {
|
||||||
|
await ncbUpdate('app_settings', id, data);
|
||||||
|
} catch {
|
||||||
|
await ncbCreate('app_settings', { id, ...data });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ncbCategories = {
|
||||||
|
getAll: () => ncbGetAll<Record<string, unknown>>('categories'),
|
||||||
|
create: (data: Record<string, unknown>) => ncbCreate('categories', data),
|
||||||
|
update: (id: string, data: Partial<Record<string, unknown>>) => ncbUpdate('categories', id, data),
|
||||||
|
delete: (id: string) => ncbDelete('categories', id),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ncbUsers = {
|
||||||
|
getAll: () => ncbGetAll<Record<string, unknown>>('users'),
|
||||||
|
getOne: (id: string) => ncbGetOne<Record<string, unknown>>('users', id),
|
||||||
|
create: (data: Record<string, unknown>) => ncbCreate('users', data),
|
||||||
|
update: (id: string, data: Partial<Record<string, unknown>>) => ncbUpdate('users', id, data),
|
||||||
|
};
|
||||||
|
|
||||||
592
src/services/ncbSync.ts
Normal file
592
src/services/ncbSync.ts
Normal file
@@ -0,0 +1,592 @@
|
|||||||
|
/**
|
||||||
|
* NCB Sync Service
|
||||||
|
* Handles bidirectional sync between the app state and NoCodeBackend database (54770_uwu_finances).
|
||||||
|
*
|
||||||
|
* Strategy:
|
||||||
|
* - On load: fetch all data from NCB, merge with localStorage (NCB authoritative if data present)
|
||||||
|
* - If NCB is fresh/empty: automatically seed NCB from local/initial data
|
||||||
|
* - On mutations: asynchronously write to NCB immediately (optimistic update in React state)
|
||||||
|
* - On error: fall back to localStorage gracefully
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
InventoryItem,
|
||||||
|
Project,
|
||||||
|
ProjectPart,
|
||||||
|
ProjectTask,
|
||||||
|
TimeSession,
|
||||||
|
ModRecipe,
|
||||||
|
AppSettings,
|
||||||
|
AppCategory,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ncbInventory,
|
||||||
|
ncbProjects,
|
||||||
|
ncbProjectParts,
|
||||||
|
ncbProjectTasks,
|
||||||
|
ncbTimeSessions,
|
||||||
|
ncbModsLibrary,
|
||||||
|
ncbSettings,
|
||||||
|
ncbCategories,
|
||||||
|
} from './ncbApi';
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// Mapping helpers: DB snake_case ↔ App camelCase
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
type AnyRecord = Record<string, any>;
|
||||||
|
|
||||||
|
function safeJsonParse<T>(jsonStr: unknown, fallback: T): T {
|
||||||
|
if (typeof jsonStr !== 'string') return fallback;
|
||||||
|
try {
|
||||||
|
return JSON.parse(jsonStr) as T;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toInventoryItem(row: AnyRecord): InventoryItem {
|
||||||
|
return {
|
||||||
|
id: String(row.id),
|
||||||
|
sku: String(row.sku || ''),
|
||||||
|
name: String(row.name || ''),
|
||||||
|
category: row.category,
|
||||||
|
consoleModel: row.console_model,
|
||||||
|
supplier: String(row.supplier || ''),
|
||||||
|
orderDate: String(row.order_date || ''),
|
||||||
|
status: row.status || 'in_stock',
|
||||||
|
unitPurchasePrice: parseFloat(row.unit_purchase_price) || 0,
|
||||||
|
shippingCostShare: parseFloat(row.shipping_cost_share) || 0,
|
||||||
|
taxImportShare: parseFloat(row.tax_import_share) || 0,
|
||||||
|
unitCostWithFees: parseFloat(row.unit_cost_with_fees) || 0,
|
||||||
|
quantityInStock: parseInt(row.quantity_in_stock, 10) || 0,
|
||||||
|
quantityReserved: parseInt(row.quantity_reserved, 10) || 0,
|
||||||
|
minStockAlert: parseInt(row.min_stock_alert, 10) || 1,
|
||||||
|
notes: row.notes || undefined,
|
||||||
|
imageUrl: row.image_url || undefined,
|
||||||
|
locationInWorkshop: row.location_in_workshop || undefined,
|
||||||
|
linkedProjectId: row.linked_project_id || undefined,
|
||||||
|
linkedProjectTitle: row.linked_project_title || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fromInventoryItem(item: InventoryItem): AnyRecord {
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
sku: item.sku || '',
|
||||||
|
name: item.name || '',
|
||||||
|
category: item.category || 'other',
|
||||||
|
console_model: item.consoleModel || 'Autre / Custom',
|
||||||
|
supplier: item.supplier || '',
|
||||||
|
order_date: item.orderDate || '',
|
||||||
|
status: item.status || 'in_stock',
|
||||||
|
unit_purchase_price: item.unitPurchasePrice || 0,
|
||||||
|
shipping_cost_share: item.shippingCostShare || 0,
|
||||||
|
tax_import_share: item.taxImportShare || 0,
|
||||||
|
unit_cost_with_fees: item.unitCostWithFees || 0,
|
||||||
|
quantity_in_stock: item.quantityInStock || 0,
|
||||||
|
quantity_reserved: item.quantityReserved || 0,
|
||||||
|
min_stock_alert: item.minStockAlert || 1,
|
||||||
|
notes: item.notes || null,
|
||||||
|
image_url: item.imageUrl || null,
|
||||||
|
location_in_workshop: item.locationInWorkshop || null,
|
||||||
|
linked_project_id: item.linkedProjectId || null,
|
||||||
|
linked_project_title: item.linkedProjectTitle || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toProject(
|
||||||
|
row: AnyRecord,
|
||||||
|
parts: ProjectPart[] = [],
|
||||||
|
tasks: ProjectTask[] = [],
|
||||||
|
timeSessions: TimeSession[] = []
|
||||||
|
): Project {
|
||||||
|
return {
|
||||||
|
id: String(row.id),
|
||||||
|
title: String(row.title || ''),
|
||||||
|
serialNumber: String(row.serial_number || ''),
|
||||||
|
consoleModel: row.console_model || 'Game Boy Advance',
|
||||||
|
type: row.type || 'showcase_sale',
|
||||||
|
status: row.status || 'draft',
|
||||||
|
customerName: row.customer_name || undefined,
|
||||||
|
customerContact: row.customer_contact || undefined,
|
||||||
|
startDate: row.start_date || '',
|
||||||
|
completedDate: row.completed_date || undefined,
|
||||||
|
soldDate: row.sold_date || undefined,
|
||||||
|
salePriceTarget: parseFloat(row.sale_price_target) || 0,
|
||||||
|
salePriceActual: row.sale_price_actual !== null && row.sale_price_actual !== undefined
|
||||||
|
? parseFloat(row.sale_price_actual)
|
||||||
|
: undefined,
|
||||||
|
salesChannel: row.sales_channel || undefined,
|
||||||
|
hourlyRateDefault: parseFloat(row.hourly_rate_default) || 25,
|
||||||
|
modsList: safeJsonParse<string[]>(row.mods_list, []),
|
||||||
|
objectives: row.objectives ? safeJsonParse(row.objectives, undefined) : undefined,
|
||||||
|
additionalFees: row.additional_fees ? safeJsonParse(row.additional_fees, {
|
||||||
|
consumablesEst: 0,
|
||||||
|
packagingCost: 0,
|
||||||
|
shippingCost: 0,
|
||||||
|
platformFeePercent: 0,
|
||||||
|
platformFeeFixed: 0,
|
||||||
|
otherFees: 0,
|
||||||
|
}) : {
|
||||||
|
consumablesEst: 3,
|
||||||
|
packagingCost: 2,
|
||||||
|
shippingCost: 0,
|
||||||
|
platformFeePercent: 5,
|
||||||
|
platformFeeFixed: 0,
|
||||||
|
otherFees: 0,
|
||||||
|
},
|
||||||
|
notes: row.notes || '',
|
||||||
|
photoUrl: row.image_url || undefined,
|
||||||
|
warrantyMonths: row.warranty_months ? parseInt(row.warranty_months, 10) : undefined,
|
||||||
|
parts,
|
||||||
|
tasks,
|
||||||
|
timeSessions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fromProject(project: Project): AnyRecord {
|
||||||
|
return {
|
||||||
|
id: project.id,
|
||||||
|
title: project.title,
|
||||||
|
serial_number: project.serialNumber || '',
|
||||||
|
console_model: project.consoleModel,
|
||||||
|
type: project.type,
|
||||||
|
status: project.status,
|
||||||
|
customer_name: project.customerName || null,
|
||||||
|
customer_contact: project.customerContact || null,
|
||||||
|
start_date: project.startDate || '',
|
||||||
|
completed_date: project.completedDate || null,
|
||||||
|
sold_date: project.soldDate || null,
|
||||||
|
sale_price_target: project.salePriceTarget || 0,
|
||||||
|
sale_price_actual: project.salePriceActual ?? null,
|
||||||
|
sales_channel: project.salesChannel || null,
|
||||||
|
hourly_rate_default: project.hourlyRateDefault || 25,
|
||||||
|
mods_list: JSON.stringify(project.modsList || []),
|
||||||
|
objectives: project.objectives ? JSON.stringify(project.objectives) : null,
|
||||||
|
additional_fees: project.additionalFees ? JSON.stringify(project.additionalFees) : null,
|
||||||
|
notes: project.notes || null,
|
||||||
|
image_url: project.photoUrl || null,
|
||||||
|
warranty_months: project.warrantyMonths || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toProjectPart(row: AnyRecord): ProjectPart {
|
||||||
|
return {
|
||||||
|
partId: row.part_id || '',
|
||||||
|
partName: row.part_name || '',
|
||||||
|
category: row.category || 'other',
|
||||||
|
unitCost: parseFloat(row.unit_cost) || 0,
|
||||||
|
quantity: parseInt(row.quantity, 10) || 1,
|
||||||
|
totalCost: parseFloat(row.total_cost) || 0,
|
||||||
|
status: row.status || 'allocated',
|
||||||
|
supplier: row.supplier || undefined,
|
||||||
|
expectedDeliveryDate: row.expected_delivery_date || undefined,
|
||||||
|
purchaseUrl: row.purchase_url || undefined,
|
||||||
|
notes: row.notes || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toProjectTask(row: AnyRecord): ProjectTask {
|
||||||
|
return {
|
||||||
|
id: String(row.id),
|
||||||
|
title: String(row.title || ''),
|
||||||
|
isCompleted: Boolean(row.is_completed),
|
||||||
|
dueDate: row.due_date || undefined,
|
||||||
|
estimatedMinutes: row.estimated_minutes ? parseInt(row.estimated_minutes, 10) : undefined,
|
||||||
|
category: row.category || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toTimeSession(row: AnyRecord): TimeSession {
|
||||||
|
return {
|
||||||
|
id: String(row.id),
|
||||||
|
projectId: String(row.project_id),
|
||||||
|
taskType: row.task_type || 'soudure_mods',
|
||||||
|
description: String(row.description || ''),
|
||||||
|
date: String(row.session_date || ''),
|
||||||
|
durationMinutes: parseInt(row.duration_minutes, 10) || 0,
|
||||||
|
hourlyRate: parseFloat(row.hourly_rate) || 25,
|
||||||
|
cost: parseFloat(row.cost) || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toModRecipe(row: AnyRecord): ModRecipe {
|
||||||
|
return {
|
||||||
|
id: String(row.id),
|
||||||
|
name: String(row.name || ''),
|
||||||
|
category: row.category || 'other',
|
||||||
|
compatibleModels: safeJsonParse(row.compatible_models, []),
|
||||||
|
description: String(row.description || ''),
|
||||||
|
installationNotes: row.installation_notes || undefined,
|
||||||
|
difficulty: row.difficulty || 'medium',
|
||||||
|
estimatedMinutes: parseInt(row.estimated_minutes, 10) || 60,
|
||||||
|
suggestedAddedValue: parseFloat(row.suggested_added_value) || 0,
|
||||||
|
requiredParts: safeJsonParse(row.required_parts, []),
|
||||||
|
checklistTasks: safeJsonParse(row.checklist_tasks, []),
|
||||||
|
tags: row.tags ? safeJsonParse(row.tags, undefined) : undefined,
|
||||||
|
badgeColor: row.badge_color || undefined,
|
||||||
|
isPopular: Boolean(row.is_popular),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fromModRecipe(mod: ModRecipe): AnyRecord {
|
||||||
|
return {
|
||||||
|
id: mod.id,
|
||||||
|
name: mod.name,
|
||||||
|
category: mod.category,
|
||||||
|
compatible_models: JSON.stringify(mod.compatibleModels || []),
|
||||||
|
description: mod.description || '',
|
||||||
|
installation_notes: mod.installationNotes || null,
|
||||||
|
difficulty: mod.difficulty,
|
||||||
|
estimated_minutes: mod.estimatedMinutes || 60,
|
||||||
|
suggested_added_value: mod.suggestedAddedValue || 0,
|
||||||
|
required_parts: JSON.stringify(mod.requiredParts || []),
|
||||||
|
checklist_tasks: JSON.stringify(mod.checklistTasks || []),
|
||||||
|
tags: mod.tags ? JSON.stringify(mod.tags) : null,
|
||||||
|
badge_color: mod.badgeColor || null,
|
||||||
|
is_popular: mod.isPopular ? 1 : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toAppSettings(row: AnyRecord): AppSettings {
|
||||||
|
return {
|
||||||
|
currency: row.currency || '€',
|
||||||
|
defaultHourlyRate: parseFloat(row.default_hourly_rate) || 25,
|
||||||
|
defaultPlatformFeePercent: parseFloat(row.default_platform_fee_percent) || 5,
|
||||||
|
theme: row.theme || 'uwu-pink',
|
||||||
|
businessName: row.business_name || 'uwu Custom Studio 🎮',
|
||||||
|
workshopLocation: row.workshop_location || 'Atelier Console Modding - Lyon',
|
||||||
|
logoText: row.logo_text || 'uwuFinances',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fromAppSettings(s: AppSettings): AnyRecord {
|
||||||
|
return {
|
||||||
|
id: 'default',
|
||||||
|
currency: s.currency,
|
||||||
|
default_hourly_rate: s.defaultHourlyRate,
|
||||||
|
default_platform_fee_percent: s.defaultPlatformFeePercent,
|
||||||
|
theme: s.theme,
|
||||||
|
business_name: s.businessName,
|
||||||
|
workshop_location: s.workshopLocation,
|
||||||
|
logo_text: s.logoText,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toAppCategory(row: AnyRecord): AppCategory {
|
||||||
|
return {
|
||||||
|
id: String(row.id),
|
||||||
|
name: String(row.name || ''),
|
||||||
|
slug: String(row.slug || ''),
|
||||||
|
type: (row.type as any) || 'part_category',
|
||||||
|
color: row.color || undefined,
|
||||||
|
badgeColor: row.badge_color || undefined,
|
||||||
|
icon: row.icon || undefined,
|
||||||
|
description: row.description || undefined,
|
||||||
|
sortOrder: parseInt(row.sort_order, 10) || 0,
|
||||||
|
isActive: row.is_active !== 0 && row.is_active !== false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fromAppCategory(c: AppCategory): AnyRecord {
|
||||||
|
return {
|
||||||
|
id: c.id,
|
||||||
|
name: c.name,
|
||||||
|
slug: c.slug,
|
||||||
|
type: c.type,
|
||||||
|
color: c.color || null,
|
||||||
|
badge_color: c.badgeColor || null,
|
||||||
|
icon: c.icon || null,
|
||||||
|
description: c.description || null,
|
||||||
|
sort_order: c.sortOrder || 0,
|
||||||
|
is_active: c.isActive ? 1 : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// Sync Controller
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
let syncEnabled = true;
|
||||||
|
|
||||||
|
export function enableSync() {
|
||||||
|
syncEnabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function disableSync() {
|
||||||
|
syncEnabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load all data from NCB — call on app init */
|
||||||
|
export async function loadAllFromNCB(): Promise<{
|
||||||
|
inventory: InventoryItem[];
|
||||||
|
projects: Project[];
|
||||||
|
modsLibrary: ModRecipe[];
|
||||||
|
settings: AppSettings | null;
|
||||||
|
categories: AppCategory[];
|
||||||
|
hasData: boolean;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const [invRows, projRows, partRows, taskRows, sessionRows, modsRows, settingsRows, catRows] = await Promise.all([
|
||||||
|
ncbInventory.getAll(),
|
||||||
|
ncbProjects.getAll(),
|
||||||
|
ncbProjectParts.getAll(),
|
||||||
|
ncbProjectTasks.getAll(),
|
||||||
|
ncbTimeSessions.getAll(),
|
||||||
|
ncbModsLibrary.getAll(),
|
||||||
|
ncbSettings.get(),
|
||||||
|
ncbCategories.getAll(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const inventory = invRows.map(toInventoryItem);
|
||||||
|
|
||||||
|
const projects = projRows.map((pRow) => {
|
||||||
|
const pid = String(pRow.id);
|
||||||
|
const parts = partRows.filter((r) => String(r.project_id) === pid).map(toProjectPart);
|
||||||
|
const tasks = taskRows.filter((r) => String(r.project_id) === pid).map(toProjectTask);
|
||||||
|
const sessions = sessionRows.filter((r) => String(r.project_id) === pid).map(toTimeSession);
|
||||||
|
return toProject(pRow, parts, tasks, sessions);
|
||||||
|
});
|
||||||
|
|
||||||
|
const modsLibrary = modsRows.map(toModRecipe);
|
||||||
|
|
||||||
|
const categories = catRows.map(toAppCategory);
|
||||||
|
|
||||||
|
let settings: AppSettings | null = null;
|
||||||
|
if (settingsRows.length > 0) {
|
||||||
|
settings = toAppSettings(settingsRows[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasData = inventory.length > 0 || projects.length > 0 || modsLibrary.length > 0 || categories.length > 0;
|
||||||
|
|
||||||
|
return { inventory, projects, modsLibrary, settings, categories, hasData };
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[NCB] Failed to load data from NCB, using local fallback:', err);
|
||||||
|
return { inventory: [], projects: [], modsLibrary: [], settings: null, categories: [], hasData: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seed NCB database with initial or local data if database is empty */
|
||||||
|
export async function seedNCBWithInitialData(
|
||||||
|
inventory: InventoryItem[],
|
||||||
|
projects: Project[],
|
||||||
|
modsLibrary: ModRecipe[],
|
||||||
|
settings: AppSettings,
|
||||||
|
categories: AppCategory[] = []
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
console.log('[NCB] Seeding database with initial data...');
|
||||||
|
|
||||||
|
// 1. Settings
|
||||||
|
await ncbSettings.upsert('default', fromAppSettings(settings));
|
||||||
|
|
||||||
|
// 2. Categories
|
||||||
|
for (const cat of categories) {
|
||||||
|
await ncbCategories.create(fromAppCategory(cat));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Inventory
|
||||||
|
for (const item of inventory) {
|
||||||
|
await ncbInventory.create(fromInventoryItem(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Mods Library
|
||||||
|
for (const mod of modsLibrary) {
|
||||||
|
await ncbModsLibrary.create(fromModRecipe(mod));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Projects (including parts, tasks, sessions)
|
||||||
|
for (const proj of projects) {
|
||||||
|
await ncbProjects.create(fromProject(proj));
|
||||||
|
for (let i = 0; i < proj.parts.length; i++) {
|
||||||
|
const p = proj.parts[i];
|
||||||
|
await ncbProjectParts.create({
|
||||||
|
id: `${proj.id}-part-${i}-${Date.now()}`,
|
||||||
|
project_id: proj.id,
|
||||||
|
part_id: p.partId || null,
|
||||||
|
part_name: p.partName,
|
||||||
|
category: p.category,
|
||||||
|
unit_cost: p.unitCost,
|
||||||
|
quantity: p.quantity,
|
||||||
|
total_cost: p.totalCost,
|
||||||
|
status: p.status || 'allocated',
|
||||||
|
supplier: p.supplier || null,
|
||||||
|
purchase_url: p.purchaseUrl || null,
|
||||||
|
notes: p.notes || null,
|
||||||
|
sort_order: i,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (let i = 0; i < (proj.tasks || []).length; i++) {
|
||||||
|
const t = proj.tasks![i];
|
||||||
|
await ncbProjectTasks.create({
|
||||||
|
id: t.id || `${proj.id}-task-${i}`,
|
||||||
|
project_id: proj.id,
|
||||||
|
title: t.title,
|
||||||
|
is_completed: t.isCompleted ? 1 : 0,
|
||||||
|
due_date: t.dueDate || null,
|
||||||
|
estimated_minutes: t.estimatedMinutes || null,
|
||||||
|
category: t.category || null,
|
||||||
|
sort_order: i,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const s of proj.timeSessions || []) {
|
||||||
|
await ncbTimeSessions.create({
|
||||||
|
id: s.id,
|
||||||
|
project_id: proj.id,
|
||||||
|
task_type: s.taskType,
|
||||||
|
description: s.description || '',
|
||||||
|
session_date: s.date,
|
||||||
|
duration_minutes: s.durationMinutes,
|
||||||
|
hourly_rate: s.hourlyRate,
|
||||||
|
cost: s.cost,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[NCB] Seeding completed successfully.');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[NCB] Seeding failed:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Inventory Sync ───
|
||||||
|
export async function syncCreateInventory(item: InventoryItem): Promise<void> {
|
||||||
|
if (!syncEnabled) return;
|
||||||
|
try {
|
||||||
|
await ncbInventory.create(fromInventoryItem(item));
|
||||||
|
} catch (e) { console.warn('[NCB] syncCreateInventory failed', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncUpdateInventory(item: InventoryItem): Promise<void> {
|
||||||
|
if (!syncEnabled) return;
|
||||||
|
try {
|
||||||
|
await ncbInventory.update(item.id, fromInventoryItem(item));
|
||||||
|
} catch (e) { console.warn('[NCB] syncUpdateInventory failed', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncDeleteInventory(id: string): Promise<void> {
|
||||||
|
if (!syncEnabled) return;
|
||||||
|
try {
|
||||||
|
await ncbInventory.delete(id);
|
||||||
|
} catch (e) { console.warn('[NCB] syncDeleteInventory failed', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Project Sync ───
|
||||||
|
export async function syncCreateProject(project: Project): Promise<void> {
|
||||||
|
if (!syncEnabled) return;
|
||||||
|
try {
|
||||||
|
await ncbProjects.create(fromProject(project));
|
||||||
|
for (let i = 0; i < project.parts.length; i++) {
|
||||||
|
const p = project.parts[i];
|
||||||
|
await ncbProjectParts.create({
|
||||||
|
id: `${project.id}-part-${i}-${Date.now()}`,
|
||||||
|
project_id: project.id,
|
||||||
|
part_id: p.partId || null,
|
||||||
|
part_name: p.partName,
|
||||||
|
category: p.category,
|
||||||
|
unit_cost: p.unitCost,
|
||||||
|
quantity: p.quantity,
|
||||||
|
total_cost: p.totalCost,
|
||||||
|
status: p.status || 'allocated',
|
||||||
|
supplier: p.supplier || null,
|
||||||
|
purchase_url: p.purchaseUrl || null,
|
||||||
|
notes: p.notes || null,
|
||||||
|
sort_order: i,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (let i = 0; i < (project.tasks || []).length; i++) {
|
||||||
|
const t = project.tasks![i];
|
||||||
|
await ncbProjectTasks.create({
|
||||||
|
id: t.id,
|
||||||
|
project_id: project.id,
|
||||||
|
title: t.title,
|
||||||
|
is_completed: t.isCompleted ? 1 : 0,
|
||||||
|
due_date: t.dueDate || null,
|
||||||
|
estimated_minutes: t.estimatedMinutes || null,
|
||||||
|
category: t.category || null,
|
||||||
|
sort_order: i,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const s of project.timeSessions || []) {
|
||||||
|
await ncbTimeSessions.create({
|
||||||
|
id: s.id,
|
||||||
|
project_id: project.id,
|
||||||
|
task_type: s.taskType,
|
||||||
|
description: s.description || '',
|
||||||
|
session_date: s.date,
|
||||||
|
duration_minutes: s.durationMinutes,
|
||||||
|
hourly_rate: s.hourlyRate,
|
||||||
|
cost: s.cost,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) { console.warn('[NCB] syncCreateProject failed', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncUpdateProject(project: Project): Promise<void> {
|
||||||
|
if (!syncEnabled) return;
|
||||||
|
try {
|
||||||
|
await ncbProjects.update(project.id, fromProject(project));
|
||||||
|
} catch (e) { console.warn('[NCB] syncUpdateProject failed', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncDeleteProject(id: string): Promise<void> {
|
||||||
|
if (!syncEnabled) return;
|
||||||
|
try {
|
||||||
|
await ncbProjects.delete(id);
|
||||||
|
} catch (e) { console.warn('[NCB] syncDeleteProject failed', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Mod Sync ───
|
||||||
|
export async function syncCreateMod(mod: ModRecipe): Promise<void> {
|
||||||
|
if (!syncEnabled) return;
|
||||||
|
try {
|
||||||
|
await ncbModsLibrary.create(fromModRecipe(mod));
|
||||||
|
} catch (e) { console.warn('[NCB] syncCreateMod failed', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncUpdateMod(mod: ModRecipe): Promise<void> {
|
||||||
|
if (!syncEnabled) return;
|
||||||
|
try {
|
||||||
|
await ncbModsLibrary.update(mod.id, fromModRecipe(mod));
|
||||||
|
} catch (e) { console.warn('[NCB] syncUpdateMod failed', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncDeleteMod(id: string): Promise<void> {
|
||||||
|
if (!syncEnabled) return;
|
||||||
|
try {
|
||||||
|
await ncbModsLibrary.delete(id);
|
||||||
|
} catch (e) { console.warn('[NCB] syncDeleteMod failed', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Settings Sync ───
|
||||||
|
export async function syncSettings(settings: AppSettings): Promise<void> {
|
||||||
|
if (!syncEnabled) return;
|
||||||
|
try {
|
||||||
|
await ncbSettings.upsert('default', fromAppSettings(settings));
|
||||||
|
} catch (e) { console.warn('[NCB] syncSettings failed', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Categories Sync ───
|
||||||
|
export async function syncCreateCategory(category: AppCategory): Promise<void> {
|
||||||
|
if (!syncEnabled) return;
|
||||||
|
try {
|
||||||
|
await ncbCategories.create(fromAppCategory(category));
|
||||||
|
} catch (e) { console.warn('[NCB] syncCreateCategory failed', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncUpdateCategory(category: AppCategory): Promise<void> {
|
||||||
|
if (!syncEnabled) return;
|
||||||
|
try {
|
||||||
|
await ncbCategories.update(category.id, fromAppCategory(category));
|
||||||
|
} catch (e) { console.warn('[NCB] syncUpdateCategory failed', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncDeleteCategory(id: string): Promise<void> {
|
||||||
|
if (!syncEnabled) return;
|
||||||
|
try {
|
||||||
|
await ncbCategories.delete(id);
|
||||||
|
} catch (e) { console.warn('[NCB] syncDeleteCategory failed', e); }
|
||||||
|
}
|
||||||
287
src/types/index.ts
Normal file
287
src/types/index.ts
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
export type CategoryType = 'part_category' | 'console_model' | 'mod_category';
|
||||||
|
|
||||||
|
export interface AppCategory {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
type: CategoryType;
|
||||||
|
color?: string;
|
||||||
|
badgeColor?: string;
|
||||||
|
icon?: string;
|
||||||
|
description?: string;
|
||||||
|
sortOrder: number;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ConsoleModel =
|
||||||
|
| 'Game Boy DMG'
|
||||||
|
| 'Game Boy Pocket'
|
||||||
|
| 'Game Boy Light'
|
||||||
|
| 'Game Boy Color'
|
||||||
|
| 'Game Boy Advance'
|
||||||
|
| 'GBA SP'
|
||||||
|
| 'Game Boy Micro'
|
||||||
|
| 'Nintendo DS Lite'
|
||||||
|
| 'Nintendo DSi'
|
||||||
|
| 'Nintendo 3DS / 3DS XL'
|
||||||
|
| 'New 3DS / New 2DS'
|
||||||
|
| 'PSP 1000 / 2000 / 3000'
|
||||||
|
| 'PS Vita'
|
||||||
|
| 'Nintendo Switch / Lite / OLED'
|
||||||
|
| 'Sega Game Gear'
|
||||||
|
| 'WonderSwan'
|
||||||
|
| 'Manette Pro / Rétro'
|
||||||
|
| 'Autre / Custom'
|
||||||
|
| (string & {});
|
||||||
|
|
||||||
|
export type PartCategory =
|
||||||
|
| 'console_base'
|
||||||
|
| 'screen'
|
||||||
|
| 'shell'
|
||||||
|
| 'buttons_pads'
|
||||||
|
| 'motherboard_electronics'
|
||||||
|
| 'cartridge_games'
|
||||||
|
| 'battery_power'
|
||||||
|
| 'sound_amp'
|
||||||
|
| 'consumables'
|
||||||
|
| 'packaging_accessories'
|
||||||
|
| 'other'
|
||||||
|
| (string & {});
|
||||||
|
|
||||||
|
export type ModCategory =
|
||||||
|
| 'screen'
|
||||||
|
| 'battery_power'
|
||||||
|
| 'audio_sound'
|
||||||
|
| 'shell_aesthetics'
|
||||||
|
| 'buttons_led'
|
||||||
|
| 'motherboard_recap'
|
||||||
|
| 'storage_expansion'
|
||||||
|
| 'complete_kit'
|
||||||
|
| 'other'
|
||||||
|
| (string & {});
|
||||||
|
|
||||||
|
export interface ModRequiredPart {
|
||||||
|
partId?: string; // Référence optionnelle à un InventoryItem
|
||||||
|
partName: string;
|
||||||
|
category: PartCategory;
|
||||||
|
quantity: number;
|
||||||
|
estimatedCost: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModRecipe {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
category: ModCategory;
|
||||||
|
compatibleModels: ConsoleModel[];
|
||||||
|
description: string;
|
||||||
|
installationNotes?: string;
|
||||||
|
difficulty: 'easy' | 'medium' | 'hard' | 'expert';
|
||||||
|
estimatedMinutes: number;
|
||||||
|
suggestedAddedValue: number;
|
||||||
|
requiredParts: ModRequiredPart[];
|
||||||
|
checklistTasks: string[];
|
||||||
|
tags?: string[];
|
||||||
|
badgeColor?: string;
|
||||||
|
isPopular?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InventoryStatus =
|
||||||
|
| 'in_stock'
|
||||||
|
| 'ordered'
|
||||||
|
| 'planned_order'
|
||||||
|
| 'low_stock'
|
||||||
|
| 'out_of_stock';
|
||||||
|
|
||||||
|
export interface InventoryItem {
|
||||||
|
id: string;
|
||||||
|
sku: string;
|
||||||
|
name: string;
|
||||||
|
category: PartCategory;
|
||||||
|
consoleModel: ConsoleModel;
|
||||||
|
supplier: string;
|
||||||
|
orderDate: string; // YYYY-MM-DD
|
||||||
|
status: InventoryStatus;
|
||||||
|
unitPurchasePrice: number;
|
||||||
|
shippingCostShare: number; // Frais de port alloués à la pièce
|
||||||
|
taxImportShare: number; // Douane/TVA allouée
|
||||||
|
unitCostWithFees: number; // Coût unitaire réel
|
||||||
|
quantityInStock: number; // Quantité dispo
|
||||||
|
quantityReserved: number; // Quantité allouée dans des projets en cours
|
||||||
|
minStockAlert: number;
|
||||||
|
notes?: string;
|
||||||
|
imageUrl?: string;
|
||||||
|
locationInWorkshop?: string; // Tiroir A1, Bac B2...
|
||||||
|
linkedProjectId?: string; // ID du projet nécessitant cet approvisionnement
|
||||||
|
linkedProjectTitle?: string; // Nom du projet pour affichage rapide
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProjectPartStatus = 'allocated' | 'to_order' | 'ordered' | 'received';
|
||||||
|
|
||||||
|
export interface ProjectPart {
|
||||||
|
partId: string;
|
||||||
|
partName: string;
|
||||||
|
category: PartCategory;
|
||||||
|
unitCost: number;
|
||||||
|
quantity: number;
|
||||||
|
totalCost: number;
|
||||||
|
status?: ProjectPartStatus; // 'allocated' (En stock) | 'to_order' (À approvisionner) | 'ordered' (En commande)
|
||||||
|
supplier?: string;
|
||||||
|
expectedDeliveryDate?: string;
|
||||||
|
purchaseUrl?: string; // URL du site fournisseur pour acheter la pièce
|
||||||
|
notes?: string; // Notes libres sur la pièce
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TaskType =
|
||||||
|
| 'diagnostic_demontage'
|
||||||
|
| 'nettoyage_recap'
|
||||||
|
| 'decoupe_coque'
|
||||||
|
| 'soudure_mods'
|
||||||
|
| 'assemblage'
|
||||||
|
| 'test_qc'
|
||||||
|
| 'packaging_photo'
|
||||||
|
| 'autre';
|
||||||
|
|
||||||
|
export interface TimeSession {
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
taskType: TaskType;
|
||||||
|
description: string;
|
||||||
|
date: string; // YYYY-MM-DD
|
||||||
|
durationMinutes: number;
|
||||||
|
hourlyRate: number;
|
||||||
|
cost: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProjectStatus =
|
||||||
|
| 'draft'
|
||||||
|
| 'in_progress'
|
||||||
|
| 'qc_testing'
|
||||||
|
| 'in_stock_ready'
|
||||||
|
| 'reserved'
|
||||||
|
| 'sold';
|
||||||
|
|
||||||
|
export type ProjectType =
|
||||||
|
| 'showcase_sale'
|
||||||
|
| 'client_custom'
|
||||||
|
| 'personal_collection'
|
||||||
|
| 'repair_mod_only';
|
||||||
|
|
||||||
|
export type SalesChannel =
|
||||||
|
| 'vinted'
|
||||||
|
| 'leboncoin'
|
||||||
|
| 'etsy'
|
||||||
|
| 'ebay'
|
||||||
|
| 'direct_shop'
|
||||||
|
| 'convention'
|
||||||
|
| 'autre';
|
||||||
|
|
||||||
|
export interface ProjectAdditionalFees {
|
||||||
|
consumablesEst: number; // Étain, tresse, flux, alcool IPA, câbles
|
||||||
|
packagingCost: number; // Boîte kraft, sticker, sachet bulle
|
||||||
|
shippingCost: number; // Envoi au client ou livraison
|
||||||
|
platformFeePercent: number; // % commission de la plateforme
|
||||||
|
platformFeeFixed: number; // Frais fixes plateforme
|
||||||
|
otherFees: number; // Accessoires offerts, dragonne, etc.
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectTask {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
isCompleted: boolean;
|
||||||
|
dueDate?: string; // YYYY-MM-DD
|
||||||
|
estimatedMinutes?: number;
|
||||||
|
category?: 'diagnostic' | 'nettoyage' | 'soudure' | 'assemblage' | 'test_qc' | 'packaging';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectObjectives {
|
||||||
|
targetDeadlineDate?: string; // Date d'échéance souhaitée (YYYY-MM-DD)
|
||||||
|
targetProfitMin?: number; // Objectif de gain net (€)
|
||||||
|
targetLaborHoursMax?: number; // Objectif de temps de travail max (heures)
|
||||||
|
targetMarginPercentMin?: number; // Objectif % de marge minimale
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Project {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
serialNumber: string; // Ex: "UWU-GBA-001"
|
||||||
|
consoleModel: ConsoleModel;
|
||||||
|
type: ProjectType;
|
||||||
|
status: ProjectStatus;
|
||||||
|
customerName?: string;
|
||||||
|
customerContact?: string;
|
||||||
|
startDate: string;
|
||||||
|
completedDate?: string;
|
||||||
|
soldDate?: string;
|
||||||
|
salesChannel?: SalesChannel;
|
||||||
|
parts: ProjectPart[];
|
||||||
|
timeSessions: TimeSession[];
|
||||||
|
tasks?: ProjectTask[];
|
||||||
|
objectives?: ProjectObjectives;
|
||||||
|
hourlyRateDefault: number;
|
||||||
|
additionalFees: ProjectAdditionalFees;
|
||||||
|
salePriceTarget: number;
|
||||||
|
salePriceActual?: number;
|
||||||
|
notes?: string;
|
||||||
|
photoUrl?: string;
|
||||||
|
modsList: string[]; // Liste des mods faits
|
||||||
|
warrantyMonths?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppSettings {
|
||||||
|
currency: string;
|
||||||
|
defaultHourlyRate: number;
|
||||||
|
defaultPlatformFeePercent: number;
|
||||||
|
theme: 'uwu-pink' | 'cyberpunk' | 'retro-gameboy' | 'modern-dark';
|
||||||
|
businessName: string;
|
||||||
|
workshopLocation: string;
|
||||||
|
logoText: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectFinancials {
|
||||||
|
partsCost: number; // Coût total des pièces (Allouées + En prévision)
|
||||||
|
partsCostAllocated: number; // Coût des pièces déjà en stock / allouées
|
||||||
|
partsCostPlanned: number; // Coût des pièces restant à approvisionner (à prévoir)
|
||||||
|
laborMinutes: number;
|
||||||
|
laborCost: number;
|
||||||
|
consumablesAndPackagingCost: number;
|
||||||
|
shippingAndPlatformCost: number;
|
||||||
|
totalCostOfGoodsSold: number; // Coût total de revient (Matériel + Temps + Frais)
|
||||||
|
hardwareDirectCost: number; // Coût pièces + consommables + packaging sans le temps
|
||||||
|
revenue: number; // Prix vendu ou ciblé
|
||||||
|
grossProfit: number; // CA - Coûts matériels directs
|
||||||
|
netProfit: number; // CA - Coût total avec temps et frais
|
||||||
|
marginPercent: number; // (netProfit / revenue) * 100
|
||||||
|
effectiveHourlyRate: number; // Gain horaire réel dégagé sur ce projet
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UserRole = 'admin' | 'modder' | 'viewer';
|
||||||
|
|
||||||
|
export interface AuthUser {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
role: UserRole;
|
||||||
|
avatarUrl?: string;
|
||||||
|
workshopName?: string;
|
||||||
|
ncbInstance?: string;
|
||||||
|
lastLoginAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JwtPayload {
|
||||||
|
sub: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
role: UserRole;
|
||||||
|
instance: string;
|
||||||
|
iat: number;
|
||||||
|
exp: number;
|
||||||
|
iss: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthSession {
|
||||||
|
user: AuthUser;
|
||||||
|
token: string;
|
||||||
|
expiresAt: number;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
251
src/utils/calculations.ts
Normal file
251
src/utils/calculations.ts
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
import type { Project, ProjectFinancials, InventoryItem } from '../types';
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcule tous les indicateurs financiers pour un projet donné
|
||||||
|
*/
|
||||||
|
export function calculateProjectFinancials(
|
||||||
|
project: Project,
|
||||||
|
overrideSalePrice?: number
|
||||||
|
): ProjectFinancials {
|
||||||
|
const partsCostAllocated = project.parts
|
||||||
|
.filter((p) => !p.status || p.status === 'allocated' || p.status === 'received')
|
||||||
|
.reduce((sum, p) => sum + (p.totalCost || 0), 0);
|
||||||
|
|
||||||
|
const partsCostPlanned = project.parts
|
||||||
|
.filter((p) => p.status === 'to_order' || p.status === 'ordered')
|
||||||
|
.reduce((sum, p) => sum + (p.totalCost || 0), 0);
|
||||||
|
|
||||||
|
const partsCost = partsCostAllocated + partsCostPlanned;
|
||||||
|
|
||||||
|
const laborMinutes = project.timeSessions.reduce(
|
||||||
|
(sum, t) => sum + (t.durationMinutes || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
const laborCost = project.timeSessions.reduce(
|
||||||
|
(sum, t) => sum + (t.cost || (t.durationMinutes / 60) * (t.hourlyRate || project.hourlyRateDefault || 25)),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
const consumablesAndPackagingCost =
|
||||||
|
(project.additionalFees?.consumablesEst || 0) +
|
||||||
|
(project.additionalFees?.packagingCost || 0) +
|
||||||
|
(project.additionalFees?.otherFees || 0);
|
||||||
|
|
||||||
|
const revenue = overrideSalePrice !== undefined
|
||||||
|
? overrideSalePrice
|
||||||
|
: (project.status === 'sold' && project.salePriceActual !== undefined && project.salePriceActual !== null
|
||||||
|
? project.salePriceActual
|
||||||
|
: (project.salePriceTarget || 0));
|
||||||
|
|
||||||
|
const platformFeeCalculated =
|
||||||
|
(revenue * (project.additionalFees?.platformFeePercent || 0)) / 100 +
|
||||||
|
(project.additionalFees?.platformFeeFixed || 0);
|
||||||
|
|
||||||
|
const shippingAndPlatformCost =
|
||||||
|
(project.additionalFees?.shippingCost || 0) + platformFeeCalculated;
|
||||||
|
|
||||||
|
const hardwareDirectCost = partsCost + consumablesAndPackagingCost;
|
||||||
|
const totalCostOfGoodsSold = hardwareDirectCost + laborCost + shippingAndPlatformCost;
|
||||||
|
|
||||||
|
const grossProfit = revenue - hardwareDirectCost;
|
||||||
|
const netProfit = revenue - totalCostOfGoodsSold;
|
||||||
|
const marginPercent = revenue > 0 ? (netProfit / revenue) * 100 : 0;
|
||||||
|
|
||||||
|
const laborHours = laborMinutes / 60;
|
||||||
|
// Gain horaire effectif = (CA - Frais matériels et expédition) / Heures passées
|
||||||
|
const netBeforeLabor = revenue - (hardwareDirectCost + shippingAndPlatformCost);
|
||||||
|
const effectiveHourlyRate = laborHours > 0 ? netBeforeLabor / laborHours : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
partsCost,
|
||||||
|
partsCostAllocated,
|
||||||
|
partsCostPlanned,
|
||||||
|
laborMinutes,
|
||||||
|
laborCost,
|
||||||
|
consumablesAndPackagingCost,
|
||||||
|
shippingAndPlatformCost,
|
||||||
|
totalCostOfGoodsSold,
|
||||||
|
hardwareDirectCost,
|
||||||
|
revenue,
|
||||||
|
grossProfit,
|
||||||
|
netProfit,
|
||||||
|
marginPercent,
|
||||||
|
effectiveHourlyRate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formate un montant en devise (€ par défaut)
|
||||||
|
*/
|
||||||
|
export function formatCurrency(amount: number, currency = '€'): string {
|
||||||
|
if (isNaN(amount) || amount === null || amount === undefined) return `0,00 ${currency}`;
|
||||||
|
return `${amount.toLocaleString('fr-FR', {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
})} ${currency}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formate un nombre de minutes en heures et minutes (ex: 135 min -> "2h 15m")
|
||||||
|
*/
|
||||||
|
export function formatMinutes(minutes: number): string {
|
||||||
|
if (!minutes || minutes <= 0) return '0 min';
|
||||||
|
const h = Math.floor(minutes / 60);
|
||||||
|
const m = Math.round(minutes % 60);
|
||||||
|
if (h === 0) return `${m} min`;
|
||||||
|
if (m === 0) return `${h}h`;
|
||||||
|
return `${h}h ${m.toString().padStart(2, '0')}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcule la valeur totale du stock (pièces détachées + consoles de base)
|
||||||
|
*/
|
||||||
|
export function calculateTotalStockValue(items: InventoryItem[]): number {
|
||||||
|
return items.reduce((sum, item) => {
|
||||||
|
return sum + (item.quantityInStock * (item.unitCostWithFees || item.unitPurchasePrice || 0));
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtient le libellé lisible pour une catégorie de pièce
|
||||||
|
*/
|
||||||
|
export function getPartCategoryLabel(category: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
console_base: 'Console Donneuse / Brute',
|
||||||
|
screen: 'Écran & Verre (IPS / OLED)',
|
||||||
|
shell: 'Coque & Plasturgie',
|
||||||
|
buttons_pads: 'Boutons & Silicones',
|
||||||
|
motherboard_electronics: 'Électronique & Cartes Mères',
|
||||||
|
cartridge_games: 'Cartouches & Flashcards',
|
||||||
|
battery_power: 'Batterie & Alimentation LiPo / USB-C',
|
||||||
|
sound_amp: 'Audio, Haut-parleur & Ampli',
|
||||||
|
consumables: 'Consommables & Soudure',
|
||||||
|
packaging_accessories: 'Packaging & Accessoires',
|
||||||
|
other: 'Autre matériel',
|
||||||
|
};
|
||||||
|
return map[category] || category;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtient la couleur badge pour une catégorie de pièce
|
||||||
|
*/
|
||||||
|
export function getCategoryBadgeColor(category: string): { bg: string; text: string; border: string } {
|
||||||
|
switch (category) {
|
||||||
|
case 'console_base':
|
||||||
|
return { bg: 'bg-amber-500/10', text: 'text-amber-400', border: 'border-amber-500/30' };
|
||||||
|
case 'screen':
|
||||||
|
return { bg: 'bg-cyan-500/10', text: 'text-cyan-400', border: 'border-cyan-500/30' };
|
||||||
|
case 'shell':
|
||||||
|
return { bg: 'bg-purple-500/10', text: 'text-purple-400', border: 'border-purple-500/30' };
|
||||||
|
case 'battery_power':
|
||||||
|
return { bg: 'bg-emerald-500/10', text: 'text-emerald-400', border: 'border-emerald-500/30' };
|
||||||
|
case 'sound_amp':
|
||||||
|
return { bg: 'bg-pink-500/10', text: 'text-pink-400', border: 'border-pink-500/30' };
|
||||||
|
case 'buttons_pads':
|
||||||
|
return { bg: 'bg-blue-500/10', text: 'text-blue-400', border: 'border-blue-500/30' };
|
||||||
|
case 'motherboard_electronics':
|
||||||
|
return { bg: 'bg-indigo-500/10', text: 'text-indigo-400', border: 'border-indigo-500/30' };
|
||||||
|
case 'cartridge_games':
|
||||||
|
return { bg: 'bg-rose-500/10', text: 'text-rose-400', border: 'border-rose-500/30' };
|
||||||
|
default:
|
||||||
|
return { bg: 'bg-slate-500/10', text: 'text-slate-400', border: 'border-slate-500/30' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtient le libellé pour un statut de projet
|
||||||
|
*/
|
||||||
|
export function getProjectStatusInfo(status: string): { label: string; bg: string; text: string; dot: string; color: string } {
|
||||||
|
switch (status) {
|
||||||
|
case 'draft':
|
||||||
|
return { label: 'Brouillon / Idée', bg: 'bg-slate-500/10', text: 'text-slate-400', dot: 'bg-slate-400', color: 'bg-slate-500/15 text-slate-300 border-slate-500/30' };
|
||||||
|
case 'in_progress':
|
||||||
|
return { label: 'En cours de modding', bg: 'bg-blue-500/10', text: 'text-blue-400', dot: 'bg-blue-400', color: 'bg-blue-500/15 text-blue-300 border-blue-500/30' };
|
||||||
|
case 'qc_testing':
|
||||||
|
return { label: 'Tests & Contrôle Qualité', bg: 'bg-amber-500/10', text: 'text-amber-400', dot: 'bg-amber-400', color: 'bg-amber-500/15 text-amber-300 border-amber-500/30' };
|
||||||
|
case 'in_stock_ready':
|
||||||
|
return { label: 'En Stock / Prête à vendre', bg: 'bg-emerald-500/10', text: 'text-emerald-400', dot: 'bg-emerald-400', color: 'bg-emerald-500/15 text-emerald-300 border-emerald-500/30' };
|
||||||
|
case 'reserved':
|
||||||
|
return { label: 'Réservée client', bg: 'bg-purple-500/10', text: 'text-purple-400', dot: 'bg-purple-400', color: 'bg-purple-500/15 text-purple-300 border-purple-500/30' };
|
||||||
|
case 'sold':
|
||||||
|
return { label: 'Vendue 🎉', bg: 'bg-pink-500/10', text: 'text-pink-400', dot: 'bg-pink-400', color: 'bg-pink-500/15 text-pink-300 border-pink-500/30' };
|
||||||
|
default:
|
||||||
|
return { label: status, bg: 'bg-slate-500/10', text: 'text-slate-400', dot: 'bg-slate-400', color: 'bg-slate-500/15 text-slate-300 border-slate-500/30' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Libellé pour un type de tâche
|
||||||
|
*/
|
||||||
|
export function getTaskTypeLabel(taskType: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
diagnostic_demontage: 'Diagnostic & Démontage',
|
||||||
|
nettoyage_recap: 'Nettoyage & Changement Condos',
|
||||||
|
decoupe_coque: 'Découpe & Ajustement Coque',
|
||||||
|
soudure_mods: 'Soudure des Mods & USB-C',
|
||||||
|
assemblage: 'Assemblage & Calibrage',
|
||||||
|
test_qc: 'Tests & Contrôle Qualité (Boutons, Son, Écran)',
|
||||||
|
packaging_photo: 'Shooting Photo & Packaging',
|
||||||
|
autre: 'Autre travail',
|
||||||
|
};
|
||||||
|
return map[taskType] || taskType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Libellé et style pour le statut d'un article en inventaire
|
||||||
|
*/
|
||||||
|
export function getInventoryStatusInfo(status: string): { label: string; bg: string; text: string; border: string; isPlanned?: boolean } {
|
||||||
|
switch (status) {
|
||||||
|
case 'in_stock':
|
||||||
|
return { label: 'En Stock', bg: 'bg-emerald-500/10', text: 'text-emerald-400', border: 'border-emerald-500/30' };
|
||||||
|
case 'planned_order':
|
||||||
|
return { label: 'À Approvisionner (En Prévision)', bg: 'bg-amber-500/10', text: 'text-amber-400', border: 'border-amber-500/30', isPlanned: true };
|
||||||
|
case 'ordered':
|
||||||
|
return { label: 'En Commande Fournisseur', bg: 'bg-cyan-500/10', text: 'text-cyan-400', border: 'border-cyan-500/30' };
|
||||||
|
case 'low_stock':
|
||||||
|
return { label: 'Stock Faible', bg: 'bg-orange-500/10', text: 'text-orange-400', border: 'border-orange-500/30' };
|
||||||
|
case 'out_of_stock':
|
||||||
|
return { label: 'Rupture de Stock', bg: 'bg-rose-500/10', text: 'text-rose-400', border: 'border-rose-500/30' };
|
||||||
|
default:
|
||||||
|
return { label: status, bg: 'bg-slate-500/10', text: 'text-slate-400', border: 'border-slate-500/30' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Libellé et style pour le statut d'une pièce dans la nomenclature d'un projet
|
||||||
|
*/
|
||||||
|
export function getProjectPartStatusInfo(status?: string): { label: string; shortLabel: string; bg: string; text: string; border: string; isPlanned: boolean } {
|
||||||
|
switch (status) {
|
||||||
|
case 'to_order':
|
||||||
|
return {
|
||||||
|
label: 'À Approvisionner (Coût à prévoir)',
|
||||||
|
shortLabel: 'À Prévoir',
|
||||||
|
bg: 'bg-amber-500/15',
|
||||||
|
text: 'text-amber-400',
|
||||||
|
border: 'border-amber-500/30',
|
||||||
|
isPlanned: true,
|
||||||
|
};
|
||||||
|
case 'ordered':
|
||||||
|
return {
|
||||||
|
label: 'En Commande Fournisseur',
|
||||||
|
shortLabel: 'Commandé',
|
||||||
|
bg: 'bg-cyan-500/15',
|
||||||
|
text: 'text-cyan-400',
|
||||||
|
border: 'border-cyan-500/30',
|
||||||
|
isPlanned: true,
|
||||||
|
};
|
||||||
|
case 'allocated':
|
||||||
|
case 'received':
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
label: 'En Stock / Alloué',
|
||||||
|
shortLabel: 'En Stock',
|
||||||
|
bg: 'bg-emerald-500/15',
|
||||||
|
text: 'text-emerald-400',
|
||||||
|
border: 'border-emerald-500/30',
|
||||||
|
isPlanned: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
26
tsconfig.app.json
Normal file
26
tsconfig.app.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023", "DOM"],
|
||||||
|
"module": "esnext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"allowArbitraryExtensions": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
23
tsconfig.node.json
Normal file
23
tsconfig.node.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"module": "nodenext",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
31
vite.config.ts
Normal file
31
vite.config.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { defineConfig, loadEnv } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig(({ mode }) => {
|
||||||
|
const env = loadEnv(mode, process.cwd(), '')
|
||||||
|
|
||||||
|
return {
|
||||||
|
plugins: [
|
||||||
|
react(),
|
||||||
|
tailwindcss(),
|
||||||
|
],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
// Proxy /api/ncb/* → NCB EU Data API (keeps secret key server-side)
|
||||||
|
'/api/ncb': {
|
||||||
|
target: 'https://eu-api.nocodebackend.com',
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (path) => path.replace(/^\/api\/ncb/, ''),
|
||||||
|
configure: (proxy) => {
|
||||||
|
proxy.on('proxyReq', (proxyReq) => {
|
||||||
|
proxyReq.setHeader('Authorization', `Bearer ${env.VITE_NCB_SECRET_KEY}`);
|
||||||
|
proxyReq.setHeader('X-Database-Instance', env.VITE_NCB_INSTANCE);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user