Clock app: vehicle-QR diesel book-out with meter photo; user access levels
- Scanning a vehicle QR (its register code, e.g. TLB04) books diesel from the main tank; litres, optional odometer and a required meter photo (stored protected, viewable from the Booked out table) - Settings: users are Full or Clock-app-only; clock-only users are confined to /clock - Clock app: tapped tab highlights at once plus a loading screen; remove the "scanned on this device" list Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
1dc1472841
commit
29a035a4f2
20 changed files with 216 additions and 62 deletions
10
app/(clock)/clock/loading.tsx
Normal file
10
app/(clock)/clock/loading.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { RotateCcw } from 'lucide-react';
|
||||
|
||||
/** Shown instantly while a tab's page loads, so a tap on the bottom bar never looks dead. */
|
||||
export default function ClockLoading() {
|
||||
return <section className="plant-clock-page p-4 sm:p-6 max-w-3xl mx-auto" aria-busy="true">
|
||||
<div className="h-4 w-24 rounded bg-border animate-pulse"/>
|
||||
<div className="h-8 w-64 max-w-full rounded bg-border animate-pulse mt-3"/>
|
||||
<div className="plant-camera mt-4 grid place-items-center"><p className="flex items-center gap-2 rounded-lg bg-[#003D5B]/90 px-4 py-2 text-sm text-white"><RotateCcw size={16} className="animate-spin"/>Loading…</p></div>
|
||||
</section>;
|
||||
}
|
||||
|
|
@ -31,6 +31,8 @@ export default async function OilsPage() {
|
|||
litres: m.litres,
|
||||
asset: assetCode.get((m as any).asset) || "",
|
||||
by: (m as any).booked_by || "",
|
||||
photo: !!(m as any).meter_photo,
|
||||
odometer: (m as any).odometer ? Number((m as any).odometer) : null,
|
||||
}))
|
||||
.sort((a, b) => b.at.localeCompare(a.at));
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export default async function SettingsPage() {
|
|||
<div className="mb-5 flex items-center gap-3"><UserRound className="h-5 w-5 text-[#30638E]" /><h2 className="text-lg font-semibold">Your account</h2></div>
|
||||
<p className="break-words text-base font-medium">{user.name || "Mega Plant user"}</p>
|
||||
<p className="mt-1 break-all text-sm text-gray-500 dark:text-gray-400">{user.email}</p>
|
||||
<p className="my-5 border-t border-gray-100 pt-5 text-sm leading-relaxed text-gray-500 dark:border-border dark:text-gray-400">Everyone on the team has the same access to the dashboard and settings.</p>
|
||||
<p className="my-5 border-t border-gray-100 pt-5 text-sm leading-relaxed text-gray-500 dark:border-border dark:text-gray-400">Full users reach the whole app. Clock-app-only users can sign in to /clock and nothing else.</p>
|
||||
<LogoutButton />
|
||||
</section>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ import { redirect } from "next/navigation";
|
|||
import { revalidatePath } from "next/cache";
|
||||
import { ClientResponseError } from "pocketbase";
|
||||
import { getSession, requireUser } from "@/lib/auth";
|
||||
import { addUser, authenticateUser, changeUserPassword, deleteUser, InactiveUserError, setUserActive } from "@/lib/auth-service";
|
||||
import { SESSION_COOKIE, authDestination, validateLogin, validateNewPassword, validateNewUser } from "@/lib/auth-policy";
|
||||
import { addUser, authenticateUser, changeUserPassword, deleteUser, InactiveUserError, setUserAccess, setUserActive } from "@/lib/auth-service";
|
||||
import { SESSION_COOKIE, authDestination, parseAccess, validateLogin, validateNewPassword, validateNewUser } from "@/lib/auth-policy";
|
||||
|
||||
export type FormState = { error?: string; success?: string };
|
||||
const value = (form: FormData, key: string) => String(form.get(key) ?? "");
|
||||
|
|
@ -61,9 +61,10 @@ export async function addUserAction(_state: FormState, form: FormData): Promise<
|
|||
const error = validateNewUser(name, email);
|
||||
if (error) return { error };
|
||||
try {
|
||||
const user = await addUser(name, email);
|
||||
const access = parseAccess(value(form, "access"));
|
||||
const user = await addUser(name, email, access);
|
||||
revalidatePath("/settings");
|
||||
return { success: `${user.name} has been added. They can sign in with ${user.email} and the starter password.` };
|
||||
return { success: `${user.name} has been added${access === "clock" ? " as a clock-app-only user" : ""}. They can sign in${access === "clock" ? " at /clock" : ""} with ${user.email} and the starter password.` };
|
||||
} catch (error) {
|
||||
const details = error instanceof ClientResponseError ? error.response?.data : null;
|
||||
return { error: details?.email?.code === "validation_not_unique"
|
||||
|
|
@ -77,14 +78,15 @@ export async function manageUserAction(_state: FormState, form: FormData): Promi
|
|||
const userId = value(form, "userId");
|
||||
const operation = value(form, "operation");
|
||||
if (userId === actor.id) return { error: "You can't disable or delete your own account." };
|
||||
if (!/^[a-z0-9]{15}$/.test(userId) || !["activate", "deactivate", "delete"].includes(operation)) {
|
||||
if (!/^[a-z0-9]{15}$/.test(userId) || !["activate", "deactivate", "delete", "full", "clock"].includes(operation)) {
|
||||
return { error: "Choose a valid user and action." };
|
||||
}
|
||||
try {
|
||||
if (operation === "delete") await deleteUser(actor.id, userId);
|
||||
else if (operation === "full" || operation === "clock") await setUserAccess(actor.id, userId, operation);
|
||||
else await setUserActive(actor.id, userId, operation === "activate");
|
||||
revalidatePath("/settings");
|
||||
return { success: operation === "delete" ? "User deleted." : operation === "activate" ? "User is active again." : "User is inactive and can no longer access the app." };
|
||||
return { success: operation === "full" ? "User now has full access." : operation === "clock" ? "User is now limited to the clock app." : operation === "delete" ? "User deleted." : operation === "activate" ? "User is active again." : "User is inactive and can no longer access the app." };
|
||||
} catch (error) {
|
||||
return { error: error instanceof ClientResponseError && error.status === 404
|
||||
? "This user no longer exists. Refresh the list."
|
||||
|
|
|
|||
|
|
@ -8,11 +8,20 @@ import { MAX_BOOK_OUT_LITRES } from '@/lib/stock-out';
|
|||
// Stock is the sum of movements, so this only ever appends.
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const user = await clockAccess(request);
|
||||
const user = await clockAccess(request, { multipart: true });
|
||||
if (user instanceof Response) return user;
|
||||
let body: { item_id?: string; asset_id?: string; litres?: unknown };
|
||||
try { body = await request.json(); } catch { return Response.json({ error: 'Supply valid JSON.' }, { status: 400 }); }
|
||||
let form: FormData;
|
||||
try { form = await request.formData(); } catch { return Response.json({ error: 'Supply the form with a meter photo.' }, { status: 400 }); }
|
||||
const body = { item_id: String(form.get('item_id') || ''), asset_id: String(form.get('asset_id') || ''), litres: form.get('litres') };
|
||||
const photo = form.get('meter_photo');
|
||||
const odoRaw = String(form.get('odometer') || '').trim();
|
||||
const odometer = odoRaw === '' ? null : Number(odoRaw);
|
||||
if (odometer !== null && (!Number.isFinite(odometer) || odometer < 0 || odometer > 99999999))
|
||||
return Response.json({ error: 'The odometer reading must be a number.' }, { status: 400 });
|
||||
const litres = Math.round(Number(body.litres) * 100) / 100;
|
||||
if (!(photo instanceof File) || photo.size === 0) return Response.json({ error: 'Take a photo of the meter first.' }, { status: 400 });
|
||||
if (!photo.type.startsWith('image/')) return Response.json({ error: 'The meter photo must be an image.' }, { status: 400 });
|
||||
if (photo.size > 10 * 1024 * 1024) return Response.json({ error: 'The photo is too large (10 MB max).' }, { status: 413 });
|
||||
if (!body.item_id || !body.asset_id) return Response.json({ error: 'Choose the product and the vehicle.' }, { status: 400 });
|
||||
if (!Number.isFinite(litres) || litres <= 0 || litres > MAX_BOOK_OUT_LITRES)
|
||||
return Response.json({ error: `Litres must be more than 0 and at most ${MAX_BOOK_OUT_LITRES}.` }, { status: 400 });
|
||||
|
|
@ -26,11 +35,13 @@ export async function POST(request: Request) {
|
|||
if (!asset) return Response.json({ error: 'That vehicle is not in the plant register.' }, { status: 404 });
|
||||
|
||||
const who = String(user.user.name || user.user.email || user.user.id);
|
||||
const move = await pb.collection('oil_stock_movements').create({
|
||||
item: item.id, asset: asset.id, kind: 'used', litres,
|
||||
occurred_on: new Date().toISOString(), // full instant: `created` is not auto-filled here
|
||||
note: `Booked out to ${asset.code}`, booked_by: who,
|
||||
});
|
||||
const data = new FormData();
|
||||
data.set('item', item.id); data.set('asset', asset.id); data.set('kind', 'used'); data.set('litres', String(litres));
|
||||
data.set('occurred_on', new Date().toISOString()); // full instant: `created` is not auto-filled here
|
||||
if (odometer !== null) data.set('odometer', String(odometer));
|
||||
data.set('note', `Booked out to ${asset.code}`); data.set('booked_by', who);
|
||||
data.set('meter_photo', photo, photo.name || 'meter.jpg');
|
||||
const move = await pb.collection('oil_stock_movements').create(data);
|
||||
revalidatePath('/running/oils');
|
||||
return clockJson({ id: move.id, item: item.name, asset: asset.code, litres, at: move.occurred_on });
|
||||
} catch (e) { return clockFailure(e); }
|
||||
|
|
|
|||
26
app/api/oils/movements/[id]/photo/route.ts
Normal file
26
app/api/oils/movements/[id]/photo/route.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { serverAuth } from "@/lib/pb";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// GET /api/oils/movements/:id/photo — the meter photo taken when litres were booked out.
|
||||
// Proxied with the server credentials (the field is protected), so only a signed-in user
|
||||
// can open it; the proxy middleware already requires a session for /api.
|
||||
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const pb = await serverAuth();
|
||||
const move = await pb.collection("oil_stock_movements").getOne(id);
|
||||
if (!move.meter_photo) return Response.json({ ok: false, error: "No photo attached" }, { status: 404 });
|
||||
const token = await pb.files.getToken();
|
||||
const res = await fetch(pb.files.getURL(move, move.meter_photo, { token }));
|
||||
if (!res.ok) return Response.json({ ok: false, error: "Photo could not be read" }, { status: 502 });
|
||||
return new Response(res.body, {
|
||||
headers: {
|
||||
"Content-Type": res.headers.get("Content-Type") ?? "image/jpeg",
|
||||
"Cache-Control": "private, max-age=3600",
|
||||
},
|
||||
});
|
||||
} catch (e: any) {
|
||||
return Response.json({ ok: false, error: e?.message || "Could not load photo" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -95,14 +95,16 @@ export function BookedOutTable({ log }: { log: OilLogRow[] }) {
|
|||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto [-webkit-overflow-scrolling:touch]">
|
||||
<table className="w-full text-sm min-w-[560px]">
|
||||
<table className="w-full text-sm min-w-[760px]">
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-gray-500 dark:text-gray-400">
|
||||
<th className="sticky left-0 bg-white dark:bg-surface py-2 pr-3 font-medium">When</th>
|
||||
<th className="py-2 pr-3 font-medium">Product</th>
|
||||
<th className="py-2 pr-3 font-medium text-right">Litres</th>
|
||||
<th className="py-2 pr-3 font-medium">Vehicle</th>
|
||||
<th className="py-2 font-medium">Booked by</th>
|
||||
<th className="py-2 pr-3 font-medium text-right">Odometer</th>
|
||||
<th className="py-2 pr-3 font-medium">Booked by</th>
|
||||
<th className="py-2 font-medium">Meter</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -112,7 +114,9 @@ export function BookedOutTable({ log }: { log: OilLogRow[] }) {
|
|||
<td className="py-2 pr-3">{r.item}</td>
|
||||
<td className="py-2 pr-3 text-right tabular-nums">{fmt(r.litres)}</td>
|
||||
<td className="py-2 pr-3 font-mono">{r.asset || "—"}</td>
|
||||
<td className="py-2">{r.by || "—"}</td>
|
||||
<td className="py-2 pr-3 text-right tabular-nums">{r.odometer != null ? fmt(r.odometer) : "—"}</td>
|
||||
<td className="py-2 pr-3">{r.by || "—"}</td>
|
||||
<td className="py-2">{r.photo ? <a href={`/api/oils/movements/${r.id}/photo`} target="_blank" rel="noopener noreferrer" className="inline-flex min-h-[44px] items-center text-[#30638E] underline">View photo</a> : "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
|
@ -121,7 +125,7 @@ export function BookedOutTable({ log }: { log: OilLogRow[] }) {
|
|||
<td className="sticky left-0 bg-white dark:bg-surface py-2 pr-3">Total · {rows.length} booking{rows.length !== 1 && "s"}</td>
|
||||
<td />
|
||||
<td className="py-2 pr-3 text-right tabular-nums">{fmt(rows.reduce((s, r) => s + r.litres, 0))}</td>
|
||||
<td colSpan={2} />
|
||||
<td colSpan={4} />
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
'use client';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ClipboardList, Droplets, Users } from 'lucide-react';
|
||||
|
||||
const TABS = [
|
||||
|
|
@ -12,12 +13,17 @@ const TABS = [
|
|||
/** Mobile-app style tab bar. Hidden on the sign-in / password screens. */
|
||||
export function ClockBottomNav() {
|
||||
const path = usePathname();
|
||||
// Highlight the tapped tab at once: the next page takes a moment to load and an
|
||||
// unchanged bar reads as "the tap did not register".
|
||||
const [tapped, setTapped] = useState<string | null>(null);
|
||||
useEffect(() => setTapped(null), [path]);
|
||||
if (path.startsWith('/clock/login') || path.startsWith('/clock/change-password')) return null;
|
||||
return <nav aria-label="Plant app" className="fixed inset-x-0 bottom-0 z-40 border-t border-border bg-surface pb-[env(safe-area-inset-bottom)]">
|
||||
<ul className="mx-auto max-w-3xl grid grid-cols-3">
|
||||
{TABS.map(({ href, label, Icon }) => {
|
||||
const active = href === '/clock' ? path === '/clock' : path.startsWith(href);
|
||||
return <li key={href}><Link href={href} aria-current={active ? 'page' : undefined}
|
||||
const here = tapped ?? path;
|
||||
const active = href === '/clock' ? here === '/clock' : here.startsWith(href);
|
||||
return <li key={href}><Link href={href} onClick={() => setTapped(href)} aria-current={active ? 'page' : undefined}
|
||||
className={`flex min-h-[56px] flex-col items-center justify-center gap-0.5 text-xs font-medium ${active ? 'text-[#30638E]' : 'text-text-muted'}`}>
|
||||
<Icon size={22} strokeWidth={active ? 2.4 : 1.8}/>{label}</Link></li>;
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import type { OilStock } from "@/lib/oil";
|
|||
import { oilQrPayload, isTank } from "@/lib/oil";
|
||||
import { Download } from "lucide-react";
|
||||
|
||||
export type OilLogRow = { id: string; at: string; item: string; litres: number; asset: string; by: string };
|
||||
export type OilLogRow = { id: string; at: string; item: string; litres: number; asset: string; by: string; photo: boolean; odometer: number | null };
|
||||
|
||||
export { downloadPdf } from "@/lib/qr-pdf";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
'use client';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { AlertTriangle, ArrowRight, CheckCircle2, LogIn, LogOut, RotateCcw, Users } from 'lucide-react';
|
||||
import { AlertTriangle, ArrowRight, CheckCircle2, LogIn, LogOut, RotateCcw } from 'lucide-react';
|
||||
import { clockTime, duration } from '@/lib/clock-display';
|
||||
import { clockRequestId } from '@/lib/clock-request';
|
||||
import { BadgeCamera } from './BadgeCamera';
|
||||
|
|
@ -23,7 +22,6 @@ export function PlantClock() {
|
|||
const [code,setCode]=useState('');
|
||||
const [busy,setBusy]=useState(false);
|
||||
const [banner,setBanner]=useState<Banner|null>(null);
|
||||
const [recent,setRecent]=useState<Saved[]>([]);
|
||||
const [now,setNow]=useState('');
|
||||
const gate=useRef(false);
|
||||
const lastBadge=useRef({value:'',until:0});
|
||||
|
|
@ -43,7 +41,7 @@ export function PlantClock() {
|
|||
const data=await response.json().catch(()=>({}));
|
||||
if(response.ok) {
|
||||
const scan=data as Saved;
|
||||
show({kind:'saved',scan});setRecent(list=>[scan,...list.filter(s=>s.id!==scan.id)].slice(0,8));setCode('');
|
||||
show({kind:'saved',scan});setCode('');
|
||||
navigator.vibrate?.(120);
|
||||
} else if(data.repeat) {
|
||||
show({kind:'repeat',...data.repeat});setCode('');
|
||||
|
|
@ -95,14 +93,5 @@ export function PlantClock() {
|
|||
<button className="plant-clock-button plant-clock-primary shrink-0" disabled={!code.trim()||busy} type="submit">Clock<ArrowRight size={16}/></button></div>
|
||||
</form>
|
||||
|
||||
<div className="plant-clock-panel mt-5 p-4 sm:p-5">
|
||||
<div className="flex items-center justify-between gap-3"><h2 className="text-base font-semibold">Scanned on this device</h2>
|
||||
<Link href="/attendance" target="_blank" rel="noopener noreferrer" className="plant-clock-button plant-clock-secondary"><Users size={16}/>Attendance</Link></div>
|
||||
{recent.length===0 ? <p className="text-sm text-text-muted mt-3">Nobody scanned yet. The first scan of the day clocks someone IN; their next scan clocks them OUT.</p>
|
||||
: <ul className="divide-y divide-border mt-2">{recent.map(s=><li key={s.id} className="py-3 flex items-center gap-3">
|
||||
<span className={`plant-clock-status shrink-0 ${s.direction==='in'?'plant-clock-status-in':''}`}>{s.direction.toUpperCase()}</span>
|
||||
<span className="min-w-0 flex-1"><span className="block text-sm font-medium break-words">{s.employeeName}</span><span className="block text-xs text-text-muted">{s.employeeNo}{s.direction==='out'?` · ${duration(s.sessionMinutes)}`:''}</span></span>
|
||||
<span className="text-sm tabular-nums shrink-0">{clockTime(s.at)}</span></li>)}</ul>}
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,22 @@
|
|||
'use client';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { AlertTriangle, CheckCircle2, Droplets, X } from 'lucide-react';
|
||||
import { AlertTriangle, Camera, CheckCircle2, Droplets, X } from 'lucide-react';
|
||||
import { BadgeCamera } from './BadgeCamera';
|
||||
import { oilCodeFromQr } from '@/lib/stock-out';
|
||||
import { assetFromQr, oilCodeFromQr } from '@/lib/stock-out';
|
||||
|
||||
/** Phone photos are 4–8 MB; shrink to ~1600px JPEG so a yard with poor signal can still upload. */
|
||||
async function shrink(file: File): Promise<File> {
|
||||
try {
|
||||
const bmp = await createImageBitmap(file);
|
||||
const scale = Math.min(1, 1600 / Math.max(bmp.width, bmp.height));
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.round(bmp.width * scale); canvas.height = Math.round(bmp.height * scale);
|
||||
canvas.getContext('2d')!.drawImage(bmp, 0, 0, canvas.width, canvas.height);
|
||||
const blob = await new Promise<Blob | null>(r => canvas.toBlob(r, 'image/jpeg', 0.8));
|
||||
return blob ? new File([blob], 'meter.jpg', { type: 'image/jpeg' }) : file;
|
||||
} catch { return file; }
|
||||
}
|
||||
|
||||
type Item = { id: string; code: string; name: string; colour: string; onHand: number; tank: boolean };
|
||||
type Asset = { id: string; code: string; name: string };
|
||||
|
|
@ -19,10 +32,18 @@ export function StockOut({ items, assets }: { items: Item[]; assets: Asset[] })
|
|||
const [busy, setBusy] = useState(false);
|
||||
const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null);
|
||||
const [unknownScan, setUnknownScan] = useState('');
|
||||
const [odometer, setOdometer] = useState('');
|
||||
const [photo, setPhoto] = useState<File | null>(null);
|
||||
const [preview, setPreview] = useState('');
|
||||
const lastScan = useRef({ value: '', until: 0 });
|
||||
|
||||
function pick(next: Item | null) {
|
||||
setItem(next); setLitres(''); setAssetId(''); setUnknownScan('');
|
||||
setItem(next); setLitres(''); setAssetId(''); setUnknownScan(''); setPhoto(null); setPreview(''); setOdometer('');
|
||||
}
|
||||
async function takePhoto(file: File | undefined) {
|
||||
if (!file) return;
|
||||
const small = await shrink(file);
|
||||
setPhoto(small); setPreview(URL.createObjectURL(small));
|
||||
}
|
||||
function onDecode(raw: string) {
|
||||
if (item || busy) return;
|
||||
|
|
@ -30,16 +51,21 @@ export function StockOut({ items, assets }: { items: Item[]; assets: Asset[] })
|
|||
lastScan.current = { value: raw, until: Date.now() + 4000 };
|
||||
const code = oilCodeFromQr(raw);
|
||||
const found = code && items.find(i => i.code.toUpperCase() === code.toUpperCase());
|
||||
if (found) { setMessage(null); pick(found); navigator.vibrate?.(120); }
|
||||
else setUnknownScan(raw.slice(0, 60));
|
||||
if (found) { setMessage(null); pick(found); navigator.vibrate?.(120); return; }
|
||||
// A vehicle's own QR books DIESEL from the main tank to that vehicle.
|
||||
const vehicle = assetFromQr(raw, assets);
|
||||
const tank = items.find(i => i.tank);
|
||||
if (vehicle && tank) { setMessage(null); pick(tank); setAssetId(vehicle.id); navigator.vibrate?.(120); return; }
|
||||
setUnknownScan(raw.slice(0, 60));
|
||||
}
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!item || busy) return;
|
||||
setBusy(true); setMessage(null);
|
||||
try {
|
||||
const res = await fetch('/api/clock/stock-out', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ item_id: item.id, asset_id: assetId, litres: litres.trim() }) });
|
||||
const body = new FormData();
|
||||
body.set('item_id', item.id); body.set('asset_id', assetId); body.set('litres', litres.trim()); body.set('meter_photo', photo!); if (odometer.trim()) body.set('odometer', odometer.trim());
|
||||
const res = await fetch('/api/clock/stock-out', { method: 'POST', body });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || 'Could not save.');
|
||||
setMessage({ ok: true, text: `${fmt(data.litres)} L of ${data.item} booked to ${data.asset}.` });
|
||||
|
|
@ -61,8 +87,8 @@ export function StockOut({ items, assets }: { items: Item[]; assets: Asset[] })
|
|||
<p className="text-base font-semibold break-words">{message.text}</p></div>}
|
||||
|
||||
{!item ? <>
|
||||
<div className="mt-4"><BadgeCamera onDecode={onDecode} paused={busy} hint="Scan the QR on the drum or tank"/></div>
|
||||
{unknownScan && <p role="alert" className="plant-clock-warning mt-3">That QR is not an oil or diesel label ({unknownScan}). Scan the label on the drum or tank.</p>}
|
||||
<div className="mt-4"><BadgeCamera onDecode={onDecode} paused={busy} hint="Scan the vehicle QR (or a drum / tank label)"/></div>
|
||||
{unknownScan && <p role="alert" className="plant-clock-warning mt-3">That QR is not a vehicle in the plant register or an oil / diesel label ({unknownScan}).</p>}
|
||||
</> : <form onSubmit={submit} className="plant-clock-panel mt-4 p-4 sm:p-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0"><div className="flex items-center gap-2"><Droplets size={20} style={{ color: item.colour }}/><h2 className="text-lg font-semibold break-words">{item.name}</h2></div>
|
||||
|
|
@ -74,7 +100,13 @@ export function StockOut({ items, assets }: { items: Item[]; assets: Asset[] })
|
|||
{over && <p className="plant-clock-warning mt-2">That is more than the {fmt(item.onHand)} L recorded. It will still be booked — check the stock level afterwards.</p>}
|
||||
<label htmlFor="vehicle-search" className="block text-sm font-medium mt-4">Which vehicle is it for?</label>
|
||||
<VehiclePicker assets={assets} value={assetId} onChange={setAssetId}/>
|
||||
<button className="plant-clock-button plant-clock-primary w-full mt-5" type="submit" disabled={busy || !(entered > 0) || !assetId}>
|
||||
<label htmlFor="odometer" className="block text-sm font-medium mt-4">Odometer / hour meter <span className="font-normal text-text-muted">(if it has one)</span></label>
|
||||
<input id="odometer" className="plant-clock-input mt-1 tabular-nums" type="number" inputMode="decimal" min="0" step="any" value={odometer} onChange={e => setOdometer(e.target.value)} placeholder="Current reading"/>
|
||||
<p className="block text-sm font-medium mt-4">Photo of the meter</p>
|
||||
<label htmlFor="meter-photo" className="plant-clock-button plant-clock-secondary w-full mt-1 cursor-pointer"><Camera size={18}/>{photo ? 'Retake photo' : 'Take photo'}</label>
|
||||
<input id="meter-photo" type="file" accept="image/*" capture="environment" className="sr-only" onChange={e => { void takePhoto(e.target.files?.[0]); e.target.value = ''; }}/>
|
||||
{preview && <img src={preview} alt="Meter photo to upload" className="mt-2 max-h-56 w-full rounded-xl object-contain bg-bg border border-border"/>}
|
||||
<button className="plant-clock-button plant-clock-primary w-full mt-5" type="submit" disabled={busy || !(entered > 0) || !assetId || !photo}>
|
||||
{busy ? 'Saving…' : 'Book out'}</button>
|
||||
</form>}
|
||||
|
||||
|
|
|
|||
|
|
@ -64,6 +64,13 @@ export function AddUserForm() {
|
|||
<label htmlFor="email" className="mb-2 block text-sm font-medium">Email address</label>
|
||||
<input id="email" name="email" type="email" value={email} onChange={(event) => setEmail(event.target.value)} autoComplete="off" required maxLength={254} className={inputClass} placeholder="person@company.com" />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="access" className="mb-2 block text-sm font-medium">Access</label>
|
||||
<select id="access" name="access" defaultValue="full" className={inputClass}>
|
||||
<option value="full">Full — main app and clock app</option>
|
||||
<option value="clock">Clock app only</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="starter" className="mb-2 block text-sm font-medium">Starter password</label>
|
||||
<input id="starter" value={STARTER_PASSWORD} readOnly className={`${inputClass} font-mono bg-gray-50`} aria-describedby="starter-hint" />
|
||||
|
|
|
|||
|
|
@ -26,10 +26,10 @@ export function UsersTable({ users, currentUserId }: { users: ManagedUser[]; cur
|
|||
{state.success && <p role="status" className="mx-5 mb-4 rounded-lg bg-[#00798C]/10 p-3 text-sm text-accent-ink sm:mx-7">{state.success}</p>}
|
||||
<div className="relative">
|
||||
<div className="overflow-x-auto" style={{ WebkitOverflowScrolling: "touch" }}>
|
||||
<table className="w-full min-w-[700px] text-left text-sm">
|
||||
<table className="w-full min-w-[820px] text-left text-sm">
|
||||
<caption className="sr-only">Mega Plant users and their access status</caption>
|
||||
<thead className="border-y border-border bg-surface-muted text-xs uppercase tracking-wide text-text-muted">
|
||||
<tr><th scope="col" className="sticky left-0 z-10 bg-surface-muted px-5 py-4 font-medium sm:px-7">User</th><th scope="col" className="px-4 py-4 font-medium">Email</th><th scope="col" className="px-4 py-4 font-medium">Status</th><th scope="col" className="px-5 py-4 font-medium sm:px-7">Actions</th></tr>
|
||||
<tr><th scope="col" className="sticky left-0 z-10 bg-surface-muted px-5 py-4 font-medium sm:px-7">User</th><th scope="col" className="px-4 py-4 font-medium">Email</th><th scope="col" className="px-4 py-4 font-medium">Access</th><th scope="col" className="px-4 py-4 font-medium">Status</th><th scope="col" className="px-5 py-4 font-medium sm:px-7">Actions</th></tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{users.map((user) => <tr key={user.id}>
|
||||
|
|
@ -38,6 +38,15 @@ export function UsersTable({ users, currentUserId }: { users: ManagedUser[]; cur
|
|||
{user.id === currentUserId && <span className="mt-1 block pl-6 text-xs text-text-muted">You</span>}
|
||||
</td>
|
||||
<td className="max-w-64 break-all px-4 py-4 text-text-muted">{user.email}</td>
|
||||
<td className="px-4 py-4 whitespace-nowrap">
|
||||
{user.id === currentUserId ? <span className="text-sm">Full</span> : <form action={action}>
|
||||
<input type="hidden" name="userId" value={user.id} />
|
||||
<select name="operation" defaultValue={user.access} disabled={pending} aria-label={`Access for ${user.name || user.email}`}
|
||||
onChange={(e) => e.currentTarget.form?.requestSubmit()} className="min-h-11 rounded-lg border border-border bg-surface px-2 text-sm">
|
||||
<option value="full">Full</option><option value="clock">Clock app only</option>
|
||||
</select>
|
||||
</form>}
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
<span className={`inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium ${user.active ? "bg-[#00798C]/10 text-accent-ink" : "bg-gray-100 text-text-muted dark:bg-white/5"}`}><span aria-hidden="true" className={`h-1.5 w-1.5 rounded-full ${user.active ? "bg-[#00798C]" : "bg-gray-400"}`} />{user.active ? "Active" : "Inactive"}</span>
|
||||
{user.active && !user.passwordChanged && <p className="mt-1 text-xs text-text-muted">First sign-in pending</p>}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,14 @@ export function validateNewUser(name: string, email: string): string | null {
|
|||
return null;
|
||||
}
|
||||
|
||||
export function authDestination(session: { passwordChanged: boolean } | null): string {
|
||||
return !session ? "/login" : session.passwordChanged ? "/" : "/change-password";
|
||||
/** "full" = the main app (and the clock app); "clock" = the Plant Clock app only. */
|
||||
export type UserAccess = "full" | "clock";
|
||||
export const USER_ACCESS: UserAccess[] = ["full", "clock"];
|
||||
export const parseAccess = (value: unknown): UserAccess => (value === "clock" ? "clock" : "full");
|
||||
|
||||
export function authDestination(session: { passwordChanged: boolean; access?: UserAccess } | null): string {
|
||||
if (!session) return "/login";
|
||||
if (!session.passwordChanged) return "/change-password";
|
||||
// A clock-only user has no home in the main app: send them to the clock.
|
||||
return session.access === "clock" ? "/clock" : "/";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
// Server-only consumers: auth.ts, server actions and proxy.ts. Never import in a client component.
|
||||
import PocketBase, { ClientResponseError, type RecordModel } from "pocketbase";
|
||||
import { STARTER_PASSWORD, validateLogin, validateNewPassword, validateNewUser } from "./auth-policy";
|
||||
import { STARTER_PASSWORD, parseAccess, type UserAccess, validateLogin, validateNewPassword, validateNewUser } from "./auth-policy";
|
||||
|
||||
export type AuthSession = { pb: PocketBase; user: RecordModel; passwordChanged: boolean };
|
||||
export type ManagedUser = { id: string; name: string; email: string; active: boolean; passwordChanged: boolean };
|
||||
export type AuthSession = { pb: PocketBase; user: RecordModel; passwordChanged: boolean; access: UserAccess };
|
||||
export type ManagedUser = { id: string; name: string; email: string; active: boolean; passwordChanged: boolean; access: UserAccess };
|
||||
export class InactiveUserError extends Error {
|
||||
constructor() { super("This account is inactive. Contact your team to restore access."); }
|
||||
}
|
||||
|
|
@ -42,7 +42,7 @@ async function session(pb: PocketBase): Promise<AuthSession> {
|
|||
if (!user || user.collectionName !== "users") throw new Error("A user account is required.");
|
||||
const state = await onboarding(user.id);
|
||||
if (state?.disabled === true) throw new InactiveUserError();
|
||||
return { pb, user: user as RecordModel, passwordChanged: state?.password_changed === true };
|
||||
return { pb, user: user as RecordModel, passwordChanged: state?.password_changed === true, access: parseAccess(state?.access) };
|
||||
}
|
||||
|
||||
export async function authenticateUser(email: string, password: string): Promise<AuthSession> {
|
||||
|
|
@ -87,14 +87,30 @@ export async function changeUserPassword(current: AuthSession, password: string)
|
|||
return { ...updated, passwordChanged: true };
|
||||
}
|
||||
|
||||
export async function addUser(name: string, email: string): Promise<RecordModel> {
|
||||
export async function addUser(name: string, email: string, access: UserAccess = "full"): Promise<RecordModel> {
|
||||
const error = validateNewUser(name, email);
|
||||
if (error) throw new Error(error);
|
||||
const pb = await adminClient();
|
||||
return pb.collection("users").create({
|
||||
const user = await pb.collection("users").create({
|
||||
name: name.trim(), email: email.trim().toLowerCase(),
|
||||
password: STARTER_PASSWORD, passwordConfirm: STARTER_PASSWORD,
|
||||
});
|
||||
if (access === "clock") {
|
||||
// Roll the account back if the restriction cannot be stored: a "clock only" user who
|
||||
// silently got full access would be worse than no user.
|
||||
try { await pb.collection("user_onboarding").create({ user: user.id, password_changed: false, access }); }
|
||||
catch (e) { await pb.collection("users").delete(user.id).catch(() => {}); throw e; }
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function setUserAccess(actorId: string, userId: string, access: UserAccess): Promise<void> {
|
||||
validateManagedUser(actorId, userId);
|
||||
const pb = await adminClient();
|
||||
await pb.collection("users").getOne(userId);
|
||||
const state = await onboarding(userId);
|
||||
if (state) await pb.collection("user_onboarding").update(state.id, { access });
|
||||
else await pb.collection("user_onboarding").create({ user: userId, access, password_changed: false });
|
||||
}
|
||||
|
||||
export async function listUsers(): Promise<ManagedUser[]> {
|
||||
|
|
@ -108,6 +124,7 @@ export async function listUsers(): Promise<ManagedUser[]> {
|
|||
id: user.id, name: typeof user.name === "string" ? user.name : "", email: user.email,
|
||||
active: byUser.get(user.id)?.disabled !== true,
|
||||
passwordChanged: byUser.get(user.id)?.password_changed === true,
|
||||
access: parseAccess(byUser.get(user.id)?.access),
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,13 +3,14 @@ import { getSession } from './auth';
|
|||
import { ClockError } from './clock';
|
||||
import { clockOriginAllowed } from './clock-origin';
|
||||
|
||||
export async function clockAccess(request:Request) {
|
||||
export async function clockAccess(request:Request,options:{multipart?:boolean}={}) {
|
||||
const session=await getSession();
|
||||
if(!session)return Response.json({error:'Please sign in.'},{status:401});
|
||||
if(!session.passwordChanged)return Response.json({error:'Change your starter password first.'},{status:403});
|
||||
if(request.method==='POST') {
|
||||
if(!clockOriginAllowed(request.headers))return Response.json({error:'Request origin is not allowed.'},{status:403});
|
||||
if(!request.headers.get('content-type')?.startsWith('application/json'))return Response.json({error:'Supply JSON.'},{status:415});
|
||||
const type=request.headers.get('content-type')||'';
|
||||
if(!type.startsWith('application/json') && !(options.multipart && type.startsWith('multipart/form-data')))return Response.json({error:'Supply JSON.'},{status:415});
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { normaliseCostCode } from "./cost-code";
|
||||
|
||||
/** The oil/diesel drum QR reads "Diesel | FUEL-DSL" — name, then item code. */
|
||||
export function oilCodeFromQr(raw: string): string | null {
|
||||
const text = (raw || "").trim();
|
||||
|
|
@ -7,3 +9,22 @@ export function oilCodeFromQr(raw: string): string | null {
|
|||
}
|
||||
|
||||
export const MAX_BOOK_OUT_LITRES = 5000;
|
||||
|
||||
/**
|
||||
* Which vehicle does a scanned QR name? The register code is the join, compared the way
|
||||
* purchase orders are ("SS-02" = "SS02"). Tolerant of how the sticker was printed: the bare
|
||||
* code, "Name | CODE", or a link ending in the code. Never a fuzzy guess — no match, no vehicle.
|
||||
*/
|
||||
export function assetFromQr<T extends { code: string }>(raw: string, assets: T[]): T | null {
|
||||
const text = (raw || "").trim();
|
||||
if (!text || text.length > 300) return null;
|
||||
const byCode = new Map(assets.map((a) => [normaliseCostCode(a.code), a]));
|
||||
const whole = byCode.get(normaliseCostCode(text));
|
||||
if (whole) return whole;
|
||||
// Try each |, /, space, comma, colon or ? separated piece, last first (the code usually trails).
|
||||
for (const part of text.split(/[|/\s,:;?=&#]+/).reverse()) {
|
||||
const hit = part && byCode.get(normaliseCostCode(part));
|
||||
if (hit) return hit;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
4
proxy.ts
4
proxy.ts
|
|
@ -14,12 +14,12 @@ export async function proxy(request: NextRequest) {
|
|||
? NextResponse.json({ error: "Account service is temporarily unavailable." }, { status: 503 })
|
||||
: new NextResponse("Account service is temporarily unavailable. Please reload to try again.", { status: 503, headers: { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" } });
|
||||
}
|
||||
const clockPath=path==="/clock"||path.startsWith("/clock/");
|
||||
const clockPath=path==="/clock"||path.startsWith("/clock/")||path.startsWith("/api/clock/");
|
||||
const destination = clockPath?clockAuthDestination(session):authDestination(session);
|
||||
// Logout is a server action and remains available during first-login onboarding.
|
||||
if ((path === "/change-password" || path === "/clock/change-password") && session) return NextResponse.next();
|
||||
if (destination !== (clockPath?"/clock":"/")) {
|
||||
if (path.startsWith("/api/")) return NextResponse.json({ error: session ? "Change your starter password first." : "Please sign in." }, { status: session ? 403 : 401 });
|
||||
if (path.startsWith("/api/")) return NextResponse.json({ error: !session ? "Please sign in." : session.passwordChanged ? "This account only has access to the clock app." : "Change your starter password first." }, { status: session ? 403 : 401 });
|
||||
return NextResponse.redirect(new URL(destination, request.url));
|
||||
}
|
||||
const response = NextResponse.next();
|
||||
|
|
|
|||
|
|
@ -16,9 +16,13 @@ if (existing) {
|
|||
}
|
||||
const disabled = fields.find((field) => field.name === "disabled");
|
||||
if (disabled && disabled.type !== "bool") throw new Error("user_onboarding.disabled must be a boolean.");
|
||||
if (!disabled) {
|
||||
await pb.collections.update(existing.id, { fields: [...fields, { name: "disabled", type: "bool" }] });
|
||||
console.log("Added private access status. Existing users remain active.");
|
||||
const add = [];
|
||||
if (!disabled) add.push({ name: "disabled", type: "bool" });
|
||||
// "full" (or empty) = main app + clock; "clock" = Plant Clock app only.
|
||||
if (!fields.some((f) => f.name === "access")) add.push({ name: "access", type: "text" });
|
||||
if (add.length) {
|
||||
await pb.collections.update(existing.id, { fields: [...fields, ...add] });
|
||||
console.log(`Added ${add.map((f) => f.name).join(", ")}. Existing users remain active with full access.`);
|
||||
} else console.log("Private user_onboarding collection already configured. No changes made.");
|
||||
} else {
|
||||
const users = await pb.collections.getOne("users");
|
||||
|
|
@ -29,6 +33,7 @@ if (existing) {
|
|||
{ name: "user", type: "relation", collectionId: users.id, required: true, maxSelect: 1, cascadeDelete: true },
|
||||
{ name: "password_changed", type: "bool" },
|
||||
{ name: "disabled", type: "bool" },
|
||||
{ name: "access", type: "text" },
|
||||
],
|
||||
indexes: ["CREATE UNIQUE INDEX idx_user_onboarding_user ON user_onboarding (user)"],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -140,6 +140,10 @@ await ensureCollection("oil_stock_movements", [
|
|||
// Not cascadeDelete — removing a machine must not erase the record of oil that was drawn.
|
||||
{ name: "asset", type: "relation", collectionId: assetsId, minSelect: 0, maxSelect: 1, cascadeDelete: false },
|
||||
{ name: "booked_by", type: "text" },
|
||||
// Odometer (km) or hour-meter reading the driver typed in. Optional: not every machine has one.
|
||||
{ name: "odometer", type: "number" },
|
||||
// Photo of the vehicle's meter at the time of fuelling, for verification.
|
||||
{ name: "meter_photo", type: "file", maxSelect: 1, maxSize: 10485760, mimeTypes: ["image/jpeg", "image/png", "image/webp", "image/heic", "image/heif"], protected: true },
|
||||
{ name: "created", type: "date", onCreate: true, presentable: false },
|
||||
{ name: "updated", type: "date", onUpdate: true, presentable: false },
|
||||
]);
|
||||
|
|
|
|||
Loading…
Reference in a new issue