Reviewed-on: #14 Co-authored-by: Nicholas Ward <nicholaspward@outlook.com>
520 lines
14 KiB
Plaintext
520 lines
14 KiB
Plaintext
---
|
||
export const prerender = false;
|
||
import BaseLayout from "../../../../layouts/BaseLayout.astro";
|
||
import DetailUtility from "../../../../components/DetailUtility.astro";
|
||
import { openDatabase } from "../../../../lib/database";
|
||
import {
|
||
createInventoryCount,
|
||
getInventoryCounts,
|
||
getInventoryLocations,
|
||
} from "../../../../lib/repository/inventory-repository";
|
||
import { readOnlyMode } from "../../../../lib/runtime";
|
||
|
||
const database = openDatabase({ readOnly: false });
|
||
if (!database) return Astro.redirect("/app/?error=database-missing", 303);
|
||
|
||
let error = "";
|
||
if (Astro.request.method === "POST") {
|
||
try {
|
||
const form = await Astro.request.formData();
|
||
const intent = String(form.get("intent") ?? "create");
|
||
|
||
if (intent === "create") {
|
||
const title = String(form.get("title") ?? "").trim();
|
||
const counted_at = String(form.get("counted_at") ?? "").trim();
|
||
const notes = String(form.get("notes") ?? "").trim();
|
||
const prepopulate = form.get("prepopulate") === "1";
|
||
|
||
if (!title) throw new Error("Title is required.");
|
||
if (!counted_at) throw new Error("Count date is required.");
|
||
|
||
const createdId = createInventoryCount(database, {
|
||
title,
|
||
counted_at,
|
||
notes,
|
||
prepopulate,
|
||
});
|
||
|
||
database.close();
|
||
return Astro.redirect(`/app/inventory/${createdId}/`, 303);
|
||
}
|
||
} catch (cause) {
|
||
error = cause instanceof Error ? cause.message : "Unable to create inventory count.";
|
||
}
|
||
}
|
||
|
||
const counts = getInventoryCounts(database);
|
||
const locations = getInventoryLocations(database);
|
||
const totalInventoryValue = counts
|
||
.filter((c) => c.status === "completed")
|
||
.reduce((sum, c) => sum + c.total_value, 0);
|
||
|
||
database.close();
|
||
---
|
||
|
||
<BaseLayout title="Inventory" immersive>
|
||
<div class="inventory-page">
|
||
<DetailUtility />
|
||
|
||
<main class="inventory-workspace">
|
||
{error && <div class="notice inventory-notice">{error}</div>}
|
||
|
||
<header class="inventory-header">
|
||
<div class="inventory-header-left">
|
||
<nav class="inventory-breadcrumbs">
|
||
<a href="/app/">
|
||
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
|
||
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
|
||
</svg>
|
||
<span>All items</span>
|
||
</a>
|
||
</nav>
|
||
<h1>Inventory Count Sessions</h1>
|
||
<p class="inventory-subtitle">
|
||
Sheet-to-shelf on-hand stock counts, valuations, and storage locations.
|
||
</p>
|
||
</div>
|
||
|
||
{!readOnlyMode && (
|
||
<div class="inventory-header-actions">
|
||
<button
|
||
type="button"
|
||
class="primary-count-btn"
|
||
onclick="document.getElementById('new-count-dialog').showModal()"
|
||
>
|
||
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
|
||
<path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6z" />
|
||
</svg>
|
||
<span>New Count Session</span>
|
||
</button>
|
||
</div>
|
||
)}
|
||
</header>
|
||
|
||
<div class="inventory-summary-cards">
|
||
<article class="inv-stat-card">
|
||
<span class="inv-stat-label">Total Count Sessions</span>
|
||
<strong class="inv-stat-value">{counts.length}</strong>
|
||
</article>
|
||
<article class="inv-stat-card">
|
||
<span class="inv-stat-label">Storage Locations</span>
|
||
<strong class="inv-stat-value">{locations.length}</strong>
|
||
</article>
|
||
<article class="inv-stat-card highlight">
|
||
<span class="inv-stat-label">Total Completed Valuation</span>
|
||
<strong class="inv-stat-value">
|
||
${totalInventoryValue.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||
</strong>
|
||
</article>
|
||
</div>
|
||
|
||
{counts.length > 0 ? (
|
||
<div class="inventory-table">
|
||
<div class="inventory-table-head">
|
||
<span class="head-col date">Count Date</span>
|
||
<span class="head-col title">Session Title</span>
|
||
<span class="head-col status">Status</span>
|
||
<span class="head-col items">Items Counted</span>
|
||
<span class="head-col value">Total Value</span>
|
||
<span class="head-col actions"></span>
|
||
</div>
|
||
|
||
<div class="inventory-table-body">
|
||
{counts.map((count) => (
|
||
<a href={`/app/inventory/${count.id}/`} class="inventory-table-row">
|
||
<span class="col date">{count.counted_at}</span>
|
||
<span class="col title">
|
||
<strong>{count.title}</strong>
|
||
{count.notes && <small>{count.notes}</small>}
|
||
</span>
|
||
<span class="col status">
|
||
<span class={`status-badge ${count.status}`}>
|
||
{count.status === "completed" ? "Completed" : "Open Draft"}
|
||
</span>
|
||
</span>
|
||
<span class="col items">{count.item_count} items</span>
|
||
<span class="col value font-mono">
|
||
${count.total_value.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||
</span>
|
||
<span class="col actions">
|
||
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
|
||
<path d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.41z"/>
|
||
</svg>
|
||
</span>
|
||
</a>
|
||
))}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div class="empty-inventory-state">
|
||
<div class="empty-inv-icon">
|
||
<svg viewBox="0 0 24 24" width="36" height="36" fill="currentColor">
|
||
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14zM7 10h2v7H7zm4-3h2v10h-2zm4 6h2v4h-2z"/>
|
||
</svg>
|
||
</div>
|
||
<h2>No inventory counts recorded yet</h2>
|
||
<p>Create your first location-specific count session to begin tracking on-hand stock and valuations.</p>
|
||
{!readOnlyMode && (
|
||
<button
|
||
type="button"
|
||
class="primary-count-btn"
|
||
onclick="document.getElementById('new-count-dialog').showModal()"
|
||
>
|
||
Start First Count Session
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<!-- New Count Session Modal Dialog -->
|
||
<dialog id="new-count-dialog" class="count-modal-dialog">
|
||
<form method="post">
|
||
<input type="hidden" name="intent" value="create" />
|
||
<div class="dialog-header">
|
||
<h2>New Inventory Count Session</h2>
|
||
<button type="button" class="close-btn" onclick="this.closest('dialog').close()">×</button>
|
||
</div>
|
||
|
||
<div class="dialog-body">
|
||
<label class="form-field">
|
||
<span>Session Title *</span>
|
||
<input
|
||
type="text"
|
||
name="title"
|
||
required
|
||
placeholder="e.g. Month-End Count - August 2026"
|
||
value={`Count - ${new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}`}
|
||
/>
|
||
</label>
|
||
|
||
<label class="form-field">
|
||
<span>Count Date *</span>
|
||
<input
|
||
type="date"
|
||
name="counted_at"
|
||
required
|
||
value={new Date().toISOString().split('T')[0]}
|
||
/>
|
||
</label>
|
||
|
||
<label class="form-field">
|
||
<span>Notes / Shift (optional)</span>
|
||
<input
|
||
type="text"
|
||
name="notes"
|
||
placeholder="e.g. Sunday night post-service count"
|
||
/>
|
||
</label>
|
||
|
||
<label class="checkbox-field">
|
||
<input type="checkbox" name="prepopulate" value="1" checked />
|
||
<span>Pre-populate all active catalog ingredients with 0 count</span>
|
||
</label>
|
||
</div>
|
||
|
||
<div class="dialog-footer">
|
||
<button type="button" class="cancel-btn" onclick="this.closest('dialog').close()">Cancel</button>
|
||
<button type="submit" class="submit-btn">Create Count Sheet</button>
|
||
</div>
|
||
</form>
|
||
</dialog>
|
||
</main>
|
||
</div>
|
||
</BaseLayout>
|
||
|
||
<style>
|
||
.inventory-workspace {
|
||
padding: 72px max(24px, calc((100vw - 1160px) / 2)) 80px;
|
||
}
|
||
.inventory-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-end;
|
||
margin-bottom: 24px;
|
||
}
|
||
.inventory-breadcrumbs a {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
color: var(--muted);
|
||
font-size: 13px;
|
||
text-decoration: none;
|
||
margin-bottom: 8px;
|
||
}
|
||
.inventory-header h1 {
|
||
font-size: 28px;
|
||
font-weight: 700;
|
||
color: var(--ink);
|
||
margin: 0 0 6px;
|
||
}
|
||
.inventory-subtitle {
|
||
margin: 0;
|
||
color: var(--muted);
|
||
font-size: 14px;
|
||
}
|
||
.primary-count-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
height: 36px;
|
||
padding: 0 18px;
|
||
background: #3d5df6;
|
||
color: white;
|
||
border: none;
|
||
border-radius: 999px;
|
||
font-family: var(--fmt-font-sans);
|
||
font-weight: 500;
|
||
font-size: 14px;
|
||
cursor: pointer;
|
||
transition: background 0.12s ease;
|
||
}
|
||
.primary-count-btn:hover {
|
||
background: #2b4be0;
|
||
}
|
||
.inventory-summary-cards {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, 1fr);
|
||
gap: 16px;
|
||
margin-bottom: 28px;
|
||
}
|
||
.inv-stat-card {
|
||
background: var(--card);
|
||
border: 1px solid var(--line);
|
||
border-radius: 8px;
|
||
padding: 16px 20px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
}
|
||
.inv-stat-card.highlight {
|
||
background: #f8faff;
|
||
border-color: #dbe4ff;
|
||
}
|
||
.inv-stat-label {
|
||
font-size: 12px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
color: var(--muted);
|
||
font-weight: 600;
|
||
}
|
||
.inv-stat-value {
|
||
font-size: 24px;
|
||
font-weight: 700;
|
||
color: var(--ink);
|
||
}
|
||
.inventory-table {
|
||
background: var(--card);
|
||
border: 1px solid var(--line);
|
||
border-radius: 8px;
|
||
overflow: hidden;
|
||
}
|
||
.inventory-table-head,
|
||
.inventory-table-row {
|
||
display: grid;
|
||
grid-template-columns: 130px minmax(180px, 1fr) 130px 140px 140px 40px;
|
||
align-items: center;
|
||
padding: 14px 20px;
|
||
gap: 16px;
|
||
}
|
||
.inventory-table-head {
|
||
background: #ffffff;
|
||
border-bottom: 1px solid var(--line);
|
||
font-size: 11px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.06em;
|
||
color: var(--muted);
|
||
font-weight: 700;
|
||
}
|
||
.inventory-table-row {
|
||
border-bottom: 1px solid var(--line);
|
||
text-decoration: none;
|
||
color: var(--ink);
|
||
transition: background 0.15s ease;
|
||
}
|
||
.inventory-table-row:last-child {
|
||
border-bottom: none;
|
||
}
|
||
.inventory-table-row:hover {
|
||
background: #f8faff;
|
||
}
|
||
.inventory-table-row .title strong {
|
||
display: block;
|
||
font-size: 14px;
|
||
}
|
||
.inventory-table-row .title small {
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
}
|
||
.status-badge {
|
||
display: inline-flex;
|
||
padding: 3px 8px;
|
||
border-radius: 4px;
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.04em;
|
||
}
|
||
.status-badge.open {
|
||
background: #fff8e6;
|
||
color: #b25e00;
|
||
border: 1px solid #ffd599;
|
||
}
|
||
.status-badge.completed {
|
||
background: #e6f9f3;
|
||
color: #0d8262;
|
||
border: 1px solid #a3ebd4;
|
||
}
|
||
.col.value {
|
||
font-weight: 700;
|
||
text-align: right;
|
||
}
|
||
.col.actions {
|
||
color: var(--muted);
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
}
|
||
.empty-inventory-state {
|
||
text-align: center;
|
||
padding: 60px 20px;
|
||
background: var(--card);
|
||
border: 1px solid var(--line);
|
||
border-radius: 8px;
|
||
}
|
||
.empty-inv-icon {
|
||
width: 64px;
|
||
height: 64px;
|
||
margin: 0 auto 16px;
|
||
background: #f1f5fe;
|
||
color: var(--blue);
|
||
border-radius: 50%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
.empty-inventory-state h2 {
|
||
font-size: 18px;
|
||
margin: 0 0 8px;
|
||
}
|
||
.empty-inventory-state p {
|
||
color: var(--muted);
|
||
font-size: 14px;
|
||
max-width: 420px;
|
||
margin: 0 auto 20px;
|
||
}
|
||
|
||
/* Modal */
|
||
.count-modal-dialog {
|
||
border: 1px solid var(--line);
|
||
border-radius: 10px;
|
||
padding: 0;
|
||
width: min(500px, calc(100vw - 32px));
|
||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
|
||
}
|
||
.count-modal-dialog::backdrop {
|
||
background: rgba(5, 8, 65, 0.4);
|
||
backdrop-filter: blur(4px);
|
||
}
|
||
.dialog-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 16px 20px;
|
||
border-bottom: 1px solid var(--line);
|
||
}
|
||
.dialog-header h2 {
|
||
font-size: 16px;
|
||
margin: 0;
|
||
}
|
||
.close-btn {
|
||
border: none;
|
||
background: transparent;
|
||
font-size: 20px;
|
||
cursor: pointer;
|
||
color: var(--muted);
|
||
}
|
||
.dialog-body {
|
||
padding: 20px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
}
|
||
.form-field {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
}
|
||
.form-field input {
|
||
padding: 8px 12px;
|
||
border: 1px solid var(--line);
|
||
border-radius: 6px;
|
||
font-size: 14px;
|
||
}
|
||
.checkbox-field {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
font-size: 13px;
|
||
color: var(--ink);
|
||
cursor: pointer;
|
||
}
|
||
.dialog-footer {
|
||
padding: 14px 20px;
|
||
border-top: 1px solid var(--line);
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 10px;
|
||
background: #ffffff;
|
||
}
|
||
.cancel-btn {
|
||
height: 36px;
|
||
padding: 0 16px;
|
||
border: none;
|
||
background: transparent;
|
||
border-radius: 999px;
|
||
cursor: pointer;
|
||
font-family: var(--fmt-font-sans);
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
color: #8b93a7;
|
||
transition: color 0.12s ease;
|
||
}
|
||
.cancel-btn:hover {
|
||
color: #050841;
|
||
}
|
||
.submit-btn {
|
||
height: 36px;
|
||
padding: 0 20px;
|
||
background: #3d5df6;
|
||
color: white;
|
||
border: none;
|
||
border-radius: 999px;
|
||
cursor: pointer;
|
||
font-family: var(--fmt-font-sans);
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
transition: background 0.12s ease;
|
||
}
|
||
.submit-btn:hover {
|
||
background: #2b4be0;
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.inventory-summary-cards {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
.inventory-table-head {
|
||
display: none;
|
||
}
|
||
.inventory-table-row {
|
||
grid-template-columns: 1fr auto;
|
||
gap: 8px;
|
||
}
|
||
.inventory-table-row .col.items,
|
||
.inventory-table-row .col.date {
|
||
display: none;
|
||
}
|
||
}
|
||
</style>
|