Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 259f00e7a0 | |||
| e9f2760815 | |||
| d0e2ee0783 | |||
| c620146c4b | |||
| 33c1095e77 |
@@ -18,7 +18,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/Backend_Tests-564%2F564-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
|
||||
<img src="https://img.shields.io/badge/Backend_Tests-569%2F569-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
|
||||
<img src="https://img.shields.io/badge/Frontend_Tests-777%2F777-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
|
||||
</p>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "medassist-ng-backend",
|
||||
"version": "1.14.4",
|
||||
"version": "1.15.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -53,6 +53,7 @@ const sensitiveRateLimitConfig = {
|
||||
const registerSchema = z.object({
|
||||
username: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(3, "Username must be at least 3 characters")
|
||||
.max(50, "Username must be at most 50 characters")
|
||||
.regex(/^[a-zA-Z0-9_-]+$/, "Username can only contain letters, numbers, underscores, and hyphens"),
|
||||
@@ -63,7 +64,7 @@ const registerSchema = z.object({
|
||||
});
|
||||
|
||||
const loginSchema = z.object({
|
||||
username: z.string().min(1, "Username is required"),
|
||||
username: z.string().trim().min(1, "Username is required"),
|
||||
password: z.string().min(1, "Password is required"),
|
||||
rememberMe: z.boolean().optional().default(false),
|
||||
});
|
||||
|
||||
@@ -245,6 +245,57 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
|
||||
expect(response.json().code).toBe("VALIDATION_ERROR");
|
||||
});
|
||||
|
||||
it("should register with trimmed username when input has whitespace", async () => {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/register",
|
||||
payload: {
|
||||
username: " trimuser ",
|
||||
password: "TestPassword123",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(201);
|
||||
expect(response.json().user.username).toBe("trimuser");
|
||||
});
|
||||
|
||||
it("should reject whitespace-only username on registration", async () => {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/register",
|
||||
payload: {
|
||||
username: " ",
|
||||
password: "TestPassword123",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().code).toBe("VALIDATION_ERROR");
|
||||
});
|
||||
|
||||
it("should reject duplicate username even with surrounding whitespace", async () => {
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/register",
|
||||
payload: {
|
||||
username: "spacedupe",
|
||||
password: "TestPassword123",
|
||||
},
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/register",
|
||||
payload: {
|
||||
username: " spacedupe ",
|
||||
password: "AnotherPassword123",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.json().code).toBe("USERNAME_EXISTS");
|
||||
});
|
||||
|
||||
it("should reject invalid username characters", async () => {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
@@ -341,6 +392,35 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
|
||||
expect(response.json().code).toBe("INVALID_CREDENTIALS");
|
||||
});
|
||||
|
||||
it("should login successfully when username has leading/trailing whitespace", async () => {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/login",
|
||||
payload: {
|
||||
username: " loginuser ",
|
||||
password: "TestPassword123",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().ok).toBe(true);
|
||||
expect(response.json().user.username).toBe("loginuser");
|
||||
});
|
||||
|
||||
it("should reject whitespace-only username on login", async () => {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/login",
|
||||
payload: {
|
||||
username: " ",
|
||||
password: "TestPassword123",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().code).toBe("VALIDATION_ERROR");
|
||||
});
|
||||
|
||||
it("should support rememberMe option", async () => {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "medassist-ng-frontend",
|
||||
"private": true,
|
||||
"version": "1.14.4",
|
||||
"version": "1.15.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Minus, Plus } from "lucide-react";
|
||||
|
||||
interface FormNumberStepperProps {
|
||||
value: string;
|
||||
onChange: (nextValue: string) => void;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
allowDecimal?: boolean;
|
||||
decrementLabel: string;
|
||||
incrementLabel: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const DECIMAL_ROUNDING_FACTOR = 1000;
|
||||
|
||||
function clamp(value: number, min: number, max?: number): number {
|
||||
const clampedMin = Math.max(min, value);
|
||||
if (max == null) return clampedMin;
|
||||
return Math.min(max, clampedMin);
|
||||
}
|
||||
|
||||
function normalizeDecimal(value: number): number {
|
||||
return Math.round(value * DECIMAL_ROUNDING_FACTOR) / DECIMAL_ROUNDING_FACTOR;
|
||||
}
|
||||
|
||||
function toDisplayValue(value: number, allowDecimal: boolean): string {
|
||||
if (!allowDecimal) return String(Math.max(0, Math.trunc(value)));
|
||||
const normalized = normalizeDecimal(value);
|
||||
return normalized.toString();
|
||||
}
|
||||
|
||||
function sanitizeRawInput(raw: string, allowDecimal: boolean): string {
|
||||
const normalizedRaw = raw.replace(",", ".");
|
||||
if (allowDecimal) {
|
||||
const cleaned = normalizedRaw.replace(/[^\d.]/g, "");
|
||||
const [integerPart = "", ...fractionalParts] = cleaned.split(".");
|
||||
if (fractionalParts.length === 0) return integerPart;
|
||||
return `${integerPart}.${fractionalParts.join("")}`;
|
||||
}
|
||||
return normalizedRaw.replace(/\D/g, "");
|
||||
}
|
||||
|
||||
function parseInputValue(raw: string, allowDecimal: boolean): number | null {
|
||||
if (raw.trim() === "") return null;
|
||||
const parsed = allowDecimal ? Number.parseFloat(raw) : Number.parseInt(raw, 10);
|
||||
if (Number.isNaN(parsed)) return null;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function FormNumberStepper({
|
||||
value,
|
||||
onChange,
|
||||
min = 0,
|
||||
max,
|
||||
step = 1,
|
||||
allowDecimal = false,
|
||||
decrementLabel,
|
||||
incrementLabel,
|
||||
className = "",
|
||||
}: FormNumberStepperProps) {
|
||||
const parsed = parseInputValue(value, allowDecimal);
|
||||
const baseValue = parsed ?? min;
|
||||
const canDecrement = baseValue > min;
|
||||
const canIncrement = max == null || baseValue < max;
|
||||
|
||||
const normalizedClassName = ["number-stepper", "form-number-stepper", className].filter(Boolean).join(" ");
|
||||
|
||||
const handleStep = (direction: -1 | 1) => {
|
||||
const nextRaw = clamp(baseValue + direction * step, min, max);
|
||||
onChange(toDisplayValue(nextRaw, allowDecimal));
|
||||
};
|
||||
|
||||
const handleInputChange = (nextRaw: string) => {
|
||||
onChange(sanitizeRawInput(nextRaw, allowDecimal));
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
const nextParsed = parseInputValue(value, allowDecimal);
|
||||
if (nextParsed == null) return;
|
||||
const clamped = clamp(nextParsed, min, max);
|
||||
onChange(toDisplayValue(clamped, allowDecimal));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={normalizedClassName}>
|
||||
{/* Input first in DOM so <label> associates with it, not the decrement button.
|
||||
CSS order restores the visual layout: [−] [input] [+]. */}
|
||||
<input
|
||||
type="text"
|
||||
inputMode={allowDecimal ? "decimal" : "numeric"}
|
||||
pattern={allowDecimal ? "[0-9]*\\.?[0-9]*" : "[0-9]*"}
|
||||
value={value}
|
||||
onChange={(e) => handleInputChange(e.target.value)}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="stepper-btn decrement"
|
||||
onClick={() => handleStep(-1)}
|
||||
disabled={!canDecrement}
|
||||
aria-label={decrementLabel}
|
||||
>
|
||||
<Minus size={16} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="stepper-btn increment"
|
||||
onClick={() => handleStep(1)}
|
||||
disabled={!canIncrement}
|
||||
aria-label={incrementLabel}
|
||||
>
|
||||
<Plus size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -275,6 +275,14 @@ export function MedDetailModal({
|
||||
|
||||
return (
|
||||
<div className="number-stepper refill-number-stepper">
|
||||
<input
|
||||
type="number"
|
||||
min={min}
|
||||
max={max}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="stepper-btn decrement"
|
||||
@@ -284,14 +292,6 @@ export function MedDetailModal({
|
||||
>
|
||||
<Minus size={16} aria-hidden="true" />
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
min={min}
|
||||
max={max}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="stepper-btn increment"
|
||||
@@ -321,16 +321,7 @@ export function MedDetailModal({
|
||||
const canIncrement = clamped < max;
|
||||
|
||||
return (
|
||||
<div className="number-stepper">
|
||||
<button
|
||||
type="button"
|
||||
className="stepper-btn decrement"
|
||||
onClick={() => onChange(Math.max(min, clamped - 1))}
|
||||
disabled={!canDecrement}
|
||||
aria-label={decrementLabel}
|
||||
>
|
||||
<Minus size={16} aria-hidden="true" />
|
||||
</button>
|
||||
<div className="number-stepper refill-number-stepper">
|
||||
<input
|
||||
type="number"
|
||||
min={min}
|
||||
@@ -341,6 +332,15 @@ export function MedDetailModal({
|
||||
onChange(Number.isNaN(parsed) ? min : Math.min(max, Math.max(min, parsed)));
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="stepper-btn decrement"
|
||||
onClick={() => onChange(Math.max(min, clamped - 1))}
|
||||
disabled={!canDecrement}
|
||||
aria-label={decrementLabel}
|
||||
>
|
||||
<Minus size={16} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="stepper-btn increment"
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { DoseUnit, FieldErrors, FormBlister, FormIntake, FormState, Medicat
|
||||
import { DOSE_UNITS } from "../types";
|
||||
import { deriveTotal } from "../utils";
|
||||
import { DateInput } from "./DateInput";
|
||||
import { FormNumberStepper } from "./FormNumberStepper";
|
||||
|
||||
// Field limits for validation
|
||||
const FIELD_LIMITS = {
|
||||
@@ -107,6 +108,8 @@ export function MobileEditModal({
|
||||
onSaveMedication,
|
||||
}: MobileEditModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const decrementValueLabel = t("editStock.decreaseValue");
|
||||
const incrementValueLabel = t("editStock.increaseValue");
|
||||
const [activeTab, setActiveTab] = useState<MobileTab>("general");
|
||||
const fieldsetRef = useRef<HTMLFieldSetElement | null>(null);
|
||||
const tabStripRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -499,32 +502,32 @@ export function MobileEditModal({
|
||||
<>
|
||||
<label>
|
||||
{t("form.packs")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
<FormNumberStepper
|
||||
value={form.packCount}
|
||||
onChange={(e) => onHandleValueChange("packCount", e.target.value)}
|
||||
onChange={(nextValue) => onHandleValueChange("packCount", nextValue)}
|
||||
min={0}
|
||||
decrementLabel={decrementValueLabel}
|
||||
incrementLabel={incrementValueLabel}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("form.blistersPerPack")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
<FormNumberStepper
|
||||
value={form.blistersPerPack}
|
||||
onChange={(e) => onHandleValueChange("blistersPerPack", e.target.value)}
|
||||
onChange={(nextValue) => onHandleValueChange("blistersPerPack", nextValue)}
|
||||
min={1}
|
||||
decrementLabel={decrementValueLabel}
|
||||
incrementLabel={incrementValueLabel}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("form.pillsPerBlister")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
<FormNumberStepper
|
||||
value={form.pillsPerBlister}
|
||||
onChange={(e) => onHandleValueChange("pillsPerBlister", e.target.value)}
|
||||
onChange={(nextValue) => onHandleValueChange("pillsPerBlister", nextValue)}
|
||||
min={1}
|
||||
decrementLabel={decrementValueLabel}
|
||||
incrementLabel={incrementValueLabel}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
@@ -536,22 +539,22 @@ export function MobileEditModal({
|
||||
<>
|
||||
<label>
|
||||
{t("form.totalCapacity")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
<FormNumberStepper
|
||||
value={form.totalPills}
|
||||
onChange={(e) => onHandleValueChange("totalPills", e.target.value)}
|
||||
onChange={(nextValue) => onHandleValueChange("totalPills", nextValue)}
|
||||
min={0}
|
||||
decrementLabel={decrementValueLabel}
|
||||
incrementLabel={incrementValueLabel}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("form.currentPills")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
<FormNumberStepper
|
||||
value={form.looseTablets}
|
||||
onChange={(e) => onHandleValueChange("looseTablets", e.target.value)}
|
||||
onChange={(nextValue) => onHandleValueChange("looseTablets", nextValue)}
|
||||
min={0}
|
||||
decrementLabel={decrementValueLabel}
|
||||
incrementLabel={incrementValueLabel}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
@@ -646,22 +649,24 @@ export function MobileEditModal({
|
||||
>
|
||||
<label className="compact">
|
||||
<span>{t("form.blisters.usage")}</span>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
pattern="[0-9]*\.?[0-9]*"
|
||||
<FormNumberStepper
|
||||
value={intake.usage}
|
||||
onChange={(e) => onSetIntakeValue(idx, "usage", e.target.value)}
|
||||
onChange={(nextValue) => onSetIntakeValue(idx, "usage", nextValue)}
|
||||
min={0.5}
|
||||
step={0.5}
|
||||
allowDecimal={true}
|
||||
decrementLabel={decrementValueLabel}
|
||||
incrementLabel={incrementValueLabel}
|
||||
/>
|
||||
</label>
|
||||
<label className="compact">
|
||||
<span>{t("form.blisters.everyDays")}</span>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
<FormNumberStepper
|
||||
value={intake.every}
|
||||
onChange={(e) => onSetIntakeValue(idx, "every", e.target.value)}
|
||||
onChange={(nextValue) => onSetIntakeValue(idx, "every", nextValue)}
|
||||
min={1}
|
||||
decrementLabel={decrementValueLabel}
|
||||
incrementLabel={incrementValueLabel}
|
||||
/>
|
||||
</label>
|
||||
<label className="compact full-row">
|
||||
@@ -740,32 +745,32 @@ export function MobileEditModal({
|
||||
<>
|
||||
<label className="prescription-field">
|
||||
{t("prescription.authorizedRefills")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
<FormNumberStepper
|
||||
value={form.prescriptionAuthorizedRefills}
|
||||
onChange={(e) => onHandleValueChange("prescriptionAuthorizedRefills", e.target.value)}
|
||||
onChange={(nextValue) => onHandleValueChange("prescriptionAuthorizedRefills", nextValue)}
|
||||
min={0}
|
||||
decrementLabel={decrementValueLabel}
|
||||
incrementLabel={incrementValueLabel}
|
||||
/>
|
||||
</label>
|
||||
<label className="prescription-field">
|
||||
{t("prescription.remainingRefills")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
<FormNumberStepper
|
||||
value={form.prescriptionRemainingRefills}
|
||||
onChange={(e) => onHandleValueChange("prescriptionRemainingRefills", e.target.value)}
|
||||
onChange={(nextValue) => onHandleValueChange("prescriptionRemainingRefills", nextValue)}
|
||||
min={0}
|
||||
decrementLabel={decrementValueLabel}
|
||||
incrementLabel={incrementValueLabel}
|
||||
/>
|
||||
</label>
|
||||
<label className="prescription-field">
|
||||
{t("prescription.lowThreshold")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
<FormNumberStepper
|
||||
value={form.prescriptionLowRefillThreshold}
|
||||
onChange={(e) => onHandleValueChange("prescriptionLowRefillThreshold", e.target.value)}
|
||||
onChange={(nextValue) => onHandleValueChange("prescriptionLowRefillThreshold", nextValue)}
|
||||
min={0}
|
||||
decrementLabel={decrementValueLabel}
|
||||
incrementLabel={incrementValueLabel}
|
||||
/>
|
||||
</label>
|
||||
<label className="prescription-field">
|
||||
|
||||
@@ -6,6 +6,7 @@ export { ConfirmModal } from "./ConfirmModal";
|
||||
export { DateInput } from "./DateInput";
|
||||
export { DateTimeInput } from "./DateTimeInput";
|
||||
export { default as ExportModal } from "./ExportModal";
|
||||
export { FormNumberStepper } from "./FormNumberStepper";
|
||||
export type { LightboxProps } from "./Lightbox";
|
||||
|
||||
export { Lightbox } from "./Lightbox";
|
||||
|
||||
@@ -5,7 +5,15 @@ import { Bell, Eye, Minus, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { ConfirmModal, DateInput, Lightbox, MedicationAvatar, MobileEditModal, ReportModal } from "../components";
|
||||
import {
|
||||
ConfirmModal,
|
||||
DateInput,
|
||||
FormNumberStepper,
|
||||
Lightbox,
|
||||
MedicationAvatar,
|
||||
MobileEditModal,
|
||||
ReportModal,
|
||||
} from "../components";
|
||||
import { useAuth } from "../components/Auth";
|
||||
import { useAppContext, useUnsavedChanges } from "../context";
|
||||
import { useMedicationForm, useModalHistory, useUnsavedChangesWarning } from "../hooks";
|
||||
@@ -175,6 +183,8 @@ export function MedicationsPage() {
|
||||
const pillsPerBlister = Number(form.pillsPerBlister) || 1;
|
||||
return packCount * blistersPerPack * pillsPerBlister;
|
||||
}, [form.packageType, form.packCount, form.blistersPerPack, form.pillsPerBlister, form.looseTablets]);
|
||||
const decrementValueLabel = t("editStock.decreaseValue");
|
||||
const incrementValueLabel = t("editStock.increaseValue");
|
||||
|
||||
const dateConsistencyError = useMemo(() => {
|
||||
const medicationStartDate = form.medicationStartDate;
|
||||
@@ -966,15 +976,6 @@ export function MedicationsPage() {
|
||||
>
|
||||
{t("form.sections.stock")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === "prescription"}
|
||||
className={`form-tab${activeTab === "prescription" ? " active" : ""}`}
|
||||
onClick={() => setActiveTab("prescription")}
|
||||
>
|
||||
{t("form.sections.prescription")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
@@ -984,6 +985,15 @@ export function MedicationsPage() {
|
||||
>
|
||||
{t("form.sections.schedule")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === "prescription"}
|
||||
className={`form-tab${activeTab === "prescription" ? " active" : ""}`}
|
||||
onClick={() => setActiveTab("prescription")}
|
||||
>
|
||||
{t("form.sections.prescription")}
|
||||
</button>
|
||||
</div>
|
||||
<fieldset className="readonly-fieldset" disabled={readOnlyView}>
|
||||
<div className={`form-tab-panel${activeTab === "general" ? " active" : ""}`}>
|
||||
@@ -1157,32 +1167,32 @@ export function MedicationsPage() {
|
||||
<>
|
||||
<label>
|
||||
{t("form.packs")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
<FormNumberStepper
|
||||
value={form.packCount}
|
||||
onChange={(e) => handleValueChange("packCount", e.target.value)}
|
||||
onChange={(nextValue) => handleValueChange("packCount", nextValue)}
|
||||
min={0}
|
||||
decrementLabel={decrementValueLabel}
|
||||
incrementLabel={incrementValueLabel}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("form.blistersPerPack")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
<FormNumberStepper
|
||||
value={form.blistersPerPack}
|
||||
onChange={(e) => handleValueChange("blistersPerPack", e.target.value)}
|
||||
onChange={(nextValue) => handleValueChange("blistersPerPack", nextValue)}
|
||||
min={1}
|
||||
decrementLabel={decrementValueLabel}
|
||||
incrementLabel={incrementValueLabel}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("form.pillsPerBlister")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
<FormNumberStepper
|
||||
value={form.pillsPerBlister}
|
||||
onChange={(e) => handleValueChange("pillsPerBlister", e.target.value)}
|
||||
onChange={(nextValue) => handleValueChange("pillsPerBlister", nextValue)}
|
||||
min={1}
|
||||
decrementLabel={decrementValueLabel}
|
||||
incrementLabel={incrementValueLabel}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
@@ -1194,22 +1204,22 @@ export function MedicationsPage() {
|
||||
<>
|
||||
<label>
|
||||
{t("form.totalCapacity")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
<FormNumberStepper
|
||||
value={form.totalPills}
|
||||
onChange={(e) => handleValueChange("totalPills", e.target.value)}
|
||||
onChange={(nextValue) => handleValueChange("totalPills", nextValue)}
|
||||
min={0}
|
||||
decrementLabel={decrementValueLabel}
|
||||
incrementLabel={incrementValueLabel}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("form.currentPills")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
<FormNumberStepper
|
||||
value={form.looseTablets}
|
||||
onChange={(e) => handleValueChange("looseTablets", e.target.value)}
|
||||
onChange={(nextValue) => handleValueChange("looseTablets", nextValue)}
|
||||
min={0}
|
||||
decrementLabel={decrementValueLabel}
|
||||
incrementLabel={incrementValueLabel}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
@@ -1300,32 +1310,32 @@ export function MedicationsPage() {
|
||||
<>
|
||||
<label className="prescription-field">
|
||||
{t("prescription.authorizedRefills")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
<FormNumberStepper
|
||||
value={form.prescriptionAuthorizedRefills}
|
||||
onChange={(e) => handleValueChange("prescriptionAuthorizedRefills", e.target.value)}
|
||||
onChange={(nextValue) => handleValueChange("prescriptionAuthorizedRefills", nextValue)}
|
||||
min={0}
|
||||
decrementLabel={decrementValueLabel}
|
||||
incrementLabel={incrementValueLabel}
|
||||
/>
|
||||
</label>
|
||||
<label className="prescription-field">
|
||||
{t("prescription.remainingRefills")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
<FormNumberStepper
|
||||
value={form.prescriptionRemainingRefills}
|
||||
onChange={(e) => handleValueChange("prescriptionRemainingRefills", e.target.value)}
|
||||
onChange={(nextValue) => handleValueChange("prescriptionRemainingRefills", nextValue)}
|
||||
min={0}
|
||||
decrementLabel={decrementValueLabel}
|
||||
incrementLabel={incrementValueLabel}
|
||||
/>
|
||||
</label>
|
||||
<label className="prescription-field">
|
||||
{t("prescription.lowThreshold")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
<FormNumberStepper
|
||||
value={form.prescriptionLowRefillThreshold}
|
||||
onChange={(e) => handleValueChange("prescriptionLowRefillThreshold", e.target.value)}
|
||||
onChange={(nextValue) => handleValueChange("prescriptionLowRefillThreshold", nextValue)}
|
||||
min={0}
|
||||
decrementLabel={decrementValueLabel}
|
||||
incrementLabel={incrementValueLabel}
|
||||
/>
|
||||
</label>
|
||||
<label className="prescription-field">
|
||||
@@ -1362,22 +1372,24 @@ export function MedicationsPage() {
|
||||
<div className="blister-inputs">
|
||||
<label>
|
||||
{t("form.blisters.usage")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
pattern="[0-9]*\.?[0-9]*"
|
||||
<FormNumberStepper
|
||||
value={intake.usage}
|
||||
onChange={(e) => setIntakeValue(idx, "usage", e.target.value)}
|
||||
onChange={(nextValue) => setIntakeValue(idx, "usage", nextValue)}
|
||||
min={0.5}
|
||||
step={0.5}
|
||||
allowDecimal={true}
|
||||
decrementLabel={decrementValueLabel}
|
||||
incrementLabel={incrementValueLabel}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("form.blisters.everyDays")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
<FormNumberStepper
|
||||
value={intake.every}
|
||||
onChange={(e) => setIntakeValue(idx, "every", e.target.value)}
|
||||
onChange={(nextValue) => setIntakeValue(idx, "every", nextValue)}
|
||||
min={1}
|
||||
decrementLabel={decrementValueLabel}
|
||||
incrementLabel={incrementValueLabel}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
|
||||
+21
-6
@@ -1127,11 +1127,15 @@ body.modal-open {
|
||||
}
|
||||
.blister-row .blister-inputs {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr 1fr;
|
||||
grid-template-columns: minmax(0, 1.05fr) minmax(0, 1.05fr) minmax(10.75rem, 1fr) minmax(7.25rem, 0.8fr);
|
||||
gap: 0.75rem;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.blister-row .blister-inputs > label {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.blister-row .blister-inputs label.taken-by-field {
|
||||
grid-column: span 2;
|
||||
}
|
||||
@@ -1154,6 +1158,17 @@ body.modal-open {
|
||||
}
|
||||
}
|
||||
|
||||
/* Desktop edit sidebar can be narrow; avoid clipping right-side controls. */
|
||||
@media (min-width: 769px) {
|
||||
.edit-sidebar .blister-row .blister-inputs {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.edit-sidebar .blister-row .blister-inputs label.taken-by-field {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
.gap {
|
||||
gap: 0.6rem;
|
||||
}
|
||||
@@ -3376,7 +3391,7 @@ button.has-validation-error {
|
||||
transition:
|
||||
opacity 0.15s,
|
||||
visibility 0.15s;
|
||||
z-index: 100;
|
||||
z-index: 1100;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -3394,7 +3409,7 @@ button.has-validation-error {
|
||||
transition:
|
||||
opacity 0.15s,
|
||||
visibility 0.15s;
|
||||
z-index: 101;
|
||||
z-index: 1101;
|
||||
}
|
||||
|
||||
/* Tooltip aligned to left edge of icon (prevents clipping inside modals) */
|
||||
@@ -4335,7 +4350,7 @@ button.has-validation-error {
|
||||
overscroll-behavior: contain;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.med-detail-modal .med-detail-body {
|
||||
@@ -4534,7 +4549,7 @@ button.has-validation-error {
|
||||
}
|
||||
|
||||
.med-detail-body {
|
||||
padding: 1.5rem 2rem 0;
|
||||
padding: 1.5rem 2rem 2rem;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
@@ -4701,7 +4716,7 @@ button.has-validation-error {
|
||||
background: var(--bg-primary);
|
||||
border-radius: 0 0 12px 12px;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding-bottom: calc(1rem + env(safe-area-inset-bottom, 0px));
|
||||
|
||||
@@ -446,7 +446,7 @@
|
||||
}
|
||||
|
||||
.refill-number-stepper input {
|
||||
order: initial;
|
||||
order: 0;
|
||||
text-align: center;
|
||||
padding: 0.75rem 0.5rem;
|
||||
}
|
||||
@@ -460,21 +460,29 @@
|
||||
}
|
||||
|
||||
.refill-number-stepper .stepper-btn.decrement {
|
||||
order: initial;
|
||||
order: -1;
|
||||
background: color-mix(in srgb, var(--danger) 22%, var(--bg-tertiary));
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.refill-number-stepper .stepper-btn.increment {
|
||||
order: initial;
|
||||
order: 1;
|
||||
border-right: none;
|
||||
border-left: 1px solid var(--border-primary);
|
||||
background: color-mix(in srgb, var(--bg-tertiary) 85%, transparent);
|
||||
background: color-mix(in srgb, var(--success) 22%, var(--bg-tertiary));
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.refill-number-stepper .stepper-btn:hover:not(:disabled) {
|
||||
filter: none;
|
||||
background: color-mix(in srgb, var(--accent) 14%, var(--bg-tertiary));
|
||||
}
|
||||
|
||||
.refill-number-stepper .stepper-btn.decrement:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--danger) 36%, var(--bg-tertiary));
|
||||
}
|
||||
|
||||
.refill-number-stepper .stepper-btn.increment:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--success) 36%, var(--bg-tertiary));
|
||||
}
|
||||
|
||||
@media (min-width: 641px) {
|
||||
@@ -488,12 +496,12 @@
|
||||
}
|
||||
|
||||
[data-theme="light"] .refill-number-stepper .stepper-btn.decrement {
|
||||
background: color-mix(in srgb, var(--bg-tertiary) 90%, transparent);
|
||||
background: color-mix(in srgb, #dc2626 18%, white);
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
[data-theme="light"] .refill-number-stepper .stepper-btn.increment {
|
||||
background: color-mix(in srgb, var(--bg-tertiary) 90%, transparent);
|
||||
background: color-mix(in srgb, #0f766e 18%, white);
|
||||
color: #0f766e;
|
||||
}
|
||||
}
|
||||
@@ -504,6 +512,111 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Form stepper keeps symmetric - value + layout in all contexts (desktop/mobile). */
|
||||
.form-number-stepper {
|
||||
display: grid;
|
||||
grid-template-columns: 2.75rem minmax(0, 1fr) 2.75rem;
|
||||
}
|
||||
|
||||
.form-number-stepper input {
|
||||
order: 0;
|
||||
text-align: center;
|
||||
padding: 0.75rem 0.5rem;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.form-number-stepper .stepper-btn {
|
||||
flex: 0 0 auto;
|
||||
border-right: 1px solid var(--border-primary);
|
||||
border-left: none;
|
||||
background: color-mix(in srgb, var(--bg-tertiary) 85%, transparent);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.form-number-stepper .stepper-btn.decrement {
|
||||
order: -1;
|
||||
background: color-mix(in srgb, var(--danger) 22%, var(--bg-tertiary));
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.form-number-stepper .stepper-btn.increment {
|
||||
order: 1;
|
||||
border-right: none;
|
||||
border-left: 1px solid var(--border-primary);
|
||||
background: color-mix(in srgb, var(--success) 22%, var(--bg-tertiary));
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.form-number-stepper .stepper-btn:hover:not(:disabled) {
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.form-number-stepper .stepper-btn.decrement:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--danger) 36%, var(--bg-tertiary));
|
||||
}
|
||||
|
||||
.form-number-stepper .stepper-btn.increment:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--success) 36%, var(--bg-tertiary));
|
||||
}
|
||||
|
||||
/* Highlight both controls when the center value field is focused (keyboard/click). */
|
||||
.form-number-stepper:has(input:focus) .stepper-btn.decrement:not(:disabled),
|
||||
.form-number-stepper:has(input:focus-visible) .stepper-btn.decrement:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--danger) 36%, var(--bg-tertiary));
|
||||
}
|
||||
|
||||
.form-number-stepper:has(input:focus) .stepper-btn.increment:not(:disabled),
|
||||
.form-number-stepper:has(input:focus-visible) .stepper-btn.increment:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--success) 36%, var(--bg-tertiary));
|
||||
}
|
||||
|
||||
/* Dense schedule grids need a compact variant so the middle value stays visible. */
|
||||
.blister-inputs .form-number-stepper,
|
||||
.mobile-edit-form .blister-row .form-number-stepper {
|
||||
grid-template-columns: 2.35rem minmax(2rem, 1fr) 2.35rem;
|
||||
}
|
||||
|
||||
.blister-inputs .form-number-stepper input,
|
||||
.mobile-edit-form .blister-row .form-number-stepper input {
|
||||
min-height: 2.35rem;
|
||||
padding: 0.5rem 0.35rem;
|
||||
}
|
||||
|
||||
.blister-inputs .form-number-stepper .stepper-btn,
|
||||
.mobile-edit-form .blister-row .form-number-stepper .stepper-btn {
|
||||
min-width: 2.35rem;
|
||||
min-height: 2.35rem;
|
||||
}
|
||||
|
||||
@media (min-width: 641px) {
|
||||
.form-number-stepper {
|
||||
display: grid;
|
||||
grid-template-columns: 2.75rem minmax(0, 1fr) 2.75rem;
|
||||
}
|
||||
|
||||
.form-number-stepper input {
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
[data-theme="light"] .form-number-stepper .stepper-btn.decrement {
|
||||
background: color-mix(in srgb, #dc2626 18%, white);
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
[data-theme="light"] .form-number-stepper .stepper-btn.increment {
|
||||
background: color-mix(in srgb, #0f766e 18%, white);
|
||||
color: #0f766e;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.form-number-stepper {
|
||||
grid-template-columns: 2.75rem minmax(0, 1fr) 2.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.edit-stock-summary {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
|
||||
Reference in New Issue
Block a user