Better form control and profile page

This commit is contained in:
2026-04-04 23:11:51 -05:00
parent 33b47d7ffb
commit 163ce564e5
6 changed files with 319 additions and 17 deletions

View File

@@ -1,6 +1,7 @@
import { useState, useRef, useEffect } from "react";
import ExcelJS from "exceljs";
import { chemicalsApi } from "../lib/api";
import { validateCAS, validateNumber, validatePhoneOrEmail } from "../lib/validators";
import type { ChemicalInventory } from "../shared/types";
import { Card } from "./ui/card";
import { Button } from "./ui/button";
@@ -199,6 +200,30 @@ export function Inventory() {
setFormError("Please fill in all required fields.");
return;
}
if (!validateCAS(String(form.casNumber || ""))) {
setFormError("CAS # must be in the format ##-##-# (e.g. 67-56-1).");
return;
}
if (!validateNumber(form.numberOfContainers, { min: 1, integer: true })) {
setFormError("# of containers must be a whole number of 1 or more.");
return;
}
if (!validateNumber(form.amountPerContainer, { min: 0 })) {
setFormError("Amount per container must be a number.");
return;
}
if (form.molecularWeight && !validateNumber(form.molecularWeight, { min: 0 })) {
setFormError("Molecular weight must be a number.");
return;
}
if (form.percentageFull != null && !validateNumber(form.percentageFull, { min: 0, max: 100 })) {
setFormError("% full must be between 0 and 100.");
return;
}
if (form.contact && !validatePhoneOrEmail(String(form.contact))) {
setFormError("Contact must be a valid phone number or email address.");
return;
}
setFormError("");
setIsSaving(true);
try {
@@ -963,7 +988,14 @@ export function Inventory() {
<div className="space-y-1">
<Label className="text-xs">CAS # <span className="text-red-500">*</span></Label>
<Input value={form.casNumber || ""} onChange={e => setField("casNumber", e.target.value)} placeholder="e.g. 67-56-1" />
<Input
value={form.casNumber || ""}
onChange={e => setField("casNumber", e.target.value)}
placeholder="e.g. 67-56-1"
inputMode="numeric"
pattern="\d{2,7}-\d{2}-\d"
title="CAS format: digits-digits-digit (e.g. 67-56-1)"
/>
</div>
<div className="space-y-1">
@@ -1009,7 +1041,14 @@ export function Inventory() {
<div className="space-y-1">
<Label className="text-xs">Amount / Container <span className="text-red-500">*</span></Label>
<Input value={form.amountPerContainer || ""} onChange={e => setField("amountPerContainer", e.target.value)} placeholder="e.g. 500" />
<Input
type="number"
min={0}
step="any"
value={form.amountPerContainer || ""}
onChange={e => setField("amountPerContainer", e.target.value)}
placeholder="e.g. 500"
/>
</div>
<div className="space-y-1 col-span-2">
@@ -1045,7 +1084,14 @@ export function Inventory() {
</div>
<div className="space-y-1">
<Label className="text-xs">Molecular Weight</Label>
<Input value={form.molecularWeight || ""} onChange={e => setField("molecularWeight", e.target.value)} placeholder="g/mol" />
<Input
type="number"
min={0}
step="any"
value={form.molecularWeight || ""}
onChange={e => setField("molecularWeight", e.target.value)}
placeholder="g/mol"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Concentration</Label>
@@ -1077,7 +1123,11 @@ export function Inventory() {
</div>
<div className="space-y-1">
<Label className="text-xs">Contact</Label>
<Input value={form.contact || ""} onChange={e => setField("contact", e.target.value)} />
<Input
value={form.contact || ""}
onChange={e => setField("contact", e.target.value)}
placeholder="Phone (e.g. 555-123-4567) or email"
/>
</div>
<div className="space-y-1 col-span-2">
<Label className="text-xs">Comments</Label>

View File

@@ -4,6 +4,7 @@ import { Card, CardContent } from './ui/card';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { useSession } from '../lib/auth-client';
import { validatePhoneOrEmail } from '../lib/validators';
const logo = '/logo.png';
@@ -23,16 +24,21 @@ export function Onboarding({ onComplete }: Props) {
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError('');
const trimmedContact = contact.trim();
if (trimmedContact && !validatePhoneOrEmail(trimmedContact)) {
setError('Contact must be a valid phone number or email address.');
return;
}
setLoading(true);
const res = await fetch('/api/profile', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
pi_first_name: piFirstName,
bldg_code: bldgCode,
lab,
contact: contact || undefined,
pi_first_name: piFirstName.trim(),
bldg_code: bldgCode.trim(),
lab: lab.trim(),
contact: trimmedContact || undefined,
}),
});
setLoading(false);
@@ -103,7 +109,7 @@ export function Onboarding({ onComplete }: Props) {
type="text"
value={contact}
onChange={e => setContact(e.target.value)}
placeholder="Phone or email"
placeholder="Phone (e.g. 555-123-4567) or email"
/>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}

View File

@@ -0,0 +1,176 @@
import { useEffect, useState } from 'react';
import { Loader2, Check } from 'lucide-react';
import { Button } from './ui/button';
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { useSession } from '../lib/auth-client';
import { validatePhoneOrEmail } from '../lib/validators';
export function ProfileSettings() {
const { data: session } = useSession();
const [piFirstName, setPiFirstName] = useState('');
const [bldgCode, setBldgCode] = useState('');
const [lab, setLab] = useState('');
const [contact, setContact] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [saved, setSaved] = useState(false);
useEffect(() => {
fetch('/api/profile', { credentials: 'include' })
.then(r => (r.ok ? r.json() : null))
.then(data => {
if (data) {
setPiFirstName(data.pi_first_name || '');
setBldgCode(data.bldg_code || '');
setLab(data.lab || '');
setContact(data.contact || '');
}
})
.finally(() => setLoading(false));
}, []);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError('');
setSaved(false);
if (!piFirstName.trim() || !bldgCode.trim() || !lab.trim()) {
setError('PI first name, building code, and lab are required.');
return;
}
if (contact.trim() && !validatePhoneOrEmail(contact.trim())) {
setError('Contact must be a valid phone number or email address.');
return;
}
setSaving(true);
const res = await fetch('/api/profile', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
pi_first_name: piFirstName.trim(),
bldg_code: bldgCode.trim(),
lab: lab.trim(),
contact: contact.trim() || undefined,
}),
});
setSaving(false);
if (res.ok) {
setSaved(true);
setTimeout(() => setSaved(false), 3000);
} else {
const data = await res.json().catch(() => ({}));
setError(data.error || 'Failed to save profile.');
}
}
if (loading) {
return (
<div className="flex h-full items-center justify-center">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="p-8 max-w-2xl mx-auto">
<div className="mb-6">
<h1 className="text-2xl font-semibold">Profile settings</h1>
<p className="text-sm text-muted-foreground mt-1">
Update your account details and lab defaults.
</p>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">Account</CardTitle>
</CardHeader>
<CardContent className="space-y-3 pb-6">
<div>
<Label className="text-xs text-muted-foreground">Name</Label>
<p className="text-sm">{session?.user.name || '—'}</p>
</div>
<div>
<Label className="text-xs text-muted-foreground">Email</Label>
<p className="text-sm">{session?.user.email || '—'}</p>
</div>
</CardContent>
</Card>
<form onSubmit={handleSubmit}>
<Card className="mt-4">
<CardHeader>
<CardTitle className="text-base">Lab defaults</CardTitle>
</CardHeader>
<CardContent className="space-y-4 pb-6">
<div className="space-y-1">
<Label htmlFor="pi">
PI first name <span className="text-red-500">*</span>
</Label>
<Input
id="pi"
value={piFirstName}
onChange={e => setPiFirstName(e.target.value)}
required
placeholder="e.g. Smith"
/>
</div>
<div className="space-y-1">
<Label htmlFor="bldg">
Building code <span className="text-red-500">*</span>
</Label>
<Input
id="bldg"
value={bldgCode}
onChange={e => setBldgCode(e.target.value)}
required
placeholder="e.g. EER"
/>
</div>
<div className="space-y-1">
<Label htmlFor="lab">
Lab <span className="text-red-500">*</span>
</Label>
<Input
id="lab"
value={lab}
onChange={e => setLab(e.target.value)}
required
placeholder="e.g. 3.822"
/>
</div>
<div className="space-y-1">
<Label htmlFor="contact">
Contact{' '}
<span className="text-muted-foreground font-normal">(optional)</span>
</Label>
<Input
id="contact"
type="text"
value={contact}
onChange={e => setContact(e.target.value)}
placeholder="Phone (e.g. 555-123-4567) or email"
/>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
{saved && (
<p className="text-sm text-green-600 flex items-center gap-1">
<Check className="w-4 h-4" /> Saved
</p>
)}
<div className="pt-1">
<Button type="submit" disabled={saving}>
{saving ? 'Saving…' : 'Save changes'}
</Button>
</div>
</CardContent>
</Card>
</form>
</div>
);
}

View File

@@ -12,6 +12,7 @@ import {
DialogFooter,
} from '../ui/dialog';
import { signUp, sendVerificationEmail } from '../../lib/auth-client';
import { validateEmail } from '../../lib/validators';
import { useEffect } from 'react';
const logo = '/logo.png';
@@ -48,6 +49,16 @@ export function SignUpForm({ onLogin }: Props) {
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError('');
const trimmedName = name.trim();
const trimmedEmail = email.trim();
if (trimmedName.length < 2) {
setError('Please enter your full name.');
return;
}
if (!validateEmail(trimmedEmail)) {
setError('Please enter a valid email address.');
return;
}
if (password.length < 8) {
setError('Password must be at least 8 characters');
return;
@@ -58,8 +69,8 @@ export function SignUpForm({ onLogin }: Props) {
}
setLoading(true);
const res = await signUp.email({
name,
email,
name: trimmedName,
email: trimmedEmail,
password,
callbackURL: window.location.origin,
});