init
This commit is contained in:
62
web/src/App.tsx
Normal file
62
web/src/App.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ApiError, api, type Staff } from './api.js';
|
||||
import Login from './views/Login.js';
|
||||
import Businesses from './views/Businesses.js';
|
||||
import BusinessDetail from './views/BusinessDetail.js';
|
||||
|
||||
type View = { name: 'businesses' } | { name: 'business'; id: string };
|
||||
|
||||
export default function App() {
|
||||
const [staff, setStaff] = useState<Staff | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [view, setView] = useState<View>({ name: 'businesses' });
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.me()
|
||||
.then(setStaff)
|
||||
.catch((err: unknown) => {
|
||||
if (!(err instanceof ApiError) || err.status !== 401) console.error(err);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
async function signOut() {
|
||||
await api.logout();
|
||||
setStaff(null);
|
||||
setView({ name: 'businesses' });
|
||||
}
|
||||
|
||||
if (loading) return <div className="p-8 text-sm text-gray-500">Loading…</div>;
|
||||
if (!staff) return <Login onLogin={setStaff} />;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 text-gray-900">
|
||||
<header className="flex items-center justify-between border-b border-gray-200 bg-white px-6 py-3">
|
||||
<button
|
||||
className="text-base font-semibold tracking-tight"
|
||||
onClick={() => setView({ name: 'businesses' })}
|
||||
>
|
||||
BizMatch
|
||||
</button>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className="text-gray-600">{staff.name}</span>
|
||||
<button
|
||||
className="rounded border border-gray-300 px-2 py-1 text-xs hover:bg-gray-100"
|
||||
onClick={signOut}
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto max-w-5xl px-6 py-6">
|
||||
{view.name === 'businesses' ? (
|
||||
<Businesses onOpen={(id) => setView({ name: 'business', id })} />
|
||||
) : (
|
||||
<BusinessDetail id={view.id} onBack={() => setView({ name: 'businesses' })} />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
75
web/src/api.ts
Normal file
75
web/src/api.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
export interface Staff {
|
||||
id: string;
|
||||
name: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export type BusinessStatus = 'ACTIVE' | 'SOLD' | 'INACTIVE';
|
||||
|
||||
export interface BusinessListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
status: BusinessStatus;
|
||||
}
|
||||
|
||||
export interface BusinessList {
|
||||
businesses: BusinessListItem[];
|
||||
counts: Record<BusinessStatus, number>;
|
||||
}
|
||||
|
||||
export interface Business {
|
||||
id: string;
|
||||
name: string;
|
||||
nas_path: string;
|
||||
status: BusinessStatus;
|
||||
}
|
||||
|
||||
export interface BusinessFile {
|
||||
name: string;
|
||||
size: number;
|
||||
mtime: string;
|
||||
}
|
||||
|
||||
export interface ScanResult {
|
||||
scanned: number;
|
||||
inserted: number;
|
||||
updated: number;
|
||||
missing: number;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(readonly status: number, message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(path, { credentials: 'same-origin', ...init });
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => null)) as { error?: string } | null;
|
||||
throw new ApiError(res.status, body?.error ?? `Request failed (${res.status})`);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
function post<T>(path: string, body?: unknown): Promise<T> {
|
||||
return request<T>(path, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body ?? {}),
|
||||
});
|
||||
}
|
||||
|
||||
export const api = {
|
||||
me: () => request<Staff>('/api/me'),
|
||||
staff: () => request<Staff[]>('/api/staff'),
|
||||
login: (staffId: string) => post<{ ok: boolean; staff: Staff }>('/api/login', { staff_id: staffId }),
|
||||
logout: () => post<{ ok: boolean }>('/api/logout'),
|
||||
businesses: (status: BusinessStatus, search: string) =>
|
||||
request<BusinessList>(
|
||||
`/api/businesses?status=${encodeURIComponent(status)}&search=${encodeURIComponent(search)}`,
|
||||
),
|
||||
business: (id: string) => request<Business>(`/api/businesses/${id}`),
|
||||
businessFiles: (id: string) => request<BusinessFile[]>(`/api/businesses/${id}/files`),
|
||||
scan: () => post<ScanResult>('/api/businesses/scan'),
|
||||
};
|
||||
1
web/src/index.css
Normal file
1
web/src/index.css
Normal file
@@ -0,0 +1 @@
|
||||
@import 'tailwindcss';
|
||||
10
web/src/main.tsx
Normal file
10
web/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App.js';
|
||||
import './index.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
76
web/src/views/BusinessDetail.tsx
Normal file
76
web/src/views/BusinessDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
119
web/src/views/Businesses.tsx
Normal file
119
web/src/views/Businesses.tsx
Normal 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
46
web/src/views/Login.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user