feat: Add Medication Refill feature with mobile UI improvements (#30)

* feat: Add Medication Refill feature with UI improvements

- Add refill functionality to medications (add packs/loose pills)
- Add refill API endpoint with history tracking
- Add refill section in edit forms (desktop & mobile)
- Add refill modal in medication detail view
- Add refill history display with expand/collapse
- Add schedule lightbox for clicking medication images
- Improve button styling with primary/info/success classes
- Move '+ New entry' button to medication list header
- Lightbox size: 50% desktop, 90% mobile
- Update selectedMed sync after stock changes
- Migrate from schema-sql.ts to Drizzle Kit migrations

* fix: Improve mobile tooltips and refill modal layout

- Center tooltips on screen for mobile devices (fixed position)
- Close tooltips automatically when scrolling on touch devices
- Use click-based tooltip activation instead of hover on mobile
- Fix refill modal buttons to display in two rows on mobile
This commit is contained in:
Daniel Volz
2026-01-17 20:39:18 +01:00
committed by GitHub
parent 269a549563
commit 82b2be48cd
21 changed files with 3963 additions and 666 deletions
+370 -30
View File
@@ -39,6 +39,13 @@ type PlannerRow = {
enough: boolean;
};
type RefillEntry = {
id: number;
packsAdded: number;
loosePillsAdded: number;
refillDate: string;
};
type FormBlister = { usage: string; every: string; startDate: string; startTime: string };
type FormState = {
@@ -337,6 +344,7 @@ function AppContent() {
const [pendingImagePreview, setPendingImagePreview] = useState<string | null>(null);
const [selectedMed, setSelectedMed] = useState<Medication | null>(null);
const [showImageLightbox, setShowImageLightbox] = useState(false);
const [scheduleLightboxImage, setScheduleLightboxImage] = useState<string | null>(null);
const [selectedUser, setSelectedUser] = useState<string | null>(null);
const [scheduleDays, setScheduleDays] = useState<number>(30);
const [showPastDays, setShowPastDays] = useState(false);
@@ -358,9 +366,18 @@ function AppContent() {
// Export/Import state
const [exporting, setExporting] = useState(false);
const [importing, setImporting] = useState(false);
// User dropdown state (for mobile click-based behavior)
const [userDropdownOpen, setUserDropdownOpen] = useState(false);
const [showImportConfirm, setShowImportConfirm] = useState(false);
const [pendingImportData, setPendingImportData] = useState<any>(null);
// Refill state
const [showRefillModal, setShowRefillModal] = useState(false);
const [refillPacks, setRefillPacks] = useState(1);
const [refillLoose, setRefillLoose] = useState(0);
const [refillSaving, setRefillSaving] = useState(false);
const [refillHistory, setRefillHistory] = useState<RefillEntry[]>([]);
const [refillHistoryExpanded, setRefillHistoryExpanded] = useState(false);
// Collapsed days state (manually collapsed days are persisted)
const [manuallyCollapsedDays, setManuallyCollapsedDays] = useState<Set<string>>(new Set());
const [manuallyExpandedDays, setManuallyExpandedDays] = useState<Set<string>>(new Set());
@@ -515,7 +532,11 @@ function AppContent() {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") {
// Close modals in order of priority (topmost first)
if (showImageLightbox) {
if (userDropdownOpen) {
setUserDropdownOpen(false);
} else if (scheduleLightboxImage) {
setScheduleLightboxImage(null);
} else if (showImageLightbox) {
setShowImageLightbox(false);
} else if (showEditModal) {
setShowEditModal(false);
@@ -533,7 +554,54 @@ function AppContent() {
};
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, [selectedMed, showImageLightbox, selectedUser, showProfile, showShareDialog, showEditModal]);
}, [selectedMed, showImageLightbox, scheduleLightboxImage, selectedUser, showProfile, showShareDialog, showEditModal, userDropdownOpen]);
// Close user dropdown when clicking outside
useEffect(() => {
if (!userDropdownOpen) return;
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (!target.closest('.user-menu')) {
setUserDropdownOpen(false);
}
};
document.addEventListener("click", handleClickOutside);
return () => document.removeEventListener("click", handleClickOutside);
}, [userDropdownOpen]);
// Close tooltips on scroll/touch (for mobile)
useEffect(() => {
const closeAllTooltips = () => {
document.querySelectorAll('.info-tooltip.tooltip-active').forEach(el => {
el.classList.remove('tooltip-active');
});
};
const handleTooltipClick = (e: Event) => {
const target = e.target as HTMLElement;
if (target.classList.contains('info-tooltip')) {
// Close other tooltips first
closeAllTooltips();
// Toggle this one
target.classList.add('tooltip-active');
} else {
closeAllTooltips();
}
};
const handleTouchMove = () => {
closeAllTooltips();
};
document.addEventListener('click', handleTooltipClick, { capture: true });
document.addEventListener('touchmove', handleTouchMove, { passive: true });
document.addEventListener('scroll', handleTouchMove, { passive: true });
return () => {
document.removeEventListener('click', handleTooltipClick, { capture: true });
document.removeEventListener('touchmove', handleTouchMove);
document.removeEventListener('scroll', handleTouchMove);
};
}, []);
// Prevent background scroll when modal is open
useEffect(() => {
@@ -556,6 +624,20 @@ function AppContent() {
};
}, [selectedMed, selectedUser, showProfile, showShareDialog, showEditModal]);
// Update selectedMed when meds change (e.g., after refill)
useEffect(() => {
if (selectedMed) {
const updated = meds.find(m => m.id === selectedMed.id);
if (updated && (
updated.packCount !== selectedMed.packCount ||
updated.looseTablets !== selectedMed.looseTablets ||
updated.updatedAt !== selectedMed.updatedAt
)) {
setSelectedMed(updated);
}
}
}, [meds, selectedMed]);
// Check if settings have changed
const settingsChanged = settings.emailEnabled !== savedSettings.emailEnabled ||
settings.notificationEmail !== savedSettings.notificationEmail ||
@@ -739,6 +821,65 @@ function AppContent() {
setSettingsSaved(true);
}
// Load refill history for a medication
async function loadRefillHistory(medId: number) {
try {
const res = await fetch(`/api/medications/${medId}/refills`, { credentials: "include" });
if (res.ok) {
const data = await res.json();
setRefillHistory(Array.isArray(data) ? data : (data.refills || []));
} else {
setRefillHistory([]);
}
} catch {
setRefillHistory([]);
}
}
// Submit a refill
async function submitRefill(medId: number) {
if (refillPacks < 1 && refillLoose < 1) return;
setRefillSaving(true);
try {
const res = await fetch(`/api/medications/${medId}/refill`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ packsAdded: refillPacks, loosePillsAdded: refillLoose }),
});
if (res.ok) {
const data = await res.json();
// Update form values if we're in edit mode
if (editingId === medId && data.newStock) {
setForm(f => ({
...f,
packCount: String(data.newStock.packCount),
looseTablets: String(data.newStock.looseTablets),
}));
}
// Reset refill form
setRefillPacks(1);
setRefillLoose(0);
setShowRefillModal(false);
// Reload medications to get updated stock
loadMeds();
// Reload refill history
await loadRefillHistory(medId);
}
} catch {
// ignore
}
setRefillSaving(false);
}
// Helper to open medication detail modal with refill history
function openMedDetail(med: Medication) {
setSelectedMed(med);
setRefillHistory([]);
setRefillHistoryExpanded(false);
loadRefillHistory(med.id);
}
async function testEmail() {
if (!settings.notificationEmail) return;
setTestingEmail(true);
@@ -1286,8 +1427,8 @@ function AppContent() {
{theme === "dark" ? "☀️" : "🌙"}
</button>
{authState?.authEnabled && user && (
<div className="user-menu">
<button className="user-menu-btn">
<div className={`user-menu ${userDropdownOpen ? 'open' : ''}`}>
<button className="user-menu-btn" onClick={() => setUserDropdownOpen(!userDropdownOpen)}>
{user.avatarUrl ? (
<img src={`/api/images/${user.avatarUrl}`} alt={user.username} className="user-avatar-img" />
) : (
@@ -1304,15 +1445,15 @@ function AppContent() {
<span className="dropdown-username">{user.username}</span>
</div>
<div className="dropdown-menu">
<button className="dropdown-item" onClick={() => setShowProfile(true)}>
<button className="dropdown-item" onClick={() => { setShowProfile(true); setUserDropdownOpen(false); }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
{t('auth.profile', 'Profile')}
</button>
<button className="dropdown-item" onClick={() => navigate('/settings')}>
<button className="dropdown-item" onClick={() => { navigate('/settings'); setUserDropdownOpen(false); }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
{t('nav.settings', 'Settings')}
</button>
<button className="dropdown-item danger" onClick={() => logout()}>
<button className="dropdown-item danger" onClick={() => { logout(); setUserDropdownOpen(false); }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
{t('auth.signOut', 'Sign Out')}
</button>
@@ -1398,7 +1539,7 @@ function AppContent() {
med ? med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets : Math.round(row.medsLeft)
);
return (
<div key={row.name} className="table-row clickable" onClick={() => med && setSelectedMed(med)}>
<div key={row.name} className="table-row clickable" onClick={() => med && openMedDetail(med)}>
<span data-label={t('table.name')} className="cell-with-avatar">
<MedicationAvatar name={row.name} imageUrl={med?.imageUrl} />
<span className="med-name-text">{row.name}</span>
@@ -1467,7 +1608,7 @@ function AppContent() {
med ? med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets : Math.round(row.medsLeft)
);
return (
<div key={row.name} className="table-row clickable" onClick={() => med && setSelectedMed(med)}>
<div key={row.name} className="table-row clickable" onClick={() => med && openMedDetail(med)}>
<span data-label={t('table.name')} className="cell-with-avatar">
<span className="med-name-line">
<MedicationAvatar name={row.name} imageUrl={med?.imageUrl} />
@@ -1595,7 +1736,15 @@ function AppContent() {
return (
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}>
<div className="time-main">
<div className="med-name"><MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" /><span className="med-name-text">{item.medName}</span>{med?.intakeRemindersEnabled && <span className="reminder-icon info-tooltip" data-tooltip={t('tooltips.intakeReminders')}>🔔</span>}</div>
<div className="med-name">
<div
className={med?.imageUrl ? "med-avatar clickable" : ""}
onClick={() => med?.imageUrl && setScheduleLightboxImage(`/api/images/${med.imageUrl}`)}
>
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
</div>
<span className="med-name-text">{item.medName}</span>{med?.intakeRemindersEnabled && <span className="reminder-icon info-tooltip" data-tooltip={t('tooltips.intakeReminders')}>🔔</span>}
</div>
<div className="tag-row">
<span className="tag subtle">{item.total} {t('common.pills')} {t('common.total')}</span>
</div>
@@ -1698,7 +1847,15 @@ function AppContent() {
return (
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}>
<div className="time-main">
<div className="med-name"><MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" /><span className="med-name-text">{item.medName}</span>{med?.intakeRemindersEnabled && <span className="reminder-icon info-tooltip" data-tooltip={t('tooltips.intakeReminders')}>🔔</span>}</div>
<div className="med-name">
<div
className={med?.imageUrl ? "med-avatar clickable" : ""}
onClick={() => med?.imageUrl && setScheduleLightboxImage(`/api/images/${med.imageUrl}`)}
>
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
</div>
<span className="med-name-text">{item.medName}</span>{med?.intakeRemindersEnabled && <span className="reminder-icon info-tooltip" data-tooltip={t('tooltips.intakeReminders')}>🔔</span>}
</div>
<div className="tag-row">
<span className="tag subtle">{item.total} {t('common.pills')} {t('common.total')}</span>
{status && <span className={`tag ${status.className}`}>
@@ -1788,6 +1945,19 @@ function AppContent() {
<article className="card meds">
<div className="card-head">
<h2>{t('medications.list.title')}</h2>
<button
type="button"
className="btn primary small"
onClick={() => {
resetForm();
// On mobile, open the edit modal
if (window.innerWidth <= 768) {
setShowEditModal(true);
}
}}
>
+ {t('form.newEntry')}
</button>
</div>
<div className="med-list">
{meds.map((med) => (
@@ -1807,7 +1977,7 @@ function AppContent() {
<div className="med-total">{t('medications.details.total')}: {med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets} {t('common.pills')}</div>
</div>
<div className="med-actions">
<button className="secondary" onClick={() => startEdit(med)}>{t('common.edit')}</button>
<button className="info" onClick={() => startEdit(med)}>{t('common.edit')}</button>
<button className="danger" onClick={() => deleteMed(med.id)}>{t('common.delete')}</button>
</div>
</div>
@@ -1909,6 +2079,44 @@ function AppContent() {
<input type="date" value={form.expiryDate} onChange={(e) => handleValueChange("expiryDate", e.target.value)} placeholder={t('common.optional')} />
</label>
{/* Refill section - only shown when editing */}
{editingId && (
<div className="full refill-section">
<h4 className="refill-title">{t('refill.title')}</h4>
<div className="refill-form-inline">
<label>
{t('refill.packs')}
<input
type="number"
min="0"
value={refillPacks}
onChange={(e) => setRefillPacks(parseInt(e.target.value) || 0)}
/>
</label>
<label>
{t('refill.loosePills')}
<input
type="number"
min="0"
value={refillLoose}
onChange={(e) => setRefillLoose(parseInt(e.target.value) || 0)}
/>
</label>
<button
type="button"
className="success"
onClick={() => submitRefill(editingId)}
disabled={(refillPacks < 1 && refillLoose < 1) || refillSaving}
>
{refillSaving ? t('refill.adding') : t('refill.button')}
</button>
{(refillPacks > 0 || refillLoose > 0) && (
<span className="refill-preview">+{refillPacks * Number(form.blistersPerPack || 0) * Number(form.pillsPerBlister || 1) + refillLoose} {t('common.pills')}</span>
)}
</div>
</div>
)}
<label className={`full ${fieldErrors.notes ? 'has-error' : ''}`}>
{t('form.notes')}
<textarea
@@ -1940,7 +2148,7 @@ function AppContent() {
/>
<span>🔔 {t('form.blisters.remind')}</span>
</label>
<button type="button" className="ghost" onClick={addBlister}>+ {t('form.blisters.addIntake')}</button>
<button type="button" className="primary" onClick={addBlister}>+ {t('form.blisters.addIntake')}</button>
</div>
</div>
{form.blisters.map((s, idx) => (
@@ -2068,7 +2276,7 @@ function AppContent() {
{plannerRows.map((row) => {
const med = meds.find(m => m.name === row.medicationName);
return (
<div key={row.medicationId} className="table-row clickable" onClick={() => med && setSelectedMed(med)}>
<div key={row.medicationId} className="table-row clickable" onClick={() => med && openMedDetail(med)}>
<span data-label={t('planner.table.medication')} className="cell-with-avatar"><MedicationAvatar name={row.medicationName} imageUrl={med?.imageUrl} />{row.medicationName}</span>
<span data-label={t('planner.table.usage')}><strong>{row.plannerUsage}</strong>&nbsp;{t('common.pills')}</span>
<span data-label={t('planner.table.blisters')}>{row.blistersNeeded} × {row.blisterSize}</span>
@@ -2196,7 +2404,7 @@ function AppContent() {
)}
{/* Skip reminders for taken doses */}
<div className="setting-row compact" style={{marginTop: "16px", paddingTop: "16px", borderTop: "1px solid var(--border-color)"}}>
<div className="setting-row compact" style={{marginTop: "16px"}}>
<label className="setting-label">
{t('settings.notifications.skipTakenDoses')}
<span className="info-tooltip small" data-tooltip={t('settings.notifications.skipTakenDosesTooltip')}></span>
@@ -2519,11 +2727,13 @@ function AppContent() {
</h2>
</div>
<div className="setting-section">
<div className="export-import-grid">
<div className="setting-group">
{/* Export */}
<div className="export-import-card">
<h3>{t('exportImport.exportTitle')}</h3>
<p className="export-import-desc">{t('exportImport.exportDesc')}</p>
<div className="action-card">
<div className="action-card-content">
<span className="action-card-title">{t('exportImport.exportTitle')}</span>
<span className="action-card-desc">{t('exportImport.exportDesc')}</span>
</div>
<button
type="button"
className="secondary"
@@ -2535,16 +2745,19 @@ function AppContent() {
</div>
{/* Import */}
<div className="export-import-card">
<h3>{t('exportImport.importTitle')}</h3>
<p className="export-import-desc">{t('exportImport.importDesc')}</p>
<label className="export-import-file-btn">
<div className="action-card">
<div className="action-card-content">
<span className="action-card-title">{t('exportImport.importTitle')}</span>
<span className="action-card-desc">{t('exportImport.importDesc')}</span>
</div>
<label className="btn secondary">
{importing ? t('exportImport.importing') : t('exportImport.import')}
<input
type="file"
accept=".json,application/json"
onChange={handleImportFileSelect}
disabled={importing}
style={{display: 'none'}}
/>
</label>
</div>
@@ -2921,6 +3134,33 @@ function AppContent() {
</div>
</div>
)}
{/* Refill History */}
{refillHistory.length > 0 && (
<div className="med-detail-section">
<h3
className="section-header-clickable"
onClick={() => setRefillHistoryExpanded(!refillHistoryExpanded)}
>
{t('refill.history')} ({refillHistory.length})
<span className="expand-arrow">{refillHistoryExpanded ? '▼' : '▶'}</span>
</h3>
{refillHistoryExpanded && (
<div className="refill-history-list">
{refillHistory.map((entry) => (
<div key={entry.id} className="refill-history-item">
<span className="refill-date">
{new Date(entry.refillDate).toLocaleDateString(i18n.language, { day: "2-digit", month: "short", year: "numeric" })}, {new Date(entry.refillDate).toLocaleTimeString(i18n.language, { hour: "2-digit", minute: "2-digit" })}
</span>
<span className="refill-amount">
+{entry.packsAdded * selectedMed.blistersPerPack * selectedMed.pillsPerBlister + entry.loosePillsAdded} {t('common.pills')}
</span>
</div>
))}
</div>
)}
</div>
)}
</div>
<div className="med-detail-footer">
@@ -2928,7 +3168,10 @@ function AppContent() {
{t('common.close')}
</button>
<div className="footer-actions">
<button className="secondary" onClick={() => { setSelectedMed(null); setShowImageLightbox(false); navigate("/medications"); startEdit(selectedMed); }}>
<button className="success" onClick={() => setShowRefillModal(true)}>
{t('refill.button')}
</button>
<button className="info" onClick={() => { setSelectedMed(null); setShowImageLightbox(false); navigate("/medications"); startEdit(selectedMed); }}>
{t('common.edit')}
</button>
{selectedMed.blisters.length > 0 && (
@@ -2952,6 +3195,56 @@ function AppContent() {
/>
</div>
)}
{/* Refill Modal */}
{showRefillModal && (
<div className="modal-overlay" onClick={(e) => { e.stopPropagation(); setShowRefillModal(false); }}>
<div className="modal-content refill-modal" onClick={(e) => e.stopPropagation()}>
<button className="modal-close" onClick={() => setShowRefillModal(false)}>×</button>
<h2>{t('refill.title')}</h2>
<p className="refill-med-name">{selectedMed.name}</p>
<div className="refill-form">
<label>
{t('refill.packs')}
<input
type="number"
min="0"
value={refillPacks}
onChange={(e) => setRefillPacks(parseInt(e.target.value) || 0)}
/>
</label>
<label>
{t('refill.loosePills')}
<input
type="number"
min="0"
value={refillLoose}
onChange={(e) => setRefillLoose(parseInt(e.target.value) || 0)}
/>
</label>
</div>
<div className="modal-footer">
<button className="ghost" onClick={() => setShowRefillModal(false)}>
{t('common.cancel')}
</button>
<div className="refill-footer-right">
<button
className="success"
onClick={() => submitRefill(selectedMed.id)}
disabled={(refillPacks < 1 && refillLoose < 1) || refillSaving}
>
{refillSaving ? t('common.saving') : t('refill.button')}
</button>
{(refillPacks > 0 || refillLoose > 0) && (
<span className="refill-preview">+{refillPacks * selectedMed.blistersPerPack * selectedMed.pillsPerBlister + refillLoose} {t('common.pills')}</span>
)}
</div>
</div>
</div>
</div>
)}
</div>
)}
@@ -2976,7 +3269,7 @@ function AppContent() {
<div
key={med.id}
className="user-med-item clickable"
onClick={() => { setSelectedUser(null); setSelectedMed(med); }}
onClick={() => { setSelectedUser(null); openMedDetail(med); }}
>
<MedicationAvatar name={med.name} imageUrl={med.imageUrl} size="sm" />
<div className="user-med-info">
@@ -3078,6 +3371,19 @@ function AppContent() {
</div>
)}
{/* Schedule Lightbox - for clicking medication images in schedule */}
{scheduleLightboxImage && (
<div className="lightbox-overlay" onClick={() => setScheduleLightboxImage(null)}>
<button className="lightbox-close" onClick={() => setScheduleLightboxImage(null)}>×</button>
<img
src={scheduleLightboxImage}
alt="Medication"
className="lightbox-image"
onClick={(e) => e.stopPropagation()}
/>
</div>
)}
{/* Mobile Edit Modal */}
{showEditModal && (
<div className="modal-overlay" onClick={() => setShowEditModal(false)}>
@@ -3085,11 +3391,6 @@ function AppContent() {
<button className="modal-close" onClick={() => { setShowEditModal(false); resetForm(); }}>×</button>
<div className="edit-modal-header">
<h2>{editingId ? t('form.editEntry') : t('form.newEntry')}</h2>
{editingId && (
<button type="button" className="btn secondary small" onClick={resetForm}>
+ {t('form.newEntry')}
</button>
)}
</div>
<form className="form-grid mobile-edit-form" onSubmit={(e) => { saveMedication(e); setShowEditModal(false); }}>
<label className={`full ${fieldErrors.name ? 'has-error' : ''}`}>
@@ -3166,6 +3467,45 @@ function AppContent() {
{t('form.expiryDate')}
<input type="date" value={form.expiryDate} onChange={(e) => setForm({ ...form, expiryDate: e.target.value })} />
</label>
{/* Refill section - only shown when editing (mobile) */}
{editingId && (
<div className="full refill-section">
<h4 className="refill-title">{t('refill.title')}</h4>
<div className="refill-form-inline">
<label>
{t('refill.packs')}
<input
type="number"
min="0"
value={refillPacks}
onChange={(e) => setRefillPacks(parseInt(e.target.value) || 0)}
/>
</label>
<label>
{t('refill.loosePills')}
<input
type="number"
min="0"
value={refillLoose}
onChange={(e) => setRefillLoose(parseInt(e.target.value) || 0)}
/>
</label>
<button
type="button"
className="success"
onClick={() => submitRefill(editingId)}
disabled={(refillPacks < 1 && refillLoose < 1) || refillSaving}
>
{refillSaving ? t('common.saving') : t('refill.button')}
</button>
{(refillPacks > 0 || refillLoose > 0) && (
<span className="refill-preview">+{refillPacks * Number(form.blistersPerPack || 0) * Number(form.pillsPerBlister || 1) + refillLoose} {t('common.pills')}</span>
)}
</div>
</div>
)}
<label className={`full ${fieldErrors.notes ? 'has-error' : ''}`}>
{t('form.notes')}
<textarea
+19 -3
View File
@@ -98,8 +98,8 @@
}
},
"form": {
"editEntry": "Eintrag bearbeiten",
"newEntry": "Neuer Eintrag",
"editEntry": "Medikament bearbeiten",
"newEntry": "Neues Medikament",
"badge": "Packungen + lose Tabletten",
"commercialName": "Handelsname",
"genericName": "Wirkstoff",
@@ -226,7 +226,7 @@
"pillWeight": "Tablettengewicht",
"expiryDate": "Ablaufdatum",
"intakeSchedule": "Einnahmeplan",
"coverageStatus": "Reichweite",
"coverageStatus": "Bestand",
"daysLeft": "Tage übrig",
"runsOut": "Aufgebraucht",
"notes": "Notizen",
@@ -383,5 +383,21 @@
"importError": "Daten konnten nicht importiert werden",
"invalidFile": "Ungültiges Dateiformat. Bitte wähle eine gültige MedAssist-Exportdatei.",
"downloadFilename": "medassist-export"
},
"refill": {
"title": "Nachfüllen",
"packs": "Packungen hinzufügen",
"loosePills": "Lose Tabletten hinzufügen",
"pillsPerPack": "1 Packung = {{count}} Tabletten",
"addToStock": "Zum Bestand hinzufügen",
"adding": "Wird hinzugefügt...",
"success": "{{pills}} Tabletten zum Bestand hinzugefügt",
"history": "Nachfüll-Verlauf",
"noHistory": "Noch keine Nachfüllungen erfasst",
"packsAdded": "{{count}} Packung",
"packsAdded_other": "{{count}} Packungen",
"pillsAdded": "{{count}} Tablette",
"pillsAdded_other": "{{count}} Tabletten",
"button": "Nachfüllen"
}
}
+18 -2
View File
@@ -100,8 +100,8 @@
}
},
"form": {
"editEntry": "Edit entry",
"newEntry": "New entry",
"editEntry": "Edit medication",
"newEntry": "New medication",
"badge": "Packs + loose pills",
"commercialName": "Commercial Name",
"genericName": "Generic Name",
@@ -385,5 +385,21 @@
"importError": "Failed to import data",
"invalidFile": "Invalid file format. Please select a valid MedAssist export file.",
"downloadFilename": "medassist-export"
},
"refill": {
"title": "Refill",
"packs": "Packs to add",
"loosePills": "Loose pills to add",
"pillsPerPack": "1 pack = {{count}} pills",
"addToStock": "Add to Stock",
"adding": "Adding...",
"success": "Added {{pills}} pills to stock",
"history": "Refill History",
"noHistory": "No refills recorded yet",
"packsAdded": "{{count}} pack",
"packsAdded_other": "{{count}} packs",
"pillsAdded": "{{count}} pill",
"pillsAdded_other": "{{count}} pills",
"button": "Refill"
}
}
+384 -75
View File
@@ -431,6 +431,46 @@ button.secondary:hover {
background: var(--bg-secondary);
}
/* Success button (Refill, etc.) */
button.success {
background: var(--success);
color: white;
border: none;
}
button.success:hover {
filter: brightness(1.1);
}
button.success:disabled {
background: var(--bg-tertiary);
color: var(--text-tertiary);
cursor: not-allowed;
}
/* Primary/Accent button (New entry, Add intake, etc.) */
button.primary {
background: var(--accent);
color: white;
border: none;
}
button.primary:hover {
background: var(--accent-light);
}
button.primary:disabled {
background: var(--bg-tertiary);
color: var(--text-tertiary);
cursor: not-allowed;
}
/* Info button (Edit, secondary actions) */
button.info {
background: #3b82f6;
color: white;
border: none;
}
button.info:hover {
background: #60a5fa;
}
/* Ghost button (Cancel, etc.) */
button.ghost {
background: transparent;
@@ -1388,13 +1428,21 @@ textarea.auto-resize {
}
.setting-row.language-row {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-start;
gap: 1.5rem;
gap: 1rem;
}
.setting-row.language-row .setting-label {
flex: 0 0 auto;
min-width: 100px;
}
.language-select {
width: auto;
min-width: 160px;
flex: 1 1 auto;
min-width: 140px;
max-width: 200px;
padding: 0.6rem 2rem 0.6rem 0.75rem;
font-size: 1rem;
@@ -1663,11 +1711,42 @@ textarea.auto-resize {
}
.info-tooltip:hover::after,
.info-tooltip:hover::before {
.info-tooltip:hover::before,
.info-tooltip:focus::after,
.info-tooltip:focus::before,
.info-tooltip.tooltip-active::after,
.info-tooltip.tooltip-active::before {
opacity: 1;
visibility: visible;
}
/* Mobile tooltip - disable hover, use click only */
@media (max-width: 640px) {
.info-tooltip:hover::after,
.info-tooltip:hover::before {
opacity: 0;
visibility: hidden;
}
.info-tooltip.tooltip-active::after {
opacity: 1;
visibility: visible;
position: fixed;
top: 50%;
left: 50%;
bottom: auto;
right: auto;
transform: translate(-50%, -50%);
max-width: calc(100vw - 32px);
width: max-content;
z-index: 9999;
}
.info-tooltip::before {
display: none;
}
}
/* Channels Overview */
.channels-overview {
display: flex;
@@ -2090,10 +2169,13 @@ textarea.auto-resize {
font-size: 0.9rem;
}
/* Compact Setting Row */
/* Compact Setting Row - for inline toggles without card styling */
.setting-row.compact {
padding: 0.75rem 1rem;
padding: 0.75rem 0;
margin-top: 0.5rem;
background: transparent;
border: none;
border-radius: 0;
}
.setting-row.compact .setting-label {
@@ -2284,6 +2366,9 @@ textarea.auto-resize {
.med-avatar-sm.med-avatar-initials { font-size: 0.65em; }
.med-avatar-lg.med-avatar-initials { font-size: 1.1em; }
.med-avatar.clickable { cursor: pointer; transition: transform 0.15s, box-shadow 0.15s; }
.med-avatar.clickable:hover { transform: scale(1.1); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); }
/* Table/Timeline cells with avatar */
.cell-with-avatar {
display: flex;
@@ -2885,14 +2970,22 @@ textarea.auto-resize {
}
.lightbox-image {
max-width: 90vw;
max-height: 85vh;
max-width: 50vw;
max-height: 50vh;
object-fit: contain;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
animation: zoomIn 0.3s ease;
}
/* Mobile: larger lightbox image */
@media (max-width: 768px) {
.lightbox-image {
max-width: 90vw;
max-height: 70vh;
}
}
@keyframes zoomIn {
from { opacity: 0; transform: scale(0.8); }
to { opacity: 1; transform: scale(1); }
@@ -2932,6 +3025,22 @@ textarea.auto-resize {
.med-detail-footer {
padding: 1rem 1.5rem;
flex-wrap: wrap;
gap: 0.75rem;
}
.med-detail-footer > button {
flex: 1 1 auto;
}
.med-detail-footer .footer-actions {
flex: 1 1 auto;
justify-content: flex-end;
}
.med-detail-footer button {
padding: 0.6rem 0.8rem;
font-size: 0.9rem;
}
.med-detail-grid {
@@ -3283,7 +3392,17 @@ h3 .reminder-icon.info-tooltip {
box-shadow: 0 16px 48px rgba(0,0,0,0.15);
}
.user-menu:hover .user-dropdown {
/* Only use hover on devices that support it (not touch) */
@media (hover: hover) and (pointer: fine) {
.user-menu:hover .user-dropdown {
opacity: 1;
visibility: visible;
transform: translateY(0) scale(1);
}
}
/* Click-based open for all devices */
.user-menu.open .user-dropdown {
opacity: 1;
visibility: visible;
transform: translateY(0) scale(1);
@@ -3717,6 +3836,215 @@ h3 .reminder-icon.info-tooltip {
align-items: center;
}
/* =============================================================================
Refill Modal & History
============================================================================= */
.refill-modal {
max-width: 500px;
padding: 1.5rem;
}
.refill-modal h2 {
font-size: 1.25rem;
margin-bottom: 0.25rem;
}
.refill-med-name {
color: var(--text-secondary);
margin-bottom: 1.5rem;
}
.refill-form {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 1.5rem;
}
.refill-form label {
display: flex;
flex-direction: column;
gap: 0.375rem;
font-weight: 500;
}
.refill-form input {
padding: 0.75rem;
border: 1px solid var(--border-primary);
border-radius: 8px;
background: var(--bg-input);
color: var(--text-primary);
font-size: 1rem;
}
.refill-footer-right {
display: flex;
align-items: center;
gap: 1rem;
}
.refill-footer-right .refill-preview {
height: 42px;
}
/* Refill modal footer mobile */
@media (max-width: 640px) {
.refill-modal .modal-footer {
flex-direction: column;
gap: 0.75rem;
}
.refill-modal .modal-footer > button,
.refill-modal .modal-footer .refill-footer-right {
width: 100%;
}
.refill-modal .modal-footer .refill-footer-right {
justify-content: space-between;
}
.refill-modal .modal-footer .refill-footer-right button {
flex: 1;
}
}
/* Inline refill form in edit modal */
.refill-form-inline {
display: flex;
align-items: flex-end;
gap: 1rem;
flex-wrap: wrap;
}
.refill-form-inline label {
flex: 1;
min-width: 100px;
}
.refill-form-inline label input {
width: 100%;
}
.refill-form-inline button {
flex-shrink: 0;
margin-bottom: 0;
align-self: flex-end;
height: 42px;
padding: 0 1rem;
min-width: 110px;
}
.refill-preview {
display: inline-flex;
align-items: center;
justify-content: center;
height: 42px;
padding: 0 0.75rem;
background: transparent;
border: 1px dashed var(--success);
border-radius: 6px;
color: var(--success);
font-size: 0.85rem;
font-weight: 600;
flex-shrink: 0;
align-self: flex-end;
box-sizing: border-box;
}
.refill-section {
border-left: 3px solid var(--success);
background: var(--bg-secondary);
margin-top: 1.5rem;
padding: 1rem 1rem 1rem 1.25rem;
border-radius: 0 8px 8px 0;
}
.refill-section .refill-title {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 0.75rem 0;
}
.refill-section .refill-form-inline button {
background: var(--success);
color: white;
border: none;
}
.refill-section .refill-form-inline button:hover:not(:disabled) {
background: var(--success-hover, #3aa865);
filter: brightness(1.1);
}
.refill-section .refill-form-inline button:disabled {
background: var(--bg-tertiary);
color: var(--text-tertiary);
cursor: not-allowed;
}
/* Clickable section header (for expand/collapse) */
.section-header-clickable {
cursor: pointer;
user-select: none;
}
.section-header-clickable:hover {
color: var(--accent);
}
/* Refill history in detail modal */
.refill-history-header {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
user-select: none;
}
.refill-history-header .collapse-icon {
font-size: 0.75rem;
color: var(--text-secondary);
}
.refill-history-header .refill-count {
font-weight: normal;
font-size: 0.875rem;
color: var(--text-secondary);
}
.refill-history-list {
margin-top: 0.75rem;
}
.refill-history-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0;
border-bottom: 1px solid var(--border-primary);
}
.refill-history-item:last-child {
border-bottom: none;
}
.refill-date {
font-size: 0.875rem;
color: var(--text-secondary);
}
.refill-details {
font-size: 0.875rem;
font-weight: 500;
color: var(--success);
}
/* Nested modal overlay */
.modal-overlay.nested {
background: rgba(0, 0, 0, 0.6);
}
/* =============================================================================
Shared Schedule Page (Public)
============================================================================= */
@@ -4007,78 +4335,59 @@ h3 .reminder-icon.info-tooltip {
width: 100%;
}
/* Export/Import Section */
.card:has(.export-import-grid) {
overflow: visible;
}
.card:has(.export-import-grid) .card-head {
overflow: visible;
}
.export-import-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1.5rem;
}
@media (max-width: 640px) {
.export-import-grid {
grid-template-columns: 1fr;
}
}
.export-import-card {
/* Action Cards (for Export/Import etc.) - similar to radio-card */
.action-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.25rem;
background: var(--bg-tertiary);
border: 1px solid var(--border-secondary);
border-radius: var(--card-radius);
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.export-import-card h3 {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.export-import-desc {
color: var(--text-secondary);
font-size: 0.85rem;
margin: 0;
line-height: 1.5;
flex: 1;
}
.export-import-card button,
.export-import-file-btn {
margin-top: auto;
align-self: flex-start;
}
.export-import-file-btn {
cursor: pointer;
display: inline-block;
padding: 0.7rem 1.25rem;
border-radius: var(--btn-radius);
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border-secondary);
font-weight: 600;
font-size: 0.9rem;
border-radius: 12px;
transition: all 0.2s ease;
}
.export-import-file-btn:hover {
background: var(--bg-hover);
.action-card:hover {
border-color: var(--border-primary);
}
.export-import-file-btn input {
display: none;
.action-card-content {
display: flex;
flex-direction: column;
gap: 0.25rem;
flex: 1;
min-width: 0;
}
.action-card-title {
font-weight: 600;
color: var(--text-primary);
font-size: 0.95rem;
}
.action-card-desc {
color: var(--text-secondary);
font-size: 0.85rem;
line-height: 1.4;
}
.action-card button,
.action-card .btn {
flex-shrink: 0;
}
@media (max-width: 480px) {
.action-card {
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
}
.action-card button,
.action-card .btn {
width: 100%;
text-align: center;
justify-content: center;
}
}