Доделываю перед сдачей

This commit is contained in:
Artem VV 2023-06-23 19:26:30 +07:00
parent 1175ccff74
commit 0ebbe91f4f
15 changed files with 906 additions and 15 deletions

View file

@ -58,6 +58,7 @@ model study_slot {
study_item study_item @relation(fields: [study_item_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "fk_study_slot_study_item") study_item study_item @relation(fields: [study_item_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "fk_study_slot_study_item")
timetable timetable? @relation(fields: [timetableId], references: [id]) timetable timetable? @relation(fields: [timetableId], references: [id])
timetableId Int? timetableId Int?
user_lesson_attended user_lesson_attended[]
} }
model timetable { model timetable {
@ -83,6 +84,8 @@ model users {
chat_message chat_message[] chat_message chat_message[]
user_in_chat user_in_chat[] user_in_chat user_in_chat[]
attended event_attendance[] attended event_attendance[]
user_lesson_attended user_lesson_attended[]
documents documents[]
} }
model user_session { model user_session {
@ -122,3 +125,34 @@ model user_in_chat {
chat chat @relation(fields: [chatId], references: [id], onDelete: Cascade) chat chat @relation(fields: [chatId], references: [id], onDelete: Cascade)
user users @relation(fields: [userId], references: [id], onDelete: Cascade) user users @relation(fields: [userId], references: [id], onDelete: Cascade)
} }
model user_lesson_attended {
user users @relation(fields: [userId], references: [id])
slot study_slot @relation(fields: [slotId], references: [id])
day DateTime @default(now())
userId Int
slotId Int
@@id([userId, slotId, day])
}
enum DocumentStatus {
STARTED
IN_PROGRESS
FINISHED
}
model documents {
id String @id @default(uuid())
title String
status DocumentStatus @default(STARTED)
comment String @default("")
filename String?
created_at DateTime @default(now())
updated_at DateTime @updatedAt
user users @relation(fields: [usersId], references: [id])
usersId Int
}

22
public/assets/js/xlsx.full.min.js vendored Normal file

File diff suppressed because one or more lines are too long

3
public/documents/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
*
!.gitkeep
!.gitignore

View file

View file

@ -25,6 +25,10 @@ const items = [
href: "/chats", href: "/chats",
title: "Чаты", title: "Чаты",
}, },
{
href: "/docs",
title: "Документы",
},
]; ];
const itemsRight = [ const itemsRight = [

View file

@ -10,7 +10,7 @@ const studyItems = await getStudyItems();
</datalist> </datalist>
<div class="row"> <div class="row">
<div class="col-12"> <div class="col-12">
<h1>Предметы</h1> <h1 class="mt-5 mb-5">Предметы</h1>
<form class="row g-3" onsubmit="createNewStudyItem(this); return false;"> <form class="row g-3" onsubmit="createNewStudyItem(this); return false;">
<div class="col-auto"> <div class="col-auto">
<label for="studyItemName" class="visually-hidden">Название предмета</label> <label for="studyItemName" class="visually-hidden">Название предмета</label>

View file

@ -31,6 +31,8 @@ if (editedUser !== null) {
--- ---
<div class="container mb-5" data-user={editedUser?.login}> <div class="container mb-5" data-user={editedUser?.login}>
<a class="btn btn-sm btn-primary" href="/joint-timetable">Перейти в сводную таблицу расписаний</a>
<hr/>
{ {
editedUser === null && ( editedUser === null && (
<form> <form>

119
src/db.ts
View file

@ -1,8 +1,9 @@
import { PrismaClient, chat, chat_message, user_in_chat, users, EventAttendanceType } from "@prisma/client"; import { PrismaClient, chat, chat_message, user_in_chat, users, EventAttendanceType, DocumentStatus } from "@prisma/client";
import { blake3 } from "@noble/hashes/blake3"; import { blake3 } from "@noble/hashes/blake3";
import { bytesToHex as toHex } from "@noble/hashes/utils"; import { bytesToHex as toHex } from "@noble/hashes/utils";
export type EventAttendanceTypeV = EventAttendanceType; export type EventAttendanceTypeV = EventAttendanceType;
export type DocumentStatusV = DocumentStatus;
const client = new PrismaClient(); const client = new PrismaClient();
@ -581,6 +582,7 @@ export async function createEvent(title: string, description: string, when: Date
return article !== null; return article !== null;
} }
export async function attendEvent(eventId: number, userId: number, attendanceType: string) { export async function attendEvent(eventId: number, userId: number, attendanceType: string) {
if (attendanceType === "UNDO") { if (attendanceType === "UNDO") {
const article = await client.event_attendance.delete({ const article = await client.event_attendance.delete({
@ -620,3 +622,118 @@ export async function attendEvent(eventId: number, userId: number, attendanceTyp
return article !== null; return article !== null;
} }
} }
export async function getTeachersAndTheirTimetables() {
const tats = await client.users.findMany({
select: {
id: true,
fullName: true,
login: true,
timetable: {
select: {
id: true,
odd: true,
day: true,
slots: {
select: {
id: true,
position: true,
study_item: {
select: {
id: true,
title: true,
},
},
where: true,
},
},
},
},
},
});
return tats;
}
export async function getTeachersDocuments(teacherId: number | undefined) {
const documents = await client.documents.findMany({
where: {
usersId: teacherId,
},
select: {
id: true,
title: true,
status: true,
created_at: true,
updated_at: true,
user: true,
comment: true,
filename: true,
},
orderBy: {
updated_at: "desc",
},
});
return documents;
}
export async function createNewDocument(title: string, userId: number) {
const document = await client.documents.create({
data: {
title: title,
usersId: userId,
},
});
return document;
}
export async function deleteDocument(documentId: string) {
const document = await client.documents.delete({
where: {
id: documentId,
},
});
return document !== null;
}
export async function updateDocumentComment(documentId: string, comment: string) {
const document = await client.documents.update({
where: {
id: documentId,
},
data: {
comment: comment,
},
});
return document !== null;
}
export async function updateDocumentFilename(documentId: string, filename: string) {
const document = await client.documents.update({
where: {
id: documentId,
},
data: {
filename: filename,
},
});
return document !== null;
}
export async function updateDocumentStatus(documentId: string, documentStatus: string) {
const document = await client.documents.update({
where: {
id: documentId,
},
data: {
status: documentStatus as DocumentStatus,
},
});
return document !== null;
}

320
src/pages/docs.astro Normal file
View file

@ -0,0 +1,320 @@
---
import Layout from "../layouts/Layout.astro";
import { getSessionUser, getTeachersDocuments, getUserSession } from "../db";
import Navbar from "../components/Navbar.astro";
if (Astro.cookies.has("session")) {
const sessId = Astro.cookies.get("session").value!;
const dbSess = await getUserSession(sessId);
if (dbSess === null) {
Astro.cookies.delete("session");
return Astro.redirect("/login");
}
} else {
return Astro.redirect("/login");
}
const formatDate = (dt: Date) => {
return (
("0" + dt.getDay()).substr(-2) +
"." +
("0" + (0 + dt.getMonth())).substr(-2) +
"." +
dt.getFullYear() +
" в " +
("0" + dt.getHours()).substr(-2) +
":" +
("0" + dt.getMinutes()).substr(-2)
);
};
const sessId = Astro.cookies.get("session").value!;
const user = (await getSessionUser(sessId))!;
const docs = await getTeachersDocuments(user.is_admin || user.is_moderator ? undefined : user.id);
---
<Layout title="Документы">
<datalist id="statusOpts">
<option value="Начат">STARTED</option>
<option value="В работе">IN_PROGRESS</option>
<option value="Закончен">FINISHED</option>
</datalist>
<main>
<Navbar is_user_admin={user.is_admin} user={user} />
<div class="container-fluid mt-5">
<h1 class="mb-5">Документы</h1>
<form id="newDocForm">
<div class="mb-3">
<label for="newDocName" class="col-sm-2 col-form-label">Название нового документа</label>
<div class="d-flex flex-row gap-3">
<input type="text" class="form-control" name="newDocName" />
<button class="btn btn-primary w-25" type="submit">Создать новый документ</button>
</div>
</div>
</form>
<hr />
<table class="table table-striped">
<thead>
<tr>
{user.is_admin || user.is_moderator ? <th scope="col">ФИО</th> : null}
<th scope="col">Название документа</th>
<th scope="col">Статус</th>
<th scope="col">Комментарий</th>
<th scope="col">Файл</th>
<th scope="col">Действия</th>
<th scope="col">Создано/изменено</th>
</tr>
</thead>
<tbody>
{
docs.map((doc) => (
<tr>
{user.is_admin || user.is_moderator ? <th scope="col">{doc.user.fullName || doc.user.login}</th> : null}
<td>{doc.title}</td>
<td class="statusCell">
<div class="btn-group" role="group">
{doc.status === "STARTED" ? (
<input
type="radio"
class="btn-check"
name={`docStatus-${doc.id}`}
id={`docStarted-${doc.id}`}
value="STARTED"
checked
/>
) : (
<input type="radio" class="btn-check" name={`docStatus-${doc.id}`} id={`docStarted-${doc.id}`} value="STARTED" />
)}
<label class="btn btn-outline-primary btn-sm" for={`docStarted-${doc.id}`}>
Начат
</label>
{doc.status === "IN_PROGRESS" ? (
<input
type="radio"
class="btn-check"
name={`docStatus-${doc.id}`}
id={`docInProgress-${doc.id}`}
value="IN_PROGRESS"
checked
/>
) : (
<input
type="radio"
class="btn-check"
name={`docStatus-${doc.id}`}
id={`docInProgress-${doc.id}`}
value="IN_PROGRESS"
/>
)}
<label class="btn btn-outline-primary btn-sm" for={`docInProgress-${doc.id}`}>
В прогрессе
</label>
{doc.status === "FINISHED" ? (
<input
type="radio"
class="btn-check"
name={`docStatus-${doc.id}`}
id={`docFinished-${doc.id}`}
value="FINISHED"
checked
/>
) : (
<input type="radio" class="btn-check" name={`docStatus-${doc.id}`} id={`docFinished-${doc.id}`} value="FINISHED" />
)}
<label class="btn btn-outline-primary btn-sm" for={`docFinished-${doc.id}`}>
Закончен
</label>
</div>
</td>
<td class="commentCell">{doc.comment}</td>
<td>
{doc.filename === null ? (
<form class="uploadDocForm" onsubmit={"return false"}>
<input type="file" name="docFile" id="docFile" style="width: 5.1rem;" />
<input type="hidden" name="docId" value={doc.id} />
<button type="submit">Загрузить</button>
</form>
) : (
<a href={`/documents/${doc.filename}`} target="_blank">
Скачать
</a>
)}
</td>
<td>
<button type="button" class="btn btn-sm btn-primary btn-ucom" data-docId={doc.id}>
Изменить комментарий
</button>
<button type="button" class="btn btn-sm btn-danger btn-ddoc" data-docId={doc.id}>
Удалить
</button>
</td>
<td>
{formatDate(doc.created_at)} / {formatDate(doc.updated_at)}
</td>
</tr>
))
}
</tbody>
</table>
</div>
</main>
</Layout>
<script>
async function changeDocStatus(docId: string, docStatus: string) {
try {
const resp = await fetch("/docsapi/updateStatus", {
method: "POST",
body: JSON.stringify({
docId,
docStatus,
}),
headers: {
"Content-Type": "application/json",
},
});
const json = await resp.json();
if (json.ok) {
location.reload();
} else {
throw new Error(json.reason);
}
} catch (e) {
console.error(e);
if (e instanceof Error) {
alert(`Ошибка: ${e.message}`);
}
}
}
async function deleteDoc(docId: string) {
try {
const resp = await fetch("/docsapi/delete", {
method: "POST",
body: JSON.stringify({
docId,
}),
headers: {
"Content-Type": "application/json",
},
});
const json = await resp.json();
if (json.ok) {
location.reload();
} else {
throw new Error(json.reason);
}
} catch (e) {
console.error(e);
if (e instanceof Error) {
alert(`Ошибка: ${e.message}`);
}
}
}
async function updateDocComment(docId: string, comment: string) {
try {
const resp = await fetch("/docsapi/updateComment", {
method: "POST",
body: JSON.stringify({
docId,
comment,
}),
headers: {
"Content-Type": "application/json",
},
});
const json = await resp.json();
if (json.ok) {
location.reload();
} else {
throw new Error(json.reason);
}
} catch (e) {
console.error(e);
if (e instanceof Error) {
alert(`Ошибка: ${e.message}`);
}
}
}
async function uploadDoc(fd: FormData) {
try {
const resp = await fetch("/docsapi/uploadFile", {
method: "POST",
body: fd,
});
const json = await resp.json();
if (json.ok) {
location.reload();
} else {
throw new Error(json.reason);
}
} catch (e) {
console.error(e);
if (e instanceof Error) {
alert(`Ошибка: ${e.message}`);
}
}
}
document.addEventListener("DOMContentLoaded", () => {
document.getElementById("newDocForm")!.addEventListener("submit", async (e) => {
e.preventDefault();
const form = e.target as HTMLFormElement;
const formData = new FormData(form);
const resp = await fetch("/docsapi/create", {
method: "POST",
body: formData,
});
if (resp.ok) {
window.location.reload();
} else {
alert("Произошла ошибка при создании документа");
}
});
document.querySelectorAll("input.btn-check[type=radio]").forEach((e) => {
e.addEventListener("change", function () {
const elemName = (e as HTMLInputElement).name;
const dId = elemName.substr(10);
const docState = (e as HTMLInputElement).value;
changeDocStatus(dId, docState);
});
});
document.querySelectorAll("button.btn-ucom").forEach((e) => {
e.addEventListener("click", function () {
const docid = (e as HTMLInputElement).dataset["docid"]!;
const newComm = prompt("Введите новый комментарий");
if (newComm === null) return;
updateDocComment(docid, newComm);
});
});
document.querySelectorAll("button.btn-ddoc").forEach((e) => {
e.addEventListener("click", function () {
const docid = (e as HTMLInputElement).dataset["docid"]!;
deleteDoc(docid);
});
});
document.querySelectorAll(".uploadDocForm").forEach((e) => {
e.addEventListener("submit", function () {
const fd = new FormData(e as HTMLFormElement);
uploadDoc(fd);
});
});
});
</script>
<style>
table {
font-size: 0.8rem;
}
</style>

View file

@ -0,0 +1,31 @@
import type { APIContext } from "astro";
import { createNewDocument, getSessionUser } from "../../db";
export async function post({ request, cookies }: APIContext) {
const response: { ok: boolean; reason?: string } = {
ok: true,
};
try {
const sessId = cookies.get("session").value!;
const user = (await getSessionUser(sessId))!;
const fd = await request.formData();
const newDocName = fd.get("newDocName");
if (!newDocName) {
throw new Error("Неправильный формат запроса");
}
await createNewDocument(newDocName.toString(), user.id);
} catch (e: any) {
response.ok = false;
if (e instanceof Error) {
response.reason = e.message;
} else {
response.reason = e.toString();
}
}
return {
body: JSON.stringify(response),
};
}

View file

@ -0,0 +1,39 @@
import type { APIContext } from "astro";
import { getSessionUser, deleteDocument } from "../../db";
import { Prisma } from "@prisma/client";
export async function post({ request, cookies }: APIContext) {
const response: { ok: boolean; reason?: string } = {
ok: true,
};
try {
const sessId = cookies.get("session").value!;
const user = (await getSessionUser(sessId))!;
const json = await request.json();
const docId = json.docId as string;
if (!docId) {
throw new Error("Предоставлены некорректные данные");
}
const ok = await deleteDocument(docId);
if (!ok) {
throw new Error("Неизвестная ошибка");
}
response.ok = true;
} catch (e: any) {
response.ok = false;
if (e instanceof Prisma.PrismaClientKnownRequestError) {
response.reason = `Неизвестная ошибка базы данных. Код ${e.code}`;
} else if (e instanceof Error) {
response.reason = e.message.trim();
} else {
response.reason = e.toString().trim();
}
}
return {
body: JSON.stringify(response),
};
}

View file

@ -0,0 +1,40 @@
import type { APIContext } from "astro";
import { getSessionUser, updateDocumentComment } from "../../db";
import { Prisma } from "@prisma/client";
export async function post({ request, cookies }: APIContext) {
const response: { ok: boolean; reason?: string } = {
ok: true,
};
try {
const sessId = cookies.get("session").value!;
const user = (await getSessionUser(sessId))!;
const json = await request.json();
const docId = json.docId as string;
const comment = json.comment as string;
if (!docId || comment === null) {
throw new Error("Предоставлены некорректные данные");
}
const ok = await updateDocumentComment(docId, comment);
if (!ok) {
throw new Error("Неизвестная ошибка");
}
response.ok = true;
} catch (e: any) {
response.ok = false;
if (e instanceof Prisma.PrismaClientKnownRequestError) {
response.reason = `Неизвестная ошибка базы данных. Код ${e.code}`;
} else if (e instanceof Error) {
response.reason = e.message.trim();
} else {
response.reason = e.toString().trim();
}
}
return {
body: JSON.stringify(response),
};
}

View file

@ -0,0 +1,40 @@
import type { APIContext } from "astro";
import { getSessionUser, updateDocumentStatus } from "../../db";
import { Prisma } from "@prisma/client";
export async function post({ request, cookies }: APIContext) {
const response: { ok: boolean; reason?: string } = {
ok: true,
};
try {
const sessId = cookies.get("session").value!;
const user = (await getSessionUser(sessId))!;
const json = await request.json();
const docId = json.docId as string;
const docStatus = json.docStatus as string;
if (!docId || !docStatus) {
throw new Error("Предоставлены некорректные данные");
}
const ok = await updateDocumentStatus(docId, docStatus);
if (!ok) {
throw new Error("Неизвестная ошибка");
}
response.ok = true;
} catch (e: any) {
response.ok = false;
if (e instanceof Prisma.PrismaClientKnownRequestError) {
response.reason = `Неизвестная ошибка базы данных. Код ${e.code}`;
} else if (e instanceof Error) {
response.reason = e.message.trim();
} else {
response.reason = e.toString().trim();
}
}
return {
body: JSON.stringify(response),
};
}

View file

@ -0,0 +1,47 @@
import type { APIContext } from "astro";
import { updateDocumentFilename } from "../../db";
import { writeFile } from "fs/promises";
import { join as joinPath } from "path";
import { cwd } from "process";
import { Prisma } from "@prisma/client";
function randomHash() {
return (Math.random() * 1e30).toString(16);
}
export async function post({ request }: APIContext) {
const response: { ok: boolean; reason?: string } = {
ok: true,
};
try {
const fd = await request.formData();
const docId = fd.get("docId") as string;
const blob = fd.get("docFile") as Blob;
if (!docId || !blob || blob.size <= 0 || blob.name === undefined) {
throw new Error("Предоставлены некорректные данные");
}
console.log(docId, blob, blob.name);
const fName = `${randomHash()}_${blob.name}`;
const buf = Buffer.from(await blob.arrayBuffer());
await updateDocumentFilename(docId, fName);
await writeFile(joinPath(cwd(), "public", "documents", fName), buf);
} catch (e: any) {
response.ok = false;
if (e instanceof Prisma.PrismaClientKnownRequestError) {
response.reason = `Неизвестная ошибка базы данных. Код ${e.code}`;
} else if (e instanceof Error) {
response.reason = e.message.trim();
} else {
response.reason = e.toString().trim();
}
}
return {
body: JSON.stringify(response),
};
}

View file

@ -0,0 +1,192 @@
---
import Layout from "../layouts/Layout.astro";
import { getSessionUser, getTeachersAndTheirTimetables } from "../db";
import Navbar from "../components/Navbar.astro";
const sessId = Astro.cookies.get("session").value!;
const user = await getSessionUser(sessId);
if (user === null || (!user.is_admin && !user.is_moderator)) {
return Astro.redirect("/login");
}
const days = ["Понедельник", "Вторник", "Среда", "Четверг", "Пятница", "Суббота"];
const positionToTime = ["08:30-10:05", "10:15-11:50", "12:00-13:35", "14:10-15:45", "15:55-17:30", "17:40-19:15"];
const tats = await getTeachersAndTheirTimetables();
const renderDay = function (subj: any | undefined, time: string, tIdx: number) {
if (subj === undefined) {
return `${time} - Нет занятия`;
}
return `${time} - ${subj.where}`;
};
---
<Layout title="Сводная таблица расписаний">
<main>
<Navbar is_user_admin={user.is_admin} user={user} />
<div class="container-fluid mt-5">
<h1 class="mb-5">Сводная таблица расписаний</h1>
<button class="btn btn-sm btn-primary" id="cellsMuter">Затушить ячейки без занятий</button>
<button class="btn btn-sm btn-primary" onclick="downloadTables()">Скачать таблицы</button>
<hr />
<h4>Нечётная неделя</h4>
<table class="table table-striped">
<thead>
<tr>
<th scope="col">ФИО</th>
{days.map((day) => <th scope="col">{day}</th>)}
</tr>
</thead>
<tbody>
{
tats.map((teacher) =>
positionToTime.map((tp, tIdx) => (
<tr>
<td>{teacher.fullName || teacher.login}</td>
{days.map((_, dIdx) => (
<td>
{renderDay(
teacher.timetable.find((ttel) => ttel.day == dIdx && ttel.odd)?.slots.find((slt) => slt.position == tIdx),
tp,
dIdx
)}
</td>
))}
</tr>
))
)
}
</tbody>
</table>
<hr />
<h4>Чётная неделя</h4>
<table class="table table-striped">
<thead>
<tr>
<th scope="col">ФИО</th>
{days.map((day) => <th scope="col">{day}</th>)}
</tr>
</thead>
<tbody>
{
tats.map((teacher) =>
positionToTime.map((tp, tIdx) => (
<tr>
<td>{teacher.fullName || teacher.login}</td>
{days.map((_, dIdx) => (
<td>
{renderDay(
teacher.timetable.find((ttel) => ttel.day == dIdx && !ttel.odd)?.slots.find((slt) => slt.position == tIdx),
tp,
dIdx
)}
</td>
))}
</tr>
))
)
}
</tbody>
</table>
</div>
</main>
</Layout>
<script src="/assets/js/xlsx.full.min.js" is:inline></script>
<script is:inline>
function downloadTables() {
const table_elts = document.querySelectorAll("table");
const worksheetOdd = XLSX.utils.table_to_sheet(table_elts[0]);
const worksheetEven = XLSX.utils.table_to_sheet(table_elts[1]);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheetOdd, "Нечётные недели");
XLSX.utils.book_append_sheet(workbook, worksheetEven, "Чётные недели");
XLSX.write(workbook, { bookType: "xlsx", bookSST: true, type: "base64" });
XLSX.writeFile(workbook, "Сводная таблица расписаний.xlsx");
}
</script>
<script>
function muteCells() {
const tables = [...document.querySelectorAll("table")];
for (const table of tables) {
const rows = table.querySelectorAll("tr");
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const cells = row.querySelectorAll("td");
for (let j = 0; j < cells.length; j++) {
const cell = cells[j];
if (cell.innerText.includes("Нет занятия")) {
cell.classList.toggle("muted");
}
}
}
}
}
function cleanupTable(table: HTMLTableElement) {
const rows = table.querySelectorAll("tr");
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const cells = row.querySelectorAll("td");
for (let j = 0; j < cells.length; j++) {
const cell = cells[j];
if (j == 0) {
let sameTextCells = [cell];
for (let k = i + 1; k < rows.length; k++) {
const nextRow = rows[k];
const nextCell = nextRow.querySelectorAll("td")[j];
if (nextCell.innerText == cell.innerText) {
sameTextCells.push(nextCell);
} else {
break;
}
}
if (sameTextCells.length > 1) {
sameTextCells[0].rowSpan = sameTextCells.length;
sameTextCells[0].style.verticalAlign = "middle";
for (let k = 1; k < sameTextCells.length; k++) {
sameTextCells[k].remove();
}
}
}
}
}
}
document.addEventListener("DOMContentLoaded", () => {
document.querySelector("#cellsMuter")!.addEventListener("click", () => {
muteCells();
});
const tables = document.querySelectorAll("table");
tables.forEach((table) => cleanupTable(table));
});
</script>
<style>
table {
font-size: 0.8rem;
}
.muted {
color: #aaa !important;
}
</style>