Complete app

This commit is contained in:
Aaron Lee 2024-01-01 00:33:21 +08:00
parent 7b895a20c1
commit 7d0b32382b
18 changed files with 919 additions and 186 deletions

View file

@ -23,9 +23,11 @@
"@trpc/server": "^10.43.6", "@trpc/server": "^10.43.6",
"@types/bcrypt": "^5.0.2", "@types/bcrypt": "^5.0.2",
"@types/jsonwebtoken": "^9.0.5", "@types/jsonwebtoken": "^9.0.5",
"@types/papaparse": "^5.3.14",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"next": "^14.0.3", "next": "^14.0.3",
"papaparse": "^5.4.1",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-hook-form": "^7.49.2", "react-hook-form": "^7.49.2",

View file

@ -15,7 +15,7 @@ export default function DashboardHeader({ url }: { url: string }) {
}, [logout.isSuccess]) }, [logout.isSuccess])
useEffect(() => { useEffect(() => {
if (session.data?.rosterOnly && !url.startsWith("/dash/admin/roster")) if (session.data?.rosterOnly && !(url.startsWith("/dash/admin/roster") || url.startsWith("/dash/admin/attendance") || url.startsWith("/dash/chgPassword")))
push("/dash/admin/roster"); push("/dash/admin/roster");
}, [session.data]) }, [session.data])
@ -34,7 +34,7 @@ export default function DashboardHeader({ url }: { url: string }) {
</div> </div>
<div className="flex items-center w-full gap-3"> <div className="flex items-center w-full gap-3">
{ {
!session?.data?.rosterOnly && (<> !session?.data?.rosterOnly && (
<Link href="/dash"> <Link href="/dash">
<button className={[ <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", "text-sm md:text-xl px-3 py-2 rounded-lg rounded-b-none focus:ring focus:ring-sky-200 focus:ring-opacity-70",
@ -42,17 +42,16 @@ export default function DashboardHeader({ url }: { url: string }) {
].join(" ")}> ].join(" ")}>
Attendance Attendance
</button> </button>
</Link> </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>
</>)
} }
<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 && ( session?.data?.isAdmin && (
<> <>
@ -64,6 +63,14 @@ export default function DashboardHeader({ url }: { url: string }) {
Roster Roster
</button> </button>
</Link> </Link>
<Link href="/dash/admin/attendance">
<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/attendance" ? "bg-emerald-600 text-white" : "bg-emerald-200 text-black"
].join(" ")}>
Attendance
</button>
</Link>
{ {
!session?.data?.rosterOnly && (<> !session?.data?.rosterOnly && (<>
<Link href="/dash/admin"> <Link href="/dash/admin">

View file

@ -1,4 +1,5 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useState } from "react";
import { SubmitHandler, useForm } from "react-hook-form"; import { SubmitHandler, useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
@ -56,10 +57,10 @@ export default function Config() {
<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" placeholder="Name" {...register("name")} /> <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" placeholder="Name" {...register("name")} />
</td> </td>
<td> <td>
<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" placeholder="Start" {...register("startTime")} /> <input type="time" 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" placeholder="Start" {...register("startTime")} />
</td> </td>
<td> <td>
<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" placeholder="End" {...register("endTime")} /> <input type="time" 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" placeholder="End" {...register("endTime")} />
</td> </td>
<td className="text-center"> <td className="text-center">
<button className="p-1 px-2 bg-emerald-600 text-white rounded-lg" type="submit">Add</button> <button className="p-1 px-2 bg-emerald-600 text-white rounded-lg" type="submit">Add</button>

View file

@ -83,7 +83,7 @@ export default function Periods() {
<div className="flex gap-3"> <div className="flex gap-3">
<div className="mb-2"> <div className="mb-2">
<label className="block text-md font-medium text-gray-700 mt-2">Date</label> <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")} /> <input type="date" 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>} {errors.date && <span className="text-red-500">{errors.date.message}</span>}
</div> </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}> <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}>

View file

@ -1,5 +1,7 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import Link from "next/link"; import Link from "next/link";
import { parse } from "papaparse";
import { useState } from "react";
import { SubmitHandler, useForm } from "react-hook-form"; import { SubmitHandler, useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
@ -19,6 +21,9 @@ export default function Users() {
users.refetch(); users.refetch();
}).catch((err) => console.log(err)); }).catch((err) => console.log(err));
const [bulkErrors, setBulkErrors] = useState<string[]>([]);
const [bulkSuccesses, setBulkSuccesses] = useState<string[]>([]);
return (<div> return (<div>
<section> <section>
<div> <div>
@ -48,11 +53,15 @@ export default function Users() {
<td>{user.name}</td> <td>{user.name}</td>
<td>{user.dname}</td> <td>{user.dname}</td>
<td>{user.rfid}</td> <td>{user.rfid}</td>
<td className="text-sky-600 underline text-center hover:cursor-pointer" <td className={[
onClick={() => toggleAdmin.mutateAsync(user.username).then(() => users.refetch())} "text-sky-600 underline text-center hover:cursor-pointer",
user.isAdmin ? "bg-emerald-300" : ""
].join(" ")} onClick={() => toggleAdmin.mutateAsync(user.username).then(() => users.refetch())}
>{user.isAdmin ? "V" : "X"}</td> >{user.isAdmin ? "V" : "X"}</td>
<td className="text-sky-600 underline text-center hover:cursor-pointer" <td className={[
onClick={() => toggleRosterOnly.mutateAsync(user.username).then(() => users.refetch())} "text-sky-600 underline text-center hover:cursor-pointer",
user.rosterOnly ? "bg-emerald-300" : ""
].join(" ")} onClick={() => toggleRosterOnly.mutateAsync(user.username).then(() => users.refetch())}
>{user.rosterOnly ? "V" : "X"}</td> >{user.rosterOnly ? "V" : "X"}</td>
<td className="text-center"> <td className="text-center">
<button className="p-1 px-2 bg-yellow-600 text-white rounded-lg" onClick={(evt) => { <button className="p-1 px-2 bg-yellow-600 text-white rounded-lg" onClick={(evt) => {
@ -71,7 +80,25 @@ export default function Users() {
</section> </section>
<p className="my-2">For editing rows, please use backend SQL.</p> <p className="my-2">For editing rows, please use backend SQL.</p>
<p className="my-2">Roster Only users must also be Admin.</p> <p className="my-2">Roster Only users must also be Admin.</p>
<hr /> <hr className="my-5" />
<h2 className="text-xl mt-5 mb-2 font-bold">Bulk create new users ()</h2>
<input className="my-1" type="file" accept=".csv" onChange={(evt) => {
if (!(evt.target.files && evt.target.files.length == 1)) return;
setBulkErrors([]);
parse(evt.target.files[0]!, { header: true, complete: async (results) => {
results.data.forEach(async (row) => {
await addUser.mutateAsync(row as AddUserSchemaType)
.then((row) => setBulkSuccesses((prev) => [...prev, row.username]))
.catch((err) => setBulkErrors((prev) => [...prev, err.message]));
})
users.refetch();
} })
}} />
{addUser.isLoading && <p className="text-yellow-500">Adding user...</p>}
{bulkSuccesses.length > 0 && bulkSuccesses.map((username) => <p className="text-emerald-500">Success: {username}</p>)}
{bulkErrors.length > 0 && bulkErrors.map((err) => <p className="text-red-500">{err}</p>)}
<p className="my-1">CSV format: username,grade,class,number,name,dname</p>
<hr className="my-5" />
<h2 className="text-xl mt-5 mb-2 font-bold">Create new user</h2> <h2 className="text-xl mt-5 mb-2 font-bold">Create new user</h2>
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit)}>
<div className="mb-5"> <div className="mb-5">

View file

@ -0,0 +1,119 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useState } from "react";
import { SubmitHandler, useForm } from "react-hook-form";
import { z } from "zod";
import { api } from "~/utils/api";
import { AddAttendance } from "~/utils/types";
export default function ListView() {
const today = new Date();
const [startDate, setStartDate] = useState(`${today.getFullYear()}-${today.getMonth() + 1}-${today.getDate()}`);
const [endDate, setEndDate] = useState(`${today.getFullYear()}-${today.getMonth() + 1}-${today.getDate()}`);
const attendanceData = api.admin.getAttendanceFromRange.useQuery({
start: startDate,
end: endDate
});
const deleteAttendanceRecord = api.admin.deleteAttendanceRecord.useMutation();
const addAttendanceRecord = api.admin.addAttendanceRecord.useMutation();
type AddAttendanceType = z.infer<typeof AddAttendance>;
const { register, handleSubmit, formState: { errors } } = useForm<AddAttendanceType>({ resolver: zodResolver(AddAttendance) });
const onSubmit: SubmitHandler<AddAttendanceType> = async (data, event) => addAttendanceRecord.mutateAsync(data).then(() => {
event?.target.reset();
attendanceData.refetch();
}).catch((err) => console.log(err));
return <div className="block">
<div className="flex gap-5 items-center mb-5">
<label className="block max-w-72">
<span className="text-gray-700">Start Date</span>
<input type="date" className="block w-full mt-1 rounded-md border-gray-300 shadow-sm focus:border-emerald-300 focus:ring focus:ring-emerald-200 focus:ring-opacity-50"
onChange={(evt) => {
setStartDate(evt.target.value);
attendanceData.refetch().catch((err) => console.log(err));
}} value={startDate}>
</input>
</label>
<label className="block max-w-72">
<span className="text-gray-700">End Date</span>
<input type="date" className="block w-full mt-1 rounded-md border-gray-300 shadow-sm focus:border-emerald-300 focus:ring focus:ring-emerald-200 focus:ring-opacity-50"
onChange={(evt) => {
setEndDate(evt.target.value);
attendanceData.refetch().catch((err) => console.log(err));
}} value={endDate}>
</input>
</label>
<button className="p-2 px-3 h-fit bg-emerald-600 text-white rounded-lg"
onClick={() => {
setStartDate(`${today.getFullYear()}-${today.getMonth() + 1}-${today.getDate()}`);
setEndDate(`${today.getFullYear()}-${today.getMonth() + 1}-${today.getDate()}`);
}}>Today</button>
{
(attendanceData.isLoading) && <div role="status">
<svg aria-hidden="true" className="w-8 h-8 text-gray-200 animate-spin dark:text-gray-600 fill-emerald-600" viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z" fill="currentColor" />
<path d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z" fill="currentFill" />
</svg>
<span className="sr-only">Loading...</span>
</div>
}
</div>
<table className="table-auto border-collapse w-fit border-2 border-black my-5">
<thead>
<tr className="*:p-1 *:border border-b-2 border-b-black">
<th>Time</th>
<th>Username</th>
<th>Grade</th>
<th>Class</th>
<th>Number</th>
<th>Name</th>
<th>Operation</th>
</tr>
</thead>
<tbody>
{
attendanceData.data?.map((attendance) => {
return (<tr className="*:p-1 *:border" key={attendance.id}>
<td>{attendance.datetime.toLocaleString()}</td>
<td>{attendance.user.username}</td>
<td>{attendance.user.grade}</td>
<td>{attendance.user.class}</td>
<td>{attendance.user.number}</td>
<td>{attendance.user.name}</td>
<td>
<button className="p-1 px-2 bg-red-600 text-white rounded-lg" onClick={(evt) => {
evt.preventDefault();
deleteAttendanceRecord.mutateAsync(attendance.id).then(() => attendanceData.refetch());
}} type="button">Delete</button>
</td>
</tr>)
})
}
</tbody>
</table>
<h3 className="my-2 font-bold text-lg">Create new record</h3>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="flex gap-5 items-end mb-2">
<label className="block max-w-72">
<span className="text-gray-700">Username</span>
<input type="text" className="block w-full mt-1 rounded-md border-gray-300 shadow-sm focus:border-emerald-300 focus:ring focus:ring-emerald-200 focus:ring-opacity-50"
{...register("username")} />
{errors.username && <p className="text-red-500">{errors.username.message}</p>}
</label>
<label className="block max-w-72">
<span className="text-gray-700">Time</span>
<input type="datetime-local" className="block w-full mt-1 rounded-md border-gray-300 shadow-sm focus:border-emerald-300 focus:ring focus:ring-emerald-200 focus:ring-opacity-50"
{...register("datetime")} />
{errors.datetime && <p className="text-red-500">{errors.datetime.message}</p>}
</label>
<button className="p-2 px-3 h-fit bg-emerald-600 text-white rounded-lg" type="submit" disabled={addAttendanceRecord.isLoading}>Add</button>
</div>
{addAttendanceRecord.isLoading && <p className="text-emerald-600">Loading...</p>}
{addAttendanceRecord.isSuccess && <p className="text-emerald-600">Success!</p>}
{addAttendanceRecord.isError && <p className="text-red-500">{addAttendanceRecord.error?.message}</p>}
</form>
</div>
}

View file

@ -0,0 +1,39 @@
import { api } from "~/utils/api";
export default function UserList() {
const users = api.admin.getAllUsersAttendTime.useQuery();
return <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>Display Name</th>
<th>Selected Time</th>
<th>Actual Time</th>
<th>Projected Time</th>
</tr>
</thead>
<tbody>
{
users.data?.map((user) => (
<tr className={`*:p-1 *: ${(user.selectedTime + user.actualTime) >= 30 ? "*:bg-emerald-200" : "*:bg-red-100"}`} 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.dname}</td>
<td>{user.selectedTime}</td>
<td>{user.actualTime.toFixed(2)}</td>
<td >{(user.selectedTime + user.actualTime).toFixed(2)}</td>
</tr>
))
}
</tbody>
</table>
}

View file

@ -0,0 +1,244 @@
import { useState } from "react";
import { api } from "~/utils/api";
export default function UserView() {
const [username, setUsername] = useState("");
const periods = api.admin.getPeriods.useQuery();
const timePeriods = api.admin.getTimePeriods.useQuery();
const userSelectedPeriods = api.admin.getUserSelectedPeriods.useQuery(username);
const userAttendance = api.admin.getUserAttendance.useQuery(username);
const userData = api.admin.getUserData.useQuery(username);
const userToggleAttendance = api.admin.userToggleAttendance.useMutation();
const userAttendTime = api.admin.userAttendTime.useQuery(username);
const userSelectedAllAttendTime = api.admin.userSelectedAllAttendTime.useQuery(username);
const userActualAttendTime = api.admin.userActualAttendTime.useQuery(username);
let periodCnt = 0;
let attCnt = 0;
let entered = false;
let passedDate = false;
return <div className="block">
<div className="flex gap-5 items-center mb-2 flex-wrap">
<label className="block max-w-72">
<span className="text-gray-700">Username</span>
<input type="text" className="block w-full mt-1 rounded-md border-gray-300 shadow-sm focus:border-emerald-300 focus:ring focus:ring-emerald-200 focus:ring-opacity-50"
onChange={(evt) => {
setUsername(evt.target.value);
userSelectedPeriods.refetch().catch((err) => console.log(err));
userAttendance.refetch().catch((err) => console.log(err));
userAttendTime.refetch().catch((err) => console.log(err));
userActualAttendTime.refetch().catch((err) => console.log(err));
userSelectedAllAttendTime.refetch().catch((err) => console.log(err));
userData.refetch().catch((err) => console.log(err));
}} value={username}>
</input>
</label>
<label className="block max-w-32">
<span className="text-gray-700">Grade</span>
<input type="text" className="block w-full mt-1 rounded-md border-gray-300 shadow-sm focus:border-emerald-300 focus:ring focus:ring-emerald-200 focus:ring-opacity-50 bg-gray-200"
disabled value={userData.data?.grade || ""}>
</input>
</label>
<label className="block max-w-32">
<span className="text-gray-700">Class</span>
<input type="text" className="block w-full mt-1 rounded-md border-gray-300 shadow-sm focus:border-emerald-300 focus:ring focus:ring-emerald-200 focus:ring-opacity-50 bg-gray-200"
disabled value={userData.data?.class || ""}>
</input>
</label>
<label className="block max-w-32">
<span className="text-gray-700">Number</span>
<input type="text" className="block w-full mt-1 rounded-md border-gray-300 shadow-sm focus:border-emerald-300 focus:ring focus:ring-emerald-200 focus:ring-opacity-50 bg-gray-200"
disabled value={userData.data?.number || ""}>
</input>
</label>
<label className="block max-w-32">
<span className="text-gray-700">Name</span>
<input type="text" className="block w-full mt-1 rounded-md border-gray-300 shadow-sm focus:border-emerald-300 focus:ring focus:ring-emerald-200 focus:ring-opacity-50 bg-gray-200"
disabled value={userData.data?.name || ""}>
</input>
</label>
<label className="block max-w-32">
<span className="text-gray-700">Display Name</span>
<input type="text" className="block w-full mt-1 rounded-md border-gray-300 shadow-sm focus:border-emerald-300 focus:ring focus:ring-emerald-200 focus:ring-opacity-50 bg-gray-200"
disabled value={userData.data?.dname || ""}>
</input>
</label>
{
(userSelectedPeriods.isLoading || userData.isLoading) && <div role="status">
<svg aria-hidden="true" className="w-8 h-8 text-gray-200 animate-spin dark:text-gray-600 fill-emerald-600" viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z" fill="currentColor" />
<path d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z" fill="currentFill" />
</svg>
<span className="sr-only">Loading...</span>
</div>
}
</div>
{userData.data === null && <div className="text-red-600">User not found.</div>}
<hr className="my-5" />
{userData.data !== null && userData.data !== undefined && <>
<div className="flex gap-2 my-5">
<div className="border rounded-xl h-32 w-40 p-2 flex items-center justify-center flex-col bg-gray-300">
<div className="text-center text-2xl font-bold"></div>
<div className="flex-grow flex items-center">
<div className="text-center text-6xl font-bold">{userSelectedAllAttendTime.data}</div>
</div>
</div>
<div className="border rounded-xl h-32 w-40 p-2 flex items-center justify-center flex-col bg-gray-300">
<div className="text-center text-xl font-bold"></div>
<div className="flex-grow flex items-center">
<div className="text-center text-6xl font-bold">{userAttendTime.data}</div>
</div>
</div>
<div className="border rounded-xl h-32 w-40 p-2 flex items-center justify-center flex-col bg-gray-300">
<div className="text-center text-xl font-bold"></div>
<div className="flex-grow flex items-center">
<div className="text-center text-6xl font-bold">{userActualAttendTime.data?.toFixed(1)}</div>
</div>
</div>
<div className={[
"border rounded-xl h-32 w-40 p-2 flex items-center justify-center flex-col",
(userActualAttendTime.data || 0) + (userAttendTime.data || 0) >= 30 ? "bg-emerald-500" : "bg-red-300"
].join(" ")}>
<div className="text-center text-xl font-bold"></div>
<div className="flex-grow flex items-center">
<div className="text-center text-6xl font-bold">{((userActualAttendTime.data || 0) + (userAttendTime.data || 0)).toFixed(1)}</div>
</div>
</div>
</div>
<table className="table-auto border-collapse border-2 border-black w-fit my-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;
const borderTop = new Date(date).setHours(0, 0, 0, 0) > new Date().getTime() && !passedDate;
if (borderTop) passedDate = true;
return (
<tr className={[
"*:p-1 *:border",
borderTop && "border-t-2 border-t-black"
].join(" ")} key={date}>
<td>{date}</td>
{
timePeriods.data?.map((timePeriod) => {
const thisPeriodId = periods.data![date]![periodCnt]?.id!;
if (periods.data![date]![periodCnt]?.timePeriodId == timePeriod.id) {
periodCnt++;
if (userSelectedPeriods.data?.findIndex((period) => period == thisPeriodId) != -1) {
return <td key={timePeriod.id * periodCnt} className="bg-emerald-200 hover:cursor-pointer"
onClick={() => userToggleAttendance.mutateAsync({
username: username,
periodId: thisPeriodId || -1,
attendance: false
}).then(() => {
userSelectedPeriods.refetch();
userAttendTime.refetch();
userSelectedAllAttendTime.refetch();
}).catch((e) => alert(e.message))}>
Will Attend
</td>
} else {
return <td key={timePeriod.id * periodCnt} className="bg-sky-100 hover:cursor-pointer"
onClick={() => userToggleAttendance.mutateAsync({
username: username,
periodId: thisPeriodId || -1,
attendance: true
}).then(() => {
userSelectedPeriods.refetch();
userAttendTime.refetch();
userSelectedAllAttendTime.refetch();
}).catch((e) => alert(e.message))}></td>
}
} else {
return <td key={timePeriod.id * periodCnt} className="bg-gray-400">N/A</td>
}
})
}
</tr>
)
})
}
</tbody>
</table>
<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;
entered = false;
const borderTop = new Date(date).setHours(0, 0, 0, 0) > new Date().getTime() && !passedDate;
if (borderTop) passedDate = true;
return (
<tr className={[
"*:p-1 *:border",
borderTop && "border-t-2 border-t-black"
].join(" ")} key={date}>
<td>{date}</td>
{
timePeriods.data?.map((timePeriod) => {
periodCnt++;
let data = "";
const thisPeriodStart = new Date(date);
thisPeriodStart.setHours(parseInt(timePeriod.start.split(":")[0]!), parseInt(timePeriod.start.split(":")[1]!), 0, 0)
const thisPeriodEnd = new Date(date);
thisPeriodEnd.setHours(parseInt(timePeriod.end.split(":")[0]!), parseInt(timePeriod.end.split(":")[1]!), 0, 0)
for (let i = attCnt; i < (userAttendance.data || []).length; i++) {
const thisAtt = userAttendance.data![i];
if (thisAtt?.datetime! < thisPeriodStart) continue;
if (thisAtt?.datetime! > thisPeriodEnd) {
attCnt = i;
break;
}
if (!entered) {
data += `${data !== "" ? " / " : ""}${thisAtt?.datetime!.toLocaleTimeString()} ~ `;
} else {
data += `${thisAtt?.datetime!.toLocaleTimeString()}`;
}
entered = !entered;
}
if (entered && data === "" && periodCnt !== timePeriods.data!.length) {
return <td key={timePeriod.id * periodCnt} className="bg-green-700 text-white"></td>
}
if (entered && periodCnt === timePeriods.data!.length) {
return <td key={timePeriod.id * periodCnt} className="bg-yellow-700 text-white">{data}</td>
}
if (data === "") {
return <td key={timePeriod.id * periodCnt} className="bg-gray-500 text-white">Absent</td>
}
return <td key={timePeriod.id * periodCnt} className="bg-green-700 text-white">{data}</td>
})
}
</tr>
)
})
}
</tbody>
</table>
</>}
</div>
}

View file

@ -36,7 +36,7 @@ export default async function handler(
return; return;
} }
} else { } else {
res.status(200).json({ status: 'RFID_ATTENDANCE_NOT_ENABLED' }); res.status(202).json({ status: 'RFID_ATTENDANCE_NOT_ENABLED' });
} }
} }
else { else {

View 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 ListView from "~/components/admin/attendance/ListView";
import UserList from "~/components/admin/attendance/UserList";
import UserView from "~/components/admin/attendance/UserView";
import { api } from "~/utils/api";
export default function Dash() {
const { push } = useRouter();
const isLoggedIn = api.admin.isLoggedIn.useQuery();
const [currentPage, setCurrentPage] = useState("list");
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/attendance" />
<div className="p-5 flex flex-col md:flex-row gap-5">
<nav className="flex flex-grow h-fit md:flex-col w-fit md: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 === "list" ? "bg-emerald-600 border-none text-white" : "bg-white text-emerald-700"
].join(" ")} onClick={() => setCurrentPage("list")}>List View</button>
<button className={[
"p-1 px-2 text-lg font-bold border-r md:border-b transition-colors",
currentPage === "user" ? "bg-emerald-600 border-none text-white" : "bg-white text-emerald-700"
].join(" ")} onClick={() => setCurrentPage("user")}>User View</button>
<button className={[
"p-1 px-2 text-lg font-bold border-r md:border-b rounded-r-lg md:rounded-none md:rounded-b-lg transition-colors",
currentPage === "userlist" ? "bg-emerald-600 border-none text-white" : "bg-white text-emerald-700"
].join(" ")} onClick={() => setCurrentPage("userlist")}>Users List</button>
</nav>
{ currentPage === "list" && <ListView /> }
{ currentPage === "user" && <UserView /> }
{ currentPage === "userlist" && <UserList /> }
</div>
</main>
</>)
}

View file

@ -30,7 +30,7 @@ export default function Dash() {
<main className=""> <main className="">
<DashboardHeader url="/dash/admin" /> <DashboardHeader url="/dash/admin" />
<div className="p-5 flex flex-col md:flex-row gap-5"> <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"> <nav className="flex flex-grow h-fit md:flex-col w-fit md:max-w-48 border border-emerald-700 rounded-lg">
<button className={[ <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", "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" currentPage === "config" ? "bg-emerald-600 border-none text-white" : "bg-white text-emerald-700"

View file

@ -21,10 +21,9 @@ export default function Dash() {
useEffect(() => { useEffect(() => {
if (lastRfid.isSuccess) { if (lastRfid.isSuccess) {
const interval = setInterval(() => { setInterval(() => {
lastRfid.refetch(); lastRfid.refetch();
}, 2000); }, 2000);
return () => clearInterval(interval);
} }
}, []) }, [])

View file

@ -19,13 +19,6 @@ export default function Dash() {
let attCnt = 0; let attCnt = 0;
let entered = false; let entered = false;
const fuseDateAndTime = (date: string, time: Date) => {
const year = new Date(date).getFullYear();
const month = new Date(date).getMonth();
const day = new Date(date).getDate();
return new Date(year, month, day, time.getHours(), time.getMinutes(), time.getSeconds(), time.getMilliseconds());
}
useEffect(() => { useEffect(() => {
if (isLoggedIn.failureCount > 0) { if (isLoggedIn.failureCount > 0) {
push("/"); push("/");
@ -44,26 +37,29 @@ export default function Dash() {
<DashboardHeader url="/dash" /> <DashboardHeader url="/dash" />
<div className="p-5"> <div className="p-5">
<div className="flex gap-2"> <div className="flex gap-2">
<div className={[ <div className="border rounded-xl h-32 w-40 p-2 flex items-center justify-center flex-col mb-5 bg-gray-300">
"border rounded-xl h-32 w-40 p-2 flex items-center justify-center flex-col mb-5",
(attendTime.data || 0) >= 30 ? "bg-emerald-500" : "bg-red-300"
].join(" ")}>
<div className="text-center text-2xl font-bold"></div> <div className="text-center text-2xl font-bold"></div>
<div className="flex-grow flex items-center"> <div className="flex-grow flex items-center">
<div className="text-center text-6xl font-bold">{attendTime.data}</div> <div className="text-center text-6xl font-bold">{attendTime.data}</div>
</div> </div>
</div> </div>
<div className={[ <div className="border rounded-xl h-32 w-40 p-2 flex items-center justify-center flex-col mb-5 bg-gray-300">
"border rounded-xl h-32 w-40 p-2 flex items-center justify-center flex-col mb-5",
(actualAttendTime.data || 0) >= 30 ? "bg-emerald-500" : "bg-red-300"
].join(" ")}>
<div className="text-center text-xl font-bold"></div> <div className="text-center text-xl font-bold"></div>
<div className="flex-grow flex items-center"> <div className="flex-grow flex items-center">
<div className="text-center text-6xl font-bold">{actualAttendTime.data?.toPrecision(2)}</div> <div className="text-center text-6xl font-bold">{actualAttendTime.data?.toFixed(1)}</div>
</div>
</div>
<div className={[
"border rounded-xl h-32 w-40 p-2 flex items-center justify-center flex-col mb-5",
(actualAttendTime.data || 0) + (attendTime.data || 0) >= 30 ? "bg-emerald-500" : "bg-red-300"
].join(" ")}>
<div className="text-center text-xl font-bold"></div>
<div className="flex-grow flex items-center">
<div className="text-center text-6xl font-bold">{((actualAttendTime.data || 0) + (attendTime.data || 0)).toFixed(1)}</div>
</div> </div>
</div> </div>
</div> </div>
<div className="text-2xl font-bold mb-2"> ( &gt; 100)</div> <div className="text-2xl font-bold mb-2"> (&gt; 100 )</div>
<table className="table-auto border-collapse border-2 border-black w-fit mb-5"> <table className="table-auto border-collapse border-2 border-black w-fit mb-5">
<thead> <thead>
<tr className="*:p-1 *:border border-b-2 border-b-black"> <tr className="*:p-1 *:border border-b-2 border-b-black">
@ -84,8 +80,7 @@ export default function Dash() {
<tr className="*:p-1 *:border" key={date}> <tr className="*:p-1 *:border" key={date}>
<td>{date}</td> <td>{date}</td>
{ {
// TODO: fix this very ugly code new Date(date).setHours(0, 0, 0, 0) > new Date().getTime() ? (
new Date(new Date(date).setDate(new Date(date).getDate() - 1)).setHours(23, 59, 59, 999) > new Date().getTime() ? (
timePeriods.data?.map((timePeriod) => { timePeriods.data?.map((timePeriod) => {
const thisPeriodId = periods.data![date]![periodCnt]?.id!; const thisPeriodId = periods.data![date]![periodCnt]?.id!;
if (periods.data![date]![periodCnt]?.timePeriodId == timePeriod.id) { if (periods.data![date]![periodCnt]?.timePeriodId == timePeriod.id) {
@ -95,7 +90,7 @@ export default function Dash() {
onClick={() => toggleAttendance.mutateAsync({ onClick={() => toggleAttendance.mutateAsync({
periodId: thisPeriodId || -1, periodId: thisPeriodId || -1,
attendance: false attendance: false
}).then(() => mySelectedPeriods.refetch()).then(() => attendTime.refetch())}> }).then(() => mySelectedPeriods.refetch()).then(() => attendTime.refetch()).catch((e) => alert(e.message))}>
Will Attend Will Attend
</td> </td>
} else { } else {
@ -103,7 +98,7 @@ export default function Dash() {
onClick={() => toggleAttendance.mutateAsync({ onClick={() => toggleAttendance.mutateAsync({
periodId: thisPeriodId || -1, periodId: thisPeriodId || -1,
attendance: true attendance: true
}).then(() => mySelectedPeriods.refetch()).then(() => attendTime.refetch())}></td> }).then(() => mySelectedPeriods.refetch()).then(() => attendTime.refetch()).catch((e) => alert(e.message))}></td>
} }
} else { } else {
return <td key={timePeriod.id * periodCnt} className="bg-gray-400">N/A</td> return <td key={timePeriod.id * periodCnt} className="bg-gray-400">N/A</td>

View file

@ -2,11 +2,12 @@ import { compareSync, hashSync } from "bcrypt";
import jwt from "jsonwebtoken"; import jwt from "jsonwebtoken";
import { adminProcedure, createTRPCRouter, loggedInProcedure, publicProcedure } from "~/server/api/trpc"; import { adminProcedure, createTRPCRouter, loggedInProcedure, publicProcedure } from "~/server/api/trpc";
import { AddPeriods, AddTimePeriodSchema, AddUserSchema, ChgPasswordSchema, LoginSchema, PublicUserType } from "~/utils/types"; import { AddAttendance, AddPeriods, AddTimePeriodSchema, AddUserSchema, ChgPasswordSchema, DateRange, LoginSchema, PublicUserType } from "~/utils/types";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { z } from "zod"; import { z } from "zod";
import { Period } from "@prisma/client"; import { Period } from "@prisma/client";
import { lastGotRfid, rfidAttendance, setRfidAttendance } from "~/utils/rfid"; import { lastGotRfid, rfidAttendance, setRfidAttendance } from "~/utils/rfid";
import { actualAttendTime, attendTime as selectedAttendTime, toggleAttendance } from "./time-sel";
export const adminRouter = createTRPCRouter({ export const adminRouter = createTRPCRouter({
isLoggedIn: loggedInProcedure.query(() => true), isLoggedIn: loggedInProcedure.query(() => true),
@ -115,9 +116,11 @@ export const adminRouter = createTRPCRouter({
getAllUsers: adminProcedure getAllUsers: adminProcedure
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
return await ctx.db.user.findMany({ return await ctx.db.user.findMany({
orderBy: { orderBy: [
username: "asc", { grade: "asc" },
}, { class: "asc" },
{ number: "asc" }
],
select: { select: {
username: true, username: true,
name: true, name: true,
@ -224,7 +227,7 @@ export const adminRouter = createTRPCRouter({
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const period = await ctx.db.period.findFirst({ const period = await ctx.db.period.findFirst({
where: { where: {
date: new Date(input.date), date: new Date(input.date.replace(/-/g, "/")),
}, },
}); });
if (period !== null) { if (period !== null) {
@ -285,6 +288,7 @@ export const adminRouter = createTRPCRouter({
timePeriodId: z.number().int(), timePeriodId: z.number().int(),
})) }))
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
console.log(new Date(input.date))
return await ctx.db.period.deleteMany({ return await ctx.db.period.deleteMany({
where: { where: {
date: new Date(input.date), date: new Date(input.date),
@ -325,9 +329,32 @@ export const adminRouter = createTRPCRouter({
status: "ERR_BADKEY", status: "ERR_BADKEY",
} }
} }
let recDateTime = new Date();
// if time is smaller than the start of the earliest timePeriod, set to the start of the earliest timePeriod
const earliestTimePeriod = await ctx.db.timePeriod.findFirst({
orderBy: {
start: "asc",
},
});
const earliestStart = new Date();
earliestStart.setHours(parseInt(earliestTimePeriod?.start.split(":")[0]!), parseInt(earliestTimePeriod?.start.split(":")[1]!), 0, 0)
if (recDateTime < earliestStart) {
recDateTime.setHours(earliestStart.getHours(), earliestStart.getMinutes(), 0, 0);
}
// if time is larger than the end of the latest timePeriod, set to the end of the latest timePeriod
const latestTimePeriod = await ctx.db.timePeriod.findFirst({
orderBy: {
end: "desc",
},
});
const latestEnd = new Date();
latestEnd.setHours(parseInt(latestTimePeriod?.end.split(":")[0]!), parseInt(latestTimePeriod?.end.split(":")[1]!), 0, 0)
if (recDateTime > latestEnd) {
recDateTime.setHours(latestEnd.getHours(), latestEnd.getMinutes(), 0, 0);
}
return await ctx.db.attendance.create({ return await ctx.db.attendance.create({
data: { data: {
datetime: new Date(), datetime: recDateTime,
user: { user: {
connect: { connect: {
rfid: input.rfid, rfid: input.rfid,
@ -357,13 +384,14 @@ export const adminRouter = createTRPCRouter({
}, },
}) })
return { return {
status: records.length % 2 ? "WELCOME" : "GOODBYE", status: records.length % 2 ? "ENTER" : "EXIT",
name: records[records.length - 1]!.user.dname, name: records[records.length - 1]!.user.dname,
} }
}).catch((err) => { }).catch((err) => {
return { throw new TRPCError({
status: "ERR_INTERNAL", code: "INTERNAL_SERVER_ERROR",
} message: "There was an error recording your attendance.",
})
}) })
}), }),
toggleRfidAttendanceForDash: adminProcedure toggleRfidAttendanceForDash: adminProcedure
@ -417,4 +445,173 @@ export const adminRouter = createTRPCRouter({
] ]
}) })
}), }),
getAttendanceFromRange: adminProcedure
.input(DateRange)
.query(async ({ input, ctx }) => {
return await ctx.db.attendance.findMany({
where: {
datetime: {
gte: new Date((new Date(input.start)).setHours(0, 0, 0, 0)),
lt: new Date((new Date(input.end)).setHours(23, 59, 59, 999)),
},
},
select: {
id: true,
datetime: true,
user: true
},
orderBy: {
datetime: "asc",
},
})
}),
deleteAttendanceRecord: adminProcedure
.input(z.number().int())
.mutation(async ({ input, ctx }) => {
return await ctx.db.attendance.delete({
where: {
id: input,
},
})
}),
addAttendanceRecord: adminProcedure
.input(AddAttendance)
.mutation(async ({ input, ctx }) => {
return await ctx.db.attendance.create({
data: {
datetime: new Date(input.datetime),
user: {
connect: {
username: input.username,
},
},
},
})
}),
getAllUsersAttendTime: adminProcedure
.query(async ({ ctx }) => {
const users = await ctx.db.user.findMany({
orderBy: [
{ grade: "asc" },
{ class: "asc" },
{ number: "asc" }
],
select: {
username: true,
name: true,
dname: true,
grade: true,
class: true,
number: true,
}
})
return await Promise.all(users.map(async (user) => {
return {
...user,
selectedTime: await selectedAttendTime(ctx, user.username),
actualTime: await actualAttendTime(ctx, user.username),
}
}))
}),
getUserSelectedPeriods: adminProcedure
.input(z.string())
.query(async ({ input, ctx }) => {
return await ctx.db.user.findUnique({
where: { username: input },
select: {
periods: {
select: {
id: true,
}
}
}
}).then((user) => user?.periods.map(period => period.id));
}),
getUserData: adminProcedure
.input(z.string())
.query(async ({ input, ctx }) => {
return await ctx.db.user.findUnique({
where: {
username: input,
},
select: {
username: true,
name: true,
dname: true,
grade: true,
class: true,
number: true,
}
})
}),
getUserAttendance: adminProcedure
.input(z.string())
.query(async ({ input, ctx }) => {
return await ctx.db.attendance.findMany({
where: {
user: {
username: input,
}
},
select: {
datetime: true,
},
orderBy: {
datetime: "asc",
},
});
}),
userAttendTime: adminProcedure
.input(z.string())
.query(async ({ input, ctx }) => {
return await selectedAttendTime(ctx, input);
}),
userActualAttendTime: adminProcedure
.input(z.string())
.query(async ({ input, ctx }) => {
return await actualAttendTime(ctx, input);
}),
userToggleAttendance: adminProcedure
.input(z.object({
username: z.string(),
periodId: z.number().int(),
attendance: z.boolean(),
}))
.mutation(async ({ input, ctx }) => {
toggleAttendance(ctx, input, input.username, false);
}),
userSelectedAllAttendTime: adminProcedure
.input(z.string())
.query(async ({ input, ctx }) => {
return await ctx.db.user.findUnique({
where: { username: input },
select: {
periods: {
select: {
timePeriod: {
select: {
start: true,
end: true,
}
}
}
}
}
}).then((user) => {
let total = 0;
if (user === null) return total;
user?.periods.forEach(period => {
const start = new Date();
const end = new Date();
const [startHour, startMinute] = period.timePeriod.start.split(":");
const [endHour, endMinute] = period.timePeriod.end.split(":");
start.setHours(parseInt(startHour!));
start.setMinutes(parseInt(startMinute!));
end.setHours(parseInt(endHour!));
end.setMinutes(parseInt(endMinute!));
total += (end.getTime() - start.getTime()) / 1000 / 60 / 60; // convert to hours
})
return total;
});
}),
}); });

View file

@ -1,7 +1,158 @@
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { z } from "zod"; import { z } from "zod";
import { createTRPCRouter, loggedInProcedure, publicProcedure } from "~/server/api/trpc"; import { Context, createTRPCRouter, loggedInProcedure, publicProcedure } from "~/server/api/trpc";
export const attendTime = async (ctx: Context, username: string) => {
const user = await ctx.db.user.findUnique({
where: { username: username },
select: {
periods: {
select: {
timePeriod: {
select: {
start: true,
end: true,
}
}
},
where: {
date: {
gte: new Date(new Date().setHours(23, 59, 59, 999)),
}
}
}
}
});
if (!user) throw new TRPCError({ code: "UNAUTHORIZED", message: "User not found" });
let total = 0;
user.periods.forEach(period => {
const start = new Date();
const end = new Date();
const [startHour, startMinute] = period.timePeriod.start.split(":");
const [endHour, endMinute] = period.timePeriod.end.split(":");
start.setHours(parseInt(startHour!));
start.setMinutes(parseInt(startMinute!));
end.setHours(parseInt(endHour!));
end.setMinutes(parseInt(endMinute!));
total += (end.getTime() - start.getTime()) / 1000 / 60 / 60; // convert to hours
})
return total;
}
export const actualAttendTime = async (ctx: Context, username: string) => {
const attendance = await ctx.db.attendance.findMany({
where: {
user: {
username: username,
}
},
select: {
datetime: true,
},
orderBy: {
datetime: "asc",
},
});
const dates = await ctx.db.period.findMany({
select: {
date: true,
},
orderBy: {
date: "asc",
}
}).then((periods) => {
const dates: string[] = [];
periods.forEach(period => {
const dateStr = period.date.toLocaleDateString();
if (!dates.includes(dateStr)) dates.push(dateStr);
})
return dates;
})
const timePeriods = await ctx.db.timePeriod.findMany({
select: {
start: true,
end: true,
},
orderBy: [
{ start: "asc" },
{ end: "asc" },
]
});
let attCnt = 0;
let calculator = 0.0;
dates.forEach((date) => {
let periodCnt = 0;
let entered = false;
timePeriods.forEach((timePeriod) => {
periodCnt++;
const thisPeriodStart = new Date(date);
thisPeriodStart.setHours(parseInt(timePeriod.start.split(":")[0]!), parseInt(timePeriod.start.split(":")[1]!), 0, 0)
const thisPeriodEnd = new Date(date);
thisPeriodEnd.setHours(parseInt(timePeriod.end.split(":")[0]!), parseInt(timePeriod.end.split(":")[1]!), 0, 0)
for (let i = attCnt; i < attendance.length; i++) {
const thisAtt = attendance[i]!;
if (thisAtt.datetime < thisPeriodStart) continue;
if (thisAtt.datetime > thisPeriodEnd) {
attCnt = i;
break;
}
if (!entered) {
calculator -= thisAtt.datetime.getTime();
} else {
calculator += thisAtt.datetime.getTime();
}
entered = !entered;
}
if (entered && periodCnt === timePeriods.length) {
calculator += thisPeriodEnd.getTime();
}
})
})
return calculator / 1000 / 60 / 60;
}
export const toggleAttendance = async (ctx: Context, input: { periodId: number, attendance: boolean }, username: string, checkWeekInAdvance: boolean = true) => {
try {
const period = await ctx.db.period.findUnique({ where: { id: input.periodId } });
if (!period) throw new TRPCError({ code: "NOT_FOUND", message: "Period not found" });
if (checkWeekInAdvance) {
const weekInAdvance = new Date();
weekInAdvance.setDate(weekInAdvance.getDate() + 7);
if (period.date < weekInAdvance) throw new TRPCError({ code: "BAD_REQUEST", message: "Period is not a week in advance. Please contact HR." });
}
if (input.attendance) {
await ctx.db.period.update({
where: { id: input.periodId },
data: {
users: {
connect: {
username: username,
}
}
}
});
} else {
await ctx.db.period.update({
where: { id: input.periodId },
data: {
users: {
disconnect: {
username: username,
}
}
}
});
}
} catch (e) {
if (e instanceof TRPCError) throw e;
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "An error occurred." });
}
return true;
}
export const timeSelRouter = createTRPCRouter({ export const timeSelRouter = createTRPCRouter({
getMySelectedPeriods: loggedInProcedure getMySelectedPeriods: loggedInProcedure
@ -25,67 +176,11 @@ export const timeSelRouter = createTRPCRouter({
attendance: z.boolean(), attendance: z.boolean(),
})) }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
try { return await toggleAttendance(ctx, input, ctx.session?.username!);
if (input.attendance) {
await ctx.db.period.update({
where: { id: input.periodId },
data: {
users: {
connect: {
username: ctx.session?.username,
}
}
}
});
} else {
await ctx.db.period.update({
where: { id: input.periodId },
data: {
users: {
disconnect: {
username: ctx.session?.username,
}
}
}
});
}
} catch (e) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "An error occurred." });
}
return true;
}), }),
attendTime: loggedInProcedure attendTime: loggedInProcedure
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
const user = await ctx.db.user.findUnique({ return await attendTime(ctx, ctx.session?.username!);
where: { username: ctx.session?.username },
select: {
periods: {
select: {
timePeriod: {
select: {
start: true,
end: true,
}
}
}
}
}
});
if (!user) throw new TRPCError({ code: "UNAUTHORIZED", message: "User not found" });
let total = 0;
user.periods.forEach(period => {
const start = new Date();
const end = new Date();
const [startHour, startMinute] = period.timePeriod.start.split(":");
const [endHour, endMinute] = period.timePeriod.end.split(":");
start.setHours(parseInt(startHour!));
start.setMinutes(parseInt(startMinute!));
end.setHours(parseInt(endHour!));
end.setMinutes(parseInt(endMinute!));
total += (end.getTime() - start.getTime()) / 1000 / 60 / 60; // convert to hours
})
return total;
}), }),
getMyAttendance: loggedInProcedure getMyAttendance: loggedInProcedure
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
@ -105,75 +200,6 @@ export const timeSelRouter = createTRPCRouter({
}), }),
actualAttendTime: loggedInProcedure actualAttendTime: loggedInProcedure
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
const attendance = await ctx.db.attendance.findMany({ return await actualAttendTime(ctx, ctx.session?.username!);
where: {
user: {
username: ctx.session?.username,
}
},
select: {
datetime: true,
},
orderBy: {
datetime: "asc",
},
});
const dates = await ctx.db.period.findMany({
select: {
date: true,
},
orderBy: {
date: "asc",
}
}).then((periods) => {
const dates: string[] = [];
periods.forEach(period => {
const dateStr = period.date.toLocaleDateString();
if (!dates.includes(dateStr)) dates.push(dateStr);
})
return dates;
})
const timePeriods = await ctx.db.timePeriod.findMany({
select: {
start: true,
end: true,
},
orderBy: [
{ start: "asc" },
{ end: "asc" },
]
});
let attCnt = 0;
let calculator = 0.0;
dates.forEach((date) => {
let periodCnt = 0;
let entered = false;
timePeriods.forEach((timePeriod) => {
periodCnt++;
const thisPeriodStart = new Date(date);
thisPeriodStart.setHours(parseInt(timePeriod.start.split(":")[0]!), parseInt(timePeriod.start.split(":")[1]!), 0, 0)
const thisPeriodEnd = new Date(date);
thisPeriodEnd.setHours(parseInt(timePeriod.end.split(":")[0]!), parseInt(timePeriod.end.split(":")[1]!), 0, 0)
for (let i = attCnt; i < attendance.length; i++) {
const thisAtt = attendance[i]!;
if (thisAtt.datetime < thisPeriodStart) continue;
if (thisAtt.datetime > thisPeriodEnd) {
attCnt = i;
break;
}
if (!entered) {
calculator -= thisAtt.datetime.getTime();
} else {
calculator += thisAtt.datetime.getTime();
}
entered = !entered;
}
if (entered && periodCnt === timePeriods.length) {
calculator += thisPeriodEnd.getTime();
}
})
})
return calculator / 1000 / 60 / 60;
}), }),
}); });

View file

@ -60,6 +60,8 @@ export const createTRPCContext = (_opts: CreateNextContextOptions) => {
}; };
}; };
export type Context = ReturnType<typeof createTRPCContext>;
/** /**
* 2. INITIALIZATION * 2. INITIALIZATION
* *

View file

@ -36,5 +36,15 @@ export const AddUserSchema = z.object({
}) })
export const AddPeriods = z.object({ 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."}) 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."})
})
export const DateRange = z.object({
start: 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: "Start date must be in the format YYYY/MM/DD."}),
end: 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: "End date must be in the format YYYY/MM/DD."}),
})
export const AddAttendance = z.object({
datetime: 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}T(0?[0-9]|1[0-9]|2[0-3]):[0-5][0-9]$/, { message: "Datetime must be in the format YYYY/MM/DDThh:mm."}),
username: z.string().min(1, { message: "Username cannot be empty." }),
}) })

View file

@ -348,6 +348,13 @@
dependencies: dependencies:
undici-types "~5.26.4" undici-types "~5.26.4"
"@types/papaparse@^5.3.14":
version "5.3.14"
resolved "https://registry.yarnpkg.com/@types/papaparse/-/papaparse-5.3.14.tgz#345cc2a675a90106ff1dc33b95500dfb30748031"
integrity sha512-LxJ4iEFcpqc6METwp9f6BV6VVc43m6MfH0VqFosHvrUgfXiFe6ww7R3itkOQ+TCK6Y+Iv/+RnnvtRZnkc5Kc9g==
dependencies:
"@types/node" "*"
"@types/prop-types@*": "@types/prop-types@*":
version "15.7.11" version "15.7.11"
resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.11.tgz#2596fb352ee96a1379c657734d4b913a613ad563" resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.11.tgz#2596fb352ee96a1379c657734d4b913a613ad563"
@ -1624,6 +1631,11 @@ p-locate@^5.0.0:
dependencies: dependencies:
p-limit "^3.0.2" p-limit "^3.0.2"
papaparse@^5.4.1:
version "5.4.1"
resolved "https://registry.yarnpkg.com/papaparse/-/papaparse-5.4.1.tgz#f45c0f871853578bd3a30f92d96fdcfb6ebea127"
integrity sha512-HipMsgJkZu8br23pW15uvo6sib6wne/4woLZPlFf3rpDyMe9ywEXUsuD7+6K9PRkJlVT51j/sCOYDKGGS3ZJrw==
parent-module@^1.0.0: parent-module@^1.0.0:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2" resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2"