Files
formulation/src/application/pages/app/inventory/index.astro
T
nicholasandnicholas 2a1e16ed30
Build & Deploy Formulation / Build & Push Image (push) Failing after 15s
Build & Deploy Formulation / deploy (push) Skipped
add core features (#14)
Reviewed-on: #14
Co-authored-by: Nicholas Ward <nicholaspward@outlook.com>
2026-08-18 18:22:34 -05:00

520 lines
14 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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>