mirror of
https://github.com/aaronleetw/savage-tracking.git
synced 2024-11-14 19:11:39 -08:00
completed admin & rfid pages
This commit is contained in:
parent
af65bb7191
commit
54cdb7e83f
16 changed files with 882 additions and 63 deletions
|
@ -13,3 +13,4 @@
|
|||
# https://www.prisma.io/docs/reference/database-reference/connection-urls#env
|
||||
DATABASE_URL="file:./db.sqlite"
|
||||
JWT_SECRET="well, now the secret is spoiled, isn't it?"
|
||||
TZ="Asia/Taipei"
|
|
@ -17,7 +17,9 @@ model User {
|
|||
name String
|
||||
username String @unique
|
||||
password String
|
||||
rfid String? @unique
|
||||
isAdmin Boolean @default(false)
|
||||
rosterOnly Boolean @default(false)
|
||||
|
||||
periods Period[]
|
||||
|
||||
|
@ -31,6 +33,10 @@ model User {
|
|||
model TimePeriod {
|
||||
id Int @id
|
||||
name String
|
||||
|
||||
start String
|
||||
end String
|
||||
|
||||
periods Period[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
|
|
@ -14,6 +14,11 @@ export default function DashboardHeader({ url }: { url: string }) {
|
|||
}
|
||||
}, [logout.isSuccess])
|
||||
|
||||
useEffect(() => {
|
||||
if (session.data?.rosterOnly && !url.startsWith("/dash/admin/roster"))
|
||||
push("/dash/admin/roster");
|
||||
}, [session.data])
|
||||
|
||||
return (
|
||||
<div className="text-3xl md:text-5xl font-bold bg-indigo-100">
|
||||
<div className="p-5 pb-0">
|
||||
|
@ -28,49 +33,57 @@ export default function DashboardHeader({ url }: { url: string }) {
|
|||
</button>
|
||||
</div>
|
||||
<div className="flex items-center w-full gap-3">
|
||||
<Link href="/dash">
|
||||
<button className={[
|
||||
"text-xl px-3 py-2 rounded-lg rounded-b-none focus:ring focus:ring-sky-200 focus:ring-opacity-70",
|
||||
url == "/dash" ? "bg-sky-600 text-white" : "bg-sky-300 text-black"
|
||||
].join(" ")}>
|
||||
Time Selection
|
||||
</button>
|
||||
</Link>
|
||||
<Link href="/dash/chgPassword">
|
||||
<button className={[
|
||||
"text-xl px-3 py-2 rounded-lg rounded-b-none focus:ring focus:ring-sky-200 focus:ring-opacity-70",
|
||||
url == "/dash/chgPassword" ? "bg-sky-600 text-white" : "bg-sky-300 text-black"
|
||||
].join(" ")}>
|
||||
Change Password
|
||||
</button>
|
||||
</Link>
|
||||
{
|
||||
!session?.data?.rosterOnly && (<>
|
||||
<Link href="/dash">
|
||||
<button className={[
|
||||
"text-sm md:text-xl px-3 py-2 rounded-lg rounded-b-none focus:ring focus:ring-sky-200 focus:ring-opacity-70",
|
||||
url == "/dash" ? "bg-sky-600 text-white" : "bg-sky-300 text-black"
|
||||
].join(" ")}>
|
||||
Time Selection
|
||||
</button>
|
||||
</Link>
|
||||
<Link href="/dash/chgPassword">
|
||||
<button className={[
|
||||
"text-sm md:text-xl px-3 py-2 rounded-lg rounded-b-none focus:ring focus:ring-sky-200 focus:ring-opacity-70",
|
||||
url == "/dash/chgPassword" ? "bg-sky-600 text-white" : "bg-sky-300 text-black"
|
||||
].join(" ")}>
|
||||
Change Password
|
||||
</button>
|
||||
</Link>
|
||||
</>)
|
||||
}
|
||||
{
|
||||
session?.data?.isAdmin && (
|
||||
<>
|
||||
<Link href="/dash/admin">
|
||||
<button className={[
|
||||
"text-xl px-3 py-2 rounded-lg rounded-b-none focus:ring focus:ring-emerald-200 focus:ring-opacity-70",
|
||||
url == "/dash/admin" ? "bg-emerald-600 text-white" : "bg-emerald-200 text-black"
|
||||
].join(" ")}>
|
||||
Admin
|
||||
</button>
|
||||
</Link>
|
||||
<Link href="/dash/admin/roster">
|
||||
<button className={[
|
||||
"text-xl px-3 py-2 rounded-lg rounded-b-none focus:ring focus:ring-emerald-200 focus:ring-opacity-70",
|
||||
"text-sm md:text-xl px-3 py-2 rounded-lg rounded-b-none focus:ring focus:ring-emerald-200 focus:ring-opacity-70",
|
||||
url == "/dash/admin/roster" ? "bg-emerald-600 text-white" : "bg-emerald-200 text-black"
|
||||
].join(" ")}>
|
||||
Print Roster
|
||||
</button>
|
||||
</Link>
|
||||
<Link href="/dash/admin/rfid">
|
||||
<button className={[
|
||||
"text-xl px-3 py-2 rounded-lg rounded-b-none focus:ring focus:ring-emerald-200 focus:ring-opacity-70",
|
||||
url == "/dash/admin/rfid" ? "bg-emerald-600 text-white" : "bg-emerald-200 text-black"
|
||||
].join(" ")}>
|
||||
RFID Log
|
||||
View Roster
|
||||
</button>
|
||||
</Link>
|
||||
{
|
||||
!session?.data?.rosterOnly && (<>
|
||||
<Link href="/dash/admin">
|
||||
<button className={[
|
||||
"text-sm md:text-xl px-3 py-2 rounded-lg rounded-b-none focus:ring focus:ring-emerald-200 focus:ring-opacity-70",
|
||||
url == "/dash/admin" ? "bg-emerald-600 text-white" : "bg-emerald-200 text-black"
|
||||
].join(" ")}>
|
||||
Admin
|
||||
</button>
|
||||
</Link>
|
||||
<Link href="/dash/admin/rfid">
|
||||
<button className={[
|
||||
"text-sm md:text-xl px-3 py-2 rounded-lg rounded-b-none focus:ring focus:ring-emerald-200 focus:ring-opacity-70",
|
||||
url == "/dash/admin/rfid" ? "bg-emerald-600 text-white" : "bg-emerald-200 text-black"
|
||||
].join(" ")}>
|
||||
RFID Setup
|
||||
</button>
|
||||
</Link>
|
||||
</>)
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
79
src/components/admin/Config.tsx
Normal file
79
src/components/admin/Config.tsx
Normal file
|
@ -0,0 +1,79 @@
|
|||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { SubmitHandler, useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { api } from "~/utils/api";
|
||||
import { AddTimePeriodSchema } from "~/utils/types";
|
||||
|
||||
export default function Config() {
|
||||
const timePeriods = api.admin.getTimePeriods.useQuery();
|
||||
const addTimePeriod = api.admin.addTimePeriod.useMutation();
|
||||
const deleteTimePeriod = api.admin.deleteTimePeriod.useMutation();
|
||||
|
||||
type AddTimePeriodSchemaType = z.infer<typeof AddTimePeriodSchema>;
|
||||
const { register, handleSubmit, formState: { errors } } = useForm<AddTimePeriodSchemaType>({ resolver: zodResolver(AddTimePeriodSchema) });
|
||||
const onSubmit: SubmitHandler<AddTimePeriodSchemaType> = async (data, event) => addTimePeriod.mutateAsync(data).then(() => {
|
||||
event?.target.reset();
|
||||
timePeriods.refetch();
|
||||
}).catch((err) => console.log(err));
|
||||
|
||||
return (<div>
|
||||
<section>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div>
|
||||
<p className="text-xl font-bold mb-1">Current time periods:</p>
|
||||
<table className="table-auto border-collapse border w-fit">
|
||||
<thead>
|
||||
<tr className="*:p-1 *:border">
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Start</th>
|
||||
<th>End</th>
|
||||
<th>Operation</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{
|
||||
timePeriods.data?.map((period) => (
|
||||
<tr className="*:p-1 *:border" key={period.id}>
|
||||
<td>{period.id}</td>
|
||||
<td>{period.name}</td>
|
||||
<td>{period.start}</td>
|
||||
<td>{period.end}</td>
|
||||
<td className="text-center">
|
||||
<button className="p-1 px-2 bg-red-600 text-white rounded-lg" onClick={(evt) => {
|
||||
evt.preventDefault();
|
||||
deleteTimePeriod.mutateAsync(period.id).then(() => timePeriods.refetch());
|
||||
}} type="button">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
}
|
||||
<tr className="*:p-1 *:border">
|
||||
<td>
|
||||
<input type="number" className="mt-1 block w-16 rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-70" placeholder="ID" {...register("id")} />
|
||||
</td>
|
||||
<td>
|
||||
<input type="text" className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-70" placeholder="Name" {...register("name")} />
|
||||
</td>
|
||||
<td>
|
||||
<input type="text" className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-70" placeholder="Start" {...register("startTime")} />
|
||||
</td>
|
||||
<td>
|
||||
<input type="text" className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-70" placeholder="End" {...register("endTime")} />
|
||||
</td>
|
||||
<td className="text-center">
|
||||
<button className="p-1 px-2 bg-emerald-600 text-white rounded-lg" type="submit">Add</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{errors.id && <p className="text-red-500">{errors.id.message}</p>}
|
||||
{errors.startTime && <p className="text-red-500">{errors.startTime.message}</p>}
|
||||
{errors.endTime && <p className="text-red-500">{errors.endTime.message}</p>}
|
||||
{errors.name && <p className="text-red-500">{errors.name.message}</p>}
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{addTimePeriod.isError && <p className="text-red-500">{addTimePeriod.error.message}</p>}
|
||||
</div>)
|
||||
}
|
93
src/components/admin/Periods.tsx
Normal file
93
src/components/admin/Periods.tsx
Normal file
|
@ -0,0 +1,93 @@
|
|||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useEffect, useState } from "react";
|
||||
import { SubmitHandler, useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { api } from "~/utils/api";
|
||||
import { AddPeriods, AddTimePeriodSchema } from "~/utils/types";
|
||||
|
||||
export default function Periods() {
|
||||
const timePeriods = api.admin.getTimePeriods.useQuery();
|
||||
const periods = api.admin.getPeriods.useQuery();
|
||||
const addPeriods = api.admin.addPeriods.useMutation();
|
||||
const enablePeriod = api.admin.enablePeriod.useMutation();
|
||||
const disablePeriod = api.admin.disablePeriod.useMutation();
|
||||
type AddPeriodsType = z.infer<typeof AddPeriods>;
|
||||
const { register, handleSubmit, formState: { errors } } = useForm<AddPeriodsType>({ resolver: zodResolver(AddPeriods) });
|
||||
const onSubmit: SubmitHandler<AddPeriodsType> = async (data, event) => addPeriods.mutateAsync(data).then(() => {
|
||||
event?.target.reset();
|
||||
periods.refetch();
|
||||
}).catch((err) => console.log(err));
|
||||
|
||||
let periodCnt = 0;
|
||||
const [periodsDataId, setPeriodsDataId] = useState<number[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const periodsDataId: number[] = [];
|
||||
timePeriods.data?.forEach((timePeriod) => {
|
||||
periodsDataId.push(timePeriod.id);
|
||||
})
|
||||
setPeriodsDataId(periodsDataId);
|
||||
}, [timePeriods.data])
|
||||
|
||||
return (<div>
|
||||
<table className="table-auto border-collapse border-2 border-black w-fit mb-5">
|
||||
<thead>
|
||||
<tr className="*:p-1 *:border border-b-2 border-b-black">
|
||||
<th>Date</th>
|
||||
{
|
||||
timePeriods.data?.map((timePeriods) => {
|
||||
return (<th key={timePeriods.id}>{timePeriods.name} ({timePeriods.start} ~ {timePeriods.end})</th>)
|
||||
})
|
||||
}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{
|
||||
Object.keys(periods.data || {}).map((date) => {
|
||||
periodCnt = 0;
|
||||
return (
|
||||
<tr className="*:p-1 *:border" key={date}>
|
||||
<td>{date}</td>
|
||||
{
|
||||
timePeriods.data?.map((timePeriod) => {
|
||||
const thisPeriodCnt = periodCnt;
|
||||
if (periods.data![date]![periodCnt]?.timePeriodId == timePeriod.id) {
|
||||
periodCnt++;
|
||||
return (<td key={timePeriod.id * periodCnt} className="bg-emerald-600 text-white hover:cursor-pointer"
|
||||
onClick={() => disablePeriod.mutateAsync(periods.data![date]![thisPeriodCnt]!.id!).then(() => periods.refetch())}>
|
||||
Disable
|
||||
</td>)
|
||||
} else {
|
||||
return (<td key={timePeriod.id * periodCnt} className="bg-yellow-600 text-white hover:cursor-pointer" onClick={() => enablePeriod.mutateAsync({
|
||||
date: date,
|
||||
timePeriodId: periodsDataId[thisPeriodCnt]!
|
||||
}).then(() => periods.refetch())}>
|
||||
Enable
|
||||
</td>)
|
||||
}
|
||||
})
|
||||
}
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
<hr />
|
||||
<h2 className="text-xl font-bold mt-5">Add Date</h2>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="flex gap-3">
|
||||
<div className="mb-2">
|
||||
<label className="block text-md font-medium text-gray-700 mt-2">Date</label>
|
||||
<input type="text" className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-emerald-300 focus:ring focus:ring-emerald-200 focus:ring-opacity-70" {...register("date")} />
|
||||
{errors.date && <span className="text-red-500">{errors.date.message}</span>}
|
||||
</div>
|
||||
<button className="block w-fit h-fit mt-9 bg-emerald-600 px-3 py-2 rounded text-white focus:ring focus:ring-emerald-200 focus:ring-opacity-70 disabled:bg-emerald-400 mb-5" disabled={addPeriods.isLoading}>
|
||||
Create Periods
|
||||
</button>
|
||||
</div>
|
||||
{addPeriods.error && <p className="text-red-500 mb-2">{addPeriods.error.message}</p>}
|
||||
{addPeriods.isSuccess && <p className="text-green-500 mb-2">Add periods success!</p>}
|
||||
</form>
|
||||
</div>)
|
||||
}
|
107
src/components/admin/Users.tsx
Normal file
107
src/components/admin/Users.tsx
Normal file
|
@ -0,0 +1,107 @@
|
|||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import Link from "next/link";
|
||||
import { SubmitHandler, useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { api } from "~/utils/api";
|
||||
import { AddTimePeriodSchema, AddUserSchema } from "~/utils/types";
|
||||
|
||||
export default function Users() {
|
||||
const users = api.admin.getAllUsers.useQuery();
|
||||
const addUser = api.admin.addUser.useMutation();
|
||||
const resetPassword = api.admin.resetPassword.useMutation();
|
||||
const toggleAdmin = api.admin.toggleUserIsAdmin.useMutation();
|
||||
const toggleRosterOnly = api.admin.toggleUserRosterOnly.useMutation();
|
||||
|
||||
type AddUserSchemaType = z.infer<typeof AddUserSchema>;
|
||||
const { register, handleSubmit, formState: { errors } } = useForm<AddUserSchemaType>({ resolver: zodResolver(AddUserSchema) });
|
||||
const onSubmit: SubmitHandler<AddUserSchemaType> = async (data, event) => addUser.mutateAsync(data).then(() => {
|
||||
event?.target.reset();
|
||||
users.refetch();
|
||||
}).catch((err) => console.log(err));
|
||||
|
||||
return (<div>
|
||||
<section>
|
||||
<div>
|
||||
<table className="table-auto border-collapse border-2 border-black w-fit">
|
||||
<thead>
|
||||
<tr className="*:p-1 *:border border-b-2 border-b-black">
|
||||
<th>Username</th>
|
||||
<th>Grade</th>
|
||||
<th>Class</th>
|
||||
<th>Number</th>
|
||||
<th>Name</th>
|
||||
<th>RFID</th>
|
||||
<th>Admin?</th>
|
||||
<th>Roster Only?</th>
|
||||
<th>Operation</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{
|
||||
users.data?.map((user) => (
|
||||
<tr className="*:p-1 *:border" key={user.username}>
|
||||
<td>{user.username}</td>
|
||||
<td>{user.grade}</td>
|
||||
<td>{user.class}</td>
|
||||
<td>{user.number}</td>
|
||||
<td>{user.name}</td>
|
||||
<td>{user.rfid}</td>
|
||||
<td className="text-sky-600 underline text-center hover:cursor-pointer"
|
||||
onClick={() => toggleAdmin.mutateAsync(user.username).then(() => users.refetch())}
|
||||
>{user.isAdmin ? "V" : "X"}</td>
|
||||
<td className="text-sky-600 underline text-center hover:cursor-pointer"
|
||||
onClick={() => toggleRosterOnly.mutateAsync(user.username).then(() => users.refetch())}
|
||||
>{user.rosterOnly ? "V" : "X"}</td>
|
||||
<td className="text-center">
|
||||
<button className="p-1 px-2 bg-yellow-600 text-white rounded-lg" onClick={(evt) => {
|
||||
evt.preventDefault();
|
||||
resetPassword.mutateAsync(user.username)
|
||||
.then(() => alert(`Password reset for ${user.username} to password.`))
|
||||
.catch((err) => alert(err))
|
||||
}} type="button">Reset Password</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<p className="my-2">For editing rows, please use backend SQL.</p>
|
||||
<p className="my-2">Roster Only users must also be Admin.</p>
|
||||
<hr />
|
||||
<h2 className="text-xl mt-5 mb-2 font-bold">Create new user</h2>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="mb-5">
|
||||
<label className="block text-md font-medium text-gray-700 mt-2">Username</label>
|
||||
<input type="text" className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-emerald-300 focus:ring focus:ring-emerald-200 focus:ring-opacity-70" {...register("username")} />
|
||||
{errors.username && <span className="text-red-500">{errors.username.message}</span>}
|
||||
</div>
|
||||
<div className="mb-5">
|
||||
<label className="block text-md font-medium text-gray-700 mt-2">Grade</label>
|
||||
<input type="text" className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-emerald-300 focus:ring focus:ring-emerald-200 focus:ring-opacity-70" {...register("grade")} />
|
||||
{errors.grade && <span className="text-red-500">{errors.grade.message}</span>}
|
||||
</div>
|
||||
<div className="mb-5">
|
||||
<label className="block text-md font-medium text-gray-700 mt-2">Class</label>
|
||||
<input type="text" className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-emerald-300 focus:ring focus:ring-emerald-200 focus:ring-opacity-70" {...register("class")} />
|
||||
{errors.class && <span className="text-red-500">{errors.class.message}</span>}
|
||||
</div>
|
||||
<div className="mb-5">
|
||||
<label className="block text-md font-medium text-gray-700 mt-2">Number</label>
|
||||
<input type="text" className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-emerald-300 focus:ring focus:ring-emerald-200 focus:ring-opacity-70" {...register("number")} />
|
||||
{errors.number && <span className="text-red-500">{errors.number.message}</span>}
|
||||
</div>
|
||||
<div className="mb-5">
|
||||
<label className="block text-md font-medium text-gray-700 mt-2">Name</label>
|
||||
<input type="text" className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-emerald-300 focus:ring focus:ring-emerald-200 focus:ring-opacity-70" {...register("name")} />
|
||||
{errors.name && <span className="text-red-500">{errors.name.message}</span>}
|
||||
</div>
|
||||
<button className="bg-emerald-600 px-3 py-2 rounded text-white focus:ring focus:ring-emerald-200 focus:ring-opacity-70 disabled:bg-emerald-400 mb-5" disabled={addUser.isLoading}>
|
||||
Add User
|
||||
</button>
|
||||
<p>Default password is <code className="bg-gray-600 text-white p-1 rounded">password</code>.</p>
|
||||
</form>
|
||||
{addUser.isError && <p className="text-red-500">{addUser.error.message}</p>}
|
||||
</div>)
|
||||
}
|
26
src/pages/api/rfid.ts
Normal file
26
src/pages/api/rfid.ts
Normal file
|
@ -0,0 +1,26 @@
|
|||
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { z } from 'zod'
|
||||
import { setLastRfid } from '~/utils/rfid'
|
||||
|
||||
type ResponseData = {
|
||||
message: string
|
||||
}
|
||||
|
||||
export default function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<ResponseData>
|
||||
) {
|
||||
if ("uid" in req.body) {
|
||||
try {
|
||||
z.string().regex(/^[0-9a-f]{8}$/i).parse(req.body.uid)
|
||||
} catch (e) {
|
||||
res.status(200).json({ message: 'Invalid UID!' })
|
||||
return
|
||||
}
|
||||
setLastRfid(req.body.uid)
|
||||
res.status(200).json({ message: 'Received!' })
|
||||
}
|
||||
else {
|
||||
res.status(200).json({ message: 'No UID!' })
|
||||
}
|
||||
}
|
53
src/pages/dash/admin/index.tsx
Normal file
53
src/pages/dash/admin/index.tsx
Normal file
|
@ -0,0 +1,53 @@
|
|||
import Head from "next/head";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import DashboardHeader from "~/components/DashboardHeader";
|
||||
import Config from "~/components/admin/Config";
|
||||
import Periods from "~/components/admin/Periods";
|
||||
import Users from "~/components/admin/Users";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
export default function Dash() {
|
||||
const { push } = useRouter();
|
||||
const isLoggedIn = api.admin.isLoggedIn.useQuery();
|
||||
|
||||
const [currentPage, setCurrentPage] = useState("config");
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoggedIn.failureCount > 0) {
|
||||
push("/");
|
||||
}
|
||||
}, [isLoggedIn.failureCount])
|
||||
|
||||
if (isLoggedIn.isLoading) return <></>
|
||||
|
||||
return (<>
|
||||
<Head>
|
||||
<title>Savage Tracking</title>
|
||||
<meta name="description" content="Time tracking app for FRC build season personnel management." />
|
||||
<link rel="icon" href="/favicon.png" />
|
||||
</Head>
|
||||
<main className="">
|
||||
<DashboardHeader url="/dash/admin" />
|
||||
<div className="p-5 flex flex-col md:flex-row gap-5">
|
||||
<nav className="flex flex-grow h-fit md:flex-col w-auto md:max-w-48 border border-emerald-700 rounded-lg">
|
||||
<button className={[
|
||||
"p-1 px-2 text-lg font-bold border-r md:border-b rounded-l-lg md:rounded-none md:rounded-t-lg transition-colors",
|
||||
currentPage === "config" ? "bg-emerald-600 border-none text-white" : "bg-white text-emerald-700"
|
||||
].join(" ")} onClick={() => setCurrentPage("config")}>Configuration</button>
|
||||
<button className={[
|
||||
"p-1 px-2 text-lg font-bold border-r md:border-b transition-colors",
|
||||
currentPage === "periods" ? "bg-emerald-600 border-none text-white" : "bg-white text-emerald-700"
|
||||
].join(" ")} onClick={() => setCurrentPage("periods")}>All Periods</button>
|
||||
<button className={[
|
||||
"p-1 px-2 text-lg font-bold rounded-r-lg md:rounded-none md:rounded-b-lg transition-colors",
|
||||
currentPage === "users" ? "bg-emerald-600 border-none text-white" : "bg-white text-emerald-700"
|
||||
].join(" ")} onClick={() => setCurrentPage("users")}>Users</button>
|
||||
</nav>
|
||||
{ currentPage === "config" && <Config /> }
|
||||
{ currentPage === "users" && <Users /> }
|
||||
{ currentPage === "periods" && <Periods /> }
|
||||
</div>
|
||||
</main>
|
||||
</>)
|
||||
}
|
79
src/pages/dash/admin/rfid.tsx
Normal file
79
src/pages/dash/admin/rfid.tsx
Normal file
|
@ -0,0 +1,79 @@
|
|||
import Head from "next/head";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import DashboardHeader from "~/components/DashboardHeader";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
export default function Dash() {
|
||||
const { push } = useRouter();
|
||||
const isLoggedIn = api.admin.isLoggedIn.useQuery();
|
||||
const users = api.admin.getAllUsers.useQuery();
|
||||
const lastRfid = api.admin.getLastRfid.useQuery();
|
||||
const setUserRfid = api.admin.setUserRfid.useMutation();
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoggedIn.failureCount > 0) {
|
||||
push("/");
|
||||
}
|
||||
}, [isLoggedIn.failureCount])
|
||||
|
||||
useEffect(() => {
|
||||
if (lastRfid.isSuccess) {
|
||||
const interval = setInterval(() => {
|
||||
lastRfid.refetch();
|
||||
}, 2000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (isLoggedIn.isLoading) return <></>
|
||||
|
||||
return (<>
|
||||
<Head>
|
||||
<title>Savage Tracking</title>
|
||||
<meta name="description" content="Time tracking app for FRC build season personnel management." />
|
||||
<link rel="icon" href="/favicon.png" />
|
||||
</Head>
|
||||
<main className="">
|
||||
<DashboardHeader url="/dash/admin/rfid" />
|
||||
<div className="m-5">
|
||||
<table className="table-auto border-collapse border-2 border-black w-fit">
|
||||
<thead>
|
||||
<tr className="*:p-1 *:border border-b-2 border-b-black">
|
||||
<th>Username</th>
|
||||
<th>Grade</th>
|
||||
<th>Class</th>
|
||||
<th>Number</th>
|
||||
<th>Name</th>
|
||||
<th>RFID</th>
|
||||
<th>Operation</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{
|
||||
users.data?.map((user) => (
|
||||
<tr className="*:p-1 *:border" key={user.username}>
|
||||
<td>{user.username}</td>
|
||||
<td>{user.grade}</td>
|
||||
<td>{user.class}</td>
|
||||
<td>{user.number}</td>
|
||||
<td>{user.name}</td>
|
||||
<td>{user.rfid}</td>
|
||||
<td className="text-emerald-600 underline text-center hover:cursor-pointer" onClick={() => {
|
||||
setUserRfid.mutateAsync({
|
||||
username: user.username,
|
||||
rfid: lastRfid.data || ""
|
||||
}).then(() => users.refetch())
|
||||
}}>
|
||||
Select
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
<h2 className="my-5 text-2xl">Last RFID: <code className="rounded bg-slate-700 text-white p-1 ml-1">{lastRfid.data}</code></h2>
|
||||
</div>
|
||||
</main>
|
||||
</>)
|
||||
}
|
30
src/pages/dash/admin/roster.tsx
Normal file
30
src/pages/dash/admin/roster.tsx
Normal file
|
@ -0,0 +1,30 @@
|
|||
import Head from "next/head";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import DashboardHeader from "~/components/DashboardHeader";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
export default function Dash() {
|
||||
const { push } = useRouter();
|
||||
const isLoggedIn = api.admin.isLoggedIn.useQuery();
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoggedIn.failureCount > 0) {
|
||||
push("/");
|
||||
}
|
||||
}, [isLoggedIn.failureCount])
|
||||
|
||||
if (isLoggedIn.isLoading) return <></>
|
||||
|
||||
return (<>
|
||||
<Head>
|
||||
<title>Savage Tracking</title>
|
||||
<meta name="description" content="Time tracking app for FRC build season personnel management." />
|
||||
<link rel="icon" href="/favicon.png" />
|
||||
</Head>
|
||||
<main className="">
|
||||
<DashboardHeader url="/dash/admin/roster" />
|
||||
</main>
|
||||
</>)
|
||||
}
|
63
src/pages/dash/chgPassword.tsx
Normal file
63
src/pages/dash/chgPassword.tsx
Normal file
|
@ -0,0 +1,63 @@
|
|||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import Head from "next/head";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { SubmitHandler, useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import DashboardHeader from "~/components/DashboardHeader";
|
||||
import { api } from "~/utils/api";
|
||||
import { ChgPasswordSchema } from "~/utils/types";
|
||||
|
||||
export default function Dash() {
|
||||
const { push } = useRouter();
|
||||
const isLoggedIn = api.admin.isLoggedIn.useQuery();
|
||||
const chgPassword = api.admin.chgPassword.useMutation();
|
||||
const logout = api.admin.logout.useMutation();
|
||||
type ChgPasswordSchemaType = z.infer<typeof ChgPasswordSchema>;
|
||||
const { register, handleSubmit, formState: { errors } } = useForm<ChgPasswordSchemaType>({ resolver: zodResolver(ChgPasswordSchema) });
|
||||
const onSubmit: SubmitHandler<ChgPasswordSchemaType> = async (data) => chgPassword.mutateAsync(data).catch((err) => console.log(err));
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoggedIn.failureCount > 1) {
|
||||
push("/");
|
||||
}
|
||||
}, [isLoggedIn.failureCount])
|
||||
|
||||
if (isLoggedIn.isLoading) return <></>
|
||||
|
||||
useEffect(() => {
|
||||
if (chgPassword.isSuccess) {
|
||||
logout.mutateAsync().then(() => push("/"));
|
||||
}
|
||||
}, [chgPassword.isSuccess])
|
||||
|
||||
return (<>
|
||||
<Head>
|
||||
<title>Savage Tracking</title>
|
||||
<meta name="description" content="Time tracking app for FRC build season personnel management." />
|
||||
<link rel="icon" href="/favicon.png" />
|
||||
</Head>
|
||||
<main className="">
|
||||
<DashboardHeader url="/dash/chgPassword" />
|
||||
<div className="max-w-[40rem] p-5">
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="mb-5">
|
||||
<label className="block text-md font-medium text-gray-700 mt-2">Old Password</label>
|
||||
<input type="password" className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-sky-300 focus:ring focus:ring-sky-200 focus:ring-opacity-70" {...register("oldPassword")} />
|
||||
{errors.oldPassword && <span className="text-red-500">{errors.oldPassword.message}</span>}
|
||||
</div>
|
||||
<div className="mb-7">
|
||||
<label className="block text-md font-medium text-gray-700 mt-2">New Password</label>
|
||||
<input type="password" className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-sky-300 focus:ring focus:ring-sky-200 focus:ring-opacity-70" {...register("newPassword")} />
|
||||
{errors.newPassword && <span>{errors.newPassword.message}</span>}
|
||||
</div>
|
||||
{chgPassword.isError && <div className="text-red-500 mb-5">{chgPassword.error.message}</div>}
|
||||
{chgPassword.isSuccess && <div className="text-green-500 mb-5">Success! Please sign in again...</div>}
|
||||
<button className="bg-sky-600 px-3 py-2 rounded text-white focus:ring focus:ring-sky-200 focus:ring-opacity-70 disabled:bg-sky-400 mb-5" disabled={chgPassword.isLoading}>
|
||||
Login
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
</>)
|
||||
}
|
|
@ -13,13 +13,12 @@ export default function Home() {
|
|||
const { push } = useRouter();
|
||||
type LoginSchemaType = z.infer<typeof LoginSchema>;
|
||||
const { register, handleSubmit, formState: { errors } } = useForm<LoginSchemaType>({ resolver: zodResolver(LoginSchema) });
|
||||
const onSubmit: SubmitHandler<LoginSchemaType> = async (data) =>
|
||||
login.mutateAsync(data).catch((err) => console.log(err))
|
||||
const onSubmit: SubmitHandler<LoginSchemaType> = async (data) => login.mutateAsync(data).catch((err) => console.log(err));
|
||||
useEffect(() => {
|
||||
if (login.isSuccess) {
|
||||
push("/dash")
|
||||
}
|
||||
}, [login.isSuccess])
|
||||
}, [login.isSuccess]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
|
@ -1,38 +1,40 @@
|
|||
import { compare, compareSync } from "bcrypt";
|
||||
import { z } from "zod";
|
||||
import { compareSync, hashSync } from "bcrypt";
|
||||
import jwt from "jsonwebtoken";
|
||||
|
||||
import { createTRPCRouter, loggedInProcedure, publicProcedure } from "~/server/api/trpc";
|
||||
import { LoginSchema, PublicUserType } from "~/utils/types";
|
||||
import { adminProcedure, createTRPCRouter, loggedInProcedure, publicProcedure } from "~/server/api/trpc";
|
||||
import { AddPeriods, AddTimePeriodSchema, AddUserSchema, ChgPasswordSchema, LoginSchema, PublicUserType } from "~/utils/types";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
import { Period } from "@prisma/client";
|
||||
import { lastGotRfid } from "~/utils/rfid";
|
||||
|
||||
export const adminRouter = createTRPCRouter({
|
||||
isLoggedIn: loggedInProcedure.query(() => true),
|
||||
login: publicProcedure
|
||||
.input(LoginSchema)
|
||||
.mutation(async ({ input, ctx }) => await ctx.db.user.findUnique({
|
||||
where: {
|
||||
username: input.username,
|
||||
}
|
||||
}).then((user) => {
|
||||
const result = compareSync(input.password, user?.password || "");
|
||||
if (result) {
|
||||
const session = PublicUserType.parse(user)!;
|
||||
const token = jwt.sign(session, process.env.JWT_SECRET || "", { expiresIn: "1d" });
|
||||
ctx.res.setHeader("Set-Cookie", `token=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${60 * 60 * 24};`);
|
||||
return {
|
||||
status: "success",
|
||||
message: "Login successful.",
|
||||
};
|
||||
} else {
|
||||
throw new Error("Please check your username and password.");
|
||||
}
|
||||
}).catch((err) => {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Please check your username and password.",
|
||||
})
|
||||
where: {
|
||||
username: input.username,
|
||||
}
|
||||
}).then((user) => {
|
||||
const result = compareSync(input.password, user?.password || "");
|
||||
if (result) {
|
||||
const session = PublicUserType.parse(user)!;
|
||||
const token = jwt.sign(session, process.env.JWT_SECRET || "", { expiresIn: "1d" });
|
||||
ctx.res.setHeader("Set-Cookie", `token=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${60 * 60 * 24};`);
|
||||
return {
|
||||
status: "success",
|
||||
message: "Login successful.",
|
||||
};
|
||||
} else {
|
||||
throw new Error("Please check your username and password.");
|
||||
}
|
||||
}).catch((err) => {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Please check your username and password.",
|
||||
})
|
||||
})
|
||||
),
|
||||
logout: loggedInProcedure
|
||||
.mutation(async ({ ctx }) => {
|
||||
|
@ -46,4 +48,240 @@ export const adminRouter = createTRPCRouter({
|
|||
.query(async ({ ctx }) => {
|
||||
return ctx.session;
|
||||
}),
|
||||
chgPassword: loggedInProcedure
|
||||
.input(ChgPasswordSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await ctx.db.user.findUnique({
|
||||
where: {
|
||||
username: ctx.session?.username,
|
||||
}
|
||||
}).then(async (user) => {
|
||||
const result = compareSync(input.oldPassword, user?.password || "");
|
||||
if (result) {
|
||||
await ctx.db.user.update({
|
||||
where: {
|
||||
username: ctx.session?.username,
|
||||
},
|
||||
data: {
|
||||
password: hashSync(input.newPassword, 10),
|
||||
},
|
||||
});
|
||||
return {
|
||||
status: "success",
|
||||
message: "Password changed successfully.",
|
||||
};
|
||||
} else {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Please check your old password.",
|
||||
})
|
||||
}
|
||||
}).catch((err) => {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Please check your old password.",
|
||||
})
|
||||
})
|
||||
}),
|
||||
getTimePeriods: loggedInProcedure
|
||||
.query(async ({ ctx }) => {
|
||||
return await ctx.db.timePeriod.findMany({
|
||||
orderBy: {
|
||||
id: "asc",
|
||||
},
|
||||
});
|
||||
}),
|
||||
addTimePeriod: adminProcedure
|
||||
.input(AddTimePeriodSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
return await ctx.db.timePeriod.create({
|
||||
data: {
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
start: input.startTime,
|
||||
end: input.endTime,
|
||||
},
|
||||
});
|
||||
}),
|
||||
deleteTimePeriod: adminProcedure
|
||||
.input(z.number().int())
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
return await ctx.db.timePeriod.delete({
|
||||
where: {
|
||||
id: input,
|
||||
},
|
||||
});
|
||||
}),
|
||||
getAllUsers: adminProcedure
|
||||
.query(async ({ ctx }) => {
|
||||
return await ctx.db.user.findMany({
|
||||
orderBy: {
|
||||
username: "asc",
|
||||
},
|
||||
select: {
|
||||
username: true,
|
||||
name: true,
|
||||
grade: true,
|
||||
class: true,
|
||||
number: true,
|
||||
isAdmin: true,
|
||||
rosterOnly: true,
|
||||
rfid: true,
|
||||
}
|
||||
});
|
||||
}),
|
||||
resetPassword: adminProcedure
|
||||
.input(z.string())
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
return await ctx.db.user.update({
|
||||
where: {
|
||||
username: input,
|
||||
},
|
||||
data: {
|
||||
password: hashSync("password", 10),
|
||||
},
|
||||
});
|
||||
}),
|
||||
toggleUserIsAdmin: adminProcedure
|
||||
.input(z.string())
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
return await ctx.db.user.update({
|
||||
where: {
|
||||
username: input,
|
||||
},
|
||||
data: {
|
||||
isAdmin: await ctx.db.user.findUnique({
|
||||
where: {
|
||||
username: input,
|
||||
},
|
||||
}).then((user) => {
|
||||
return !user?.isAdmin;
|
||||
}),
|
||||
},
|
||||
});
|
||||
}),
|
||||
toggleUserRosterOnly: adminProcedure
|
||||
.input(z.string())
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
return await ctx.db.user.update({
|
||||
where: {
|
||||
username: input,
|
||||
},
|
||||
data: {
|
||||
rosterOnly: await ctx.db.user.findUnique({
|
||||
where: {
|
||||
username: input,
|
||||
},
|
||||
}).then((user) => {
|
||||
return !user?.rosterOnly;
|
||||
}),
|
||||
},
|
||||
});
|
||||
}),
|
||||
addUser: adminProcedure
|
||||
.input(AddUserSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
return await ctx.db.user.create({
|
||||
data: {
|
||||
username: input.username,
|
||||
name: input.name,
|
||||
grade: input.grade,
|
||||
class: input.class,
|
||||
number: input.number,
|
||||
password: hashSync("password", 10),
|
||||
},
|
||||
});
|
||||
}),
|
||||
getPeriods: loggedInProcedure
|
||||
.query(async ({ ctx }) => {
|
||||
return await ctx.db.period.findMany({
|
||||
orderBy: [{
|
||||
date: "asc",
|
||||
}, {
|
||||
timePeriodId: "asc",
|
||||
}],
|
||||
}).then((periods) => {
|
||||
const groupedPeriods: { [key: string]: Period[] } = {};
|
||||
periods.forEach((period) => {
|
||||
const utcDate = period.date.toLocaleDateString();
|
||||
if (groupedPeriods[utcDate] === undefined) {
|
||||
groupedPeriods[utcDate] = [];
|
||||
}
|
||||
groupedPeriods[utcDate]?.push(period);
|
||||
});
|
||||
return groupedPeriods;
|
||||
}).catch((err) => {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "There was an error getting the periods.",
|
||||
})
|
||||
})
|
||||
}),
|
||||
addPeriods: adminProcedure
|
||||
.input(AddPeriods)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await ctx.db.timePeriod.findMany().then(
|
||||
async (timePeriods) => {
|
||||
timePeriods.forEach(async (timePeriod) => {
|
||||
await ctx.db.period.create({
|
||||
data: {
|
||||
date: new Date(input.date),
|
||||
timePeriodId: timePeriod.id,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
).then(() => {
|
||||
return {
|
||||
status: "success",
|
||||
message: "Periods added successfully.",
|
||||
};
|
||||
}).catch((err) => {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "There was an error adding the periods.",
|
||||
})
|
||||
})
|
||||
}),
|
||||
enablePeriod: adminProcedure
|
||||
.input(z.object({
|
||||
date: z.string(),
|
||||
timePeriodId: z.number().int(),
|
||||
}))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
return await ctx.db.period.create({
|
||||
data: {
|
||||
date: new Date(input.date),
|
||||
timePeriodId: input.timePeriodId,
|
||||
},
|
||||
})
|
||||
}),
|
||||
disablePeriod: adminProcedure
|
||||
.input(z.number().int())
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
return await ctx.db.period.delete({
|
||||
where: {
|
||||
id: input,
|
||||
},
|
||||
})
|
||||
}),
|
||||
getLastRfid: adminProcedure
|
||||
.query(() => {
|
||||
return lastGotRfid;
|
||||
}),
|
||||
setUserRfid: adminProcedure
|
||||
.input(z.object({
|
||||
username: z.string(),
|
||||
rfid: z.string(),
|
||||
}))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
return await ctx.db.user.update({
|
||||
where: {
|
||||
username: input.username,
|
||||
},
|
||||
data: {
|
||||
rfid: input.rfid,
|
||||
},
|
||||
})
|
||||
}),
|
||||
});
|
||||
|
|
3
src/utils/config.ts
Normal file
3
src/utils/config.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export const config = {
|
||||
latestSignup: "2024-01-01T00:00:00.000Z",
|
||||
}
|
4
src/utils/rfid.ts
Normal file
4
src/utils/rfid.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
export let lastGotRfid = "";
|
||||
export const setLastRfid = (uid: string) => {
|
||||
lastGotRfid = uid;
|
||||
};
|
|
@ -6,9 +6,34 @@ export const PublicUserType = z.object({
|
|||
name: z.string(),
|
||||
username: z.string(),
|
||||
isAdmin: z.boolean(),
|
||||
rosterOnly: z.boolean().default(false),
|
||||
}).or(z.undefined());
|
||||
|
||||
export const LoginSchema = z.object({
|
||||
username: z.string().min(1, { message: "Username cannot be empty." }),
|
||||
password: z.string(),
|
||||
})
|
||||
|
||||
export const ChgPasswordSchema = z.object({
|
||||
oldPassword: z.string().min(1, { message: "Old password cannot be empty." }),
|
||||
newPassword: z.string().min(8, { message: "New password must be at least 8 characters long." }),
|
||||
})
|
||||
|
||||
export const AddTimePeriodSchema = z.object({
|
||||
id: z.coerce.number().int({ message: "ID must be an integer." }).min(1, { message: "ID must be greater than 0." }).max(999, { message: "ID must be less than 1000." }),
|
||||
name: z.string().min(1, { message: "Name cannot be empty." }),
|
||||
startTime: z.string().regex(/^(0[0-9]|1[0-9]|2[0-3]):[0-5][0-9]$/, { message: "Start time must be in the format HH:MM."}),
|
||||
endTime: z.string().regex(/^(0[0-9]|1[0-9]|2[0-3]):[0-5][0-9]$/, { message: "End time must be in the format HH:MM."}),
|
||||
})
|
||||
|
||||
export const AddUserSchema = z.object({
|
||||
username: z.string().min(1, { message: "Username cannot be empty." }),
|
||||
name: z.string().min(1, { message: "Name cannot be empty." }),
|
||||
grade: z.coerce.number().int().nullable(),
|
||||
class: z.string().nullable(),
|
||||
number: z.coerce.number().int().nullable(),
|
||||
})
|
||||
|
||||
export const AddPeriods = z.object({
|
||||
date: z.string().regex(/^20[2-3][0-9]\/(0?[1-9]|1[0-2]){1}\/(0?[1-9]|1[0-9]|2[0-9]|3[0-1]){1}$/, { message: "Periods must be in the format YYYY/MM/DD."})
|
||||
})
|
Loading…
Reference in a new issue