Просмотр исходного кода

Initial commit. Good start. Working site

Bernn 2 месяцев назад
Сommit
7574af1746
5 измененных файлов с 818 добавлено и 0 удалено
  1. 0 0
      May2026PL.csv
  2. BIN
      _private/pl.db
  3. 111 0
      api.php
  4. 426 0
      index.php
  5. 281 0
      upload.php

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
May2026PL.csv


BIN
_private/pl.db


+ 111 - 0
api.php

@@ -0,0 +1,111 @@
+<?php
+header('Content-Type: application/json; charset=utf-8');
+header('Cache-Control: no-store');
+
+$dbPath = __DIR__ . '/_private/pl.db';
+
+if (!file_exists($dbPath) || filesize($dbPath) === 0) {
+    echo json_encode(['error' => 'Database not initialized. Please upload a CSV file first.']);
+    exit;
+}
+
+try {
+    $db = new PDO('sqlite:' . $dbPath);
+    $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
+    $db->exec('PRAGMA query_only = ON');
+} catch (Exception $e) {
+    echo json_encode(['error' => 'Database error: ' . $e->getMessage()]);
+    exit;
+}
+
+$action = $_GET['action'] ?? '';
+
+switch ($action) {
+
+    case 'months':
+        $rows = $db->query(
+            'SELECT id, label, year, month_num, sort_order, is_total, is_partial
+             FROM months ORDER BY sort_order'
+        )->fetchAll(PDO::FETCH_ASSOC);
+
+        foreach ($rows as &$r) {
+            $r['id']        = (int)$r['id'];
+            $r['year']      = $r['year'] !== null ? (int)$r['year'] : null;
+            $r['month_num'] = $r['month_num'] !== null ? (int)$r['month_num'] : null;
+            $r['sort_order']= (int)$r['sort_order'];
+            $r['is_total']  = (int)$r['is_total'];
+            $r['is_partial']= (int)$r['is_partial'];
+        }
+        echo json_encode($rows, JSON_UNESCAPED_UNICODE);
+        break;
+
+    case 'data':
+        $idsParam = $_GET['ids'] ?? '';
+        $idList   = array_unique(array_filter(array_map('intval', explode(',', $idsParam))));
+
+        if (empty($idList)) {
+            echo json_encode(['columns' => [], 'rows' => []]);
+            break;
+        }
+
+        $ph = implode(',', array_fill(0, count($idList), '?'));
+
+        // Columns in chronological order
+        $colStmt = $db->prepare(
+            "SELECT id, label, year, month_num, sort_order, is_total, is_partial
+             FROM months WHERE id IN ($ph) ORDER BY sort_order"
+        );
+        $colStmt->execute($idList);
+        $columns = $colStmt->fetchAll(PDO::FETCH_ASSOC);
+        foreach ($columns as &$c) {
+            $c['id']        = (int)$c['id'];
+            $c['year']      = $c['year'] !== null ? (int)$c['year'] : null;
+            $c['month_num'] = $c['month_num'] !== null ? (int)$c['month_num'] : null;
+            $c['is_total']  = (int)$c['is_total'];
+            $c['is_partial']= (int)$c['is_partial'];
+        }
+
+        // All account rows
+        $accounts = $db->query(
+            'SELECT id, name, row_type, indent_level FROM accounts ORDER BY sort_order'
+        )->fetchAll(PDO::FETCH_ASSOC);
+
+        // Values for the requested months
+        $valStmt = $db->prepare(
+            "SELECT account_id, month_id, value FROM pl_values WHERE month_id IN ($ph)"
+        );
+        $valStmt->execute($idList);
+
+        $values = [];
+        while ($v = $valStmt->fetch(PDO::FETCH_ASSOC)) {
+            $values[(int)$v['account_id']][(int)$v['month_id']] = (float)$v['value'];
+        }
+
+        $rows = [];
+        foreach ($accounts as $a) {
+            $aid  = (int)$a['id'];
+            $vals = $values[$aid] ?? [];
+            // Convert integer keys to string keys for clean JSON object
+            $out = new stdClass();
+            foreach ($vals as $mid => $val) {
+                $out->$mid = $val;
+            }
+            $rows[] = [
+                'id'     => $aid,
+                'name'   => $a['name'],
+                'type'   => $a['row_type'],
+                'indent' => (int)$a['indent_level'],
+                'values' => $out,
+            ];
+        }
+
+        echo json_encode(
+            ['columns' => $columns, 'rows' => $rows],
+            JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION
+        );
+        break;
+
+    default:
+        http_response_code(400);
+        echo json_encode(['error' => 'Unknown action']);
+}

+ 426 - 0
index.php

@@ -0,0 +1,426 @@
+<?php
+$dbPath = __DIR__ . '/_private/pl.db';
+$hasData = file_exists($dbPath) && filesize($dbPath) > 0;
+?>
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>P&amp;L Viewer — ICG Magnetics</title>
+<style>
+*{box-sizing:border-box;margin:0;padding:0}
+html,body{height:100%;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;font-size:13px;background:#f1f5f9;color:#1e293b}
+
+/* ─── Layout ─── */
+#app{display:flex;height:100vh;overflow:hidden}
+#sidebar{width:220px;min-width:220px;background:#1e293b;color:#e2e8f0;display:flex;flex-direction:column;overflow:hidden;flex-shrink:0}
+#main{flex:1;display:flex;flex-direction:column;min-width:0;overflow:hidden}
+
+/* ─── Sidebar header ─── */
+#sb-head{padding:14px 14px 10px;border-bottom:1px solid #334155;flex-shrink:0}
+#sb-head h1{font-size:14px;font-weight:700;color:#f8fafc;letter-spacing:-.2px;line-height:1.3}
+#sb-head .sub{font-size:10px;color:#64748b;margin-top:2px;letter-spacing:.2px;text-transform:uppercase}
+.upload-link{display:flex;align-items:center;gap:6px;margin-top:10px;padding:7px 10px;background:#3b82f6;color:white;border-radius:5px;text-decoration:none;font-size:12px;font-weight:600;justify-content:center}
+.upload-link:hover{background:#2563eb}
+
+/* ─── Quick-select ─── */
+#qsel{padding:10px 12px 8px;border-bottom:1px solid #334155;flex-shrink:0}
+#qsel .qlabel{font-size:9px;text-transform:uppercase;letter-spacing:.6px;color:#64748b;margin-bottom:6px}
+.qrow{display:flex;gap:4px;flex-wrap:wrap}
+.qb{flex:1;min-width:calc(50% - 2px);padding:5px 2px;background:#334155;border:none;border-radius:4px;color:#cbd5e1;font-size:11px;cursor:pointer;text-align:center;line-height:1.2}
+.qb:hover{background:#475569;color:#f1f5f9}
+
+/* ─── Year/month scroll ─── */
+#yr-scroll{flex:1;overflow-y:auto;padding:6px 0}
+#yr-scroll::-webkit-scrollbar{width:3px}
+#yr-scroll::-webkit-scrollbar-thumb{background:#334155;border-radius:2px}
+
+.yr-item{}
+.yr-row{display:flex;align-items:center;padding:5px 12px;cursor:pointer;user-select:none;gap:0}
+.yr-row:hover{background:#27374d}
+.yr-cb{margin-right:7px;accent-color:#3b82f6;cursor:pointer;flex-shrink:0}
+.yr-lbl{flex:1;font-size:12px;font-weight:600;color:#cbd5e1}
+.yr-arrow{font-size:9px;color:#64748b;transition:transform .15s;display:inline-block}
+.yr-arrow.open{transform:rotate(90deg)}
+
+.mo-list{display:none;padding:1px 0 4px 32px}
+.mo-list.open{display:block}
+.mo-row{display:flex;align-items:center;padding:3px 6px;cursor:pointer;border-radius:3px}
+.mo-row:hover{background:#27374d}
+.mo-cb{margin-right:7px;accent-color:#3b82f6;cursor:pointer}
+.mo-lbl{font-size:11px;color:#94a3b8;cursor:pointer}
+.mo-lbl.partial{color:#f59e0b}
+
+/* ─── Top bar ─── */
+#top-bar{background:white;border-bottom:1px solid #e2e8f0;padding:10px 18px;display:flex;align-items:center;gap:12px;flex-shrink:0;min-height:46px}
+#top-bar h2{font-size:14px;font-weight:700;color:#0f172a;white-space:nowrap}
+#sel-info{font-size:12px;color:#64748b}
+#total-wrap{margin-left:auto;display:flex;align-items:center;gap:5px;font-size:12px;color:#475569;white-space:nowrap}
+#total-wrap input{accent-color:#3b82f6}
+
+/* ─── Table container ─── */
+#tbl-wrap{flex:1;overflow:auto;padding:12px 16px}
+#tbl-wrap.loading{opacity:.4;pointer-events:none;transition:opacity .1s}
+
+#empty-state{text-align:center;padding:60px 20px;color:#94a3b8}
+#empty-state h3{font-size:16px;color:#64748b;margin-bottom:8px}
+#empty-state a{color:#3b82f6}
+
+/* ─── P&L Table ─── */
+table.plt{border-collapse:collapse;white-space:nowrap;font-size:12px;min-width:100%}
+
+/* Sticky header */
+table.plt thead th{
+  background:#1e293b;color:#e2e8f0;font-weight:600;
+  text-align:right;position:sticky;top:0;z-index:3;
+  padding:7px 10px;border-bottom:2px solid #0f172a;
+  font-size:11px;letter-spacing:.2px
+}
+table.plt thead th.col-name{
+  text-align:left;position:sticky;left:0;z-index:4;
+  width:220px;min-width:220px;max-width:220px
+}
+table.plt thead th.col-total{border-left:2px solid #475569}
+
+/* Sticky name column */
+table.plt td.col-name{
+  position:sticky;left:0;z-index:1;
+  width:220px;min-width:220px;max-width:220px;
+  overflow:hidden;text-overflow:ellipsis;
+  padding:5px 8px
+}
+
+/* Value cells */
+table.plt td.val{text-align:right;padding:5px 10px;font-variant-numeric:tabular-nums}
+table.plt td.val.neg{color:#ef4444}
+table.plt td.val.zero{color:#cbd5e1}
+table.plt td.col-total{border-left:2px solid #cbd5e1}
+
+/* indent */
+.i0{padding-left:8px!important}
+.i1{padding-left:18px!important}
+.i2{padding-left:30px!important}
+
+/* ── section header rows ── */
+tr.r-header td{
+  background:#0f172a!important;color:#94a3b8;
+  font-weight:700;font-size:10px;text-transform:uppercase;
+  letter-spacing:.6px;padding:8px 8px 5px
+}
+tr.r-header.L0 td{background:#0f172a!important;color:#64748b;padding-top:12px}
+tr.r-header.L1 td{background:#1e293b!important;color:#94a3b8}
+
+/* ── item rows ── */
+tr.r-item td{background:#fff;border-bottom:1px solid #f1f5f9}
+tr.r-item:nth-child(even) td{background:#f8fafc}
+tr.r-item td.col-name{background:inherit}
+
+/* ── subtotal rows ── */
+tr.r-subtotal td{
+  background:#dde3ed!important;font-weight:700;
+  border-top:1px solid #c7d0e0;border-bottom:1px solid #c7d0e0
+}
+tr.r-subtotal.L0 td{background:#c7d0e0!important;font-size:13px}
+
+/* ── grand total row ── */
+tr.r-total td{
+  background:#1e3a5f!important;color:#f0f9ff;
+  font-weight:800;font-size:13px;
+  border-top:3px solid #3b82f6
+}
+tr.r-total td.col-name{background:#1e3a5f!important;color:#f0f9ff}
+tr.r-total td.neg{color:#fca5a5!important}
+</style>
+</head>
+<body>
+<div id="app">
+
+  <!-- ═══ SIDEBAR ═══ -->
+  <div id="sidebar">
+    <div id="sb-head">
+      <h1>ICG Magnetics</h1>
+      <div class="sub">Profit &amp; Loss</div>
+      <a href="upload.php" class="upload-link">&#8593; Upload New CSV</a>
+    </div>
+
+    <div id="qsel">
+      <div class="qlabel">Quick Select</div>
+      <div class="qrow">
+        <button class="qb" onclick="qs('thisYear')">This Year</button>
+        <button class="qb" onclick="qs('lastYear')">Last Year</button>
+        <button class="qb" onclick="qs('ytd')">YTD</button>
+        <button class="qb" onclick="qs('last12')">Last 12 Mo</button>
+        <button class="qb" onclick="qs('none')">Clear</button>
+        <button class="qb" onclick="qs('all')">All</button>
+      </div>
+    </div>
+
+    <div id="yr-scroll">
+      <div id="yr-list">
+        <?php if (!$hasData): ?>
+        <div style="padding:20px 14px;color:#475569;font-size:12px;line-height:1.6">
+          No data yet.<br>
+          <a href="upload.php" style="color:#3b82f6">Upload a CSV</a> to get started.
+        </div>
+        <?php else: ?>
+        <div style="padding:16px;color:#475569;font-size:12px">Loading…</div>
+        <?php endif; ?>
+      </div>
+    </div>
+  </div><!-- /sidebar -->
+
+  <!-- ═══ MAIN ═══ -->
+  <div id="main">
+    <div id="top-bar">
+      <h2>Profit &amp; Loss Statement</h2>
+      <span id="sel-info"></span>
+      <div id="total-wrap">
+        <input type="checkbox" id="show-total" onchange="render()">
+        <label for="show-total">Show TOTAL column</label>
+      </div>
+    </div>
+
+    <div id="tbl-wrap">
+      <?php if (!$hasData): ?>
+      <div id="empty-state">
+        <h3>No Data Available</h3>
+        <p>Please <a href="upload.php">upload a QuickBooks P&amp;L CSV export</a> to get started.</p>
+      </div>
+      <?php endif; ?>
+    </div>
+  </div>
+
+</div><!-- /app -->
+
+<script>
+'use strict';
+
+// ── State ──
+let allMonths = [];          // flat array from API
+let yearMap   = {};          // year -> [{id, label, month_num, is_partial}, ...]
+let selected  = new Set();   // selected month IDs
+let plData    = null;        // last API /data response
+let debounce  = null;
+
+const MO = ['','Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
+
+// ── Boot ──
+<?php if ($hasData): ?>
+(async () => {
+  try {
+    const res = await fetch('api.php?action=months');
+    if (!res.ok) throw new Error(res.status);
+    allMonths = await res.json();
+    if (allMonths.error) throw new Error(allMonths.error);
+    buildSidebar();
+    qs('thisYear');
+  } catch(e) {
+    document.getElementById('yr-list').innerHTML =
+      '<div style="padding:14px;color:#f87171;font-size:12px">Error: '+esc(String(e.message))+'</div>';
+  }
+})();
+<?php endif; ?>
+
+// ── Build sidebar ──
+function buildSidebar() {
+  yearMap = {};
+  allMonths.forEach(m => {
+    if (m.is_total) return;
+    (yearMap[m.year] = yearMap[m.year] || []).push(m);
+  });
+
+  const years = Object.keys(yearMap).map(Number).sort((a,b)=>b-a);
+  const curY  = new Date().getFullYear();
+
+  let html = '';
+  years.forEach(y => {
+    const mos   = yearMap[y];
+    const open  = y >= curY - 1;
+    html += `<div class="yr-item" id="yi-${y}">
+      <div class="yr-row" onclick="toggleYear(${y})">
+        <input class="yr-cb" type="checkbox" id="yc-${y}"
+               onclick="event.stopPropagation();toggleYearCb(${y})">
+        <span class="yr-lbl">${y}</span>
+        <span class="yr-arrow${open?' open':''}" id="ya-${y}">&#9654;</span>
+      </div>
+      <div class="mo-list${open?' open':''}" id="ml-${y}">
+        ${mos.map(m=>`
+        <div class="mo-row">
+          <input class="mo-cb" type="checkbox" id="mc-${m.id}" value="${m.id}" onchange="onMoCb()">
+          <label class="mo-lbl${m.is_partial?' partial':''}" for="mc-${m.id}">${MO[m.month_num]}${m.is_partial?' <em>(partial)</em>':''}</label>
+        </div>`).join('')}
+      </div>
+    </div>`;
+  });
+  document.getElementById('yr-list').innerHTML = html;
+}
+
+// ── Sidebar interactions ──
+function toggleYear(y) {
+  document.getElementById('ml-'+y).classList.toggle('open');
+  document.getElementById('ya-'+y).classList.toggle('open');
+}
+
+function toggleYearCb(y) {
+  const cb = document.getElementById('yc-'+y);
+  (yearMap[y]||[]).forEach(m => {
+    const mcb = document.getElementById('mc-'+m.id);
+    if (!mcb) return;
+    mcb.checked = cb.checked;
+    cb.checked ? selected.add(m.id) : selected.delete(m.id);
+  });
+  syncYearCb(y);
+  schedule();
+}
+
+function onMoCb() {
+  selected.clear();
+  allMonths.forEach(m => {
+    const cb = document.getElementById('mc-'+m.id);
+    if (cb && cb.checked) selected.add(m.id);
+  });
+  Object.keys(yearMap).forEach(y => syncYearCb(+y));
+  schedule();
+}
+
+function syncYearCb(y) {
+  const cb  = document.getElementById('yc-'+y);
+  if (!cb) return;
+  const mos = yearMap[y] || [];
+  const n   = mos.filter(m => selected.has(m.id)).length;
+  cb.indeterminate = n > 0 && n < mos.length;
+  cb.checked = n === mos.length && mos.length > 0;
+}
+
+// ── Quick selects ──
+function qs(mode) {
+  const now  = new Date();
+  const curY = now.getFullYear();
+  const curM = now.getMonth() + 1;
+
+  allMonths.forEach(m => {
+    if (m.is_total) return;
+    let pick = false;
+    if      (mode==='all')      pick = true;
+    else if (mode==='thisYear') pick = m.year === curY;
+    else if (mode==='lastYear') pick = m.year === curY - 1;
+    else if (mode==='ytd')      pick = m.year === curY && m.month_num <= curM;
+    else if (mode==='last12') {
+      const md = m.year*12 + m.month_num;
+      const nd = curY*12 + curM;
+      pick = md > nd-12 && md <= nd;
+    }
+    const cb = document.getElementById('mc-'+m.id);
+    if (cb) cb.checked = pick;
+    pick ? selected.add(m.id) : selected.delete(m.id);
+  });
+
+  Object.keys(yearMap).forEach(y => syncYearCb(+y));
+  schedule();
+}
+
+// ── Data loading ──
+function schedule() {
+  clearTimeout(debounce);
+  debounce = setTimeout(load, 120);
+}
+
+async function load() {
+  if (selected.size === 0) {
+    document.getElementById('sel-info').textContent = '';
+    document.getElementById('tbl-wrap').innerHTML =
+      '<div style="padding:40px;text-align:center;color:#94a3b8;font-size:13px">Select months from the sidebar to view data.</div>';
+    return;
+  }
+
+  const wrap = document.getElementById('tbl-wrap');
+  wrap.classList.add('loading');
+
+  // Include TOTAL column id if show-total is checked
+  let ids = [...selected];
+  if (document.getElementById('show-total').checked) {
+    const tot = allMonths.find(m => m.is_total);
+    if (tot && !ids.includes(tot.id)) ids.push(tot.id);
+  }
+
+  try {
+    const res = await fetch('api.php?action=data&ids='+encodeURIComponent(ids.join(',')));
+    plData = await res.json();
+    render();
+  } catch(e) {
+    wrap.innerHTML = '<div style="padding:40px;text-align:center;color:#ef4444">Error loading data: '+esc(e.message)+'</div>';
+  } finally {
+    wrap.classList.remove('loading');
+  }
+}
+
+// ── Render table ──
+function render() {
+  if (!plData || !plData.rows) return;
+
+  const showTotal = document.getElementById('show-total').checked;
+  const cols = (plData.columns || []).filter(c => !c.is_total || showTotal);
+
+  const selCount = selected.size;
+  document.getElementById('sel-info').textContent =
+    selCount + ' month' + (selCount!==1?'s':'') + ' selected';
+
+  // Build header
+  let h = '<table class="plt"><thead><tr><th class="col-name">Account</th>';
+  cols.forEach(c => {
+    const cls = c.is_total ? ' col-total' : '';
+    h += `<th class="${cls}">${esc(c.label)}</th>`;
+  });
+  h += '</tr></thead><tbody>';
+
+  // Render rows — skip item rows where every selected column is 0/missing
+  plData.rows.forEach(row => {
+    if (row.type === 'item') {
+      const hasData = cols.some(c => {
+        const v = row.values[String(c.id)];
+        return v !== undefined && v !== null && parseFloat(v) !== 0;
+      });
+      if (!hasData) return;
+    }
+    const L    = row.indent;          // 0, 1, or 2
+    const rCls = 'r-'+row.type + (row.type==='header'||row.type==='subtotal' ? ' L'+L : '');
+    const iCls = 'i'+L;
+
+    if (row.type === 'header') {
+      h += `<tr class="${rCls}"><td colspan="${cols.length+1}" class="col-name ${iCls}">${esc(row.name)}</td></tr>`;
+      return;
+    }
+
+    h += `<tr class="${rCls}"><td class="col-name ${iCls}">${esc(row.name)}</td>`;
+    cols.forEach(c => {
+      const v   = row.values[String(c.id)];
+      const num = (v === undefined || v === null) ? 0 : parseFloat(v);
+      const vcls = 'val'
+        + (c.is_total ? ' col-total' : '')
+        + (num < 0 ? ' neg' : (num === 0 ? ' zero' : ''));
+      h += `<td class="${vcls}">${fmt(num)}</td>`;
+    });
+    h += '</tr>';
+  });
+
+  h += '</tbody></table>';
+  document.getElementById('tbl-wrap').innerHTML = h;
+}
+
+// ── Helpers ──
+function fmt(n) {
+  if (n === 0) return '—';
+  const abs = Math.abs(n);
+  const s   = abs.toLocaleString('en-US', {minimumFractionDigits:2, maximumFractionDigits:2});
+  return (n < 0 ? '-' : '') + '$' + s;
+}
+
+function esc(s) {
+  return String(s||'')
+    .replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
+    .replace(/"/g,'&quot;');
+}
+</script>
+</body>
+</html>

+ 281 - 0
upload.php

@@ -0,0 +1,281 @@
+<?php
+$dbPath = __DIR__ . '/_private/pl.db';
+$message = '';
+$messageType = '';
+
+function parseMonthLabel(string $label): ?array {
+    $label = trim($label);
+    $months = [
+        'Jan'=>1,'Feb'=>2,'Mar'=>3,'Apr'=>4,'May'=>5,'Jun'=>6,
+        'Jul'=>7,'Aug'=>8,'Sep'=>9,'Oct'=>10,'Nov'=>11,'Dec'=>12
+    ];
+    if (strtoupper($label) === 'TOTAL') {
+        return ['year'=>null,'month_num'=>null,'is_total'=>1,'is_partial'=>0];
+    }
+    // Partial month: "May 1 - 11, 26"
+    if (preg_match('/^(\w{3})\s+\d+\s*-\s*\d+,\s*(\d{2})$/', $label, $m)) {
+        $mn = $months[$m[1]] ?? null;
+        if (!$mn) return null;
+        $yy = (int)$m[2];
+        return ['year'=>$yy>=90?1900+$yy:2000+$yy,'month_num'=>$mn,'is_total'=>0,'is_partial'=>1];
+    }
+    // Standard: "May 90"
+    if (preg_match('/^(\w{3})\s+(\d{2})$/', $label, $m)) {
+        $mn = $months[$m[1]] ?? null;
+        if (!$mn) return null;
+        $yy = (int)$m[2];
+        return ['year'=>$yy>=90?1900+$yy:2000+$yy,'month_num'=>$mn,'is_total'=>0,'is_partial'=>0];
+    }
+    return null;
+}
+
+function importCSV(string $filepath, string $dbPath): array {
+    if (!file_exists($filepath) || !is_readable($filepath)) {
+        return ['ok'=>false,'msg'=>'File not found or not readable: '.$filepath];
+    }
+
+    $dir = dirname($dbPath);
+    if (!is_dir($dir) && !mkdir($dir, 0775, true)) {
+        return ['ok'=>false,'msg'=>'Cannot create database directory.'];
+    }
+
+    try {
+        $db = new PDO('sqlite:'.$dbPath);
+        $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
+    } catch (Exception $e) {
+        return ['ok'=>false,'msg'=>'Cannot open database: '.$e->getMessage()];
+    }
+
+    $db->exec('PRAGMA journal_mode=WAL');
+    $db->exec('DROP TABLE IF EXISTS pl_values');
+    $db->exec('DROP TABLE IF EXISTS accounts');
+    $db->exec('DROP TABLE IF EXISTS months');
+
+    $db->exec('CREATE TABLE months (
+        id INTEGER PRIMARY KEY AUTOINCREMENT,
+        label TEXT NOT NULL,
+        year INTEGER,
+        month_num INTEGER,
+        sort_order INTEGER NOT NULL,
+        is_total INTEGER NOT NULL DEFAULT 0,
+        is_partial INTEGER NOT NULL DEFAULT 0
+    )');
+    $db->exec('CREATE TABLE accounts (
+        id INTEGER PRIMARY KEY AUTOINCREMENT,
+        name TEXT NOT NULL,
+        row_type TEXT NOT NULL,
+        indent_level INTEGER NOT NULL DEFAULT 0,
+        sort_order INTEGER NOT NULL
+    )');
+    $db->exec('CREATE TABLE pl_values (
+        account_id INTEGER NOT NULL,
+        month_id INTEGER NOT NULL,
+        value REAL NOT NULL DEFAULT 0,
+        PRIMARY KEY (account_id, month_id)
+    )');
+
+    $handle = fopen($filepath, 'r');
+    if (!$handle) return ['ok'=>false,'msg'=>'Cannot open file.'];
+
+    // Strip UTF-8 BOM if present
+    $bom = fread($handle, 3);
+    if ($bom !== "\xEF\xBB\xBF") rewind($handle);
+
+    $header = fgetcsv($handle);
+    if (!$header) { fclose($handle); return ['ok'=>false,'msg'=>'Empty or invalid CSV.']; }
+
+    $db->beginTransaction();
+
+    // Insert months from header (col 0 is the row-label column, skip it)
+    $monthStmt = $db->prepare(
+        'INSERT INTO months (label, year, month_num, sort_order, is_total, is_partial) VALUES (?,?,?,?,?,?)'
+    );
+    $monthIds = []; // col_index => month.id
+
+    for ($i = 1; $i < count($header); $i++) {
+        $label = trim($header[$i]);
+        if ($label === '') continue;
+        $parsed = parseMonthLabel($label);
+        if (!$parsed || $parsed['is_partial']) continue;
+        if ($parsed['year'] !== null && $parsed['year'] < 1996) continue;
+        $monthStmt->execute([
+            $label, $parsed['year'], $parsed['month_num'],
+            $i, $parsed['is_total'], $parsed['is_partial']
+        ]);
+        $monthIds[$i] = (int)$db->lastInsertId();
+    }
+
+    // Known row classifications
+    $level0Headers  = ['Ordinary Income/Expense','Other Income/Expense'];
+    $level1Headers  = ['Income','Cost of Goods Sold','Expense','Other Income','Other Expense'];
+    $level0Subtotals = ['Gross Profit','Net Ordinary Income','Net Other Income'];
+    $level1Subtotals = ['Total Income','Total COGS','Total Expense','Total Other Income','Total Other Expense'];
+    $totals          = ['Net Income'];
+
+    $accountStmt = $db->prepare(
+        'INSERT INTO accounts (name, row_type, indent_level, sort_order) VALUES (?,?,?,?)'
+    );
+    $valueStmt = $db->prepare(
+        'INSERT OR REPLACE INTO pl_values (account_id, month_id, value) VALUES (?,?,?)'
+    );
+
+    $sortOrder    = 0;
+    $accountCount = 0;
+    $valueCount   = 0;
+
+    while (($row = fgetcsv($handle)) !== false) {
+        if (empty($row)) continue;
+        $name = trim($row[0] ?? '');
+        if ($name === '') continue;
+
+        // Detect whether any value columns are non-empty
+        $hasValues = false;
+        for ($i = 1; $i < count($row); $i++) {
+            if (isset($row[$i]) && trim($row[$i]) !== '') { $hasValues = true; break; }
+        }
+
+        if (!$hasValues) {
+            $rowType = 'header';
+            $indent  = in_array($name, $level0Headers) ? 0 : 1;
+        } elseif (in_array($name, $totals)) {
+            $rowType = 'total';
+            $indent  = 0;
+        } elseif (in_array($name, $level0Subtotals)) {
+            $rowType = 'subtotal';
+            $indent  = 0;
+        } elseif (in_array($name, $level1Subtotals)) {
+            $rowType = 'subtotal';
+            $indent  = 1;
+        } else {
+            $rowType = 'item';
+            $indent  = 2;
+        }
+
+        $accountStmt->execute([$name, $rowType, $indent, $sortOrder++]);
+        $accountId = (int)$db->lastInsertId();
+        $accountCount++;
+
+        if ($hasValues) {
+            for ($i = 1; $i < count($row); $i++) {
+                if (!isset($monthIds[$i])) continue;
+                $raw = trim($row[$i] ?? '');
+                if ($raw === '') continue;
+                $val = (float)str_replace([',', ' '], '', $raw);
+                $valueStmt->execute([$accountId, $monthIds[$i], $val]);
+                $valueCount++;
+            }
+        }
+    }
+
+    $db->commit();
+    fclose($handle);
+
+    $numMonths = count($monthIds);
+    return [
+        'ok'  => true,
+        'msg' => "Successfully imported $accountCount accounts across $numMonths months ($valueCount data points)."
+    ];
+}
+
+// Handle POST
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+    if (isset($_POST['import_existing'])) {
+        $csvPath = __DIR__ . '/May2026PL.csv';
+        $result  = importCSV($csvPath, $dbPath);
+    } elseif (isset($_FILES['csvfile']) && $_FILES['csvfile']['error'] === UPLOAD_ERR_OK) {
+        $result = importCSV($_FILES['csvfile']['tmp_name'], $dbPath);
+    } else {
+        $uploadErr = $_FILES['csvfile']['error'] ?? -1;
+        $result    = ['ok'=>false,'msg'=>'Upload error (code '.$uploadErr.'). Check php.ini upload_max_filesize.'];
+    }
+    $message     = $result['msg'];
+    $messageType = $result['ok'] ? 'success' : 'error';
+}
+
+$hasExistingCsv = file_exists(__DIR__ . '/May2026PL.csv');
+$hasDb          = file_exists($dbPath) && filesize($dbPath) > 0;
+?>
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<title>Upload P&amp;L Data — ICG Magnetics</title>
+<style>
+*{box-sizing:border-box;margin:0;padding:0}
+body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f1f5f9;color:#1e293b;min-height:100vh;display:flex;align-items:center;justify-content:center}
+.card{background:white;border-radius:12px;box-shadow:0 4px 24px rgba(0,0,0,.08);width:100%;max-width:520px;padding:36px 40px}
+.card h1{font-size:22px;font-weight:700;color:#0f172a;margin-bottom:4px}
+.card .sub{font-size:13px;color:#64748b;margin-bottom:28px}
+.section{margin-bottom:24px;padding:20px;border:1px solid #e2e8f0;border-radius:8px}
+.section h2{font-size:13px;font-weight:600;color:#475569;text-transform:uppercase;letter-spacing:.5px;margin-bottom:14px}
+.btn{display:inline-block;padding:10px 20px;border-radius:6px;font-size:14px;font-weight:600;cursor:pointer;border:none;transition:.15s}
+.btn-primary{background:#3b82f6;color:white}
+.btn-primary:hover{background:#2563eb}
+.btn-secondary{background:#e2e8f0;color:#475569}
+.btn-secondary:hover{background:#cbd5e1}
+.btn-view{background:#10b981;color:white;text-decoration:none;display:inline-block;padding:10px 20px;border-radius:6px;font-size:14px;font-weight:600}
+.btn-view:hover{background:#059669}
+input[type=file]{display:block;width:100%;padding:10px;border:2px dashed #cbd5e1;border-radius:6px;margin-bottom:12px;font-size:13px;color:#475569;cursor:pointer;background:#f8fafc}
+input[type=file]:hover{border-color:#94a3b8}
+.alert{padding:14px 16px;border-radius:6px;font-size:13px;margin-bottom:20px;line-height:1.5}
+.alert.success{background:#d1fae5;color:#065f46;border:1px solid #6ee7b7}
+.alert.error{background:#fee2e2;color:#991b1b;border:1px solid #fca5a5}
+.back{display:inline-block;margin-top:20px;color:#3b82f6;text-decoration:none;font-size:13px}
+.back:hover{text-decoration:underline}
+.note{font-size:11px;color:#94a3b8;margin-top:8px;line-height:1.5}
+.existing-badge{display:inline-block;background:#d1fae5;color:#065f46;font-size:11px;padding:2px 7px;border-radius:10px;margin-left:6px;font-weight:500}
+.overwrite-warning{font-size:12px;color:#b45309;margin-top:10px;padding:8px 12px;background:#fef3c7;border-radius:5px}
+</style>
+</head>
+<body>
+<div class="card">
+  <h1>Upload P&amp;L Data</h1>
+  <div class="sub">ICG Magnetics · QuickBooks CSV Import</div>
+
+  <?php if ($message): ?>
+  <div class="alert <?= htmlspecialchars($messageType) ?>">
+    <?= htmlspecialchars($message) ?>
+    <?php if ($messageType === 'success'): ?>
+    <div style="margin-top:12px">
+      <a href="index.php" class="btn-view">View P&amp;L →</a>
+    </div>
+    <?php endif; ?>
+  </div>
+  <?php endif; ?>
+
+  <?php if ($hasExistingCsv): ?>
+  <div class="section">
+    <h2>Quick Import <span class="existing-badge">File Found</span></h2>
+    <p style="font-size:13px;color:#475569;margin-bottom:12px">
+      Found <strong>May2026PL.csv</strong> on this server. Click to import it directly.
+    </p>
+    <?php if ($hasDb): ?>
+    <div class="overwrite-warning">⚠ This will replace all existing P&amp;L data.</div>
+    <?php endif; ?>
+    <form method="post" style="margin-top:12px">
+      <button type="submit" name="import_existing" class="btn btn-primary">Import May2026PL.csv</button>
+    </form>
+  </div>
+  <?php endif; ?>
+
+  <div class="section">
+    <h2>Upload New CSV</h2>
+    <p style="font-size:13px;color:#475569;margin-bottom:12px">
+      Upload a QuickBooks P&amp;L export (by month). All existing data will be replaced.
+    </p>
+    <?php if ($hasDb): ?>
+    <div class="overwrite-warning">⚠ This will replace all existing P&amp;L data.</div>
+    <?php endif; ?>
+    <form method="post" enctype="multipart/form-data" style="margin-top:12px">
+      <input type="file" name="csvfile" accept=".csv,text/csv">
+      <div class="note">Export from QuickBooks: Reports → Profit &amp; Loss → Columns: Month → Export to CSV</div>
+      <button type="submit" class="btn btn-primary" style="margin-top:12px">Upload &amp; Import</button>
+    </form>
+  </div>
+
+  <?php if ($hasDb && !$message): ?>
+  <a href="index.php" class="back">← Back to P&amp;L Viewer</a>
+  <?php endif; ?>
+</div>
+</body>
+</html>

Некоторые файлы не были показаны из-за большого количества измененных файлов