mirror of
https://github.com/aaronleetw/savage-tracking.git
synced 2024-11-14 19:11:39 -08:00
Complete app
This commit is contained in:
parent
7b895a20c1
commit
7d0b32382b
18 changed files with 919 additions and 186 deletions
|
@ -23,9 +23,11 @@
|
|||
"@trpc/server": "^10.43.6",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/jsonwebtoken": "^9.0.5",
|
||||
"@types/papaparse": "^5.3.14",
|
||||
"bcrypt": "^5.1.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"next": "^14.0.3",
|
||||
"papaparse": "^5.4.1",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-hook-form": "^7.49.2",
|
||||
|
|
|
@ -15,7 +15,7 @@ export default function DashboardHeader({ url }: { url: string }) {
|
|||
}, [logout.isSuccess])
|
||||
|
||||
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");
|
||||
}, [session.data])
|
||||
|
||||
|
@ -34,7 +34,7 @@ export default function DashboardHeader({ url }: { url: string }) {
|
|||
</div>
|
||||
<div className="flex items-center w-full gap-3">
|
||||
{
|
||||
!session?.data?.rosterOnly && (<>
|
||||
!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",
|
||||
|
@ -42,7 +42,8 @@ export default function DashboardHeader({ url }: { url: string }) {
|
|||
].join(" ")}>
|
||||
Attendance
|
||||
</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",
|
||||
|
@ -51,8 +52,6 @@ export default function DashboardHeader({ url }: { url: string }) {
|
|||
Change Password
|
||||
</button>
|
||||
</Link>
|
||||
</>)
|
||||
}
|
||||
{
|
||||
session?.data?.isAdmin && (
|
||||
<>
|
||||
|
@ -64,6 +63,14 @@ export default function DashboardHeader({ url }: { url: string }) {
|
|||
Roster
|
||||
</button>
|
||||
</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 && (<>
|
||||
<Link href="/dash/admin">
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
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";
|
||||
|
@ -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")} />
|
||||
</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>
|
||||
<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 className="text-center">
|
||||
<button className="p-1 px-2 bg-emerald-600 text-white rounded-lg" type="submit">Add</button>
|
||||
|
|
|
@ -83,7 +83,7 @@ export default function Periods() {
|
|||
<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")} />
|
||||
<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>}
|
||||
</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}>
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import Link from "next/link";
|
||||
import { parse } from "papaparse";
|
||||
import { useState } from "react";
|
||||
import { SubmitHandler, useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { api } from "~/utils/api";
|
||||
|
@ -19,6 +21,9 @@ export default function Users() {
|
|||
users.refetch();
|
||||
}).catch((err) => console.log(err));
|
||||
|
||||
const [bulkErrors, setBulkErrors] = useState<string[]>([]);
|
||||
const [bulkSuccesses, setBulkSuccesses] = useState<string[]>([]);
|
||||
|
||||
return (<div>
|
||||
<section>
|
||||
<div>
|
||||
|
@ -48,11 +53,15 @@ export default function Users() {
|
|||
<td>{user.name}</td>
|
||||
<td>{user.dname}</td>
|
||||
<td>{user.rfid}</td>
|
||||
<td className="text-sky-600 underline text-center hover:cursor-pointer"
|
||||
onClick={() => toggleAdmin.mutateAsync(user.username).then(() => users.refetch())}
|
||||
<td className={[
|
||||
"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>
|
||||
<td className="text-sky-600 underline text-center hover:cursor-pointer"
|
||||
onClick={() => toggleRosterOnly.mutateAsync(user.username).then(() => users.refetch())}
|
||||
<td className={[
|
||||
"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>
|
||||
<td className="text-center">
|
||||
<button className="p-1 px-2 bg-yellow-600 text-white rounded-lg" onClick={(evt) => {
|
||||
|
@ -71,7 +80,25 @@ export default function Users() {
|
|||
</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 />
|
||||
<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>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="mb-5">
|
||||
|
|
119
src/components/admin/attendance/ListView.tsx
Normal file
119
src/components/admin/attendance/ListView.tsx
Normal 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>
|
||||
}
|
39
src/components/admin/attendance/UserList.tsx
Normal file
39
src/components/admin/attendance/UserList.tsx
Normal 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>
|
||||
|
||||
}
|
244
src/components/admin/attendance/UserView.tsx
Normal file
244
src/components/admin/attendance/UserView.tsx
Normal 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>
|
||||
}
|
|
@ -36,7 +36,7 @@ export default async function handler(
|
|||
return;
|
||||
}
|
||||
} else {
|
||||
res.status(200).json({ status: 'RFID_ATTENDANCE_NOT_ENABLED' });
|
||||
res.status(202).json({ status: 'RFID_ATTENDANCE_NOT_ENABLED' });
|
||||
}
|
||||
}
|
||||
else {
|
||||
|
|
53
src/pages/dash/admin/attendance.tsx
Normal file
53
src/pages/dash/admin/attendance.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 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>
|
||||
</>)
|
||||
}
|
|
@ -30,7 +30,7 @@ export default function Dash() {
|
|||
<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">
|
||||
<nav className="flex flex-grow h-fit md:flex-col w-fit 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"
|
||||
|
|
|
@ -21,10 +21,9 @@ export default function Dash() {
|
|||
|
||||
useEffect(() => {
|
||||
if (lastRfid.isSuccess) {
|
||||
const interval = setInterval(() => {
|
||||
setInterval(() => {
|
||||
lastRfid.refetch();
|
||||
}, 2000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
|
|
@ -19,13 +19,6 @@ export default function Dash() {
|
|||
let attCnt = 0;
|
||||
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(() => {
|
||||
if (isLoggedIn.failureCount > 0) {
|
||||
push("/");
|
||||
|
@ -44,26 +37,29 @@ export default function Dash() {
|
|||
<DashboardHeader url="/dash" />
|
||||
<div className="p-5">
|
||||
<div className="flex gap-2">
|
||||
<div className={[
|
||||
"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="border rounded-xl h-32 w-40 p-2 flex items-center justify-center flex-col mb-5 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">{attendTime.data}</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) >= 30 ? "bg-emerald-500" : "bg-red-300"
|
||||
].join(" ")}>
|
||||
<div className="border rounded-xl h-32 w-40 p-2 flex items-center justify-center flex-col mb-5 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">{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 className="text-2xl font-bold mb-2">請點選下方切換狀態 (上方綠燈 > 100)</div>
|
||||
<div className="text-2xl font-bold mb-2">請點選下方切換狀態 (> 100 小時會變綠燈)</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">
|
||||
|
@ -84,8 +80,7 @@ export default function Dash() {
|
|||
<tr className="*:p-1 *:border" key={date}>
|
||||
<td>{date}</td>
|
||||
{
|
||||
// TODO: fix this very ugly code
|
||||
new Date(new Date(date).setDate(new Date(date).getDate() - 1)).setHours(23, 59, 59, 999) > new Date().getTime() ? (
|
||||
new Date(date).setHours(0, 0, 0, 0) > new Date().getTime() ? (
|
||||
timePeriods.data?.map((timePeriod) => {
|
||||
const thisPeriodId = periods.data![date]![periodCnt]?.id!;
|
||||
if (periods.data![date]![periodCnt]?.timePeriodId == timePeriod.id) {
|
||||
|
@ -95,7 +90,7 @@ export default function Dash() {
|
|||
onClick={() => toggleAttendance.mutateAsync({
|
||||
periodId: thisPeriodId || -1,
|
||||
attendance: false
|
||||
}).then(() => mySelectedPeriods.refetch()).then(() => attendTime.refetch())}>
|
||||
}).then(() => mySelectedPeriods.refetch()).then(() => attendTime.refetch()).catch((e) => alert(e.message))}>
|
||||
Will Attend
|
||||
</td>
|
||||
} else {
|
||||
|
@ -103,7 +98,7 @@ export default function Dash() {
|
|||
onClick={() => toggleAttendance.mutateAsync({
|
||||
periodId: thisPeriodId || -1,
|
||||
attendance: true
|
||||
}).then(() => mySelectedPeriods.refetch()).then(() => attendTime.refetch())}></td>
|
||||
}).then(() => mySelectedPeriods.refetch()).then(() => attendTime.refetch()).catch((e) => alert(e.message))}></td>
|
||||
}
|
||||
} else {
|
||||
return <td key={timePeriod.id * periodCnt} className="bg-gray-400">N/A</td>
|
||||
|
|
|
@ -2,11 +2,12 @@ import { compareSync, hashSync } from "bcrypt";
|
|||
import jwt from "jsonwebtoken";
|
||||
|
||||
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 { z } from "zod";
|
||||
import { Period } from "@prisma/client";
|
||||
import { lastGotRfid, rfidAttendance, setRfidAttendance } from "~/utils/rfid";
|
||||
import { actualAttendTime, attendTime as selectedAttendTime, toggleAttendance } from "./time-sel";
|
||||
|
||||
export const adminRouter = createTRPCRouter({
|
||||
isLoggedIn: loggedInProcedure.query(() => true),
|
||||
|
@ -115,9 +116,11 @@ export const adminRouter = createTRPCRouter({
|
|||
getAllUsers: adminProcedure
|
||||
.query(async ({ ctx }) => {
|
||||
return await ctx.db.user.findMany({
|
||||
orderBy: {
|
||||
username: "asc",
|
||||
},
|
||||
orderBy: [
|
||||
{ grade: "asc" },
|
||||
{ class: "asc" },
|
||||
{ number: "asc" }
|
||||
],
|
||||
select: {
|
||||
username: true,
|
||||
name: true,
|
||||
|
@ -224,7 +227,7 @@ export const adminRouter = createTRPCRouter({
|
|||
.mutation(async ({ input, ctx }) => {
|
||||
const period = await ctx.db.period.findFirst({
|
||||
where: {
|
||||
date: new Date(input.date),
|
||||
date: new Date(input.date.replace(/-/g, "/")),
|
||||
},
|
||||
});
|
||||
if (period !== null) {
|
||||
|
@ -285,6 +288,7 @@ export const adminRouter = createTRPCRouter({
|
|||
timePeriodId: z.number().int(),
|
||||
}))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
console.log(new Date(input.date))
|
||||
return await ctx.db.period.deleteMany({
|
||||
where: {
|
||||
date: new Date(input.date),
|
||||
|
@ -325,9 +329,32 @@ export const adminRouter = createTRPCRouter({
|
|||
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({
|
||||
data: {
|
||||
datetime: new Date(),
|
||||
datetime: recDateTime,
|
||||
user: {
|
||||
connect: {
|
||||
rfid: input.rfid,
|
||||
|
@ -357,13 +384,14 @@ export const adminRouter = createTRPCRouter({
|
|||
},
|
||||
})
|
||||
return {
|
||||
status: records.length % 2 ? "WELCOME" : "GOODBYE",
|
||||
status: records.length % 2 ? "ENTER" : "EXIT",
|
||||
name: records[records.length - 1]!.user.dname,
|
||||
}
|
||||
}).catch((err) => {
|
||||
return {
|
||||
status: "ERR_INTERNAL",
|
||||
}
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "There was an error recording your attendance.",
|
||||
})
|
||||
})
|
||||
}),
|
||||
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;
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
|
|
@ -1,64 +1,11 @@
|
|||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createTRPCRouter, loggedInProcedure, publicProcedure } from "~/server/api/trpc";
|
||||
import { Context, createTRPCRouter, loggedInProcedure, publicProcedure } from "~/server/api/trpc";
|
||||
|
||||
export const timeSelRouter = createTRPCRouter({
|
||||
getMySelectedPeriods: loggedInProcedure
|
||||
.query(async ({ ctx }) => {
|
||||
export const attendTime = async (ctx: Context, username: string) => {
|
||||
const user = await ctx.db.user.findUnique({
|
||||
where: { username: ctx.session?.username },
|
||||
select: {
|
||||
periods: {
|
||||
select: {
|
||||
id: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if (!user) throw new TRPCError({ code: "UNAUTHORIZED", message: "User not found" });
|
||||
return user.periods.map(period => period.id);
|
||||
}),
|
||||
toggleAttendance: loggedInProcedure
|
||||
.input(z.object({
|
||||
periodId: z.number().int(),
|
||||
attendance: z.boolean(),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
try {
|
||||
|
||||
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
|
||||
.query(async ({ ctx }) => {
|
||||
const user = await ctx.db.user.findUnique({
|
||||
where: { username: ctx.session?.username },
|
||||
where: { username: username },
|
||||
select: {
|
||||
periods: {
|
||||
select: {
|
||||
|
@ -68,6 +15,11 @@ export const timeSelRouter = createTRPCRouter({
|
|||
end: true,
|
||||
}
|
||||
}
|
||||
},
|
||||
where: {
|
||||
date: {
|
||||
gte: new Date(new Date().setHours(23, 59, 59, 999)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -86,29 +38,13 @@ export const timeSelRouter = createTRPCRouter({
|
|||
total += (end.getTime() - start.getTime()) / 1000 / 60 / 60; // convert to hours
|
||||
})
|
||||
return total;
|
||||
}),
|
||||
getMyAttendance: loggedInProcedure
|
||||
.query(async ({ ctx }) => {
|
||||
return await ctx.db.attendance.findMany({
|
||||
where: {
|
||||
user: {
|
||||
username: ctx.session?.username,
|
||||
}
|
||||
},
|
||||
select: {
|
||||
datetime: true,
|
||||
},
|
||||
orderBy: {
|
||||
datetime: "asc",
|
||||
},
|
||||
});
|
||||
}),
|
||||
actualAttendTime: loggedInProcedure
|
||||
.query(async ({ ctx }) => {
|
||||
|
||||
export const actualAttendTime = async (ctx: Context, username: string) => {
|
||||
const attendance = await ctx.db.attendance.findMany({
|
||||
where: {
|
||||
user: {
|
||||
username: ctx.session?.username,
|
||||
username: username,
|
||||
}
|
||||
},
|
||||
select: {
|
||||
|
@ -175,5 +111,95 @@ export const timeSelRouter = createTRPCRouter({
|
|||
})
|
||||
})
|
||||
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({
|
||||
getMySelectedPeriods: loggedInProcedure
|
||||
.query(async ({ ctx }) => {
|
||||
const user = await ctx.db.user.findUnique({
|
||||
where: { username: ctx.session?.username },
|
||||
select: {
|
||||
periods: {
|
||||
select: {
|
||||
id: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if (!user) throw new TRPCError({ code: "UNAUTHORIZED", message: "User not found" });
|
||||
return user.periods.map(period => period.id);
|
||||
}),
|
||||
toggleAttendance: loggedInProcedure
|
||||
.input(z.object({
|
||||
periodId: z.number().int(),
|
||||
attendance: z.boolean(),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return await toggleAttendance(ctx, input, ctx.session?.username!);
|
||||
}),
|
||||
attendTime: loggedInProcedure
|
||||
.query(async ({ ctx }) => {
|
||||
return await attendTime(ctx, ctx.session?.username!);
|
||||
}),
|
||||
getMyAttendance: loggedInProcedure
|
||||
.query(async ({ ctx }) => {
|
||||
return await ctx.db.attendance.findMany({
|
||||
where: {
|
||||
user: {
|
||||
username: ctx.session?.username,
|
||||
}
|
||||
},
|
||||
select: {
|
||||
datetime: true,
|
||||
},
|
||||
orderBy: {
|
||||
datetime: "asc",
|
||||
},
|
||||
});
|
||||
}),
|
||||
actualAttendTime: loggedInProcedure
|
||||
.query(async ({ ctx }) => {
|
||||
return await actualAttendTime(ctx, ctx.session?.username!);
|
||||
}),
|
||||
});
|
||||
|
|
|
@ -60,6 +60,8 @@ export const createTRPCContext = (_opts: CreateNextContextOptions) => {
|
|||
};
|
||||
};
|
||||
|
||||
export type Context = ReturnType<typeof createTRPCContext>;
|
||||
|
||||
/**
|
||||
* 2. INITIALIZATION
|
||||
*
|
||||
|
|
|
@ -36,5 +36,15 @@ export const AddUserSchema = 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." }),
|
||||
})
|
12
yarn.lock
12
yarn.lock
|
@ -348,6 +348,13 @@
|
|||
dependencies:
|
||||
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@*":
|
||||
version "15.7.11"
|
||||
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:
|
||||
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:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2"
|
||||
|
|
Loading…
Reference in a new issue