390 lines · 16.2 KB
Raw Download
1
<?php
2
require_once __DIR__ . '/inc/layout.php';
3
4
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
5
header('Pragma: no-cache');
6
header('Expires: 0');
7
8
// Same stale-cache defense as index.php; the deduct, edit, and delete POSTs
9
// pass through, and ?edit / ?empty / ?all ride along on the stamped URL.
10
cache_buster_redirect();
11
12
require_login();
13
14
// Where a write sends you back to — the same list you were looking at, so an
15
// expanded "show all" history survives the round trip.
16
function usage_return(array $params = []): string
17
{
18
    if (isset($_POST['all']) || isset($_GET['all'])) {
19
        $params['all'] = 1;
20
    }
21
    return 'use.php' . ($params ? '?' . http_build_query($params) : '');
22
}
23
24
// A spool that a deduction just emptied is offered up for deletion on the
25
// next page load, via ?empty=<id>. The redirect only carries the id — the
26
// offer is re-checked against the spool's real remaining amount before it is
27
// shown, so a stale or hand-typed link cannot put a full spool on the block.
28
function empty_spool_param(int $filamentId): array
29
{
30
    $st = db()->prepare('SELECT remaining_g FROM filaments WHERE id = ?');
31
    $st->execute([$filamentId]);
32
    $row = $st->fetch();
33
    return $row && spool_is_empty($row) ? ['empty' => $filamentId] : [];
34
}
35
36
// A spool's last_used_at is just the date of the newest entry in its usage
37
// log, so once an entry is deleted or moved to another spool it has to be
38
// recomputed — removing the newest usage must not leave the spool still
39
// claiming it was used then. No entries left means the spool has no known
40
// usage date, which is what NULL means everywhere else.
41
function sync_last_used(int $filamentId): void
42
{
43
    db()->prepare(
44
        "UPDATE filaments SET last_used_at = (SELECT MAX(created_at) FROM usage_log WHERE filament_id = ?),
45
         updated_at = datetime('now') WHERE id = ?"
46
    )->execute([$filamentId, $filamentId]);
47
}
48
49
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
50
    check_csrf();
51
    $action = (string)($_POST['action'] ?? '');
52
53
    // Taking a used-up spool out of the inventory, from the offer that
54
    // appears after the deduction that emptied it.
55
    if ($action === 'delete_spool') {
56
        $fid = (int)($_POST['filament_id'] ?? 0);
57
        $st = db()->prepare('SELECT * FROM filaments WHERE id = ?');
58
        $st->execute([$fid]);
59
        $spool = $st->fetch();
60
61
        if (!$spool) {
62
            flash('That spool is no longer in the inventory.');
63
        } else {
64
            backup_db();
65
            // usage_log rows for the spool go with it (ON DELETE CASCADE) —
66
            // the offer says so before this runs.
67
            db()->prepare('DELETE FROM filaments WHERE id = ?')->execute([$fid]);
68
            flash('Deleted ' . spool_label($spool) . ' from the inventory.');
69
        }
70
        header('Location: ' . usage_return());
71
        exit;
72
    }
73
74
    // Correcting a logged print: both paths first put the entry's grams back
75
    // on the spool they came off, which is the whole point of the feature —
76
    // usage entered by mistake should leave the inventory as it was.
77
    if ($action === 'delete' || $action === 'edit') {
78
        $uid = (int)($_POST['usage_id'] ?? 0);
79
        $st = db()->prepare('SELECT * FROM usage_log WHERE id = ?');
80
        $st->execute([$uid]);
81
        $entry = $st->fetch();
82
83
        if (!$entry) {
84
            flash('That usage entry no longer exists.');
85
            header('Location: ' . usage_return());
86
            exit;
87
        }
88
        $oldFid = (int)$entry['filament_id'];
89
        $oldGrams = (float)$entry['grams'];
90
91
        if ($action === 'delete') {
92
            backup_db();
93
            $pdo = db();
94
            $pdo->beginTransaction();
95
            $pdo->prepare('UPDATE filaments SET remaining_g = remaining_g + ? WHERE id = ?')
96
                ->execute([$oldGrams, $oldFid]);
97
            $pdo->prepare('DELETE FROM usage_log WHERE id = ?')->execute([$uid]);
98
            $pdo->commit();
99
            sync_last_used($oldFid);
100
            flash('Deleted the ' . round($oldGrams, 1) . ' g entry — that filament is back on the spool.');
101
            header('Location: ' . usage_return());
102
            exit;
103
        }
104
105
        $fid = (int)($_POST['filament_id'] ?? 0);
106
        $grams = (float)($_POST['grams'] ?? 0);
107
        $file = trim((string)($_POST['source_file'] ?? ''));
108
        $file = $file === '' ? null : mb_substr($file, 0, 200);
109
110
        $st = db()->prepare('SELECT * FROM filaments WHERE id = ?');
111
        $st->execute([$fid]);
112
        $spool = $st->fetch();
113
114
        if (!$spool || $grams <= 0) {
115
            flash('Pick a spool and enter a positive amount of grams.');
116
            header('Location: use.php?edit=' . $uid . (isset($_POST['all']) ? '&all=1' : '') . '#u' . $uid);
117
            exit;
118
        }
119
        backup_db();
120
        $pdo = db();
121
        $pdo->beginTransaction();
122
        // Refund the old spool, then charge the new one — the same two steps
123
        // whether or not the entry was moved to a different spool.
124
        $pdo->prepare('UPDATE filaments SET remaining_g = remaining_g + ? WHERE id = ?')
125
            ->execute([$oldGrams, $oldFid]);
126
        $pdo->prepare(
127
            "UPDATE filaments SET remaining_g = MAX(remaining_g - ?, 0), updated_at = datetime('now') WHERE id = ?"
128
        )->execute([$grams, $fid]);
129
        $pdo->prepare('UPDATE usage_log SET filament_id = ?, grams = ?, source_file = ? WHERE id = ?')
130
            ->execute([$fid, $grams, $file, $uid]);
131
        $pdo->commit();
132
        sync_last_used($oldFid);
133
        sync_last_used($fid);
134
        flash('Updated that entry to ' . round($grams, 1) . ' g on ' . spool_label($spool) . '.');
135
        header('Location: ' . usage_return(empty_spool_param($fid)));
136
        exit;
137
    }
138
139
    $fid = (int)($_POST['filament_id'] ?? 0);
140
    $grams = (float)($_POST['grams'] ?? 0);
141
    $file = trim((string)($_POST['source_file'] ?? ''));
142
    $file = $file === '' ? null : mb_substr($file, 0, 200);
143
144
    $st = db()->prepare('SELECT * FROM filaments WHERE id = ?');
145
    $st->execute([$fid]);
146
    $spool = $st->fetch();
147
148
    $dest = [];
149
    if (!$spool || $grams <= 0) {
150
        flash('Pick a spool and enter a positive amount of grams.');
151
    } else {
152
        backup_db();
153
        $pdo = db();
154
        $pdo->beginTransaction();
155
        $pdo->prepare(
156
            "UPDATE filaments SET remaining_g = MAX(remaining_g - ?, 0),
157
             last_used_at = datetime('now'), updated_at = datetime('now') WHERE id = ?"
158
        )->execute([$grams, $fid]);
159
        $pdo->prepare('INSERT INTO usage_log (filament_id, grams, source_file) VALUES (?,?,?)')
160
            ->execute([$fid, $grams, $file]);
161
        $pdo->commit();
162
        flash('Deducted ' . round($grams, 1) . ' g from ' . spool_label($spool) . '.');
163
        $dest = empty_spool_param($fid);
164
    }
165
    header('Location: ' . usage_return($dest));
166
    exit;
167
}
168
169
$spools = db()->query(
170
    'SELECT id, brand, type, color_name, color_hex, color_hex2, notes, abrasive, highflow, silk, matte, rainbow, transparent, twotone, sparkle, original_g, remaining_g
171
     FROM filaments
172
     ORDER BY (last_used_at IS NULL), last_used_at DESC, brand COLLATE NOCASE'
173
)->fetchAll();
174
175
// "#DA6019 · 812 g left (81%)" — the line under the selected spool's name.
176
function spool_meta(array $s): string
177
{
178
    $parts = [];
179
    if ($s['color_hex']) {
180
        $parts[] = strtoupper($s['color_hex']);
181
    }
182
    $parts[] = round((float)$s['remaining_g']) . ' g left (' . spool_pct($s) . '%)';
183
    return implode(' · ', $parts);
184
}
185
186
// The last handful of prints is enough to spot a mistake you just made, but
187
// correcting an older one means being able to reach it, so ?all=1 opens the
188
// full log (capped so a long history cannot blow the page up).
189
$showAll = isset($_GET['all']);
190
$usageCount = (int)db()->query('SELECT COUNT(*) FROM usage_log')->fetchColumn();
191
$recent = db()->query(
192
    'SELECT u.id, u.filament_id, u.grams, u.source_file, u.created_at, f.brand, f.type, f.color_name
193
     FROM usage_log u JOIN filaments f ON f.id = u.filament_id
194
     ORDER BY u.id DESC LIMIT ' . ($showAll ? 500 : 8)
195
)->fetchAll();
196
197
// The entry expanded into an edit form, from the Edit link on its card.
198
$editId = (int)($_GET['edit'] ?? 0);
199
200
// The spool the last deduction emptied, offered up for deletion. Re-checked
201
// against the live remaining amount so the offer cannot outlive the emptiness
202
// that prompted it — undoing the usage entry that emptied the spool, then
203
// going back, must not still offer to bin a spool with filament on it.
204
$emptyId = (int)($_GET['empty'] ?? 0);
205
$emptySpool = null;
206
foreach ($spools as $s) {
207
    if ((int)$s['id'] === $emptyId && spool_is_empty($s)) {
208
        $emptySpool = $s;
209
        break;
210
    }
211
}
212
$emptyLogs = 0;
213
if ($emptySpool) {
214
    $st = db()->prepare('SELECT COUNT(*) FROM usage_log WHERE filament_id = ?');
215
    $st->execute([$emptyId]);
216
    $emptyLogs = (int)$st->fetchColumn();
217
}
218
219
$sel = (int)($_GET['id'] ?? 0);
220
221
// The spool the browser will have selected on load — the ?id= one if it still
222
// exists, otherwise the first (most recently used). The preview card is
223
// rendered from it server-side so it is correct before any JS runs.
224
$current = null;
225
foreach ($spools as $s) {
226
    if ((int)$s['id'] === $sel) {
227
        $current = $s;
228
        break;
229
    }
230
}
231
$current ??= ($spools[0] ?? null);
232
233
page_header('Use filament');
234
?>
235
<h1>Use filament</h1>
236
<?php if ($emptySpool): ?>
237
<div class="card row" id="emptyOffer">
238
  <?= swatch_html($emptySpool) ?>
239
  <div class="grow">
240
    <strong><?= e(spool_label($emptySpool)) ?> is used up</strong>
241
    <div class="muted">
242
      0 g left. Delete it from the inventory?
243
      <?php if ($emptyLogs): ?>Its <?= $emptyLogs ?> logged print<?= $emptyLogs === 1 ? '' : 's' ?> will go with it.<?php endif; ?>
244
    </div>
245
    <form method="post">
246
      <?= csrf_field() ?>
247
      <input type="hidden" name="action" value="delete_spool">
248
      <input type="hidden" name="filament_id" value="<?= (int)$emptySpool['id'] ?>">
249
      <?php if ($showAll): ?><input type="hidden" name="all" value="1"><?php endif; ?>
250
      <div class="actions" style="margin-bottom:0">
251
        <button class="danger btn-sm">Delete spool</button>
252
        <a class="btn secondary btn-sm" href="<?= $showAll ? 'use.php?all=1' : 'use.php' ?>">Keep it</a>
253
      </div>
254
    </form>
255
  </div>
256
</div>
257
<?php endif; ?>
258
<?php if (!$spools): ?>
259
<p class="muted">No spools yet — <a href="spool.php">add one</a> first.</p>
260
<?php else: ?>
261
<form method="post">
262
  <?= csrf_field() ?>
263
264
  <label for="filament_id">Spool (most recently used first)</label>
265
  <select id="filament_id" name="filament_id" required>
266
    <?php foreach ($spools as $s): ?>
267
    <option value="<?= (int)$s['id'] ?>"
268
            data-type="<?= e($s['type']) ?>"
269
            data-color="<?= e($s['color_hex'] ?: '#888888') ?>"
270
            data-color2="<?= e(!empty($s['color_hex2']) ? $s['color_hex2'] : ($s['color_hex'] ?: '#888888')) ?>"
271
            data-pie="<?= e(pie_color($s['color_hex'])) ?>"
272
            data-pct="<?= spool_pct($s) ?>"
273
            data-label="<?= e(spool_label($s)) ?>"
274
            data-meta="<?= e(spool_meta($s)) ?>"
275
            data-notes="<?= e((string)$s['notes']) ?>"
276
            data-abrasive="<?= (int)$s['abrasive'] ?>"
277
            data-highflow="<?= (int)$s['highflow'] ?>"
278
            data-silk="<?= (int)$s['silk'] ?>"
279
            data-matte="<?= (int)$s['matte'] ?>"
280
            data-rainbow="<?= (int)$s['rainbow'] ?>"
281
            data-transparent="<?= (int)$s['transparent'] ?>"
282
            data-twotone="<?= (int)$s['twotone'] ?>"
283
            data-sparkle="<?= (int)$s['sparkle'] ?>"
284
            data-small="<?= is_small_spool($s) ? 1 : 0 ?>"
285
            <?= $s['id'] == $sel ? 'selected' : '' ?>>
286
      <?= e(spool_label($s)) ?> — <?= round((float)$s['remaining_g']) ?> g left
287
    </option>
288
    <?php endforeach; ?>
289
  </select>
290
291
  <?php $pct = spool_pct($current); ?>
292
  <div class="card row" id="spoolPreview">
293
    <?= swatch_html($current, false, 'pvSwatch') ?>
294
    <div class="grow">
295
      <strong id="pvLabel"><?= e(spool_label($current)) ?></strong>
296
      <span class="badge" id="pvAbrasive" <?= $current['abrasive'] ? '' : 'hidden' ?>>Abrasive</span>
297
      <span class="badge" id="pvHighflow" <?= $current['highflow'] ? '' : 'hidden' ?>>High-flow</span>
298
      <span class="badge" id="pvSilk" <?= $current['silk'] ? '' : 'hidden' ?>>Silk</span>
299
      <span class="badge" id="pvMatte" <?= $current['matte'] ? '' : 'hidden' ?>>Matte</span>
300
      <span class="badge" id="pvClear" <?= $current['transparent'] ? '' : 'hidden' ?>>Clear</span>
301
      <span class="badge" id="pvSparkle" <?= $current['sparkle'] ? '' : 'hidden' ?>>Sparkle</span>
302
      <div class="muted" id="pvMeta"><?= e(spool_meta($current)) ?></div>
303
      <div class="muted" id="pvNotes" <?= $current['notes'] ? '' : 'hidden' ?>><?= e((string)$current['notes']) ?></div>
304
      <div class="bar"><i id="pvBar" style="width:<?= $pct ?>%"></i></div>
305
    </div>
306
  </div>
307
308
  <label>Print file (optional)</label>
309
  <label class="drop" id="drop">
310
    Drop a .gcode / .bgcode file here, or tap to choose — the filament used by the print is read from the file.
311
    <input type="file" id="gfile" accept=".gcode,.bgcode,.gco,.g" hidden>
312
  </label>
313
  <p class="muted" id="parseMsg"></p>
314
315
  <label for="grams">Filament used (g)</label>
316
  <input type="number" id="grams" name="grams" step="0.01" min="0.01" inputmode="decimal" required>
317
  <input type="hidden" id="sourceFile" name="source_file">
318
319
  <div class="actions">
320
    <button>Deduct from spool</button>
321
    <a class="btn secondary" href="index.php">Cancel</a>
322
  </div>
323
</form>
324
<?php endif; ?>
325
<?php if ($recent): ?>
326
<h1><?= $showAll ? 'Usage history' : 'Recent usage' ?></h1>
327
<?php $back = $showAll ? 'use.php?all=1' : 'use.php'; ?>
328
<?php foreach ($recent as $u): $uid = (int)$u['id']; ?>
329
<?php if ($uid === $editId): ?>
330
<div class="card" id="u<?= $uid ?>">
331
  <form method="post">
332
    <?= csrf_field() ?>
333
    <input type="hidden" name="action" value="edit">
334
    <input type="hidden" name="usage_id" value="<?= $uid ?>">
335
    <?php if ($showAll): ?><input type="hidden" name="all" value="1"><?php endif; ?>
336
337
    <label for="editSpool">Spool</label>
338
    <select id="editSpool" name="filament_id" required>
339
      <?php foreach ($spools as $s): ?>
340
      <option value="<?= (int)$s['id'] ?>" <?= (int)$s['id'] === (int)$u['filament_id'] ? 'selected' : '' ?>>
341
        <?= e(spool_label($s)) ?> — <?= round((float)$s['remaining_g']) ?> g left
342
      </option>
343
      <?php endforeach; ?>
344
    </select>
345
346
    <label for="editGrams">Filament used (g)</label>
347
    <input type="number" id="editGrams" name="grams" step="0.01" min="0.01" inputmode="decimal" required
348
           value="<?= e((string)round((float)$u['grams'], 2)) ?>">
349
350
    <label for="editFile">Print file (optional)</label>
351
    <input id="editFile" name="source_file" maxlength="200" value="<?= e((string)$u['source_file']) ?>">
352
353
    <p class="muted">Logged <?= e(rel_time($u['created_at'])) ?>. Saving corrects the spool it was deducted from.</p>
354
    <div class="actions" style="margin-bottom:0">
355
      <button>Save entry</button>
356
      <a class="btn secondary" href="<?= e($back) ?>">Cancel</a>
357
    </div>
358
  </form>
359
</div>
360
<?php else: ?>
361
<div class="card row" id="u<?= $uid ?>">
362
  <div class="grow">
363
    <strong><?= e(round((float)$u['grams'], 1) . ' g') ?></strong> — <?= e(spool_label($u)) ?>
364
    <div class="muted"><?= e($u['source_file'] ?: 'manual entry') ?> · <?= e(rel_time($u['created_at'])) ?></div>
365
  </div>
366
  <div class="rowlinks">
367
    <a href="use.php?edit=<?= $uid ?><?= $showAll ? '&amp;all=1' : '' ?>#u<?= $uid ?>">Edit</a>
368
    <form method="post" onsubmit="return confirm('Delete this usage entry? The <?= e(round((float)$u['grams'], 1)) ?> g goes back on the spool.')">
369
      <?= csrf_field() ?>
370
      <input type="hidden" name="action" value="delete">
371
      <input type="hidden" name="usage_id" value="<?= $uid ?>">
372
      <?php if ($showAll): ?><input type="hidden" name="all" value="1"><?php endif; ?>
373
      <button class="danger btn-sm">Delete</button>
374
    </form>
375
  </div>
376
</div>
377
<?php endif; ?>
378
<?php endforeach; ?>
379
<?php if ($showAll && $usageCount > count($recent)): ?>
380
<p class="muted">Showing the newest <?= count($recent) ?> of <?= $usageCount ?> entries.</p>
381
<?php endif; ?>
382
<?php if ($usageCount > count($recent) || $showAll): ?>
383
<div class="actions">
384
  <a class="btn secondary" href="<?= $showAll ? 'use.php' : 'use.php?all=1' ?>"><?= $showAll ? 'Show recent only' : 'Show all ' . $usageCount . ' entries' ?></a>
385
</div>
386
<?php endif; ?>
387
<?php endif; ?>
388
<?php
389
page_footer();
390