Add script to build and push Docker images to registry
- Introduced `push-images.sh` script for building and pushing backend and frontend images. - Added functionality to select or input image tags. - Integrated environment variable support for registry configuration. - Implemented prompts for user confirmation before building and pushing images. - Updated `docker-compose.prod.yml` with new image tags after pushing.
This commit is contained in:
+510
-283
@@ -1,334 +1,561 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
const healthSchema = z.object({ status: z.string() });
|
||||
|
||||
type Slice = {
|
||||
usage: number;
|
||||
every: number;
|
||||
start: string; // ISO date string
|
||||
usage: number;
|
||||
every: number;
|
||||
start: string;
|
||||
};
|
||||
|
||||
type Medication = {
|
||||
id: number;
|
||||
name: string;
|
||||
count: number;
|
||||
stripSize: number;
|
||||
slices: Slice[];
|
||||
updatedAt: string | number | null;
|
||||
id: number;
|
||||
name: string;
|
||||
count: number;
|
||||
strips: number;
|
||||
stripSize: number;
|
||||
packCount?: number;
|
||||
stripsPerPack?: number;
|
||||
tabsPerStrip?: number;
|
||||
looseTablets?: number;
|
||||
slices: Slice[];
|
||||
updatedAt: string | number | null;
|
||||
};
|
||||
|
||||
type PlannerRow = {
|
||||
medicationId: number;
|
||||
medicationName: string;
|
||||
plannerUsage: number;
|
||||
stripSize: number;
|
||||
stripsNeeded: number;
|
||||
stripsAvailable: number;
|
||||
enough: boolean;
|
||||
};
|
||||
|
||||
type FormSlice = { usage: string; every: string; start: string };
|
||||
|
||||
type FormState = {
|
||||
name: string;
|
||||
count: string;
|
||||
stripSize: string;
|
||||
slices: Array<{ usage: string; every: string; start: string }>;
|
||||
name: string;
|
||||
packCount: string;
|
||||
stripsPerPack: string;
|
||||
tabsPerStrip: string;
|
||||
looseTablets: string;
|
||||
slices: FormSlice[];
|
||||
};
|
||||
|
||||
const defaultSlice = (): FormState["slices"][number] => ({
|
||||
usage: "1",
|
||||
every: "1",
|
||||
start: new Date().toISOString().slice(0, 16),
|
||||
});
|
||||
const defaultSlice = (): FormSlice => ({ usage: "1", every: "1", start: toInputValue(new Date().toISOString()) });
|
||||
|
||||
function useHealth() {
|
||||
const [status, setStatus] = useState<string>("loading");
|
||||
useEffect(() => {
|
||||
fetch("/api/health", { credentials: "include" })
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
const parsed = healthSchema.safeParse(data);
|
||||
if (parsed.success) setStatus(parsed.data.status);
|
||||
else setStatus("error");
|
||||
})
|
||||
.catch(() => setStatus("error"));
|
||||
}, []);
|
||||
return status;
|
||||
}
|
||||
const defaultForm = (): FormState => ({ name: "", packCount: "1", stripsPerPack: "1", tabsPerStrip: "1", looseTablets: "0", slices: [defaultSlice()] });
|
||||
|
||||
const todayIso = () => new Date().toISOString();
|
||||
const plusDaysIso = (days: number) => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + days);
|
||||
return d.toISOString();
|
||||
};
|
||||
|
||||
type Coverage = {
|
||||
name: string;
|
||||
medsLeft: number;
|
||||
daysLeft: number | null;
|
||||
depletionDate: string | null;
|
||||
depletionTime: number | null;
|
||||
nextDose: string | null;
|
||||
};
|
||||
|
||||
export default function App() {
|
||||
const status = useHealth();
|
||||
const [meds, setMeds] = useState<Medication[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [form, setForm] = useState<FormState>({ name: "", count: "0", stripSize: "1", slices: [defaultSlice()] });
|
||||
const [meds, setMeds] = useState<Medication[]>([]);
|
||||
const [plannerRows, setPlannerRows] = useState<PlannerRow[]>([]);
|
||||
const [plannerLoading, setPlannerLoading] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [form, setForm] = useState<FormState>(defaultForm());
|
||||
const [range, setRange] = useState<{ start: string; end: string }>({ start: toInputValue(todayIso()), end: toInputValue(plusDaysIso(3)) });
|
||||
const [view, setView] = useState<"dashboard" | "medications" | "planner">("dashboard");
|
||||
|
||||
const activeCount = meds.length;
|
||||
const schedule = useMemo(() => buildSchedulePreview(meds), [meds]);
|
||||
const totalTablets = useMemo(() => deriveTotal(form), [form]);
|
||||
const coverage = useMemo(() => calculateCoverage(meds, schedule.events), [meds, schedule.events]);
|
||||
const depletionByMed = useMemo(() => Object.fromEntries(coverage.all.map((c) => [c.name, c.depletionTime])), [coverage.all]);
|
||||
const groupedSchedule = useMemo(() => {
|
||||
const days = new Map<string, { dateStr: string; meds: Map<string, { medName: string; total: number; times: string[]; lastWhen: number }> }>();
|
||||
schedule.events.slice(0, 30).forEach((event) => {
|
||||
const day = days.get(event.dateStr) ?? { dateStr: event.dateStr, meds: new Map() };
|
||||
const medEntry = day.meds.get(event.medName) ?? { medName: event.medName, total: 0, times: [], lastWhen: event.when };
|
||||
medEntry.total += event.usage;
|
||||
medEntry.times.push(event.timeStr);
|
||||
medEntry.lastWhen = Math.max(medEntry.lastWhen, event.when);
|
||||
day.meds.set(event.medName, medEntry);
|
||||
days.set(event.dateStr, day);
|
||||
});
|
||||
return Array.from(days.values()).map((d) => ({ dateStr: d.dateStr, meds: Array.from(d.meds.values()) }));
|
||||
}, [schedule.events]);
|
||||
|
||||
const schedule = useMemo(() => buildSchedulePreview(meds), [meds]);
|
||||
useEffect(() => {
|
||||
loadMeds();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadMeds();
|
||||
}, []);
|
||||
function loadMeds() {
|
||||
setLoading(true);
|
||||
fetch("/api/medications")
|
||||
.then((res) => res.json())
|
||||
.then((data: Medication[]) => setMeds(data))
|
||||
.catch(() => setMeds([]))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
function loadMeds() {
|
||||
setLoading(true);
|
||||
fetch("/medications")
|
||||
.then((res) => res.json())
|
||||
.then((data: Medication[]) => setMeds(data))
|
||||
.catch(() => setMeds([]))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
async function deleteMed(id: number) {
|
||||
await fetch(`/api/medications/${id}`, { method: "DELETE" }).catch(() => null);
|
||||
if (editingId === id) resetForm();
|
||||
loadMeds();
|
||||
}
|
||||
|
||||
function setSliceValue(idx: number, field: keyof FormState["slices"][number], value: string) {
|
||||
setForm((prev) => {
|
||||
const next = [...prev.slices];
|
||||
next[idx] = { ...next[idx], [field]: value };
|
||||
return { ...prev, slices: next };
|
||||
});
|
||||
}
|
||||
function setSliceValue(idx: number, field: keyof FormSlice, value: string) {
|
||||
setForm((prev) => {
|
||||
const next = [...prev.slices];
|
||||
next[idx] = { ...next[idx], [field]: value };
|
||||
return { ...prev, slices: next };
|
||||
});
|
||||
}
|
||||
|
||||
function addSlice() {
|
||||
setForm((prev) => ({ ...prev, slices: [...prev.slices, defaultSlice()] }));
|
||||
}
|
||||
function addSlice() {
|
||||
setForm((prev) => ({ ...prev, slices: [...prev.slices, defaultSlice()] }));
|
||||
}
|
||||
|
||||
function removeSlice(idx: number) {
|
||||
setForm((prev) => ({ ...prev, slices: prev.slices.filter((_, i) => i !== idx) }));
|
||||
}
|
||||
function removeSlice(idx: number) {
|
||||
setForm((prev) => ({ ...prev, slices: prev.slices.filter((_, i) => i !== idx) }));
|
||||
}
|
||||
|
||||
function startEdit(med: Medication) {
|
||||
setEditingId(med.id);
|
||||
setForm({
|
||||
name: med.name,
|
||||
count: String(med.count),
|
||||
stripSize: String(med.stripSize),
|
||||
slices: med.slices.map((s) => ({
|
||||
usage: String(s.usage),
|
||||
every: String(s.every),
|
||||
start: toInputValue(s.start),
|
||||
})),
|
||||
});
|
||||
}
|
||||
function startEdit(med: Medication) {
|
||||
setEditingId(med.id);
|
||||
setForm({
|
||||
name: med.name,
|
||||
packCount: String(med.packCount ?? 1),
|
||||
stripsPerPack: String(med.stripsPerPack ?? med.strips ?? 1),
|
||||
tabsPerStrip: String(med.tabsPerStrip ?? med.stripSize ?? 1),
|
||||
looseTablets: String(med.looseTablets ?? 0),
|
||||
slices: med.slices.map((s) => ({ usage: String(s.usage), every: String(s.every), start: toInputValue(s.start) })),
|
||||
});
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
setEditingId(null);
|
||||
setForm({ name: "", count: "0", stripSize: "1", slices: [defaultSlice()] });
|
||||
}
|
||||
function resetForm() {
|
||||
setEditingId(null);
|
||||
setForm(defaultForm());
|
||||
}
|
||||
|
||||
async function saveMedication(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!form.name.trim()) return;
|
||||
setSaving(true);
|
||||
function handleValueChange<K extends keyof FormState>(key: K, value: string) {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name: form.name.trim(),
|
||||
count: Number(form.count) || 0,
|
||||
stripSize: Number(form.stripSize) || 1,
|
||||
slices: form.slices.map((s) => ({
|
||||
usage: Number(s.usage) || 0,
|
||||
every: Math.max(1, Number(s.every) || 1),
|
||||
start: toIsoString(s.start),
|
||||
})),
|
||||
};
|
||||
async function saveMedication(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!form.name.trim()) return;
|
||||
setSaving(true);
|
||||
|
||||
const method = editingId ? "PUT" : "POST";
|
||||
const url = editingId ? `/medications/${editingId}` : "/medications";
|
||||
const payload = {
|
||||
name: form.name.trim(),
|
||||
packCount: Number(form.packCount) || 0,
|
||||
stripsPerPack: Math.max(1, Number(form.stripsPerPack) || 1),
|
||||
tabsPerStrip: Math.max(1, Number(form.tabsPerStrip) || 1),
|
||||
looseTablets: Math.max(0, Number(form.looseTablets) || 0),
|
||||
slices: form.slices.map((s) => ({ usage: Number(s.usage) || 0, every: Math.max(1, Number(s.every) || 1), start: toIsoString(s.start) })),
|
||||
};
|
||||
|
||||
await fetch(url, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}).catch(() => null);
|
||||
const method = editingId ? "PUT" : "POST";
|
||||
const url = editingId ? `/api/medications/${editingId}` : "/api/medications";
|
||||
|
||||
setSaving(false);
|
||||
resetForm();
|
||||
loadMeds();
|
||||
}
|
||||
await fetch(url, { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }).catch(() => null);
|
||||
|
||||
return (
|
||||
<main className="page">
|
||||
<header className="hero">
|
||||
<div>
|
||||
<p className="eyebrow">Medassist · Planner</p>
|
||||
<h1>Medikationspläne anlegen & bearbeiten</h1>
|
||||
<p className="sub">Slices wie früher: usage, every (days), start (Datum/Uhrzeit), plus Bestand & Strip-Size.</p>
|
||||
<div className="badges">
|
||||
<span className="pill success">Backend: {status}</span>
|
||||
<span className="pill">Einträge: {meds.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stats">
|
||||
<div className="stat">
|
||||
<p className="label">Heute geplant</p>
|
||||
<p className="value">{schedule.today}</p>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<p className="label">Nächste 3 Tage</p>
|
||||
<p className="value">{schedule.nextThree}</p>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<p className="label">Aktive Slices</p>
|
||||
<p className="value">{schedule.totalSlices}</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
setSaving(false);
|
||||
resetForm();
|
||||
loadMeds();
|
||||
}
|
||||
|
||||
<section className="grid">
|
||||
<article className="card meds">
|
||||
<div className="card-head">
|
||||
<h2>Medikamentenliste</h2>
|
||||
<span className="pill">{loading ? "lädt..." : `${meds.length} gesamt`}</span>
|
||||
</div>
|
||||
<div className="med-list">
|
||||
{meds.map((med) => (
|
||||
<div key={med.id} className="med-row">
|
||||
<div>
|
||||
<div className="med-name">{med.name}</div>
|
||||
<div className="muted">Bestand: {med.count} · Strip-Size: {med.stripSize}</div>
|
||||
<div className="tag subtle">Slices: {med.slices.length}</div>
|
||||
<div className="slice-list">
|
||||
{med.slices.map((s, idx) => (
|
||||
<div key={`${med.id}-${idx}`} className="slice-pill">
|
||||
<span className="pill">{s.usage} meds</span>
|
||||
<span className="pill neutral">alle {s.every} Tage</span>
|
||||
<span className="pill subtle">ab {formatDateTime(s.start)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="med-actions">
|
||||
<button className="ghost" onClick={() => startEdit(med)}>Bearbeiten</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
async function runPlanner(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setPlannerLoading(true);
|
||||
const body = { startDate: toIsoString(range.start), endDate: toIsoString(range.end) };
|
||||
const rows = await fetch("/api/medications/usage", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) })
|
||||
.then((res) => res.json())
|
||||
.catch(() => []) as PlannerRow[];
|
||||
setPlannerRows(rows);
|
||||
setPlannerLoading(false);
|
||||
}
|
||||
|
||||
<article className="card form">
|
||||
<div className="card-head">
|
||||
<h2>{editingId ? "Eintrag bearbeiten" : "Neuer Eintrag"}</h2>
|
||||
<span className="pill">Slices wie alte App</span>
|
||||
</div>
|
||||
<form className="form-grid" onSubmit={saveMedication}>
|
||||
<label>
|
||||
Name
|
||||
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="z.B. Lisinopril" required />
|
||||
</label>
|
||||
<label>
|
||||
Bestand (count)
|
||||
<input type="number" min="0" value={form.count} onChange={(e) => setForm({ ...form, count: e.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
Strip Size
|
||||
<input type="number" min="1" value={form.stripSize} onChange={(e) => setForm({ ...form, stripSize: e.target.value })} />
|
||||
</label>
|
||||
function resetRange() {
|
||||
setRange({ start: toInputValue(todayIso()), end: toInputValue(plusDaysIso(3)) });
|
||||
setPlannerRows([]);
|
||||
}
|
||||
|
||||
<div className="full slices">
|
||||
<div className="card-head">
|
||||
<h3>Slice / Plan</h3>
|
||||
<button type="button" className="ghost" onClick={addSlice}>+ Slice</button>
|
||||
</div>
|
||||
{form.slices.map((s, idx) => (
|
||||
<div key={idx} className="slice-row">
|
||||
<label>
|
||||
Usage (meds)
|
||||
<input type="number" min="0" step="0.1" value={s.usage} onChange={(e) => setSliceValue(idx, "usage", e.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
Every (days)
|
||||
<input type="number" min="1" value={s.every} onChange={(e) => setSliceValue(idx, "every", e.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
Start (Datum/Zeit)
|
||||
<input type="datetime-local" value={s.start} onChange={(e) => setSliceValue(idx, "start", e.target.value)} />
|
||||
</label>
|
||||
{form.slices.length > 1 && (
|
||||
<button type="button" className="ghost" onClick={() => removeSlice(idx)}>Entfernen</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
return (
|
||||
<main className="page">
|
||||
<header className="hero">
|
||||
<div>
|
||||
<p className="eyebrow">Medassist · Planner</p>
|
||||
<h1>Manage medication plans</h1>
|
||||
</div>
|
||||
<div className="tabs">
|
||||
<button className={view === "dashboard" ? "pill primary" : "pill"} onClick={() => setView("dashboard")}>Dashboard</button>
|
||||
<button className={view === "medications" ? "pill primary" : "pill"} onClick={() => setView("medications")}>Medications</button>
|
||||
<button className={view === "planner" ? "pill primary" : "pill"} onClick={() => setView("planner")}>Planner</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="full align-end gap">
|
||||
{editingId && (
|
||||
<button type="button" className="ghost" onClick={resetForm}>
|
||||
Abbrechen
|
||||
</button>
|
||||
)}
|
||||
<button type="submit" disabled={saving}>{saving ? "Speichern..." : "Speichern"}</button>
|
||||
</div>
|
||||
</form>
|
||||
</article>
|
||||
</section>
|
||||
{view === "dashboard" && (
|
||||
<>
|
||||
<section className="grid">
|
||||
<article className="card">
|
||||
<div className="card-head">
|
||||
<h2>Reorder Reminder</h2>
|
||||
<span className="pill neutral">Stock watch</span>
|
||||
</div>
|
||||
{coverage.low.length === 0 ? (
|
||||
<p className="success-text">All good, enough stock.</p>
|
||||
) : (
|
||||
<div className="table">
|
||||
<div className="table-head">
|
||||
<span>Name</span>
|
||||
<span>Current pills</span>
|
||||
<span>Days left</span>
|
||||
<span>Runs out</span>
|
||||
<span>Next dose</span>
|
||||
</div>
|
||||
{coverage.low.map((row) => (
|
||||
<div key={row.name} className="table-row">
|
||||
<span>{row.name}</span>
|
||||
<span className={row.medsLeft <= 0 ? "danger-text" : ""}>{formatNumber(row.medsLeft)}</span>
|
||||
<span className={row.daysLeft !== null && row.daysLeft <= 0 ? "danger-text" : ""}>{formatNumber(row.daysLeft)}</span>
|
||||
<span>{row.depletionDate ?? "-"}</span>
|
||||
<span>{row.nextDose ?? "-"}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section className="grid">
|
||||
<article className="card">
|
||||
<div className="card-head">
|
||||
<h2>Nächste Einnahmen (3 Tage)</h2>
|
||||
<span className="pill neutral">Preview</span>
|
||||
</div>
|
||||
<div className="timeline">
|
||||
{schedule.events.slice(0, 10).map((event) => (
|
||||
<div key={event.id} className="time-row">
|
||||
<div className="time-chip">{event.timeStr}</div>
|
||||
<div>
|
||||
<div className="med-name">{event.medName}</div>
|
||||
<div className="muted">{event.dateStr}</div>
|
||||
<div className="tag subtle">{event.usage} meds</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
<section className="grid">
|
||||
<article className="card">
|
||||
<div className="card-head">
|
||||
<h2>Medication Overview</h2>
|
||||
<span className="pill neutral">Stock</span>
|
||||
</div>
|
||||
<div className="table table-4">
|
||||
<div className="table-head">
|
||||
<span>Name</span>
|
||||
<span>Current pills</span>
|
||||
<span>Days left</span>
|
||||
<span>Runs out</span>
|
||||
</div>
|
||||
{coverage.all.map((row) => (
|
||||
<div key={row.name} className="table-row">
|
||||
<span>{row.name}</span>
|
||||
<span className={row.medsLeft <= 0 ? "danger-text" : ""}>{formatNumber(row.medsLeft)}</span>
|
||||
<span>{formatNumber(row.daysLeft)}</span>
|
||||
<span>{row.depletionDate ?? "-"}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section className="grid">
|
||||
<article className="card">
|
||||
<div className="card-head">
|
||||
<h2>Upcoming Schedules</h2>
|
||||
<span className="pill neutral">Next 10</span>
|
||||
</div>
|
||||
<div className="timeline">
|
||||
{groupedSchedule.map((day) => (
|
||||
<div key={day.dateStr} className="day-block">
|
||||
<div className="day-divider">{day.dateStr}</div>
|
||||
{day.meds.map((item) => {
|
||||
const depletionTime = depletionByMed[item.medName];
|
||||
const outOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
|
||||
return (
|
||||
<div key={`${day.dateStr}-${item.medName}`} className="time-row">
|
||||
<div className="time-main">
|
||||
<div className="med-name">{item.medName}</div>
|
||||
<div className="tag-row">
|
||||
<span className="tag subtle">{item.total} pills total</span>
|
||||
<span className={`tag ${outOfStock ? "danger" : "success"}`}>
|
||||
{outOfStock ? "⚠ No pills left" : "✓ Stock OK"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="time-col">
|
||||
<div className="time-chip times-chip">{item.times.join(" · ")}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{view === "medications" && (
|
||||
<section className="grid">
|
||||
<article className="card meds">
|
||||
<div className="card-head">
|
||||
<h2>Medication list</h2>
|
||||
<span className="pill neutral">{loading ? "Loading..." : `${meds.length} entries`}</span>
|
||||
</div>
|
||||
<div className="med-list">
|
||||
{meds.map((med) => (
|
||||
<div key={med.id} className="med-row">
|
||||
<div className="med-header">
|
||||
<div className="med-info">
|
||||
<div className="med-name">{med.name}</div>
|
||||
<div className="med-details">
|
||||
<span>Packs: <strong>{med.packCount ?? 1}</strong></span>
|
||||
<span>Blisters per pack: <strong>{med.stripsPerPack ?? med.strips ?? 1}</strong></span>
|
||||
<span>Pills per blister: <strong>{med.tabsPerStrip ?? med.stripSize}</strong></span>
|
||||
<span>Loose: <strong>{med.looseTablets ?? 0}</strong></span>
|
||||
</div>
|
||||
<div className="med-total">Total: {med.count} pills</div>
|
||||
</div>
|
||||
<div className="med-actions">
|
||||
<button className="ghost" onClick={() => startEdit(med)}>Edit</button>
|
||||
<button className="ghost danger" onClick={() => deleteMed(med.id)}>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="slice-list">
|
||||
{med.slices.map((s, idx) => (
|
||||
<div key={`${med.id}-${idx}`} className="slice-row-simple">
|
||||
{s.usage} {s.usage === 1 ? "pill" : "pills"} · every {s.every} {s.every === 1 ? "day" : "days"} · from {formatDateTime(s.start)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="card form">
|
||||
<div className="card-head">
|
||||
<h2>{editingId ? "Edit entry" : "New entry"}</h2>
|
||||
<span className="pill">Packs + loose pills</span>
|
||||
</div>
|
||||
<form className="form-grid" onSubmit={saveMedication}>
|
||||
<label>
|
||||
Name
|
||||
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="z.B. Lisinopril" required />
|
||||
</label>
|
||||
<label>
|
||||
Packs
|
||||
<input type="number" min="0" value={form.packCount} onChange={(e) => handleValueChange("packCount", e.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
Blisters per pack
|
||||
<input type="number" min="1" value={form.stripsPerPack} onChange={(e) => handleValueChange("stripsPerPack", e.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
Pills per blister
|
||||
<input type="number" min="1" value={form.tabsPerStrip} onChange={(e) => handleValueChange("tabsPerStrip", e.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
Loose pills
|
||||
<input type="number" min="0" value={form.looseTablets} onChange={(e) => handleValueChange("looseTablets", e.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
Total (pills)
|
||||
<div className="static-value">{formatNumber(totalTablets)}</div>
|
||||
</label>
|
||||
|
||||
<div className="full slices">
|
||||
<div className="card-head">
|
||||
<h3>Intake schedule</h3>
|
||||
<button type="button" className="ghost" onClick={addSlice}>+ Intake</button>
|
||||
</div>
|
||||
{form.slices.map((s, idx) => (
|
||||
<div key={idx} className="slice-row">
|
||||
<div className="slice-inputs">
|
||||
<label>
|
||||
Usage (pills)
|
||||
<input type="number" min="0" step="0.1" value={s.usage} onChange={(e) => setSliceValue(idx, "usage", e.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
Every (days)
|
||||
<input type="number" min="1" value={s.every} onChange={(e) => setSliceValue(idx, "every", e.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
Start (date/time)
|
||||
<input type="datetime-local" step="60" value={s.start} onChange={(e) => setSliceValue(idx, "start", e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
{form.slices.length > 1 && (
|
||||
<button type="button" className="ghost" onClick={() => removeSlice(idx)}>Remove</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="full align-end gap">
|
||||
{editingId && (
|
||||
<button type="button" className="ghost" onClick={resetForm}>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
<button type="submit" disabled={saving}>{saving ? "Saving..." : "Save"}</button>
|
||||
</div>
|
||||
</form>
|
||||
</article>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{view === "planner" && (
|
||||
<section className="grid">
|
||||
<article className="card">
|
||||
<div className="card-head">
|
||||
<h2>Demand Calculator</h2>
|
||||
<span className="pill neutral">Plan your supply</span>
|
||||
</div>
|
||||
<form className="planner" onSubmit={runPlanner}>
|
||||
<label>
|
||||
From
|
||||
<input type="datetime-local" step="60" value={range.start} onChange={(e) => setRange({ ...range, start: e.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
Until
|
||||
<input type="datetime-local" step="60" value={range.end} onChange={(e) => setRange({ ...range, end: e.target.value })} />
|
||||
</label>
|
||||
<div className="planner-actions">
|
||||
<button type="button" className="ghost" onClick={resetRange}>Reset</button>
|
||||
<button type="submit" disabled={plannerLoading}>{plannerLoading ? "Calculating..." : "Calculate"}</button>
|
||||
</div>
|
||||
</form>
|
||||
{plannerRows.length > 0 && (
|
||||
<div className="table">
|
||||
<div className="table-head">
|
||||
<span>Medication</span>
|
||||
<span>Usage</span>
|
||||
<span>Blisters needed</span>
|
||||
<span>Available</span>
|
||||
<span>Status</span>
|
||||
</div>
|
||||
{plannerRows.map((row) => (
|
||||
<div key={row.medicationId} className="table-row">
|
||||
<span>{row.medicationName}</span>
|
||||
<span><strong>{row.plannerUsage}</strong> pills</span>
|
||||
<span>{row.stripsNeeded} × {row.stripSize}</span>
|
||||
<span>{row.stripsAvailable}</span>
|
||||
<span className={row.enough ? "status-chip success" : "status-chip danger"}>{row.enough ? "Enough" : "Low"}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function deriveTotal(form: FormState) {
|
||||
const packCount = Number(form.packCount) || 0;
|
||||
const stripsPerPack = Number(form.stripsPerPack) || 0;
|
||||
const tabsPerStrip = Number(form.tabsPerStrip) || 1;
|
||||
const looseTablets = Number(form.looseTablets) || 0;
|
||||
return packCount * stripsPerPack * tabsPerStrip + looseTablets;
|
||||
}
|
||||
|
||||
function toIsoString(value: string) {
|
||||
// Accept datetime-local value; fallback to now
|
||||
if (!value) return new Date().toISOString();
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? new Date().toISOString() : date.toISOString();
|
||||
if (!value) return new Date().toISOString();
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? new Date().toISOString() : date.toISOString();
|
||||
}
|
||||
|
||||
function toInputValue(value: string) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return new Date().toISOString().slice(0, 16);
|
||||
const iso = date.toISOString();
|
||||
return iso.slice(0, 16);
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return new Date().toISOString().slice(0, 16);
|
||||
const iso = date.toISOString();
|
||||
return iso.slice(0, 16);
|
||||
}
|
||||
|
||||
function formatDateTime(value: string) {
|
||||
const d = new Date(value);
|
||||
if (Number.isNaN(d.getTime())) return value;
|
||||
return d.toLocaleString([], { weekday: "short", day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" });
|
||||
const d = new Date(value);
|
||||
if (Number.isNaN(d.getTime())) return value;
|
||||
return d.toLocaleString([], { weekday: "short", day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
function buildSchedulePreview(meds: Medication[]) {
|
||||
const events: Array<{ id: string; medName: string; timeStr: string; dateStr: string; usage: number; when: number }> = [];
|
||||
const now = new Date();
|
||||
const end = new Date();
|
||||
end.setDate(end.getDate() + 3);
|
||||
const events: Array<{ id: string; medName: string; timeStr: string; dateStr: string; usage: number; when: number }> = [];
|
||||
const now = new Date();
|
||||
const end = new Date();
|
||||
end.setDate(end.getDate() + 3);
|
||||
|
||||
meds.forEach((med) => {
|
||||
med.slices.forEach((slice, idx) => {
|
||||
const start = new Date(slice.start);
|
||||
if (Number.isNaN(start.getTime())) return;
|
||||
// generate occurrences within next 3 days (simplified)
|
||||
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + slice.every)) {
|
||||
if (d < now) continue;
|
||||
const whenMs = d.getTime();
|
||||
events.push({
|
||||
id: `${med.id}-${idx}-${whenMs}`,
|
||||
medName: med.name,
|
||||
usage: slice.usage,
|
||||
when: whenMs,
|
||||
timeStr: d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
|
||||
dateStr: d.toLocaleDateString([], { weekday: "short", day: "2-digit", month: "short" }),
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
meds.forEach((med) => {
|
||||
med.slices.forEach((slice, idx) => {
|
||||
const start = new Date(slice.start);
|
||||
if (Number.isNaN(start.getTime())) return;
|
||||
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + slice.every)) {
|
||||
if (d < now) continue;
|
||||
const whenMs = d.getTime();
|
||||
events.push({
|
||||
id: `${med.id}-${idx}-${whenMs}`,
|
||||
medName: med.name,
|
||||
usage: slice.usage,
|
||||
when: whenMs,
|
||||
timeStr: d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
|
||||
dateStr: d.toLocaleDateString([], { weekday: "short", day: "2-digit", month: "short" }),
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
events.sort((a, b) => a.when - b.when);
|
||||
events.sort((a, b) => a.when - b.when);
|
||||
|
||||
const todayCount = events.filter((e) => {
|
||||
const t = new Date(e.when);
|
||||
const n = new Date();
|
||||
return t.getFullYear() === n.getFullYear() && t.getMonth() === n.getMonth() && t.getDate() === n.getDate();
|
||||
}).length;
|
||||
const todayCount = events.filter((e) => {
|
||||
const t = new Date(e.when);
|
||||
const n = new Date();
|
||||
return t.getFullYear() === n.getFullYear() && t.getMonth() === n.getMonth() && t.getDate() === n.getDate();
|
||||
}).length;
|
||||
|
||||
return { events, today: todayCount, nextThree: events.length, totalSlices: meds.reduce((acc, m) => acc + m.slices.length, 0) };
|
||||
return { events, today: todayCount, nextThree: events.length, totalSlices: meds.reduce((acc, m) => acc + m.slices.length, 0) };
|
||||
}
|
||||
|
||||
function formatNumber(value: number | null) {
|
||||
if (value === null || Number.isNaN(value)) return "-";
|
||||
if (Math.abs(value % 1) < 0.05) return Math.round(value).toLocaleString();
|
||||
return value.toFixed(1);
|
||||
}
|
||||
|
||||
function calculateCoverage(meds: Medication[], events: Array<{ medName: string; when: number }>) {
|
||||
const MS_PER_DAY = 86_400_000;
|
||||
const now = Date.now();
|
||||
|
||||
const coverage: Coverage[] = meds.map((m) => {
|
||||
const dailyRate = m.slices.reduce((sum, s) => sum + (s.every > 0 ? s.usage / s.every : 0), 0);
|
||||
|
||||
let consumed = 0;
|
||||
m.slices.forEach((s) => {
|
||||
const start = new Date(s.start).getTime();
|
||||
if (Number.isNaN(start) || start > now) return;
|
||||
const period = Math.max(1, s.every) * MS_PER_DAY;
|
||||
const occurrences = Math.floor((now - start) / period) + 1; // include today if started
|
||||
consumed += occurrences * s.usage;
|
||||
});
|
||||
|
||||
const medsLeft = Math.max(0, m.count - consumed);
|
||||
const rawDaysLeft = dailyRate > 0 ? medsLeft / dailyRate : null;
|
||||
const daysLeft = rawDaysLeft !== null ? Math.max(0, Math.floor(rawDaysLeft)) : null; // conservative: round down
|
||||
const depletionMs = daysLeft !== null ? now + daysLeft * MS_PER_DAY : null;
|
||||
const depletionDate = depletionMs !== null ? new Date(depletionMs).toLocaleDateString([], { weekday: "short", day: "2-digit", month: "short" }) : null;
|
||||
const nextEvent = events.find((e) => e.medName === m.name);
|
||||
|
||||
return {
|
||||
name: m.name,
|
||||
medsLeft: Number(medsLeft.toFixed(1)),
|
||||
daysLeft,
|
||||
depletionDate,
|
||||
depletionTime: depletionMs,
|
||||
nextDose: nextEvent ? new Date(nextEvent.when).toLocaleString([], { weekday: "short", day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" }) : null,
|
||||
};
|
||||
});
|
||||
|
||||
const low = coverage.filter((c) => c.medsLeft <= 0 || (c.daysLeft !== null && c.daysLeft <= 3));
|
||||
return { low, all: coverage };
|
||||
}
|
||||
|
||||
+213
-34
@@ -16,20 +16,26 @@ body {
|
||||
}
|
||||
|
||||
.hero {
|
||||
background: linear-gradient(135deg, rgba(67, 106, 255, 0.12), rgba(115, 195, 255, 0.1));
|
||||
border: 1px solid rgba(73, 117, 255, 0.25);
|
||||
border-radius: 20px;
|
||||
padding: 1.6rem;
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
background: linear-gradient(135deg, rgba(67, 106, 255, 0.08), rgba(115, 195, 255, 0.06));
|
||||
border: 1px solid rgba(73, 117, 255, 0.2);
|
||||
border-radius: 16px;
|
||||
padding: 1.25rem 1.5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
box-shadow: 0 18px 45px rgba(0, 0, 0, 0.3);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.hero h1 { margin: 0.2rem 0 0.4rem; font-size: 2rem; }
|
||||
.hero h1 { margin: 0.15rem 0 0; font-size: 1.6rem; font-weight: 600; }
|
||||
.sub { color: #b7c2e5; margin: 0; }
|
||||
.eyebrow { letter-spacing: 0.08em; text-transform: uppercase; color: #7ca7ff; font-size: 0.8rem; margin: 0; }
|
||||
.eyebrow { letter-spacing: 0.06em; text-transform: uppercase; color: #7ca7ff; font-size: 0.75rem; margin: 0; font-weight: 500; }
|
||||
|
||||
.tabs { display: flex; gap: 0.5rem; }
|
||||
.tabs .pill { cursor: pointer; transition: all 150ms ease; }
|
||||
.tabs .pill:hover { background: rgba(47, 134, 246, 0.15); }
|
||||
.tabs .pill.primary { background: rgba(47, 134, 246, 0.25); border-color: #2f86f6; }
|
||||
|
||||
.stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.75rem; align-items: center; }
|
||||
.stat { background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 14px; padding: 0.9rem; }
|
||||
@@ -55,60 +61,233 @@ body {
|
||||
|
||||
.badges { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.6rem; }
|
||||
|
||||
.meds .med-list { display: flex; flex-direction: column; gap: 0.9rem; }
|
||||
.med-row { display: flex; justify-content: space-between; gap: 1rem; border: 1px solid #1f2a3d; padding: 0.9rem; border-radius: 12px; background: #0d1424; }
|
||||
.med-name { font-weight: 600; }
|
||||
.meds .med-list { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||
.med-row { display: flex; flex-direction: column; gap: 0.75rem; border: 1px solid #1f2a3d; padding: 1rem; border-radius: 10px; background: #0d1424; position: relative; }
|
||||
.med-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; }
|
||||
.med-info { flex: 1; min-width: 0; }
|
||||
.med-name { font-weight: 600; font-size: 1.1rem; margin-bottom: 0.4rem; }
|
||||
.med-details { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.25rem 1.5rem; color: #a3adc2; font-size: 0.9rem; margin-bottom: 0.5rem; }
|
||||
.med-details strong { color: #dceaff; font-weight: 600; margin-left: 0.25rem; }
|
||||
.med-total { color: #dceaff; font-weight: 600; font-size: 0.95rem; margin-bottom: 0.5rem; }
|
||||
.muted { color: #a3adc2; font-size: 0.95rem; }
|
||||
.small { font-size: 0.9rem; }
|
||||
|
||||
.tag { display: inline-block; background: rgba(255, 255, 255, 0.06); border-radius: 10px; padding: 0.2rem 0.55rem; margin-top: 0.35rem; color: #dce3f5; font-size: 0.85rem; }
|
||||
.tag.subtle { background: rgba(255, 255, 255, 0.04); color: #c1cbe0; }
|
||||
.slice-list { display: flex; flex-direction: column; gap: 0.4rem; margin-top: 0.5rem; width: 100%; }
|
||||
.slice-row-simple { color: #cbd5f5; font-size: 0.9rem; padding: 0.5rem 0.75rem; background: #0f192c; border: 1px solid #1f2a3d; border-radius: 6px; width: 100%; }
|
||||
|
||||
.med-actions { display: flex; align-items: center; gap: 0.5rem; }
|
||||
|
||||
|
||||
.tag { display: inline-flex; align-items: center; gap: 0.3rem; background: rgba(255, 255, 255, 0.06); border-radius: 6px; padding: 0.3rem 0.6rem; color: #dce3f5; font-size: 0.8rem; font-weight: 500; }
|
||||
.tag.subtle { background: rgba(255, 255, 255, 0.04); color: #a3adc2; font-size: 0.85rem; }
|
||||
.tag.success { background: rgba(57, 217, 138, 0.12); color: #6ee7b7; border: 1px solid rgba(57, 217, 138, 0.25); }
|
||||
.tag.danger { background: rgba(255, 94, 94, 0.12); color: #fca5a5; border: 1px solid rgba(255, 94, 94, 0.3); }
|
||||
.tag-row { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; margin-top: 0.25rem; }
|
||||
.danger-text { color: #ff8f8f; font-weight: 700; }
|
||||
.success-text { color: #9be8c7; font-weight: 700; }
|
||||
|
||||
.med-actions { display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0; }
|
||||
.med-actions button { padding: 0.5rem 0.9rem; }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.med-header { flex-direction: column; }
|
||||
.med-actions { align-self: flex-start; }
|
||||
}
|
||||
.slice-list { display: flex; flex-wrap: wrap; gap: 0.35rem; margin-top: 0.6rem; }
|
||||
.slice-pill { display: flex; gap: 0.4rem; flex-wrap: wrap; }
|
||||
.slice-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 0.65rem; background: #0d1424; border: 1px solid #1f2a3d; padding: 0.85rem; border-radius: 10px; margin-bottom: 0.5rem; }
|
||||
.slice-row { display: flex; flex-direction: column; gap: 0.75rem; background: rgba(15, 25, 44, 0.5); border: 1px solid #1f2a3d; padding: 1rem; border-radius: 8px; margin-bottom: 0.65rem; }
|
||||
.slice-row .slice-inputs { display: grid; grid-template-columns: 1fr 1fr 2fr; gap: 1rem; align-items: end; }
|
||||
.slice-row button { align-self: flex-end; width: auto; }
|
||||
.slice-row:last-child { margin-bottom: 0; }
|
||||
.slices h3 { margin: 0; }
|
||||
.gap { gap: 0.6rem; }
|
||||
|
||||
button {
|
||||
padding: 0.65rem 1rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #2f86f6;
|
||||
padding: 0.7rem 1.25rem;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: linear-gradient(135deg, #2f86f6, #3fa9f5);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: transform 120ms ease, box-shadow 120ms ease;
|
||||
font-size: 0.9rem;
|
||||
transition: transform 120ms ease, box-shadow 120ms ease, opacity 120ms ease;
|
||||
}
|
||||
button:hover { transform: translateY(-1px); box-shadow: 0 10px 25px rgba(47, 134, 246, 0.35); }
|
||||
button.ghost { background: transparent; border-color: #3a475f; color: #d0d8ec; box-shadow: none; }
|
||||
button.ghost:hover { background: rgba(255, 255, 255, 0.05); transform: none; }
|
||||
button:hover { transform: translateY(-1px); box-shadow: 0 8px 20px rgba(47, 134, 246, 0.35); }
|
||||
button:active { transform: translateY(0); }
|
||||
button.ghost { background: transparent; border: 1px solid #3a475f; color: #d0d8ec; box-shadow: none; }
|
||||
button.ghost:hover { background: rgba(255, 255, 255, 0.06); transform: none; }
|
||||
button.ghost.danger { border-color: #5a3a3a; color: #ff9a9a; }
|
||||
button.ghost.danger:hover { background: rgba(255, 94, 94, 0.1); }
|
||||
|
||||
input, select {
|
||||
width: 100%;
|
||||
padding: 0.65rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #1f2a3d;
|
||||
background: #0d1322;
|
||||
padding: 0.7rem 0.85rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #2a3a4d;
|
||||
background: #0a1018;
|
||||
color: #e5e7eb;
|
||||
font-size: 0.95rem;
|
||||
transition: border-color 150ms ease, box-shadow 150ms ease;
|
||||
}
|
||||
input:focus, select:focus {
|
||||
outline: none;
|
||||
border-color: #2f86f6;
|
||||
box-shadow: 0 0 0 3px rgba(47, 134, 246, 0.15);
|
||||
}
|
||||
|
||||
.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 0.75rem 1rem; }
|
||||
.form-grid label { display: flex; flex-direction: column; gap: 0.35rem; color: #cfd8f1; font-weight: 500; }
|
||||
.form-grid .full { grid-column: 1 / -1; }
|
||||
.align-end { display: flex; justify-content: flex-end; }
|
||||
.static-value {
|
||||
padding: 0.7rem 0.85rem;
|
||||
border-radius: 8px;
|
||||
background: rgba(47, 134, 246, 0.08);
|
||||
border: 1px solid #2f86f6;
|
||||
color: #dceaff;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.timeline { display: flex; flex-direction: column; gap: 0.85rem; }
|
||||
.time-row { display: flex; gap: 0.9rem; align-items: center; border-bottom: 1px solid #1f2a3d; padding-bottom: 0.75rem; }
|
||||
.form-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1rem 1.25rem; }
|
||||
.form-grid label { display: flex; flex-direction: column; gap: 0.4rem; color: #a3b3c8; font-size: 0.85rem; font-weight: 500; text-transform: uppercase; letter-spacing: 0.03em; }
|
||||
.form-grid .full { grid-column: 1 / -1; }
|
||||
.align-end { display: flex; justify-content: flex-end; gap: 0.75rem; }
|
||||
|
||||
.timeline { display: flex; flex-direction: column; gap: 1rem; }
|
||||
.day-block { border: 1px solid #1f2a3d; border-radius: 16px; padding: 1rem 1.25rem; background: linear-gradient(135deg, #0d1322 0%, #111827 100%); box-shadow: 0 8px 32px rgba(0,0,0,0.25); }
|
||||
.day-divider {
|
||||
margin: 0 0 0.75rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid rgba(47, 134, 246, 0.2);
|
||||
color: #7ca7ff;
|
||||
font-weight: 700;
|
||||
font-size: 0.95rem;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.time-row { display: grid; grid-template-columns: minmax(200px, 280px) 1fr; align-items: center; gap: 1rem; padding: 0.75rem 0; border-bottom: 1px solid rgba(255,255,255,0.05); }
|
||||
.time-row:last-child { border-bottom: none; padding-bottom: 0; }
|
||||
.time-chip { min-width: 80px; text-align: center; border: 1px solid #2f86f6; border-radius: 10px; padding: 0.45rem 0.6rem; background: rgba(47, 134, 246, 0.1); color: #dceaff; font-weight: 600; }
|
||||
.time-main { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||
.time-main .med-name { font-size: 1rem; font-weight: 600; color: #e5e7eb; margin: 0; }
|
||||
.time-col { display: flex; align-items: center; justify-content: flex-start; }
|
||||
.time-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border: 1px solid rgba(47, 134, 246, 0.4);
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: rgba(47, 134, 246, 0.08);
|
||||
color: #93c5fd;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.times-chip { white-space: nowrap; }
|
||||
|
||||
.highlights { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 0.75rem; margin-bottom: 0.75rem; }
|
||||
|
||||
.card p { margin: 0; }
|
||||
|
||||
.table { width: 100%; display: flex; flex-direction: column; gap: 0; margin-top: 0.5rem; }
|
||||
.table-head, .table-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 2fr) 100px 140px 140px 120px;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
.table-head {
|
||||
color: #7ca7ff;
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
background: rgba(47, 134, 246, 0.06);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.table-row {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 0.35rem;
|
||||
transition: background 150ms ease;
|
||||
}
|
||||
.table-row:hover { background: rgba(255, 255, 255, 0.04); }
|
||||
.table-row:last-child { margin-bottom: 0; }
|
||||
.table-4 .table-head, .table-4 .table-row {
|
||||
grid-template-columns: minmax(200px, 2.2fr) 150px 130px 170px;
|
||||
}
|
||||
|
||||
.status-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.4rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.status-chip.success {
|
||||
background: rgba(57, 217, 138, 0.15);
|
||||
color: #6ee7b7;
|
||||
border: 1px solid rgba(57, 217, 138, 0.3);
|
||||
}
|
||||
.status-chip.success::before {
|
||||
content: "✓";
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.status-chip.danger {
|
||||
background: rgba(255, 94, 94, 0.15);
|
||||
color: #fca5a5;
|
||||
border: 1px solid rgba(255, 94, 94, 0.3);
|
||||
}
|
||||
.status-chip.danger::before {
|
||||
content: "!";
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.table-head, .table-row {
|
||||
grid-template-columns: 1.6fr 1fr 1fr;
|
||||
grid-auto-flow: row;
|
||||
grid-auto-rows: auto;
|
||||
}
|
||||
.table-head span:nth-child(n+4), .table-row span:nth-child(n+4) { display: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.hero { grid-template-columns: 1fr; }
|
||||
.stats { grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); }
|
||||
}
|
||||
|
||||
.planner {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: linear-gradient(135deg, rgba(47, 134, 246, 0.04), rgba(115, 195, 255, 0.02));
|
||||
border: 1px solid rgba(47, 134, 246, 0.15);
|
||||
border-radius: 12px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.planner label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
color: #93a3b8;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.planner-actions {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.planner { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user