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>
1256 lines
73 KiB
HTML
1256 lines
73 KiB
HTML
<!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. 1</th><th>Ligne CO-17 Ann. 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>
|