This commit is contained in:
2026-07-23 17:48:44 -05:00
commit 5830cd7ab5
33 changed files with 6866 additions and 0 deletions

View File

@@ -0,0 +1,76 @@
import { useEffect, useState } from 'react';
import { api, type Business, type BusinessFile } from '../api.js';
function formatSize(bytes: number): string {
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
return `${Math.max(1, Math.round(bytes / 1024))} KB`;
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
export default function BusinessDetail({ id, onBack }: { id: string; onBack: () => void }) {
const [business, setBusiness] = useState<Business | null>(null);
const [files, setFiles] = useState<BusinessFile[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
api.business(id).then(setBusiness).catch((err: Error) => setError(err.message));
api.businessFiles(id).then(setFiles).catch((err: Error) => setError(err.message));
}, [id]);
return (
<div>
<button onClick={onBack} className="mb-4 text-sm text-blue-600 hover:underline">
Back to businesses
</button>
{error && <p className="mb-3 text-sm text-red-600">{error}</p>}
{business && (
<div className="mb-5">
<div className="flex items-center gap-3">
<h1 className="text-lg font-semibold">{business.name}</h1>
<span className="rounded-full border border-gray-300 bg-white px-2 py-0.5 text-xs text-gray-600">
{business.status}
</span>
</div>
<p className="mt-1 font-mono text-xs text-gray-500">{business.nas_path}</p>
</div>
)}
<table className="w-full border-collapse bg-white text-sm">
<thead>
<tr className="border-b border-gray-200 text-left text-xs uppercase tracking-wide text-gray-500">
<th className="px-3 py-2 font-medium">File</th>
<th className="w-24 px-3 py-2 font-medium">Size</th>
<th className="w-48 px-3 py-2 font-medium">Modified</th>
</tr>
</thead>
<tbody>
{files?.map((f) => (
<tr key={f.name} className="border-b border-gray-100">
<td className="px-3 py-1.5">{f.name}</td>
<td className="px-3 py-1.5 text-gray-500">{formatSize(f.size)}</td>
<td className="px-3 py-1.5 text-gray-500">{formatDate(f.mtime)}</td>
</tr>
))}
{files && files.length === 0 && (
<tr>
<td colSpan={3} className="px-3 py-4 text-gray-500">
No files in this directory.
</td>
</tr>
)}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,119 @@
import { useEffect, useState } from 'react';
import { api, type BusinessList, type BusinessStatus } from '../api.js';
const TABS: { status: BusinessStatus; label: string }[] = [
{ status: 'ACTIVE', label: 'Active' },
{ status: 'SOLD', label: 'Sold' },
{ status: 'INACTIVE', label: 'Inactive' },
];
export default function Businesses({ onOpen }: { onOpen: (id: string) => void }) {
const [tab, setTab] = useState<BusinessStatus>('ACTIVE');
const [search, setSearch] = useState('');
const [data, setData] = useState<BusinessList | null>(null);
const [error, setError] = useState<string | null>(null);
const [scanning, setScanning] = useState(false);
const [scanMsg, setScanMsg] = useState<string | null>(null);
const [reload, setReload] = useState(0);
useEffect(() => {
let cancelled = false;
api
.businesses(tab, search)
.then((res) => {
if (!cancelled) {
setData(res);
setError(null);
}
})
.catch((err: Error) => !cancelled && setError(err.message));
return () => {
cancelled = true;
};
}, [tab, search, reload]);
async function scan() {
setScanning(true);
setScanMsg(null);
try {
const res = await api.scan();
setScanMsg(
`Scanned ${res.scanned} · inserted ${res.inserted} · updated ${res.updated} · missing ${res.missing}`,
);
setReload((n) => n + 1);
setTimeout(() => setScanMsg(null), 8000);
} catch (err) {
setScanMsg((err as Error).message);
} finally {
setScanning(false);
}
}
return (
<div>
<div className="mb-4 flex items-center justify-between gap-3">
<div className="flex gap-1">
{TABS.map((t) => (
<button
key={t.status}
onClick={() => setTab(t.status)}
className={`rounded px-3 py-1.5 text-sm ${
tab === t.status
? 'bg-gray-900 text-white'
: 'border border-gray-300 bg-white hover:bg-gray-100'
}`}
>
{t.label} ({data?.counts[t.status] ?? 0})
</button>
))}
</div>
<div className="flex items-center gap-2">
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search name…"
className="w-56 rounded border border-gray-300 px-2 py-1.5 text-sm"
/>
<button
onClick={scan}
disabled={scanning}
className="rounded bg-gray-900 px-3 py-1.5 text-sm text-white disabled:opacity-50"
>
{scanning ? 'Scanning…' : 'Scan NAS now'}
</button>
</div>
</div>
{scanMsg && <p className="mb-3 text-sm text-gray-600">{scanMsg}</p>}
{error && <p className="mb-3 text-sm text-red-600">{error}</p>}
<table className="w-full border-collapse bg-white text-sm">
<thead>
<tr className="border-b border-gray-200 text-left text-xs uppercase tracking-wide text-gray-500">
<th className="px-3 py-2 font-medium">Name</th>
<th className="w-32 px-3 py-2 font-medium">Status</th>
</tr>
</thead>
<tbody>
{data?.businesses.map((b) => (
<tr
key={b.id}
onClick={() => onOpen(b.id)}
className="cursor-pointer border-b border-gray-100 hover:bg-gray-50"
>
<td className="px-3 py-1.5">{b.name}</td>
<td className="px-3 py-1.5 text-gray-500">{b.status}</td>
</tr>
))}
{data && data.businesses.length === 0 && (
<tr>
<td colSpan={2} className="px-3 py-4 text-gray-500">
No businesses.
</td>
</tr>
)}
</tbody>
</table>
</div>
);
}

46
web/src/views/Login.tsx Normal file
View File

@@ -0,0 +1,46 @@
import { useEffect, useState } from 'react';
import { api, type Staff } from '../api.js';
export default function Login({ onLogin }: { onLogin: (staff: Staff) => void }) {
const [staff, setStaff] = useState<Staff[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
api
.staff()
.then((list) => setStaff(list.filter((s) => s.active)))
.catch((err: Error) => setError(err.message));
}, []);
async function pick(member: Staff) {
try {
const res = await api.login(member.id);
onLogin(res.staff);
} catch (err) {
setError((err as Error).message);
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50">
<div className="w-80 rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
<h1 className="mb-4 text-lg font-semibold">Who is working?</h1>
{error && <p className="mb-3 text-sm text-red-600">{error}</p>}
<div className="flex flex-col gap-2">
{staff.map((member) => (
<button
key={member.id}
className="rounded border border-gray-300 px-3 py-2 text-left text-sm hover:bg-gray-100"
onClick={() => pick(member)}
>
{member.name}
</button>
))}
{staff.length === 0 && !error && (
<p className="text-sm text-gray-500">No staff members yet.</p>
)}
</div>
</div>
</div>
);
}