monregistraire/static/index.html
Daniel Allaire be7a64a6cc Lancement v1.0 — Gestionnaire d'immobilisations fiscales (Chezlepro Inc.)
Application web locale (FastAPI + SQLite + React SPA) pour la gestion des
immobilisations fiscales d'une PME québécoise (T2 / CO-17).

─── Modèle de données ───────────────────────────────────────────────────────
• Deux niveaux : PARENT (immobilisation fiscale) + PIÈCE (inventaire physique)
• Trois vues par actif : Propriétaire, CPA, Auditeur
• Porteur d'étiquette d'assemblage (unique par immobilisation)
• Piste d'audit journalisée sur chaque modification

─── Mécanique fiscale ───────────────────────────────────────────────────────
• Pool DPA par catégorie (Cat. 8, 10, 12, 46, 50) — amortissement dégressif
• Règle de la demi-année paramétrable (coeff. 0..1 par exercice)
• Perte finale et récupération d'amortissement calculées automatiquement
• Annexe 8 / Schedule 8 multi-catégories (JSON + CSV téléchargeable)
• Rapport fiscal HTML imprimable — lignes Schedule 1 (T2 : 104/107/410/414 ;
  CO-17 : 130/135/150/155)
• Export immobilisations par catégorie (JSON + CSV)

─── Fonctionnalités ─────────────────────────────────────────────────────────
• CRUD complet : parents et pièces, rattachement/détachement (libre/stock/rebut)
• Écriture comptable générée au détachement
• Registre de pool DPA multi-exercices avec override manuel
• Sauvegarde/restauration (SQLite native backup, horodatée)
• Factures jointes aux pièces (PDF/JPG/PNG, max 20 Mo, UUID + regex)
• UI mobile-first : bottom nav, FAB, bottom sheet, safe-area iPhone
• Guide utilisateur complet in-app (aide.html, 11 sections)
• Données de démonstration riches (6 immos, 5 catégories, perte finale +
  récupération incluses)

─── Qualité ─────────────────────────────────────────────────────────────────
• 50 tests pytest — 100 % verts (calculs DPA, export, backup, factures, seed)
• Lancement en 1 commande : ./lancer.sh (Mac/Linux) ou lancer.bat (Windows)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 10:30:53 -04:00

1256 lines
73 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Gestionnaire d'immobilisations</title>
<script src="https://unpkg.com/react@18/umd/react.production.min.js" crossorigin></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js" crossorigin></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<style>
* { box-sizing: border-box; }
body { margin: 0; font-family: -apple-system, Segoe UI, Roboto, sans-serif; background: #0f172a; color: #e2e8f0; }
#root { display: flex; height: 100vh; height: 100dvh; }
/* ── Main column ── */
.main { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0; }
.topbar { padding: 12px 16px; border-bottom: 1px solid #334155; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; background: #0b1220; }
.crumb { color: #60a5fa; cursor: pointer; font-size: 14px; }
.crumb:hover { text-decoration: underline; }
.crumb.current { color: #e2e8f0; cursor: default; }
.crumb-sep { color: #475569; }
.spacer { flex: 1; }
button.bar { border: none; padding: 8px 12px; border-radius: 6px; cursor: pointer; font-size: 13px; color: white; white-space: nowrap; }
.btn-new { background: #16a34a; }
.btn-export { background: #7c3aed; }
.btn-ghost { background: #334155; }
/* ── Grid ── */
.grid-wrap { flex: 1; overflow-y: auto; padding: 20px 16px; }
.section-title { font-size: 12px; text-transform: uppercase; letter-spacing: .08em; color: #64748b; margin: 0 0 10px; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 14px; margin-bottom: 28px; }
.card { background: #1e293b; border: 1px solid #334155; border-radius: 10px; padding: 14px 16px; cursor: pointer; transition: border-color .15s, transform .1s; position: relative; }
.card:hover { border-color: #2563eb; }
.card.selected { border-color: #facc15; }
.card-type { font-size: 10px; text-transform: uppercase; letter-spacing: .08em; padding: 2px 8px; border-radius: 999px; display: inline-block; margin-bottom: 8px; }
.t-parent { background: #1d4ed8; }
.t-piece { background: #15803d; }
.t-piece-free { background: #b45309; }
.card-name { font-weight: 600; font-size: 15px; margin-bottom: 3px; }
.card-sub { font-size: 12px; color: #94a3b8; }
.card-foot { margin-top: 10px; font-size: 11px; color: #64748b; display: flex; justify-content: space-between; align-items: center; }
.badge-status { font-size: 10px; padding: 2px 6px; border-radius: 4px; background: #334155; }
.drill { position: absolute; bottom: 12px; right: 14px; font-size: 11px; color: #60a5fa; }
.empty { color: #64748b; padding: 14px 0; }
/* ── Side panel (desktop) ── */
.panel { width: 410px; background: #0b1220; border-left: 1px solid #334155; display: flex; flex-direction: column; overflow: hidden; }
.panel-head { padding: 14px 16px; border-bottom: 1px solid #334155; display: flex; justify-content: space-between; align-items: flex-start; gap: 8px; }
.panel-head h3 { margin: 0 0 3px; font-size: 16px; }
.panel-head .sub { font-size: 12px; color: #64748b; }
.icon-btn { background: #1e293b; border: 1px solid #334155; color: #e2e8f0; border-radius: 6px; padding: 7px 11px; cursor: pointer; font-size: 12px; white-space: nowrap; }
.tabs { display: flex; gap: 4px; padding: 10px 14px 0; }
.tab { flex: 1; padding: 9px; text-align: center; background: #1e293b; border: 1px solid #334155; cursor: pointer; font-size: 12px; border-radius: 6px 6px 0 0; }
.tab.active { background: #2563eb; border-color: #2563eb; }
.panel-body { flex: 1; overflow-y: auto; padding: 14px; }
.field { margin-bottom: 10px; font-size: 13px; }
.field b { color: #94a3b8; display: block; font-size: 11px; text-transform: uppercase; margin-bottom: 2px; }
.kpi { display: flex; gap: 8px; margin-bottom: 12px; }
.kpi > div { flex: 1; background: #1e293b; border: 1px solid #334155; border-radius: 8px; padding: 10px; }
.kpi .v { font-size: 17px; font-weight: 700; font-variant-numeric: tabular-nums; }
.kpi .l { font-size: 10px; color: #94a3b8; text-transform: uppercase; }
.actions { display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
button.act { flex: 1; min-width: 90px; padding: 10px 8px; border: none; border-radius: 6px; cursor: pointer; font-size: 13px; color: white; }
.btn-free { background: #475569; } .btn-stock { background: #0891b2; } .btn-scrap { background: #dc2626; }
.btn-attach { background: #2563eb; } .btn-save { background: #16a34a; } .btn-cancel { background: #475569; }
.btn-delete { background: #991b1b; }
select, input { width: 100%; padding: 8px; background: #1e293b; border: 1px solid #334155; color: #e2e8f0; border-radius: 6px; font-size: 13px; margin-bottom: 8px; }
label.fl { font-size: 11px; color: #94a3b8; text-transform: uppercase; display: block; margin-bottom: 2px; }
.locked { font-size: 11px; color: #64748b; font-style: italic; }
.journal { margin-top: 14px; background: #1e293b; border: 1px solid #334155; border-radius: 8px; padding: 12px; font-size: 12px; }
.journal table { width: 100%; border-collapse: collapse; }
.journal td { padding: 4px; border-bottom: 1px solid #334155; vertical-align: top; }
.num { text-align: right; font-variant-numeric: tabular-nums; }
.note { font-size: 11px; color: #64748b; margin-top: 8px; line-height: 1.4; }
.note.cpa { color: #f59e0b; border-left: 2px solid #f59e0b; padding-left: 6px; margin-top: 10px; }
.audit-item { font-size: 11px; border-left: 2px solid #334155; padding-left: 8px; margin-bottom: 6px; }
/* ── Modals ── */
.modal-bg { position: fixed; inset: 0; background: rgba(0,0,0,.65); display: flex; align-items: center; justify-content: center; z-index: 60; }
.modal { background: #0f172a; border: 1px solid #334155; border-radius: 12px; padding: 22px; width: 760px; max-width: 94vw; max-height: 90vh; overflow-y: auto; }
.modal h3 { margin: 0 0 14px; }
.modal-actions { display: flex; gap: 8px; margin-top: 14px; }
.fmt { color: #facc15; }
table.export { width: 100%; border-collapse: collapse; font-size: 13px; }
table.export th, table.export td { padding: 7px 9px; border-bottom: 1px solid #334155; text-align: left; }
table.export th { color: #94a3b8; font-size: 11px; text-transform: uppercase; }
table.export tr.total td { font-weight: 700; background: #1e293b; }
/* ── Sheet handle (mobile only, invisible on desktop) ── */
.sheet-handle { display: none; }
/* ── Bottom nav & FAB (mobile only) ── */
.bottom-nav { display: none; }
.fab, .fab-menu { display: none; }
.sheet-backdrop { display: none; }
.desktop-btns { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
/* ════════════════════════════════════════
MOBILE (≤ 767 px)
════════════════════════════════════════ */
@media (max-width: 767px) {
#root { flex-direction: column; height: 100dvh; overflow: hidden; }
/* main takes full width */
.main { width: 100%; }
.grid-wrap { padding-bottom: calc(72px + env(safe-area-inset-bottom, 0px) + 16px); }
/* hide desktop side panel & buttons */
.desktop-btns { display: none; }
.panel {
position: fixed; bottom: 0; left: 0; right: 0;
width: 100%; max-height: 82dvh;
border-left: none; border-top: 1px solid #334155;
border-radius: 20px 20px 0 0;
transform: translateY(102%);
transition: transform .32s cubic-bezier(.32,.72,0,1);
z-index: 45; overflow-y: auto;
padding-bottom: env(safe-area-inset-bottom, 0px);
}
.panel.open { transform: translateY(0); }
/* drag handle */
.sheet-handle { display: block; width: 38px; height: 4px; border-radius: 2px; background: #475569; margin: 10px auto 4px; flex-shrink: 0; }
/* backdrop */
.sheet-backdrop { display: block; position: fixed; inset: 0; background: rgba(0,0,0,.52); z-index: 44; }
/* bottom navigation */
.bottom-nav {
display: flex; position: fixed; bottom: 0; left: 0; right: 0;
height: calc(60px + env(safe-area-inset-bottom, 0px));
padding-bottom: env(safe-area-inset-bottom, 0px);
background: #0b1220; border-top: 1px solid #334155; z-index: 50;
}
.nav-item {
flex: 1; display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 2px;
color: #64748b; cursor: pointer; font-size: 10px; min-height: 44px;
-webkit-tap-highlight-color: transparent;
transition: color .15s;
}
.nav-item:active, .nav-item.active { color: #60a5fa; }
.nav-icon { font-size: 20px; line-height: 1.1; }
.nav-spacer { flex: 1; pointer-events: none; } /* center gap for FAB */
/* FAB */
.fab {
display: flex; position: fixed;
bottom: calc(36px + env(safe-area-inset-bottom, 0px));
left: 50%; transform: translateX(-50%);
width: 54px; height: 54px; border-radius: 27px;
background: #16a34a; border: 3px solid #0f172a;
color: white; font-size: 26px; font-weight: 300; cursor: pointer;
box-shadow: 0 4px 18px rgba(0,0,0,.55);
z-index: 51; align-items: center; justify-content: center;
transition: background .2s, transform .2s;
-webkit-tap-highlight-color: transparent;
}
.fab.fab-open { background: #7c3aed; }
/* FAB menu */
.fab-menu {
display: flex; position: fixed;
bottom: calc(102px + env(safe-area-inset-bottom, 0px));
left: 50%; transform: translateX(-50%);
flex-direction: column; gap: 10px; z-index: 51; align-items: center;
}
.fab-opt {
background: #1e293b; border: 1px solid #475569; color: #e2e8f0;
border-radius: 12px; padding: 14px 22px; font-size: 15px; cursor: pointer;
white-space: nowrap; display: flex; gap: 12px; align-items: center;
min-width: 210px; box-shadow: 0 6px 20px rgba(0,0,0,.45);
-webkit-tap-highlight-color: transparent;
}
/* tool sheet (fiscal / plus menus) */
.tool-sheet {
position: fixed;
bottom: calc(60px + env(safe-area-inset-bottom, 0px));
left: 0; right: 0; z-index: 46;
background: #0b1220; border: 1px solid #334155; border-bottom: none;
border-radius: 20px 20px 0 0;
animation: sheetUp .22s ease;
}
.tool-sheet-handle { width: 38px; height: 4px; border-radius: 2px; background: #475569; margin: 10px auto 2px; }
.tool-opt {
display: flex; align-items: center; gap: 16px;
padding: 17px 22px; font-size: 16px; cursor: pointer;
border: none; background: none; color: #e2e8f0; width: 100%; text-align: left;
-webkit-tap-highlight-color: transparent;
}
.tool-opt:active { background: #1e293b; }
.tool-icon { font-size: 22px; width: 30px; text-align: center; }
.tool-sep { height: 1px; background: #334155; margin: 0 22px; }
@keyframes sheetUp { from { transform: translateY(100%); } to { transform: translateY(0); } }
/* larger tap targets in panels */
button.act { padding: 13px 8px; font-size: 14px; min-height: 46px; }
.icon-btn { padding: 10px 14px; font-size: 13px; min-height: 40px; }
.tab { padding: 11px; font-size: 13px; min-height: 44px; }
/* cards: 1 column, bigger */
.grid { grid-template-columns: 1fr; gap: 10px; }
.card { padding: 14px 16px; min-height: 78px; }
.card-name { font-size: 16px; }
/* modals full-screen on mobile */
.modal-bg { align-items: flex-end; }
.modal { width: 100% !important; max-width: 100vw !important; border-radius: 20px 20px 0 0; border-left: none; border-right: none; border-bottom: none; max-height: 94dvh; padding-bottom: calc(16px + env(safe-area-inset-bottom, 0px)); }
/* topbar minimal */
.topbar { padding: 10px 14px; }
}
</style>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
const { useState, useEffect, useCallback, useRef } = React;
const fmt = n => Number(n).toLocaleString("fr-CA", {minimumFractionDigits:2, maximumFractionDigits:2});
const today = () => new Date().toISOString().slice(0,10);
const api = (url, method, body) => fetch(url, {method, headers:{"Content-Type":"application/json"}, body: body?JSON.stringify(body):undefined})
.then(r => r.ok ? r.json() : r.json().then(e=>Promise.reject(e)));
const PIECE_FIELDS = [
{label:"Nom / désignation", path:"name"},
{label:"Numéro de série", path:"view_admin.serial_number"},
{label:"Étiquette d'inventaire", path:"view_admin.inventory_tag"},
{label:"Fabricant", path:"view_admin.manufacturer"},
{label:"Modèle", path:"view_admin.model"},
{label:"Format", path:"view_admin.form_factor"},
{label:"Statut", path:"view_admin.operational_status"},
{label:"Coût (CAD)", path:"view_cpa.capitalizable_base_cad", num:true},
{label:"Coût de remplacement (CAD)", path:"view_admin.replacement_cost_cad", num:true},
{label:"Date d'acquisition", path:"view_cpa.acquisition_date", type:"date"},
{label:"Date de mise en service", path:"view_cpa.in_service_date", type:"date"},
{label:"Durée de vie utile (ans)", path:"view_admin.useful_life_years", num:true},
{label:"Fournisseur", path:"view_auditor.supplier"},
{label:"N° de facture", path:"view_auditor.invoice_number"},
];
const PARENT_FIELDS = [
{label:"Nom / désignation", path:"name"},
{label:"Assembleur", path:"view_admin.assembler"},
{label:"Statut", path:"view_admin.operational_status"},
{label:"Catégorie DPA", path:"view_cpa.cca_class.id", num:true, select:"cca"},
{label:"Date de mise en service", path:"view_cpa.in_service_date", type:"date"},
];
function getPath(obj, dotted) { return dotted.split(".").reduce((o,k)=> (o==null?o:o[k]), obj); }
const NOTE_CPA = "Ces calculs sont structurellement corrects mais indicatifs. DPA, FNACC, perte finale et récupération doivent être validés par un CPA avant toute déclaration fiscale. Aucun taux d'accélération n'est câblé en dur.";
function App() {
const [assets, setAssets] = useState([]);
const [currentParent, setCurrentParent] = useState(null);
const [selectedId, setSelectedId] = useState(null);
const [view, setView] = useState("owner");
const [accounting, setAccounting] = useState(null);
const [scrapCatId, setScrapCatId] = useState(null);
const [modal, setModal] = useState(null);
const [fabOpen, setFabOpen] = useState(false);
const [toolSheet, setToolSheet] = useState(null); // "fiscal" | "plus" | null
const load = useCallback(()=>api("/api/assets","GET").then(setAssets), []);
useEffect(()=>{ load(); }, [load]);
const byId = id => assets.find(a=>a.id===id) || null;
const selected = byId(selectedId);
const parents = assets.filter(a=>a.type==="parent");
const childrenCount = id => assets.filter(a=>a.parent_id===id).length;
let levelItems, freePieces = [];
if (currentParent === null) {
levelItems = parents;
freePieces = assets.filter(a=>a.type==="piece" && a.parent_id===null);
} else {
levelItems = assets.filter(a=>a.parent_id===currentParent);
}
const crumbs = [];
let p = currentParent;
while (p!=null){ const n=byId(p); if(!n)break; crumbs.unshift(n); p=n.parent_id; }
const goRoot = ()=>{ setCurrentParent(null); setSelectedId(null); setAccounting(null); setScrapCatId(null); setToolSheet(null); setFabOpen(false); };
const pick = n => { setSelectedId(n.id); setView("owner"); setAccounting(null); setScrapCatId(null); setFabOpen(false); setToolSheet(null); };
const drill = n => { if(n.type==="parent"){ setCurrentParent(n.id); setSelectedId(null); setAccounting(null); setFabOpen(false); } };
const closeSheet = () => { setSelectedId(null); setAccounting(null); setScrapCatId(null); };
const refresh = data => setAssets(data.assets);
const detach = disp => {
if(!selected) return;
const catIdNow = selected.view_cpa?.cca_class?.id || 50;
api(`/api/assets/${selected.id}/detach`,"POST",{disposition:disp, detachment_date:today()})
.then(d=>{ setAccounting(d.accounting); setAssets(d.assets); if(d.accounting===null) setSelectedId(null); if(disp==="scrap") setScrapCatId(catIdNow); })
.catch(e=>alert("Erreur : "+(e.detail||"inconnue")));
};
const attach = pid => {
if(!selected||!pid) return;
api(`/api/assets/${selected.id}/attach`,"POST",{parent_id:Number(pid)})
.then(d=>{ refresh(d); setSelectedId(null); }).catch(e=>alert("Erreur : "+(e.detail||"inconnue")));
};
const remove = () => {
if(!selected) return;
if(!confirm(`Supprimer « ${selected.name} » ? ${selected.type==="parent"?"Ses pièces deviendront libres.":""}`)) return;
api(`/api/assets/${selected.id}`,"DELETE").then(d=>{ refresh(d); setSelectedId(null); })
.catch(e=>alert("Erreur : "+(e.detail||"inconnue")));
};
const setTagBearer = pieceId => {
api(`/api/assets/${pieceId}/set-tag-bearer`,"POST")
.then(d=>refresh(d)).catch(e=>alert("Erreur : "+(e.detail||"inconnue")));
};
/* swipe-down-to-close the mobile sheet */
const swipeStartY = useRef(0);
const onTouchStart = e => { swipeStartY.current = e.touches[0].clientY; };
const onTouchEnd = e => { if(e.changedTouches[0].clientY - swipeStartY.current > 72) closeSheet(); };
const openModal = m => { setModal(m); setToolSheet(null); setFabOpen(false); };
return (
<React.Fragment>
{/* ── Backdrops ── */}
{selected && <div className="sheet-backdrop" onClick={closeSheet}/>}
{toolSheet && <div className="sheet-backdrop" style={{zIndex:45}} onClick={()=>setToolSheet(null)}/>}
{fabOpen && <div className="sheet-backdrop" style={{zIndex:50, background:"rgba(0,0,0,.35)"}} onClick={()=>setFabOpen(false)}/>}
{/* ══════════════════════════════
MAIN AREA
══════════════════════════════ */}
<div className="main">
<div className="topbar">
<span className={"crumb"+(currentParent===null?" current":"")} onClick={goRoot}>Immobilisations</span>
{crumbs.map(c=>(<React.Fragment key={c.id}><span className="crumb-sep"></span><span className="crumb current">{c.name}</span></React.Fragment>))}
<div className="spacer"/>
{/* Desktop buttons — hidden on mobile */}
<div className="desktop-btns">
<button className="bar btn-export" onClick={()=>openModal("rapport")}>Rapport fiscal</button>
<button className="bar btn-export" onClick={()=>openModal("annexe8")}>Annexe 8</button>
<button className="bar btn-export" onClick={()=>openModal("registry")}>Registre pool</button>
<button className="bar btn-export" onClick={()=>openModal("export")}>Export immos</button>
<button className="bar btn-new" onClick={()=>openModal("create_parent")}>+ Immobilisation</button>
<button className="bar btn-new" onClick={()=>openModal("create_piece")}>+ Pièce</button>
<a className="bar btn-ghost" href="/static/aide.html" target="_blank" style={{textDecoration:"none"}}>? Aide</a>
<button className="bar btn-ghost" onClick={()=>openModal("backup")}>Sauvegardes</button>
<button className="bar btn-ghost" onClick={()=>api("/api/seed-demo","POST").then(refresh)}>Données démo</button>
<button className="bar btn-ghost" onClick={()=>{ if(confirm("Vider toute la base ?")) api("/api/reset","POST").then(d=>{refresh(d); setSelectedId(null);}); }}>Réinitialiser</button>
</div>
</div>
<div className="grid-wrap">
{currentParent===null ? (
<React.Fragment>
<p className="section-title">Immobilisations fiscales ({parents.length})</p>
{parents.length===0 && <div className="empty">Aucune immobilisation. Crée-en une, ou charge les données démo.</div>}
<Grid items={levelItems} selectedId={selectedId} childrenCount={childrenCount} onPick={pick} onDrill={drill}/>
<p className="section-title">Pièces libres ({freePieces.length})</p>
{freePieces.length===0 && <div className="empty">Aucune pièce libre.</div>}
<Grid items={freePieces} selectedId={selectedId} childrenCount={childrenCount} onPick={pick} onDrill={()=>{}} freeZone/>
</React.Fragment>
) : (
<React.Fragment>
<p className="section-title">Pièces de ce parent ({levelItems.length})</p>
{levelItems.length===0 && <div className="empty">Aucune pièce rattachée.</div>}
<Grid items={levelItems} selectedId={selectedId} childrenCount={childrenCount} onPick={pick} onDrill={()=>{}}/>
</React.Fragment>
)}
</div>
</div>
{/* ══════════════════════════════
DETAIL PANEL / MOBILE SHEET
══════════════════════════════ */}
<div className={"panel"+(selected?" open":"")} onTouchStart={onTouchStart} onTouchEnd={onTouchEnd}>
<div className="sheet-handle"/>
{!selected && <div className="panel-body" style={{color:"#64748b"}}>
Sélectionne une immobilisation ou une pièce pour voir et éditer ses informations.
</div>}
{selected && (
<React.Fragment>
<div className="panel-head">
<div>
<h3>{selected.name}</h3>
<div className="sub">{selected.type==="parent"?"Immobilisation fiscale":"Pièce d'inventaire"} · {selected.view_admin.inventory_tag||"sans étiquette"}</div>
</div>
<div style={{display:"flex", gap:6, flexDirection:"column"}}>
<button className="icon-btn" onClick={()=>openModal("edit")}>Modifier</button>
<button className="icon-btn" onClick={remove} style={{borderColor:"#991b1b"}}>Supprimer</button>
</div>
</div>
<div className="tabs">
{[["owner","Propriétaire"],["cpa","CPA"],["auditor","Auditeur"]].map(([k,l])=>(
<div key={k} className={"tab"+(view===k?" active":"")} onClick={()=>setView(k)}>{l}</div>
))}
</div>
<div className="panel-body">
{/* ===== PIÈCE : Propriétaire ===== */}
{selected.type==="piece" && view==="owner" && (
<div>
<div style={{display:"flex", gap:8, marginBottom:12, flexWrap:"wrap", alignItems:"center"}}>
<span style={{fontSize:11, padding:"2px 8px", borderRadius:999, background:
selected.view_admin.operational_status==="Actif"?"#15803d":
selected.view_admin.operational_status==="Rebut"?"#7f1d1d":"#44403c",
color:"#e2e8f0"}}>{selected.view_admin.operational_status}</span>
<span style={{fontSize:12, color:"#94a3b8"}}>
{selected.parent_id ? (byId(selected.parent_id)?.name||"—") : "Pièce libre"}
</span>
</div>
<Field label="Fabricant · Modèle" value={`${selected.view_admin.manufacturer||"—"} ${selected.view_admin.model||""}`.trim()}/>
<Field label="Format" value={selected.view_admin.form_factor||"—"}/>
{selected.parent_id !== null ? (
<React.Fragment>
<div className="field">
<b>{selected.view_admin.is_tag_bearer?"Étiquette d'assemblage (porteur ⭐)":"Étiquette d'assemblage (héritée)"}</b>
{selected.view_admin.inventory_tag||<span style={{color:"#64748b"}}>aucune</span>}
</div>
{selected.view_admin.is_tag_bearer
? <div className="note" style={{color:"#4ade80",borderLeft:"2px solid #4ade80",paddingLeft:6,marginBottom:8}}>Cette pièce porte l'étiquette de l'assemblage.</div>
: <button className="act btn-attach" style={{marginBottom:10,width:"100%"}} onClick={()=>setTagBearer(selected.id)}> Désigner comme porteur d'étiquette</button>}
</React.Fragment>
) : (
<React.Fragment>
<Field label="Étiquette d'inventaire" value={selected.view_admin.inventory_tag||""}/>
</React.Fragment>
)}
<div className="kpi">
<div><div className="v">{fmt(selected.view_cpa.capitalizable_base_cad)} $</div><div className="l">Coût d'acquisition</div></div>
<div><div className="v">{fmt(selected.view_cpa.nbv_linear_cad ?? selected.view_cpa.capitalizable_base_cad)} $</div><div className="l">VNC linéaire</div></div>
</div>
{(() => {
const cost = selected.view_cpa.capitalizable_base_cad;
const nbvL = selected.view_cpa.nbv_linear_cad ?? cost;
const pct = cost > 0 ? Math.min(100, Math.round((1 - nbvL / cost) * 100)) : 100;
const col = pct > 80 ? "#ef4444" : pct > 50 ? "#f59e0b" : "#4ade80";
return (
<div style={{marginBottom:12}}>
<div style={{display:"flex",justifyContent:"space-between",fontSize:11,color:"#94a3b8",marginBottom:4}}>
<span>Amortissement linéaire indicatif</span><span style={{color:col}}>{pct}% amorti</span>
</div>
<div style={{background:"#334155",height:6,borderRadius:3}}>
<div style={{background:col,height:"100%",borderRadius:3,width:`${pct}%`,transition:"width .4s"}}/>
</div>
<div style={{fontSize:11,color:"#64748b",marginTop:4}}>Durée utile : {selected.view_admin.useful_life_years} ans · Résiduelle : {selected.view_cpa.remaining_useful_life_years?.toFixed(1)} ans</div>
</div>
);
})()}
<Field label="Coût de remplacement" value={fmt(selected.view_admin.replacement_cost_cad)+" $"}/>
{selected.parent_id === null && (
<React.Fragment>
<div className="section-title" style={{marginTop:10}}>Rattacher à une immobilisation</div>
<AttachControl parents={parents} onAttach={attach}/>
</React.Fragment>
)}
{selected.parent_id !== null && selected.view_admin.operational_status !== "Rebut" && (
<React.Fragment>
<div className="section-title" style={{marginTop:10}}>Détacher</div>
<div className="actions">
<button className="act btn-free" onClick={()=>detach("free")}>Libre</button>
<button className="act btn-stock" onClick={()=>detach("stock")}>Stock</button>
<button className="act btn-scrap" onClick={()=>detach("scrap")}>Rebut</button>
</div>
</React.Fragment>
)}
{accounting && (
<div className="journal">
<div style={{fontWeight:600, marginBottom:8, fontSize:13}}>Écriture comptable — {accounting.disposition_type}</div>
<table><tbody>
{accounting.entries?.map((e,i)=>(
<tr key={i}><td>{e.account}</td><td className="num">{e.debit?fmt(e.debit):""}</td><td className="num">{e.credit?fmt(e.credit):""}</td></tr>
))}
</tbody></table>
</div>
)}
{accounting && scrapCatId && <DispForm accounting={accounting} catId={scrapCatId}/>}
</div>
)}
{/* ===== PIÈCE : CPA ===== */}
{selected.type==="piece" && view==="cpa" && (
<div>
<div className="kpi">
<div><div className="v">{selected.view_cpa.cca_class.dpa_rate*100}%</div><div className="l">DPA · Cat. {selected.view_cpa.cca_class.id}</div></div>
<div><div className="v">{fmt(selected.view_cpa.capitalizable_base_cad)} $</div><div className="l">Base capitalisable</div></div>
</div>
<div className="kpi">
<div><div className="v">{fmt(selected.view_cpa.net_book_value_cad||0)} $</div><div className="l">FNACC indicative</div></div>
<div><div className="v">{selected.view_cpa.reference_year}</div><div className="l">Exercice de référence</div></div>
</div>
<Field label="Catégorie DPA" value={`Cat. ${selected.view_cpa.cca_class.id} — ${selected.view_cpa.cca_class.description}`}/>
<Field label="Date d'acquisition" value={selected.view_cpa.acquisition_date||"—"}/>
<Field label="Date de mise en service" value={selected.view_cpa.in_service_date||"—"}/>
<Field label="Règle de la demi-année" value={selected.view_cpa.half_year_rule_applies?"Applicable":"Non"}/>
<div style={{background:"#1e293b",border:"1px solid #334155",borderRadius:8,padding:12,marginTop:8}}>
<div style={{fontSize:11,color:"#94a3b8",textTransform:"uppercase",marginBottom:8}}>Amortissement linéaire indicatif</div>
<div className="kpi" style={{marginBottom:0}}>
<div><div className="v">{fmt(selected.view_cpa.accum_deprec_linear_cad||0)} $</div><div className="l">Cumulé</div></div>
<div><div className="v">{fmt(selected.view_cpa.nbv_linear_cad ?? selected.view_cpa.capitalizable_base_cad)} $</div><div className="l">VNC</div></div>
<div><div className="v">{(selected.view_cpa.remaining_useful_life_years||0).toFixed(1)}</div><div className="l">Ans restants</div></div>
</div>
</div>
<div className="note cpa">{NOTE_CPA}</div>
</div>
)}
{/* ===== PIÈCE : Auditeur ===== */}
{selected.type==="piece" && view==="auditor" && (
<div>
<Field label="Numéro de série" value={selected.view_admin.serial_number||"—"}/>
<Field label="Étiquette d'inventaire" value={selected.view_admin.inventory_tag||""}/>
<Field label="Fournisseur" value={selected.view_auditor.supplier||""}/>
<Field label="N° de facture" value={selected.view_auditor.invoice_number||""}/>
<InvoiceAttachment asset={selected} onRefresh={refresh}/>
<div className="section-title" style={{marginTop:12}}>Piste d'audit</div>
{(selected.view_auditor.audit_trail||[]).slice().reverse().map((e,i)=>(
<div key={i} className="audit-item">
<div style={{color:"#facc15"}}>{e.action}</div>
<div>{e.description}</div>
<div style={{color:"#64748b"}}>{e.timestamp}</div>
</div>
))}
</div>
)}
{/* ===== PARENT : Propriétaire ===== */}
{selected.type==="parent" && view==="owner" && (
<div>
<div className="kpi">
<div><div className="v">{fmt(selected.view_cpa.nbv_linear_total_cad||0)} $</div><div className="l">VNC linéaire totale</div></div>
<div><div className="v">{fmt(selected.view_admin.total_replacement_cost_cad||0)} $</div><div className="l">Coût remplacement</div></div>
</div>
<div className="kpi">
<div><div className="v">{fmt(selected.view_cpa.capitalizable_base_cad)} $</div><div className="l">Coût d'acquisition</div></div>
<div><div className="v">{selected.view_admin.pieces_count||0}</div><div className="l">Pièces rattachées</div></div>
</div>
<Field label="Statut" value={selected.view_admin.operational_status||""}/>
<Field label="Assembleur" value={selected.view_admin.assembler||""}/>
<Field label="Date de mise en service" value={selected.view_cpa.in_service_date||""}/>
{(() => {
const pieces = assets.filter(a=>a.parent_id===selected.id);
const bearerTag = pieces.find(pc=>pc.view_admin.is_tag_bearer)?.view_admin?.inventory_tag;
return (<React.Fragment>
{bearerTag
? <div style={{marginBottom:10,fontSize:12,color:"#94a3b8"}}>Étiquette : <b style={{color:"#e2e8f0"}}>{bearerTag}</b></div>
: <div style={{marginBottom:10,fontSize:12,color:"#64748b"}}>Aucun porteur d'étiquette désigné.</div>}
<div className="section-title" style={{marginBottom:8}}>Composition</div>
{pieces.length===0 && <div className="empty">Aucune pièce rattachée.</div>}
{pieces.map(pc=>{
const cost=pc.view_cpa.capitalizable_base_cad;
const nbvL=pc.view_cpa.nbv_linear_cad??cost;
const pct=cost>0?Math.min(100,Math.round((1-nbvL/cost)*100)):100;
const col=pct>80?"#ef4444":pct>50?"#f59e0b":"#4ade80";
return (
<div key={pc.id} className="audit-item" style={{borderLeftColor:pc.view_admin.is_tag_bearer?"#facc15":"#15803d"}}>
<div style={{display:"flex",justifyContent:"space-between",cursor:"pointer"}} onClick={()=>pick(pc)}>
<div>
<div style={{display:"flex",alignItems:"center",gap:6}}>
{pc.view_admin.is_tag_bearer && <span style={{fontSize:10,background:"#713f12",color:"#facc15",padding:"1px 6px",borderRadius:999}}>PORTEUR ⭐</span>}
<span>{pc.name}</span>
</div>
<div style={{color:"#64748b",fontSize:11}}>{pc.view_admin.manufacturer} {pc.view_admin.model}</div>
</div>
<div style={{textAlign:"right",whiteSpace:"nowrap",fontSize:12}}>
<div style={{color:col}}>{fmt(nbvL)} $</div>
<div style={{color:"#64748b"}}>{pct}% amorti</div>
</div>
</div>
<div style={{background:"#334155",height:4,borderRadius:2,marginTop:4}}>
<div style={{background:col,height:"100%",borderRadius:2,width:`${pct}%`}}/>
</div>
{!pc.view_admin.is_tag_bearer && (
<button style={{marginTop:4,fontSize:11,padding:"2px 8px",background:"#1e3a5f",border:"1px solid #2563eb",color:"#93c5fd",borderRadius:4,cursor:"pointer"}}
onClick={e=>{e.stopPropagation();setTagBearer(pc.id);}}>⭐ Désigner porteur</button>
)}
</div>
);
})}
<div className="kpi" style={{marginTop:12}}>
<div><div className="v">{fmt(selected.view_cpa.capitalizable_base_cad)}</div><div className="l">Coût total (Σ)</div></div>
<div><div className="v">{fmt(selected.view_cpa.nbv_linear_total_cad||0)}</div><div className="l">VNC linéaire (Σ)</div></div>
</div>
</React.Fragment>);
})()}
<button className="act btn-attach" style={{width:"100%",marginTop:8}} onClick={()=>drill(selected)}>
Voir les pièces de cette immobilisation →
</button>
</div>
)}
{/* ===== PARENT : CPA ===== */}
{selected.type==="parent" && view==="cpa" && (
<div>
<div className="kpi">
<div><div className="v">{selected.view_cpa.cca_class.dpa_rate*100}%</div><div className="l">DPA · Cat. {selected.view_cpa.cca_class.id}</div></div>
<div><div className="v">{fmt(selected.view_cpa.capitalizable_base_cad)}</div><div className="l">Base fiscale (Σ coûts)</div></div>
</div>
<div className="kpi">
<div><div className="v">{fmt(selected.view_cpa.net_book_value_cad||0)}</div><div className="l">FNACC indicative (Σ pièces)</div></div>
<div><div className="v">{fmt(selected.view_admin.total_replacement_cost_cad||0)}</div><div className="l">Coût remplacement total</div></div>
</div>
<Field label="Catégorie DPA" value={`Cat. ${selected.view_cpa.cca_class.id} — ${selected.view_cpa.cca_class.description}`}/>
<Field label="Date de mise en service" value={selected.view_cpa.in_service_date||""}/>
<Field label="Règle de la demi-année" value={selected.view_cpa.half_year_rule_applies?"Applicable":"Non"}/>
<div className="note cpa">{NOTE_CPA}</div>
</div>
)}
{/* ===== PARENT : Auditeur ===== */}
{selected.type==="parent" && view==="auditor" && (
<div>
<Field label="Assembleur" value={selected.view_admin.assembler||""}/>
<div className="section-title" style={{marginTop:8}}>Pièces composantes</div>
{assets.filter(a=>a.parent_id===selected.id).map(pc=>(
<div key={pc.id} className="audit-item" style={{marginBottom:8}}>
<div style={{fontWeight:600}}>{pc.name}</div>
<div style={{color:"#64748b",fontSize:11}}>S/N : {pc.view_admin.serial_number||""} · {pc.view_auditor?.supplier||""} · {pc.view_auditor?.invoice_number||""}</div>
</div>
))}
<div className="section-title" style={{marginTop:12}}>Piste d'audit</div>
{(selected.view_auditor.audit_trail||[]).slice().reverse().map((e,i)=>(
<div key={i} className="audit-item">
<div style={{color:"#facc15"}}>{e.action}</div>
<div>{e.description}</div>
<div style={{color:"#64748b"}}>{e.timestamp}</div>
</div>
))}
</div>
)}
</div>
</React.Fragment>
)}
</div>
{/* ══════════════════════════════
MOBILE FAB
══════════════════════════════ */}
{fabOpen && (
<div className="fab-menu">
<button className="fab-opt" onClick={()=>{ setFabOpen(false); openModal("create_piece"); }}>
<span>🔩</span> Pièce d'inventaire
</button>
<button className="fab-opt" onClick={()=>{ setFabOpen(false); openModal("create_parent"); }}>
<span>🖥️</span> Immobilisation fiscale
</button>
</div>
)}
<button className={"fab"+(fabOpen?" fab-open":"")} onClick={()=>setFabOpen(!fabOpen)}
style={{fontSize: fabOpen?"28px":"30px", letterSpacing:0}}>
{fabOpen ? "×" : "+"}
</button>
{/* ══════════════════════════════
TOOL SHEETS (mobile)
══════════════════════════════ */}
{toolSheet==="fiscal" && (
<div className="tool-sheet">
<div className="tool-sheet-handle"/>
<button className="tool-opt" onClick={()=>openModal("rapport")}><span className="tool-icon">📊</span>Rapport fiscal</button>
<div className="tool-sep"/>
<button className="tool-opt" onClick={()=>openModal("annexe8")}><span className="tool-icon">📋</span>Annexe 8 / Schedule 8</button>
<div className="tool-sep"/>
<button className="tool-opt" onClick={()=>openModal("registry")}><span className="tool-icon">🗂️</span>Registre pool DPA</button>
<div className="tool-sep"/>
<button className="tool-opt" onClick={()=>openModal("export")}><span className="tool-icon">📤</span>Export immobilisations</button>
</div>
)}
{toolSheet==="plus" && (
<div className="tool-sheet">
<div className="tool-sheet-handle"/>
<button className="tool-opt" onClick={()=>openModal("backup")}><span className="tool-icon">💾</span>Sauvegardes</button>
<div className="tool-sep"/>
<button className="tool-opt" onClick={()=>{ setToolSheet(null); window.open('/static/aide.html','_blank'); }}><span className="tool-icon">❓</span>Guide d'utilisation</button>
<div className="tool-sep"/>
<button className="tool-opt" onClick={()=>{ setToolSheet(null); api("/api/seed-demo","POST").then(refresh); }}><span className="tool-icon">🎲</span>Données démo</button>
<div className="tool-sep"/>
<button className="tool-opt" style={{color:"#f87171"}} onClick={()=>{ setToolSheet(null); if(confirm("Vider toute la base ?")) api("/api/reset","POST").then(d=>{refresh(d);setSelectedId(null);}); }}><span className="tool-icon">🗑️</span>Réinitialiser la base</button>
</div>
)}
{/* ══════════════════════════════
BOTTOM NAVIGATION (mobile)
══════════════════════════════ */}
<nav className="bottom-nav">
<div className={"nav-item"+(currentParent===null&&!toolSheet?" active":"")} onClick={goRoot}>
<span className="nav-icon">🏠</span><span>Accueil</span>
</div>
<div className={"nav-item"+(toolSheet==="fiscal"?" active":"")} onClick={()=>{ setToolSheet(t=>t==="fiscal"?null:"fiscal"); setFabOpen(false); closeSheet(); }}>
<span className="nav-icon">📊</span><span>Fiscal</span>
</div>
<div className="nav-spacer"/>
<div className={"nav-item"+(toolSheet==="plus"?" active":"")} onClick={()=>{ setToolSheet(t=>t==="plus"?null:"plus"); setFabOpen(false); closeSheet(); }}>
<span className="nav-icon">⚙️</span><span>Plus</span>
</div>
<div className="nav-item" onClick={()=>window.open('/static/aide.html','_blank')}>
<span className="nav-icon">❓</span><span>Aide</span>
</div>
</nav>
{/* ══════════════════════════════
MODALS
══════════════════════════════ */}
{modal==="create_parent" && <CreateModal fixedType="parent" onClose={()=>setModal(null)} onCreated={d=>{refresh(d); setModal(null);}}/>}
{modal==="create_piece" && <CreateModal fixedType="piece" onClose={()=>setModal(null)} onCreated={d=>{refresh(d); setModal(null);}}/>}
{modal==="edit" && selected && <EditModal asset={selected} onClose={()=>setModal(null)} onSaved={d=>{refresh(d); setModal(null);}}/>}
{modal==="export" && <ExportModal onClose={()=>setModal(null)}/>}
{modal==="rapport" && <RapportModal onClose={()=>setModal(null)}/>}
{modal==="registry" && <RegistryModal assets={assets} onClose={()=>setModal(null)}/>}
{modal==="annexe8" && <Annexe8Modal onClose={()=>setModal(null)}/>}
{modal==="backup" && <BackupModal onClose={()=>setModal(null)} onRestore={()=>{load(); setSelectedId(null); setModal(null);}}/>}
</React.Fragment>
);
}
function Grid({items, selectedId, childrenCount, onPick, onDrill, freeZone}) {
return (
<div className="grid">
{items.map(node=>{
const isParent = node.type==="parent";
const kids = childrenCount(node.id);
const cls = isParent?"t-parent":(freeZone?"t-piece-free":"t-piece");
const lbl = isParent?"Immo fiscale":(freeZone?"Pièce libre":"Pièce");
return (
<div key={node.id} className={"card"+(selectedId===node.id?" selected":"")} onClick={()=>onPick(node)}>
<span className={"card-type "+cls}>{lbl}</span>
<div className="card-name">{node.name}</div>
<div className="card-sub">{node.view_admin.manufacturer} {node.view_admin.model}</div>
<div className="card-foot">
<span className="badge-status">{node.view_admin.operational_status}</span>
<span>{fmt(node.view_cpa.capitalizable_base_cad)} $</span>
</div>
{isParent && kids>0 && (
<div className="drill" onClick={e=>{e.stopPropagation(); onDrill(node);}}>
{kids} pièce{kids>1?"s":""}
</div>
)}
</div>
);
})}
</div>
);
}
function AttachControl({parents, onAttach}) {
const [sel,setSel]=useState("");
return (<div>
<select value={sel} onChange={e=>setSel(e.target.value)}>
<option value="">— Choisir un parent —</option>
{parents.map(p=><option key={p.id} value={p.id}>{p.name}</option>)}
</select>
<button className="act btn-attach" onClick={()=>onAttach(sel)} disabled={!sel}>Rattacher</button>
</div>);
}
function InvoiceAttachment({asset, onRefresh}) {
const fileId = asset.view_auditor?.invoice_pdf_file_id || "";
const [uploading, setUploading] = useState(false);
const saveFileId = newId =>
api(`/api/assets/${asset.id}`,"PATCH",{changes:{"view_auditor.invoice_pdf_file_id": newId}})
.then(d => onRefresh(d));
const upload = async e => {
const file = e.target.files[0];
if (!file) return;
e.target.value = "";
setUploading(true);
const fd = new FormData();
fd.append("file", file);
try {
const r = await fetch("/api/invoices", {method:"POST", body:fd});
const d = await r.json();
if (!r.ok) { alert("Erreur : " + (d.detail||"upload échoué")); return; }
if (fileId) await fetch(`/api/invoices/${fileId}`, {method:"DELETE"});
await saveFileId(d.file_id);
} catch { alert("Erreur lors de l'envoi."); }
finally { setUploading(false); }
};
const remove = async () => {
if (!confirm("Retirer la facture jointe ?")) return;
await fetch(`/api/invoices/${fileId}`, {method:"DELETE"});
await saveFileId("");
};
const ext = fileId.split(".").pop().toLowerCase();
const isImage = ["jpg","jpeg","png"].includes(ext);
return (
<div className="field">
<b>Facture jointe</b>
{fileId ? (
<div style={{display:"flex", gap:6, marginTop:6, flexWrap:"wrap"}}>
<a href={`/api/invoices/${fileId}`} target="_blank" rel="noreferrer" className="icon-btn">
{isImage ? "🖼 Voir image" : "📄 Ouvrir PDF"}
</a>
<label className="icon-btn" style={{cursor:"pointer"}}>
{uploading ? "Envoi…" : "↩ Remplacer"}
<input type="file" accept=".pdf,.jpg,.jpeg,.png" style={{display:"none"}} onChange={upload} disabled={uploading}/>
</label>
<button className="icon-btn" style={{borderColor:"#dc2626",color:"#f87171"}} onClick={remove}>✕ Retirer</button>
</div>
) : (
<div style={{marginTop:6}}>
<label className="icon-btn" style={{cursor:"pointer", display:"inline-block"}}>
{uploading ? "Envoi en cours…" : "📎 Joindre (PDF · JPG · PNG)"}
<input type="file" accept=".pdf,.jpg,.jpeg,.png" style={{display:"none"}} onChange={upload} disabled={uploading}/>
</label>
</div>
)}
</div>
);
}
function CreateModal({fixedType, onClose, onCreated}) {
const isParent = fixedType==="parent";
const [cats,setCats]=useState([]);
const t = today();
const [f,setF]=useState({
type:fixedType, name:"", cost_cad:"", replacement_cost_cad:"",
manufacturer:"", model:"", form_factor:"", serial_number:"", inventory_tag:"",
acquisition_date: t,
in_service_date: isParent ? t : "",
cca_class_id:50, assembler:"Chezlepro Inc.", supplier:"", invoice_number:""
});
const up=(k,v)=>setF(s=>({...s,[k]:v}));
useEffect(()=>{ api("/api/cca-categories","GET").then(setCats); },[]);
const submit=()=>{
if(!f.name.trim()){alert("Nom requis."); return;}
api("/api/assets","POST",{...f, cost_cad:Number(f.cost_cad)||0, replacement_cost_cad:Number(f.replacement_cost_cad)||0})
.then(onCreated).catch(e=>alert("Erreur : "+(e.detail||"inconnue")));
};
return (<div className="modal-bg" onClick={onClose}><div className="modal" onClick={e=>e.stopPropagation()}>
<h3>Nouvelle {isParent?"immobilisation fiscale":"pièce d'inventaire"}</h3>
<label className="fl">Nom / désignation *</label><input value={f.name} onChange={e=>up("name",e.target.value)} autoFocus/>
{isParent ? (<React.Fragment>
<label className="fl">Assembleur</label><input value={f.assembler} onChange={e=>up("assembler",e.target.value)}/>
<label className="fl">Catégorie DPA</label>
<select value={f.cca_class_id} onChange={e=>up("cca_class_id",Number(e.target.value))}>
{cats.map(c=><option key={c.id} value={c.id}>Cat. {c.id} — {c.description} ({Math.round(c.dpa_rate*100)}%)</option>)}
</select>
<label className="fl">Date de mise en service</label><input type="date" value={f.in_service_date} onChange={e=>up("in_service_date",e.target.value)}/>
<div className="note fmt">L'immobilisation est créée vide. Sa valeur = somme des pièces que tu y rattacheras.</div>
</React.Fragment>) : (<React.Fragment>
<div style={{display:"grid", gridTemplateColumns:"1fr 1fr", gap:"0 14px"}}>
<div><label className="fl">Coût (CAD)</label><input type="number" value={f.cost_cad} onChange={e=>up("cost_cad",e.target.value)}/></div>
<div><label className="fl">Coût remplacement (CAD)</label><input type="number" value={f.replacement_cost_cad} onChange={e=>up("replacement_cost_cad",e.target.value)}/></div>
<div><label className="fl">Numéro de série</label><input value={f.serial_number} onChange={e=>up("serial_number",e.target.value)}/></div>
<div><label className="fl">Étiquette d'inventaire</label><input value={f.inventory_tag} onChange={e=>up("inventory_tag",e.target.value)}/></div>
<div><label className="fl">Fabricant</label><input value={f.manufacturer} onChange={e=>up("manufacturer",e.target.value)}/></div>
<div><label className="fl">Modèle</label><input value={f.model} onChange={e=>up("model",e.target.value)}/></div>
<div><label className="fl">Format</label><input value={f.form_factor} onChange={e=>up("form_factor",e.target.value)}/></div>
<div><label className="fl">Date d'acquisition</label><input type="date" value={f.acquisition_date} onChange={e=>up("acquisition_date",e.target.value)}/></div>
<div><label className="fl">Fournisseur</label><input value={f.supplier} onChange={e=>up("supplier",e.target.value)}/></div>
<div><label className="fl">N° de facture</label><input value={f.invoice_number} onChange={e=>up("invoice_number",e.target.value)}/></div>
</div>
<div className="note fmt">Créée libre. La date de mise en service sera posée au rattachement.</div>
</React.Fragment>)}
<div className="modal-actions">
<button className="act btn-cancel" onClick={onClose}>Annuler</button>
<button className="act btn-save" onClick={submit}>Créer</button>
</div>
</div></div>);
}
function EditModal({asset, onClose, onSaved}) {
const fields = asset.type==="parent" ? PARENT_FIELDS : PIECE_FIELDS;
const [cats,setCats]=useState([]);
const initial = {};
fields.forEach(fd => { initial[fd.path] = fd.path==="name" ? asset.name : getPath(asset, fd.path); });
const [vals,setVals]=useState(initial);
const up=(path,v)=>setVals(s=>({...s,[path]:v}));
useEffect(()=>{ api("/api/cca-categories","GET").then(setCats); },[]);
const submit=()=>{
const changes={};
fields.forEach(fd=>{
let v = vals[fd.path];
if(fd.num) v = Number(v);
const orig = fd.path==="name"?asset.name:getPath(asset,fd.path);
if(v!==orig && v!==undefined && v!=="") changes[fd.path]=v;
});
if(Object.keys(changes).length===0){ onClose(); return; }
api(`/api/assets/${asset.id}`,"PATCH",{changes}).then(onSaved).catch(e=>alert("Erreur : "+(e.detail||"inconnue")));
};
return (<div className="modal-bg" onClick={onClose}><div className="modal" onClick={e=>e.stopPropagation()}>
<h3>Modifier — {asset.name}</h3>
{asset.type==="parent" && <div className="locked" style={{marginBottom:10}}>La base amortissable ({fmt(asset.view_cpa.capitalizable_base_cad)} $) est calculée à partir des pièces.</div>}
<div style={{display:"grid", gridTemplateColumns:"1fr 1fr", gap:"0 14px"}}>
{fields.map(fd=>(
<div key={fd.path}>
<label className="fl">{fd.label}</label>
{fd.select==="cca" ? (
<select value={vals[fd.path] ?? ""} onChange={e=>up(fd.path, Number(e.target.value))}>
{cats.map(c=><option key={c.id} value={c.id}>Cat. {c.id} — {c.description} ({Math.round(c.dpa_rate*100)}%)</option>)}
</select>
) : (
<input type={fd.type||(fd.num?"number":"text")} step={fd.num?"0.01":undefined} value={vals[fd.path] ?? ""} onChange={e=>up(fd.path, e.target.value)}/>
)}
</div>
))}
</div>
{asset.type==="piece" && <div className="note fmt">Modifier le coût recalcule automatiquement la base de l'immobilisation parente.</div>}
{asset.type==="parent" && <div className="note">Chaque modification est journalisée dans la piste d'audit.</div>}
<div className="modal-actions">
<button className="act btn-cancel" onClick={onClose}>Annuler</button>
<button className="act btn-save" onClick={submit}>Enregistrer</button>
</div>
</div></div>);
}
function ExportModal({onClose}) {
const [data,setData]=useState(null);
useEffect(()=>{ api("/api/fiscal-export","GET").then(setData); },[]);
return (<div className="modal-bg" onClick={onClose}><div className="modal" onClick={e=>e.stopPropagation()}>
<h3>Export fiscal — immobilisations par catégorie</h3>
<div className="note cpa" style={{marginBottom:12}}>{NOTE_CPA}</div>
{!data && <div className="empty">Chargement…</div>}
{data && data.classes.length===0 && <div className="empty">Aucune immobilisation à exporter.</div>}
{data && data.classes.map(cls=>(
<div key={cls.cca_class_id} style={{marginBottom:20}}>
<div className="section-title">Catégorie {cls.cca_class_id} — {cls.description} · DPA {Math.round(cls.dpa_rate*100)}%</div>
<table className="export">
<thead><tr><th>Immobilisation</th><th>Étiquette</th><th className="num">Base CAD</th><th>Mise en service</th><th>½ an</th><th className="num">Pièces</th></tr></thead>
<tbody>
{cls.items.map(it=>(<tr key={it.id}><td>{it.name}</td><td>{it.inventory_tag||""}</td><td className="num">{fmt(it.base_cad)}</td><td>{it.in_service_date}</td><td>{it.half_year_rule?"Oui":"Non"}</td><td className="num">{it.pieces_count}</td></tr>))}
<tr className="total"><td colSpan="2">TOTAL Cat. {cls.cca_class_id}</td><td className="num">{fmt(cls.total_base_cad)}</td><td colSpan="3"></td></tr>
</tbody>
</table>
</div>
))}
<div className="modal-actions">
<button className="act btn-cancel" onClick={onClose}>Fermer</button>
<a className="act btn-save" style={{textAlign:"center",textDecoration:"none",lineHeight:"20px"}} href="/api/fiscal-export.csv">Télécharger CSV</a>
</div>
</div></div>);
}
function RegistryModal({assets, onClose}) {
const [allCats, setAllCats] = useState([]);
const [catId, setCatId] = useState(null);
const [data, setData] = useState(null);
const activeCatIds = [...new Set(assets.filter(a=>a.type==="parent").map(a=>a.view_cpa.cca_class.id))].sort((a,b)=>a-b);
useEffect(()=>{
api("/api/cca-categories","GET").then(cats=>{
setAllCats(cats);
const first = activeCatIds[0] ?? 50;
setCatId(first);
});
},[]);
useEffect(()=>{
if(catId!==null) api(`/api/cca-registry?cca_class_id=${catId}`,"GET").then(setData);
},[catId]);
const saveYear = (y, patch) => {
const base = {year:y.year, cca_class_id:catId, dispositions:Number(y.dispositions),
adjustment_coeff:Number(y.adjustment_coeff), pool_physically_empty: y.pool_physically_empty==="1"||y.pool_physically_empty===true,
additions_override: y.additions_override===""||y.additions_override==null?null:Number(y.additions_override)};
api("/api/cca-registry/year","POST",{...base, ...patch}).then(setData).catch(e=>alert("Erreur : "+(e.detail||"")));
};
const activeCats = allCats.filter(c=>activeCatIds.includes(c.id));
const catInfo = allCats.find(c=>c.id===catId);
return (<div className="modal-bg" onClick={onClose}><div className="modal" onClick={e=>e.stopPropagation()}>
<h3>Registre fiscal — pool DPA par catégorie</h3>
<div className="note cpa" style={{marginBottom:10}}>{NOTE_CPA}</div>
{activeCats.length > 1 && (
<div style={{marginBottom:12}}>
<label className="fl">Catégorie active</label>
<select value={catId??""} onChange={e=>{setCatId(Number(e.target.value)); setData(null);}}>
{activeCats.map(c=><option key={c.id} value={c.id}>Cat. {c.id} — {c.description} ({Math.round(c.dpa_rate*100)}%)</option>)}
</select>
</div>
)}
{!data && <div className="empty">Chargement…</div>}
{data && (<React.Fragment>
<div className="note" style={{marginBottom:10}}>
<b>Cat. {catId} — {catInfo?.description} ({Math.round((catInfo?.dpa_rate||0)*100)}%)</b> · Actifs physiques : <b>{data.physical_count}</b>.{" "}
Coeff 0,5 = demi-année standard.
</div>
{data.years.length===0 && <div className="empty">Aucune année. Ajoute des acquisitions datées, ou crée une année ci-dessous.</div>}
<table className="export">
<thead><tr>
<th>Année</th><th className="num">FNACC ouv.</th><th className="num">Ajouts</th>
<th className="num">Dispo.</th><th className="num">Coeff</th><th className="num">DPA</th>
<th className="num">FNACC clôt.</th><th>Vidé?</th>
</tr></thead>
<tbody>
{data.years.map(y=>(
<tr key={y.year}>
<td>{y.year}</td>
<td className="num">{fmt(y.opening_ucc)}</td>
<td className="num">{fmt(y.additions)}</td>
<td className="num">
<input style={{width:80,margin:0,padding:4}} type="number" defaultValue={y.dispositions}
onBlur={e=>saveYear({...y,dispositions:e.target.value})}/>
</td>
<td className="num">
<input style={{width:60,margin:0,padding:4}} type="number" step="0.1" min="0" max="1" defaultValue={y.adjustment_coeff}
onBlur={e=>saveYear({...y,adjustment_coeff:e.target.value})}/>
</td>
<td className="num" style={{color:Number(y.terminal_loss)>0?"#f87171":Number(y.recapture)>0?"#facc15":"#e2e8f0"}}>
{Number(y.terminal_loss)>0?"PF "+fmt(y.terminal_loss):Number(y.recapture)>0?"Réc "+fmt(y.recapture):fmt(y.cca_claimed)}
</td>
<td className="num">{fmt(y.closing_ucc)}</td>
<td><input type="checkbox" defaultChecked={false} onChange={e=>saveYear({...y,pool_physically_empty:e.target.checked})} title="Catégorie vidée perte finale"/></td>
</tr>
))}
</tbody>
</table>
<div className="note">PF = perte finale (déductible 100 %) · Réc = récupération (revenu imposable).</div>
<AddYearRow onAdd={(yr)=>saveYear({year:yr,dispositions:0,adjustment_coeff:0.5,pool_physically_empty:false,additions_override:null})}/>
</React.Fragment>)}
<div className="modal-actions">
<button className="act btn-cancel" onClick={onClose}>Fermer</button>
{catId && <a className="act btn-save" style={{textAlign:"center",textDecoration:"none",lineHeight:"20px"}} href={`/api/cca-registry.csv?cca_class_id=${catId}`}>Télécharger CSV</a>}
</div>
</div></div>);
}
function AddYearRow({onAdd}) {
const [yr,setYr]=useState(new Date().getFullYear());
return (<div style={{display:"flex",gap:8,alignItems:"flex-end",marginTop:12}}>
<div style={{flex:"0 0 140px"}}><label className="fl">Ajouter une année</label>
<input type="number" value={yr} onChange={e=>setYr(e.target.value)} style={{margin:0}}/></div>
<button className="act btn-attach" style={{flex:"0 0 120px"}} onClick={()=>onAdd(Number(yr))}>Ajouter</button>
</div>);
}
function Field({label, value}) { return <div className="field"><b>{label}</b>{String(value)}</div>; }
function DispForm({accounting, catId}) {
const detachYear = accounting.detachment_date ? Number(accounting.detachment_date.slice(0,4)) : new Date().getFullYear();
const [year, setYear] = useState(detachYear);
const [amount, setAmount] = useState(0);
const [done, setDone] = useState(false);
const [err, setErr] = useState(null);
if(done) return (
<div className="note" style={{color:"#4ade80",marginTop:10,borderLeft:"2px solid #4ade80",paddingLeft:6}}>
Disposition inscrite au pool Cat. {catId} — exercice {year} ({fmt(amount)} $).
</div>
);
return (
<div style={{marginTop:12,background:"#1e293b",border:"1px solid #475569",borderRadius:8,padding:12}}>
<div style={{fontWeight:600,marginBottom:8,fontSize:13}}>Inscrire la disposition au pool fiscal · Cat. {catId}</div>
<div style={{display:"grid",gridTemplateColumns:"1fr 1fr",gap:"0 12px"}}>
<div><label className="fl">Exercice fiscal</label>
<input type="number" value={year} onChange={e=>setYear(Number(e.target.value))} style={{margin:0}}/></div>
<div><label className="fl">Produit de disposition (CAD)</label>
<input type="number" step="0.01" value={amount} onChange={e=>setAmount(e.target.value)} style={{margin:0}}/></div>
</div>
<div className="note" style={{marginTop:6,marginBottom:8}}>
Chezlepro ne revend pas de pièces usagées → produit = <b>0 $</b>. N'inscrire un montant que si un produit réel a été encaissé.
</div>
{err && <div className="note" style={{color:"#f87171"}}>{err}</div>}
<button className="act btn-save" style={{width:"100%",marginTop:4}}
onClick={()=>{
api("/api/cca-registry/year/add-disposition","POST",{year:Number(year),cca_class_id:catId,amount:Number(amount)})
.then(()=>setDone(true)).catch(e=>setErr("Erreur : "+(e.detail||"inconnue")));
}}>
Confirmer ({Number(amount)===0?"aucun produit, pool inchangé":`${fmt(amount)} $ inscrits`})
</button>
</div>
);
}
function Annexe8Modal({onClose}) {
const [year, setYear] = useState(new Date().getFullYear()-1);
const [data, setData] = useState(null);
const loadData = y => api(`/api/annexe8?year=${y}`,"GET").then(setData).catch(()=>setData(null));
useEffect(()=>{ loadData(year); },[year]);
const totalDpa = data ? data.classes.reduce((s,c)=>s+(c.year_data?Number(c.year_data.cca_claimed):0),0) : 0;
return (<div className="modal-bg" onClick={onClose}><div className="modal" onClick={e=>e.stopPropagation()}>
<h3>Annexe 8 — Déduction pour amortissement (DPA)</h3>
<div className="note cpa" style={{marginBottom:12}}>{NOTE_CPA}</div>
<div style={{display:"flex",gap:10,alignItems:"flex-end",marginBottom:16}}>
<div style={{flex:"0 0 180px"}}>
<label className="fl">Exercice fiscal</label>
<input type="number" value={year} onChange={e=>setYear(Number(e.target.value))} style={{margin:0}}/>
</div>
<div className="note" style={{alignSelf:"center"}}>Chezlepro Inc. — toutes catégories actives</div>
</div>
{!data && <div className="empty">Chargement…</div>}
{data && data.classes.length===0 && <div className="empty">Aucune catégorie active.</div>}
{data && data.classes.length>0 && (
<React.Fragment>
<table className="export">
<thead><tr>
<th>Catégorie</th>
<th className="num">FNACC ouv.</th><th className="num">Ajouts</th>
<th className="num">Dispos.</th><th className="num">Ajust.</th>
<th className="num">Base DPA</th><th className="num">DPA</th>
<th className="num">FNACC clôt.</th>
</tr></thead>
<tbody>
{data.classes.map(cls=>{
const y = cls.year_data;
return (
<tr key={cls.cca_class_id}>
<td>Cat. {cls.cca_class_id}<br/><span style={{color:"#64748b",fontSize:10}}>{cls.description} · {Math.round(cls.dpa_rate*100)}%</span></td>
{y ? (<React.Fragment>
<td className="num">{fmt(y.opening_ucc)}</td>
<td className="num">{fmt(y.additions)}</td>
<td className="num">{fmt(y.dispositions)}</td>
<td className="num">{fmt(y.adjustment)}</td>
<td className="num">{fmt(y.base_for_cca)}</td>
<td className="num" style={{color:Number(y.terminal_loss)>0?"#f87171":Number(y.recapture)>0?"#facc15":"#e2e8f0"}}>
{Number(y.terminal_loss)>0?"PF "+fmt(y.terminal_loss):Number(y.recapture)>0?"Réc "+fmt(y.recapture):fmt(y.cca_claimed)}
</td>
<td className="num">{fmt(y.closing_ucc)}</td>
</React.Fragment>) : (
<td colSpan="7" style={{color:"#64748b"}}>— Aucune donnée pour {year} —</td>
)}
</tr>
);
})}
<tr className="total">
<td>TOTAL DPA réclamée</td><td colSpan="5"></td>
<td className="num">{fmt(totalDpa)}</td><td></td>
</tr>
</tbody>
</table>
<div className="note" style={{marginTop:8}}>PF = perte finale · Réc = récupération · Généré le {data.generated_at}</div>
</React.Fragment>
)}
<div className="modal-actions">
<button className="act btn-cancel" onClick={onClose}>Fermer</button>
<a className="act btn-save" style={{textAlign:"center",textDecoration:"none",lineHeight:"20px"}} href={`/api/annexe8.csv?year=${year}`}>Télécharger CSV</a>
</div>
</div></div>);
}
function RapportModal({onClose}) {
const thisYear = new Date().getFullYear();
const [year, setYear] = useState(thisYear - 1);
const [data, setData] = useState(null);
const [busy, setBusy] = useState(false);
const loadData = y => {
setBusy(true);
api(`/api/rapport-fiscal?year=${y}`,"GET")
.then(d=>{ setData(d); setBusy(false); })
.catch(()=>setBusy(false));
};
useEffect(()=>{ loadData(year); }, [year]);
const s1 = data?.schedule1;
const net = s1 ? s1.book_deprec_cad + s1.recapture_cad - s1.cca_claimed_cad - s1.terminal_loss_cad : 0;
return (<div className="modal-bg" onClick={onClose}><div className="modal" style={{width:820}} onClick={e=>e.stopPropagation()}>
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:16}}>
<h3 style={{margin:0}}>Rapport fiscal</h3>
<div style={{display:"flex",gap:8,alignItems:"center"}}>
<label style={{fontSize:12,color:"#94a3b8"}}>Exercice</label>
<select style={{width:90}} value={year} onChange={e=>setYear(Number(e.target.value))}>
{[...Array(6)].map((_,i)=>{const y=thisYear-1-i; return <option key={y} value={y}>{y}</option>;})}
</select>
<a href={`/api/rapport-fiscal.html?year=${year}`} target="_blank"
className="bar btn-export" style={{textDecoration:"none",padding:"9px 13px",fontSize:13}}>
Ouvrir rapport imprimable
</a>
</div>
</div>
{busy && <div className="empty">Calcul en cours…</div>}
{!busy && s1 && (<React.Fragment>
<p style={{fontSize:12,color:"#94a3b8",margin:"0 0 14px"}}>
Ajustements à inscrire dans l'<b>Annexe 1 / Schedule 1</b> de ton T2 et CO-17.
</p>
<table className="export">
<thead>
<tr><th>Ajustement</th><th>Ligne T2 Sch.&nbsp;1</th><th>Ligne CO-17 Ann.&nbsp;1</th><th style={{textAlign:"right"}}>Montant</th><th>Effet</th></tr>
</thead>
<tbody>
<tr>
<td>Amortissement comptable (add-back)<div style={{fontSize:10,color:"#64748b"}}>estimé selon durées de vie utiles</div></td>
<td>104</td><td>130</td>
<td className="num fmt">{fmt(s1.book_deprec_cad)} $</td>
<td style={{color:"#f87171"}}>+ revenu</td>
</tr>
<tr>
<td>Récupération d'amortissement</td>
<td>107</td><td>135</td>
<td className="num fmt">{s1.recapture_cad ? fmt(s1.recapture_cad)+" $" : ""}</td>
<td style={{color:"#f87171"}}>+ revenu</td>
</tr>
<tr style={{background:"#1e3a5f"}}>
<td><b>DPA réclamée — toutes catégories</b></td>
<td><b>410</b></td><td><b>150</b></td>
<td className="num fmt" style={{color:"#4ade80"}}><b>{fmt(s1.cca_claimed_cad)} $</b></td>
<td style={{color:"#4ade80"}}> revenu</td>
</tr>
<tr>
<td>Perte finale</td>
<td>414</td><td>155</td>
<td className="num fmt">{s1.terminal_loss_cad ? fmt(s1.terminal_loss_cad)+" $" : ""}</td>
<td style={{color:"#4ade80"}}> revenu</td>
</tr>
<tr style={{background:"#1e293b",borderTop:"2px solid #334155"}}>
<td colSpan={3}><b>{net > 0 ? "Augmentation nette du revenu imposable" : "Réduction nette du revenu imposable"}</b></td>
<td className="num fmt"><b style={{color:net>0?"#f87171":"#4ade80"}}>{fmt(Math.abs(net))} $</b></td>
<td style={{color:net>0?"#f87171":"#4ade80"}}><b>{net>0?"+":""}</b></td>
</tr>
</tbody>
</table>
<div className="note" style={{marginTop:12}}>
Numéros de lignes pour l'exercice {year}. Vérifier avec les formulaires officiels de l'année de production.
</div>
</React.Fragment>)}
<div className="modal-actions">
<button className="act btn-cancel" onClick={onClose}>Fermer</button>
</div>
</div></div>);
}
function BackupModal({onClose, onRestore}) {
const [backups, setBackups] = useState([]);
const [busy, setBusy] = useState(false);
const loadBackups = () => api("/api/backups","GET").then(setBackups);
useEffect(()=>{ loadBackups(); }, []);
const createBackup = () => {
setBusy(true);
api("/api/backup","POST")
.then(d=>setBackups(d.backups))
.catch(e=>alert("Erreur : "+(e.detail||"inconnue")))
.finally(()=>setBusy(false));
};
const restore = filename => {
if(!confirm(`Restaurer « ${filename} » ?\n\nToutes les données actuelles seront remplacées.`)) return;
setBusy(true);
api("/api/restore","POST",{filename})
.then(()=>onRestore())
.catch(e=>{ alert("Erreur : "+(e.detail||"inconnue")); setBusy(false); });
};
return (<div className="modal-bg" onClick={onClose}><div className="modal" onClick={e=>e.stopPropagation()}>
<h3>Sauvegardes</h3>
<button onClick={createBackup} disabled={busy} className="bar btn-new" style={{marginBottom:16}}>
{busy?"En cours":"Créer une sauvegarde maintenant"}
</button>
{backups.length===0
? <div className="empty">Aucune sauvegarde. Crée-en une ci-dessus.</div>
: <table className="export">
<thead><tr><th>Fichier</th><th>Date</th><th className="num">Taille</th><th></th></tr></thead>
<tbody>
{backups.map(b=>(
<tr key={b.filename}>
<td style={{fontFamily:"monospace",fontSize:12}}>{b.filename}</td>
<td>{b.created_at}</td>
<td className="num">{b.size_kb} ko</td>
<td><button onClick={()=>restore(b.filename)} disabled={busy} className="icon-btn" style={{borderColor:"#dc2626",color:"#f87171"}}>Restaurer</button></td>
</tr>
))}
</tbody>
</table>}
<div className="note" style={{marginTop:12}}>Sauvegardes stockées dans <code>data/backups/</code>.</div>
<div className="modal-actions">
<button className="act btn-cancel" onClick={onClose}>Fermer</button>
</div>
</div></div>);
}
ReactDOM.createRoot(document.getElementById("root")).render(<App />);
</script>
</body>
</html>