410 lines · 16.5 KB
Raw Download
1
<?php
2
require_once __DIR__ . '/inc/layout.php';
3
require_once __DIR__ . '/inc/report.php';
4
5
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
6
header('Pragma: no-cache');
7
header('Expires: 0');
8
9
// The cache-buster redirect must not swallow bulk-edit POSTs.
10
cache_buster_redirect();
11
12
// Fallback scheduler for the daily email report: if one is due and no cron
13
// job fired it, the first inventory view of the day sends it.
14
maybe_send_daily_report();
15
16
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'bulk') {
17
    require_login();
18
    check_csrf();
19
20
    $ids = array_values(array_filter(array_map('intval', (array)($_POST['ids'] ?? []))));
21
22
    // Only fields the user actually filled in become part of the UPDATE;
23
    // everything left at "keep" is untouched on the selected spools.
24
    $set = [];
25
    $vals = [];
26
    $type = trim((string)($_POST['type'] ?? ''));
27
    if ($type !== '') {
28
        $set[] = 'type=?';
29
        $vals[] = $type;
30
    }
31
    $brand = trim((string)($_POST['brand'] ?? ''));
32
    if ($brand !== '') {
33
        $set[] = 'brand=?';
34
        $vals[] = $brand;
35
    }
36
    $cost = trim((string)($_POST['cost'] ?? ''));
37
    if ($cost !== '') {
38
        $set[] = 'cost=?';
39
        $vals[] = (float)$cost;
40
    }
41
    if (isset($_POST['set_color']) && preg_match('/^#[0-9a-fA-F]{6}$/', (string)($_POST['color_hex'] ?? ''))) {
42
        $set[] = 'color_hex=?';
43
        $vals[] = (string)$_POST['color_hex'];
44
    }
45
    foreach (['abrasive', 'highflow', 'silk', 'matte', 'rainbow', 'transparent', 'twotone', 'sparkle'] as $flag) {
46
        $v = (string)($_POST['f_' . $flag] ?? '');
47
        if ($v === '0' || $v === '1') {
48
            $set[] = "$flag=?";
49
            $vals[] = (int)$v;
50
        }
51
    }
52
53
    if (!$ids) {
54
        flash('Select at least one spool to bulk edit.');
55
        header('Location: index.php?bulk=1');
56
        exit;
57
    }
58
    if (!$set) {
59
        flash('Set at least one field to change.');
60
        header('Location: index.php?bulk=1');
61
        exit;
62
    }
63
64
    $ph = implode(',', array_fill(0, count($ids), '?'));
65
    backup_db();
66
    db()->prepare(
67
        'UPDATE filaments SET ' . implode(', ', $set) . ", updated_at=datetime('now') WHERE id IN ($ph)"
68
    )->execute(array_merge($vals, $ids));
69
    flash('Updated ' . count($ids) . ' spool' . (count($ids) === 1 ? '' : 's') . '.');
70
    header('Location: index.php');
71
    exit;
72
}
73
74
if (!is_logged_in() && !config()['public_view']) {
75
    header('Location: login.php');
76
    exit;
77
}
78
79
// hex_hsl(), hue_key(), pie_color() and spool_pct() live in inc/layout.php —
80
// use.php renders the same swatch.
81
82
$sorts = [
83
    'used'  => '(last_used_at IS NULL), last_used_at DESC, brand COLLATE NOCASE, type COLLATE NOCASE',
84
    'type'  => 'type COLLATE NOCASE, color_name COLLATE NOCASE, brand COLLATE NOCASE',
85
    'color' => 'id', // real ordering happens in PHP below (hue needs math SQLite can't do)
86
    'name'  => "(color_name IS NULL OR color_name = ''), color_name COLLATE NOCASE, brand COLLATE NOCASE",
87
    'brand' => "(brand IS NULL OR brand = ''), brand COLLATE NOCASE, type COLLATE NOCASE",
88
    'left'  => 'remaining_g DESC, type COLLATE NOCASE',
89
    'added' => 'created_at DESC, id DESC',
90
];
91
$sortLabels = [
92
    'used'  => 'Recently used', 'type' => 'Type', 'color' => 'Color', 'name' => 'Color name',
93
    'brand' => 'Manufacturer', 'left' => 'Amount left', 'added' => 'Recently added',
94
];
95
96
$sort = $_GET['sort'] ?? $_SESSION['sort'] ?? 'used';
97
if (!is_string($sort) || !isset($sorts[$sort])) {
98
    $sort = 'used';
99
}
100
$_SESSION['sort'] = $sort;
101
102
// List or color-grid view, remembered in the session like the sort.
103
$view = $_GET['view'] ?? $_SESSION['view'] ?? 'list';
104
if (!is_string($view) || !in_array($view, ['list', 'grid'], true)) {
105
    $view = 'list';
106
}
107
$_SESSION['view'] = $view;
108
109
$rows = db()->query('SELECT * FROM filaments ORDER BY ' . $sorts[$sort])->fetchAll();
110
if ($sort === 'color') {
111
    // Rainbow spools have no single hue — group them ahead of the hue wheel.
112
    $colorKey = fn(array $r) => !empty($r['rainbow']) ? [-1, 0.0, 0.0] : hue_key($r['color_hex']);
113
    usort($rows, fn($a, $b) => $colorKey($a) <=> $colorKey($b));
114
}
115
116
$cur = config()['currency'];
117
118
// ---- filters: AND across the groups, OR within a group ----
119
$flagDefs = ['abrasive' => 'Abrasive', 'highflow' => 'High-flow', 'silk' => 'Silk', 'matte' => 'Matte',
120
             'rainbow' => 'Rainbow', 'twotone' => 'Two-tone', 'transparent' => 'Clear', 'sparkle' => 'Sparkle'];
121
$remDefs  = ['full' => 'Nearly full (85%+)', 'partial' => 'Partly used', 'low' => 'Low (under 15%)'];
122
123
$clean   = fn($v) => array_values(array_unique(array_filter((array)$v, fn($s) => is_string($s) && $s !== '')));
124
$lc      = fn(?string $s) => mb_strtolower(trim((string)$s));
125
$fTypes  = $clean($_GET['ft'] ?? []);
126
$fBrands = $clean($_GET['fb'] ?? []);
127
$fBrandKeys = array_map($lc, $fBrands);
128
$fRem    = array_values(array_intersect(array_keys($remDefs), $clean($_GET['fr'] ?? [])));
129
$fProps  = array_values(array_intersect(array_keys($flagDefs), $clean($_GET['fp'] ?? [])));
130
$q       = trim((string)($_GET['q'] ?? ''));
131
$nFilters = count($fTypes) + count($fBrands) + count($fRem) + count($fProps) + ($q !== '' ? 1 : 0);
132
$filterOpen = isset($_GET['filter']) || $nFilters > 0;
133
134
$allCount = count($rows);
135
if ($nFilters) {
136
    $rows = array_values(array_filter($rows, function (array $r) use ($fTypes, $fBrandKeys, $fRem, $fProps, $q, $lc) {
137
        if ($fTypes && !in_array($r['type'], $fTypes, true)) {
138
            return false;
139
        }
140
        if ($fBrandKeys && !in_array($lc($r['brand']), $fBrandKeys, true)) {
141
            return false;
142
        }
143
        if ($fRem) {
144
            $pct = spool_pct($r);
145
            $bucket = $pct >= 85 ? 'full' : ($pct < 15 ? 'low' : 'partial');
146
            if (!in_array($bucket, $fRem, true)) {
147
                return false;
148
            }
149
        }
150
        if ($fProps) {
151
            $any = false;
152
            foreach ($fProps as $f) {
153
                if (!empty($r[$f])) {
154
                    $any = true;
155
                    break;
156
                }
157
            }
158
            if (!$any) {
159
                return false;
160
            }
161
        }
162
        if ($q !== '' && !str_contains($lc($r['type'] . ' ' . $r['brand'] . ' ' . $r['color_name'] . ' ' . $r['notes']), $lc($q))) {
163
            return false;
164
        }
165
        return true;
166
    }));
167
}
168
169
// index.php URL keeping the current bulk/filter query; null drops a key.
170
// sort is left out — the session remembers it.
171
function index_url(array $overrides = []): string
172
{
173
    $params = $_GET;
174
    unset($params['cb'], $params['sort']);
175
    foreach ($overrides as $k => $v) {
176
        if ($v === null) {
177
            unset($params[$k]);
178
        } else {
179
            $params[$k] = $v;
180
        }
181
    }
182
    $qs = http_build_query($params);
183
    return 'index.php' . ($qs === '' ? '' : '?' . $qs);
184
}
185
186
// One spool's full card: list view renders it directly (optionally with the
187
// bulk-edit checkbox), the color grid renders it hidden with a DOM id so
188
// tapping the spool's mini swatch can reveal it.
189
function spool_card(array $r, bool $bulk = false, ?string $domId = null): void
190
{
191
    $cur = config()['currency'];
192
    $pct = spool_pct($r);
193
    $details = [round((float)$r['remaining_g']) . ' g left (' . $pct . '%)'];
194
    if ($r['cost'] !== null && $r['cost'] !== '') {
195
        $details[] = $cur . number_format((float)$r['cost'], 2);
196
    }
197
    // A part-used spool with no logged usage has an unknown history —
198
    // "never used" would be wrong, so the label is only shown when there is
199
    // a usage date or the spool is still full.
200
    if ($r['last_used_at'] || $pct >= 100) {
201
        $details[] = rel_time($r['last_used_at']);
202
    }
203
?>
204
<div class="card row"<?= $domId !== null ? ' id="' . e($domId) . '" hidden' : '' ?>>
205
<?php if ($bulk): ?>
206
  <label class="bulkpick"><input type="checkbox" name="ids[]" value="<?= (int)$r['id'] ?>" aria-label="Select <?= e(spool_label($r)) ?>"></label>
207
<?php endif; ?>
208
  <?= swatch_html($r) ?>
209
  <div class="grow">
210
    <strong><?= e($r['type']) ?></strong><?php if (!empty($r['color_name'])): ?> <?= e($r['color_name']) ?><?php endif; ?><?php if ($r['brand']): ?> · <?= e($r['brand']) ?><?php endif; ?>
211
    <?php if ($r['abrasive']): ?><span class="badge">Abrasive</span><?php endif; ?>
212
    <?php if ($r['highflow']): ?><span class="badge">High-flow</span><?php endif; ?>
213
    <?php if ($r['silk']): ?><span class="badge">Silk</span><?php endif; ?>
214
    <?php if ($r['matte']): ?><span class="badge">Matte</span><?php endif; ?>
215
    <?php if ($r['transparent']): ?><span class="badge">Clear</span><?php endif; ?>
216
    <?php if ($r['sparkle']): ?><span class="badge">Sparkle</span><?php endif; ?>
217
    <div class="muted"><?= e(implode(' · ', $details)) ?></div>
218
    <?php if ($r['notes']): ?><div class="muted"><?= e($r['notes']) ?></div><?php endif; ?>
219
    <div class="bar"><i style="width:<?= $pct ?>%"></i></div>
220
  </div>
221
<?php if (is_logged_in() && !$bulk): ?>
222
  <div class="rowlinks">
223
    <a href="use.php?id=<?= (int)$r['id'] ?>">Use</a>
224
    <a href="spool.php?id=<?= (int)$r['id'] ?>">Edit</a>
225
  </div>
226
<?php endif; ?>
227
</div>
228
<?php
229
}
230
231
$bulk = is_logged_in() && isset($_GET['bulk']) && $rows;
232
if ($bulk) {
233
    $view = 'list'; // bulk edit needs the per-row checkboxes of the list view
234
}
235
236
// Types and brands actually in the inventory: the filter checkbox lists,
237
// plus brand autocomplete for the bulk-edit form.
238
$allTypes = db()->query('SELECT type FROM filaments GROUP BY type COLLATE NOCASE ORDER BY type COLLATE NOCASE')->fetchAll(PDO::FETCH_COLUMN);
239
$brands = db()->query(
240
    "SELECT brand FROM filaments WHERE brand IS NOT NULL AND brand <> ''
241
     GROUP BY brand COLLATE NOCASE ORDER BY brand COLLATE NOCASE"
242
)->fetchAll(PDO::FETCH_COLUMN);
243
244
page_header('Inventory');
245
246
?>
247
<h1>Inventory</h1>
248
<?php if (is_logged_in() && !$bulk): ?>
249
<div class="actions">
250
  <a class="btn" href="spool.php">+ Add spool</a>
251
  <a class="btn secondary" href="use.php">Use filament</a>
252
  <?php if ($rows): ?><a class="btn secondary" href="<?= e(index_url(['bulk' => 1])) ?>">Bulk edit</a><?php endif; ?>
253
</div>
254
<?php endif; ?>
255
<?php if (!$allCount): ?>
256
<p class="muted">No filaments yet<?= is_logged_in() ? ' — add your first spool above.' : '.' ?></p>
257
<?php else: ?>
258
<form method="get" id="listCtl">
259
  <div class="sortbar">
260
    <label for="sortSel">Sort</label>
261
    <select id="sortSel" name="sort">
262
      <?php foreach ($sortLabels as $k => $label): ?>
263
      <option value="<?= e($k) ?>" <?= $k === $sort ? 'selected' : '' ?>><?= e($label) ?></option>
264
      <?php endforeach; ?>
265
    </select>
266
    <?php if ($bulk): ?><input type="hidden" name="bulk" value="1"><?php endif; ?>
267
    <?php if (!$bulk): ?>
268
    <a class="btn secondary btn-sm" href="<?= e(index_url(['view' => $view === 'grid' ? 'list' : 'grid'])) ?>"><?= $view === 'grid' ? 'List' : 'Grid' ?></a>
269
    <?php endif; ?>
270
    <?php if ($filterOpen): ?>
271
    <input type="hidden" name="filter" value="1">
272
    <?php else: ?>
273
    <a class="btn secondary btn-sm" href="<?= e(index_url(['filter' => 1])) ?>">Filters</a>
274
    <?php endif; ?>
275
    <noscript><button>Apply</button></noscript>
276
  </div>
277
<?php if ($filterOpen): ?>
278
  <div class="card filterpanel">
279
    <div class="filtersec"><strong>Type</strong>
280
      <div class="check-row">
281
        <?php foreach ($allTypes as $t): ?>
282
        <label class="check"><input type="checkbox" name="ft[]" value="<?= e($t) ?>" <?= in_array($t, $fTypes, true) ? 'checked' : '' ?>> <?= e($t) ?></label>
283
        <?php endforeach; ?>
284
      </div>
285
    </div>
286
    <?php if ($brands): ?>
287
    <div class="filtersec"><strong>Manufacturer</strong>
288
      <div class="check-row">
289
        <?php foreach ($brands as $b): ?>
290
        <label class="check"><input type="checkbox" name="fb[]" value="<?= e($b) ?>" <?= in_array($lc($b), $fBrandKeys, true) ? 'checked' : '' ?>> <?= e($b) ?></label>
291
        <?php endforeach; ?>
292
      </div>
293
    </div>
294
    <?php endif; ?>
295
    <div class="filtersec"><strong>Amount left</strong>
296
      <div class="check-row">
297
        <?php foreach ($remDefs as $k => $label): ?>
298
        <label class="check"><input type="checkbox" name="fr[]" value="<?= e($k) ?>" <?= in_array($k, $fRem, true) ? 'checked' : '' ?>> <?= e($label) ?></label>
299
        <?php endforeach; ?>
300
      </div>
301
    </div>
302
    <div class="filtersec"><strong>Properties</strong>
303
      <div class="check-row">
304
        <?php foreach ($flagDefs as $k => $label): ?>
305
        <label class="check"><input type="checkbox" name="fp[]" value="<?= e($k) ?>" <?= in_array($k, $fProps, true) ? 'checked' : '' ?>> <?= e($label) ?></label>
306
        <?php endforeach; ?>
307
      </div>
308
    </div>
309
    <div class="filtersec"><strong>Search</strong>
310
      <input type="search" name="q" value="<?= e($q) ?>" placeholder="Color name, notes, brand…" enterkeyhint="search">
311
    </div>
312
    <div class="actions" style="margin:10px 0 0">
313
      <noscript><button>Apply filters</button></noscript>
314
      <a class="btn secondary" href="<?= e(index_url(['ft' => null, 'fb' => null, 'fr' => null, 'fp' => null, 'q' => null, 'filter' => null])) ?>"><?= $nFilters ? 'Clear filters' : 'Close' ?></a>
315
      <?php if ($nFilters): ?><span class="muted" style="align-self:center">Showing <?= count($rows) ?> of <?= $allCount ?> spools</span><?php endif; ?>
316
    </div>
317
  </div>
318
<?php endif; ?>
319
</form>
320
<?php if (!$rows): ?>
321
<p class="muted">No spools match the filters.</p>
322
<?php endif; ?>
323
<?php endif; ?>
324
<?php if ($bulk): ?>
325
<form method="post" action="index.php">
326
  <?= csrf_field() ?>
327
  <input type="hidden" name="action" value="bulk">
328
  <div class="card">
329
    <strong>Bulk edit</strong>
330
    <p class="muted">Tick the spools below, set only the fields you want to change (everything on "keep" stays as is), then apply.</p>
331
    <div class="bulkgrid">
332
      <label>Type
333
        <select name="type">
334
          <option value="">— keep —</option>
335
          <?php foreach (filament_types() as $t): ?><option value="<?= e($t) ?>"><?= e($t) ?></option><?php endforeach; ?>
336
        </select>
337
      </label>
338
      <label>Brand
339
        <input name="brand" list="brandList" placeholder="— keep —">
340
        <datalist id="brandList">
341
          <?php foreach ($brands as $b): ?><option value="<?= e($b) ?>"></option><?php endforeach; ?>
342
        </datalist>
343
      </label>
344
      <label>Cost (<?= e($cur) ?>)
345
        <input type="number" name="cost" step="0.01" min="0" inputmode="decimal" placeholder="— keep —">
346
      </label>
347
      <div>
348
        <label for="bulkColor">Color</label>
349
        <div class="row">
350
          <label class="check" style="margin:0;flex-shrink:0"><input type="checkbox" name="set_color"> Set to</label>
351
          <input type="color" id="bulkColor" name="color_hex" value="#da6019">
352
        </div>
353
      </div>
354
      <?php foreach (['abrasive' => 'Abrasive', 'highflow' => 'High-flow', 'silk' => 'Silk', 'matte' => 'Matte', 'rainbow' => 'Rainbow', 'transparent' => 'Transparent', 'twotone' => 'Two-tone', 'sparkle' => 'Sparkle'] as $flag => $label): ?>
355
      <label><?= e($label) ?>
356
        <select name="f_<?= e($flag) ?>">
357
          <option value="">— keep —</option>
358
          <option value="1">Yes</option>
359
          <option value="0">No</option>
360
        </select>
361
      </label>
362
      <?php endforeach; ?>
363
    </div>
364
    <div class="actions" style="margin-bottom:0">
365
      <button>Apply to selected</button>
366
      <a class="btn secondary" href="<?= e(index_url(['bulk' => null])) ?>">Done</a>
367
      <label class="check" style="margin:0"><input type="checkbox" id="bulkAll"> Select all</label>
368
    </div>
369
  </div>
370
<?php endif; ?>
371
<?php if ($view === 'grid'): ?>
372
<?php
373
// Bucket the (already sorted and filtered) rows by type: groups are
374
// alphabetical, spools inside a group keep the active sort order.
375
$byType = [];
376
foreach ($rows as $r) {
377
    $byType[$r['type']][] = $r;
378
}
379
uksort($byType, 'strcasecmp');
380
foreach ($byType as $t => $group):
381
?>
382
<section class="typegroup">
383
  <h2 class="typehead"><?= e($t) ?> <span class="muted"><?= count($group) ?></span></h2>
384
  <div class="colorgrid">
385
    <?php foreach ($group as $r): ?>
386
    <button type="button" class="gswatch" data-id="<?= (int)$r['id'] ?>" aria-expanded="false" aria-controls="gc<?= (int)$r['id'] ?>"
387
            title="<?= e(spool_label($r)) ?> — <?= spool_pct($r) ?>% left" aria-label="<?= e(spool_label($r)) ?> — <?= spool_pct($r) ?>% left">
388
      <?= swatch_html($r, true) ?>
389
    </button>
390
    <?php endforeach; ?>
391
  </div>
392
  <?php foreach ($group as $r) {
393
      spool_card($r, false, 'gc' . (int)$r['id']);
394
  } ?>
395
</section>
396
<?php endforeach; ?>
397
<?php else: ?>
398
<?php foreach ($rows as $r) {
399
    spool_card($r, $bulk);
400
} ?>
401
<?php endif; ?>
402
<?php if ($bulk): ?>
403
</form>
404
<?php endif; ?>
405
<?php if ($allCount): ?>
406
<p class="statline"><?= number_format($allCount) ?> spool<?= $allCount === 1 ? '' : 's' ?> · <a href="stats.php">View stats</a></p>
407
<?php endif; ?>
408
<?php
409
page_footer();
410