254 lines
11 KiB
TypeScript
254 lines
11 KiB
TypeScript
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
|
|
import { json, redirect } from "@remix-run/node";
|
|
import { Form, Link, useActionData, useLoaderData } from "@remix-run/react";
|
|
import { Alert, Button, Card, Col, Form as BootstrapForm, ListGroup, Row } from "react-bootstrap";
|
|
|
|
import { hashPassword } from "~/auth/validate";
|
|
import { enhance } from "~/utils/db.server";
|
|
|
|
export async function loader({ request }: LoaderFunctionArgs) {
|
|
const { dbe } = await enhance(request);
|
|
|
|
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
|
|
|
|
const [containersWithoutBarcode, checkoutsLastHour, latestCheckout, users] = await Promise.all([
|
|
dbe.container.count({ where: { barcode: null } }),
|
|
dbe.history.count({ where: { checkoutAt: { gte: oneHourAgo } } }),
|
|
dbe.history.findFirst({
|
|
orderBy: { checkoutAt: "desc" },
|
|
select: {
|
|
id: true,
|
|
checkoutAt: true,
|
|
container: {
|
|
select: {
|
|
drink: {
|
|
select: {
|
|
name: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
dbe.user.findMany({
|
|
select: { id: true, username: true, type: true },
|
|
orderBy: { username: "asc" },
|
|
}),
|
|
]);
|
|
|
|
return json({
|
|
containersWithoutBarcode,
|
|
checkoutsLastHour,
|
|
latestCheckout,
|
|
users,
|
|
});
|
|
}
|
|
|
|
export async function action({ request }: ActionFunctionArgs) {
|
|
const { dbe } = await enhance(request);
|
|
const form = await request.formData();
|
|
const intent = form.get("intent")?.toString();
|
|
|
|
if (intent === "undo-last-checkout") {
|
|
const checkoutId = Number(form.get("checkoutId"));
|
|
|
|
if (!checkoutId) {
|
|
return json({ error: "No checkout selected." });
|
|
}
|
|
|
|
const checkout = await dbe.history.findUnique({
|
|
where: { id: checkoutId },
|
|
select: { container_id: true },
|
|
});
|
|
|
|
if (checkout) {
|
|
await dbe.container.update({
|
|
where: { id: checkout.container_id },
|
|
data: { inventory: { increment: 1 } },
|
|
});
|
|
await dbe.history.delete({ where: { id: checkoutId } });
|
|
}
|
|
|
|
return redirect("/admin");
|
|
}
|
|
|
|
if (intent === "add-user") {
|
|
const username = form.get("username")?.toString().trim();
|
|
const password = form.get("password")?.toString() ?? "";
|
|
const type = form.get("type")?.toString();
|
|
|
|
if (!username || !password) {
|
|
return json({ error: "Username and password are required." });
|
|
}
|
|
|
|
const normalizedType = (type === "Admin" || type === "Scanner") ? type : "User";
|
|
|
|
try {
|
|
await dbe.user.create({
|
|
data: {
|
|
username,
|
|
password: hashPassword(password),
|
|
type: normalizedType,
|
|
},
|
|
});
|
|
|
|
return json({ success: "User added" });
|
|
} catch {
|
|
return json({ error: "That username is already in use." });
|
|
}
|
|
}
|
|
|
|
if (intent === "set-password") {
|
|
const userId = Number(form.get("userId"));
|
|
const password = form.get("password")?.toString() ?? "";
|
|
|
|
if (!userId || !password) {
|
|
return json({ error: "A user and a password are required." });
|
|
}
|
|
|
|
const existingUser = await dbe.user.findUnique({ where: { id: userId }, select: { id: true } });
|
|
if (!existingUser) {
|
|
return json({ error: "User not found." });
|
|
}
|
|
|
|
await dbe.user.update({
|
|
where: { id: userId },
|
|
data: { password: hashPassword(password) },
|
|
});
|
|
|
|
return json({ success: "Password set" });
|
|
}
|
|
|
|
return json({ error: "Unknown action." });
|
|
}
|
|
|
|
export default function AdminRoute() {
|
|
const loaderData = useLoaderData<typeof loader>();
|
|
const actionData = useActionData<typeof action>();
|
|
|
|
return (
|
|
<div>
|
|
<h1>Admin</h1>
|
|
|
|
<h2 className="mt-4">Dashboard</h2>
|
|
<Row className="g-3">
|
|
<Col md={4}>
|
|
<Card className="h-100">
|
|
<Card.Body>
|
|
<Card.Title>Containers without barcode</Card.Title>
|
|
<Card.Text className="display-6">{loaderData.containersWithoutBarcode}</Card.Text>
|
|
</Card.Body>
|
|
</Card>
|
|
</Col>
|
|
<Col md={4}>
|
|
<Card className="h-100">
|
|
<Card.Body>
|
|
<Card.Title>Checkouts in the last hour</Card.Title>
|
|
<Card.Text className="display-6">{loaderData.checkoutsLastHour}</Card.Text>
|
|
</Card.Body>
|
|
</Card>
|
|
</Col>
|
|
<Col md={4}>
|
|
<Card className="h-100">
|
|
<Card.Body>
|
|
<Card.Title>Last checked out</Card.Title>
|
|
<Card.Text>
|
|
{loaderData.latestCheckout?.container.drink.name ?? "No recent checkouts"}
|
|
</Card.Text>
|
|
{loaderData.latestCheckout ? (
|
|
<Form method="post">
|
|
<input type="hidden" name="intent" value="undo-last-checkout" />
|
|
<input type="hidden" name="checkoutId" value={loaderData.latestCheckout.id} />
|
|
<Button type="submit" variant="outline-danger">Undo</Button>
|
|
</Form>
|
|
) : null}
|
|
</Card.Body>
|
|
</Card>
|
|
</Col>
|
|
</Row>
|
|
|
|
<h2 className="mt-4">Quick actions</h2>
|
|
<ListGroup>
|
|
<ListGroup.Item><Link to="/admin/new">Add new items</Link></ListGroup.Item>
|
|
<ListGroup.Item><Link to="/admin/edit">Edit or remove items</Link></ListGroup.Item>
|
|
<ListGroup.Item><Link to="/admin/manage/inventory">Change inventory numbers</Link></ListGroup.Item>
|
|
<ListGroup.Item><Link to="/admin/manage/undo-checkout">Undo checkout</Link></ListGroup.Item>
|
|
</ListGroup>
|
|
|
|
<h2 className="mt-4">Reports</h2>
|
|
<ListGroup>
|
|
<ListGroup.Item><Link to="/admin/reports">Reports</Link></ListGroup.Item>
|
|
</ListGroup>
|
|
|
|
<h2 className="mt-4">User management</h2>
|
|
{actionData?.error ? <Alert variant="danger" className="mb-3">{actionData.error}</Alert> : null}
|
|
{actionData?.success ? <Alert variant="success" className="mb-3">{actionData.success}</Alert> : null}
|
|
<Row className="g-3 mb-4">
|
|
<Col lg={6}>
|
|
<Card>
|
|
<Card.Body>
|
|
<Card.Title>Add user</Card.Title>
|
|
<Form method="post">
|
|
<input type="hidden" name="intent" value="add-user" />
|
|
<BootstrapForm.Group className="mb-3">
|
|
<BootstrapForm.Label>Username</BootstrapForm.Label>
|
|
<BootstrapForm.Control name="username" required />
|
|
</BootstrapForm.Group>
|
|
<BootstrapForm.Group className="mb-3">
|
|
<BootstrapForm.Label>Password</BootstrapForm.Label>
|
|
<BootstrapForm.Control type="password" name="password" required />
|
|
</BootstrapForm.Group>
|
|
<BootstrapForm.Group className="mb-3">
|
|
<BootstrapForm.Label>Role</BootstrapForm.Label>
|
|
<BootstrapForm.Select name="type" defaultValue="User">
|
|
<option value="User">User</option>
|
|
<option value="Scanner">Scanner</option>
|
|
<option value="Admin">Admin</option>
|
|
</BootstrapForm.Select>
|
|
</BootstrapForm.Group>
|
|
<Button type="submit">Create user</Button>
|
|
</Form>
|
|
</Card.Body>
|
|
</Card>
|
|
</Col>
|
|
<Col lg={6}>
|
|
<Card>
|
|
<Card.Body>
|
|
<Card.Title>Set password</Card.Title>
|
|
<Form method="post">
|
|
<input type="hidden" name="intent" value="set-password" />
|
|
<BootstrapForm.Group className="mb-3">
|
|
<BootstrapForm.Label>User</BootstrapForm.Label>
|
|
<BootstrapForm.Select name="userId" defaultValue={loaderData.users[0]?.id ?? ""}>
|
|
{loaderData.users.map((user) => (
|
|
<option key={user.id} value={user.id}>{user.username} ({user.type})</option>
|
|
))}
|
|
</BootstrapForm.Select>
|
|
</BootstrapForm.Group>
|
|
<BootstrapForm.Group className="mb-3">
|
|
<BootstrapForm.Label>New password</BootstrapForm.Label>
|
|
<BootstrapForm.Control type="password" name="password" required />
|
|
</BootstrapForm.Group>
|
|
<Button type="submit" variant="outline-primary">Update password</Button>
|
|
</Form>
|
|
</Card.Body>
|
|
</Card>
|
|
</Col>
|
|
</Row>
|
|
|
|
<Card className="mb-4">
|
|
<Card.Body>
|
|
<Card.Title>Existing users</Card.Title>
|
|
<ListGroup variant="flush">
|
|
{loaderData.users.map((user) => (
|
|
<ListGroup.Item key={user.id} className="d-flex justify-content-between align-items-center">
|
|
<span>{user.username}</span>
|
|
<span className="text-muted">{user.type}</span>
|
|
</ListGroup.Item>
|
|
))}
|
|
</ListGroup>
|
|
</Card.Body>
|
|
</Card>
|
|
</div>
|
|
);
|
|
} |