mirror of https://github.com/Lapikud/tipilan
parent
efc0e34ae7
commit
f87f8374f4
25 changed files with 4773 additions and 792 deletions
@ -0,0 +1,10 @@ |
||||
import { defineConfig } from "drizzle-kit"; |
||||
|
||||
export default defineConfig({ |
||||
schema: "./src/db/schema.ts", |
||||
out: "./migrations", |
||||
dialect: "sqlite", |
||||
dbCredentials: { |
||||
url: "data/tipilan.db", |
||||
}, |
||||
}); |
||||
@ -0,0 +1,43 @@ |
||||
CREATE TABLE `member` ( |
||||
`id` text PRIMARY KEY NOT NULL, |
||||
`user_id` text NOT NULL, |
||||
`team_id` text, |
||||
`role` text NOT NULL, |
||||
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade, |
||||
FOREIGN KEY (`team_id`) REFERENCES `team`(`id`) ON UPDATE no action ON DELETE cascade |
||||
); |
||||
--> statement-breakpoint |
||||
CREATE UNIQUE INDEX `user_team_unique` ON `member` (`user_id`,`team_id`);--> statement-breakpoint |
||||
CREATE TABLE `team` ( |
||||
`id` text PRIMARY KEY NOT NULL, |
||||
`name` text NOT NULL |
||||
); |
||||
--> statement-breakpoint |
||||
CREATE TABLE `tournament_team` ( |
||||
`id` text PRIMARY KEY NOT NULL, |
||||
`tournament_id` text NOT NULL, |
||||
`team_id` text NOT NULL, |
||||
`registration_date` integer NOT NULL, |
||||
FOREIGN KEY (`tournament_id`) REFERENCES `tournament`(`id`) ON UPDATE no action ON DELETE cascade, |
||||
FOREIGN KEY (`team_id`) REFERENCES `team`(`id`) ON UPDATE no action ON DELETE cascade |
||||
); |
||||
--> statement-breakpoint |
||||
CREATE UNIQUE INDEX `tournament_team_unique` ON `tournament_team` (`tournament_id`,`team_id`);--> statement-breakpoint |
||||
CREATE TABLE `tournament` ( |
||||
`id` text PRIMARY KEY NOT NULL, |
||||
`name` text NOT NULL |
||||
); |
||||
--> statement-breakpoint |
||||
CREATE TABLE `user` ( |
||||
`id` text PRIMARY KEY NOT NULL, |
||||
`email` text NOT NULL, |
||||
`steam_id` text, |
||||
`first_name` text NOT NULL, |
||||
`last_name` text NOT NULL, |
||||
`ticket_id` text, |
||||
`ticket_type` text |
||||
); |
||||
--> statement-breakpoint |
||||
CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`);--> statement-breakpoint |
||||
CREATE UNIQUE INDEX `user_steam_id_unique` ON `user` (`steam_id`);--> statement-breakpoint |
||||
CREATE UNIQUE INDEX `user_ticket_id_unique` ON `user` (`ticket_id`); |
||||
@ -0,0 +1 @@ |
||||
DROP INDEX `user_email_unique`; |
||||
@ -0,0 +1,295 @@ |
||||
{ |
||||
"version": "6", |
||||
"dialect": "sqlite", |
||||
"id": "efe4e865-a061-4ab0-8023-5211e6d86e14", |
||||
"prevId": "00000000-0000-0000-0000-000000000000", |
||||
"tables": { |
||||
"member": { |
||||
"name": "member", |
||||
"columns": { |
||||
"id": { |
||||
"name": "id", |
||||
"type": "text", |
||||
"primaryKey": true, |
||||
"notNull": true, |
||||
"autoincrement": false |
||||
}, |
||||
"user_id": { |
||||
"name": "user_id", |
||||
"type": "text", |
||||
"primaryKey": false, |
||||
"notNull": true, |
||||
"autoincrement": false |
||||
}, |
||||
"team_id": { |
||||
"name": "team_id", |
||||
"type": "text", |
||||
"primaryKey": false, |
||||
"notNull": false, |
||||
"autoincrement": false |
||||
}, |
||||
"role": { |
||||
"name": "role", |
||||
"type": "text", |
||||
"primaryKey": false, |
||||
"notNull": true, |
||||
"autoincrement": false |
||||
} |
||||
}, |
||||
"indexes": { |
||||
"user_team_unique": { |
||||
"name": "user_team_unique", |
||||
"columns": [ |
||||
"user_id", |
||||
"team_id" |
||||
], |
||||
"isUnique": true |
||||
} |
||||
}, |
||||
"foreignKeys": { |
||||
"member_user_id_user_id_fk": { |
||||
"name": "member_user_id_user_id_fk", |
||||
"tableFrom": "member", |
||||
"tableTo": "user", |
||||
"columnsFrom": [ |
||||
"user_id" |
||||
], |
||||
"columnsTo": [ |
||||
"id" |
||||
], |
||||
"onDelete": "cascade", |
||||
"onUpdate": "no action" |
||||
}, |
||||
"member_team_id_team_id_fk": { |
||||
"name": "member_team_id_team_id_fk", |
||||
"tableFrom": "member", |
||||
"tableTo": "team", |
||||
"columnsFrom": [ |
||||
"team_id" |
||||
], |
||||
"columnsTo": [ |
||||
"id" |
||||
], |
||||
"onDelete": "cascade", |
||||
"onUpdate": "no action" |
||||
} |
||||
}, |
||||
"compositePrimaryKeys": {}, |
||||
"uniqueConstraints": {}, |
||||
"checkConstraints": {} |
||||
}, |
||||
"team": { |
||||
"name": "team", |
||||
"columns": { |
||||
"id": { |
||||
"name": "id", |
||||
"type": "text", |
||||
"primaryKey": true, |
||||
"notNull": true, |
||||
"autoincrement": false |
||||
}, |
||||
"name": { |
||||
"name": "name", |
||||
"type": "text", |
||||
"primaryKey": false, |
||||
"notNull": true, |
||||
"autoincrement": false |
||||
} |
||||
}, |
||||
"indexes": {}, |
||||
"foreignKeys": {}, |
||||
"compositePrimaryKeys": {}, |
||||
"uniqueConstraints": {}, |
||||
"checkConstraints": {} |
||||
}, |
||||
"tournament_team": { |
||||
"name": "tournament_team", |
||||
"columns": { |
||||
"id": { |
||||
"name": "id", |
||||
"type": "text", |
||||
"primaryKey": true, |
||||
"notNull": true, |
||||
"autoincrement": false |
||||
}, |
||||
"tournament_id": { |
||||
"name": "tournament_id", |
||||
"type": "text", |
||||
"primaryKey": false, |
||||
"notNull": true, |
||||
"autoincrement": false |
||||
}, |
||||
"team_id": { |
||||
"name": "team_id", |
||||
"type": "text", |
||||
"primaryKey": false, |
||||
"notNull": true, |
||||
"autoincrement": false |
||||
}, |
||||
"registration_date": { |
||||
"name": "registration_date", |
||||
"type": "integer", |
||||
"primaryKey": false, |
||||
"notNull": true, |
||||
"autoincrement": false |
||||
} |
||||
}, |
||||
"indexes": { |
||||
"tournament_team_unique": { |
||||
"name": "tournament_team_unique", |
||||
"columns": [ |
||||
"tournament_id", |
||||
"team_id" |
||||
], |
||||
"isUnique": true |
||||
} |
||||
}, |
||||
"foreignKeys": { |
||||
"tournament_team_tournament_id_tournament_id_fk": { |
||||
"name": "tournament_team_tournament_id_tournament_id_fk", |
||||
"tableFrom": "tournament_team", |
||||
"tableTo": "tournament", |
||||
"columnsFrom": [ |
||||
"tournament_id" |
||||
], |
||||
"columnsTo": [ |
||||
"id" |
||||
], |
||||
"onDelete": "cascade", |
||||
"onUpdate": "no action" |
||||
}, |
||||
"tournament_team_team_id_team_id_fk": { |
||||
"name": "tournament_team_team_id_team_id_fk", |
||||
"tableFrom": "tournament_team", |
||||
"tableTo": "team", |
||||
"columnsFrom": [ |
||||
"team_id" |
||||
], |
||||
"columnsTo": [ |
||||
"id" |
||||
], |
||||
"onDelete": "cascade", |
||||
"onUpdate": "no action" |
||||
} |
||||
}, |
||||
"compositePrimaryKeys": {}, |
||||
"uniqueConstraints": {}, |
||||
"checkConstraints": {} |
||||
}, |
||||
"tournament": { |
||||
"name": "tournament", |
||||
"columns": { |
||||
"id": { |
||||
"name": "id", |
||||
"type": "text", |
||||
"primaryKey": true, |
||||
"notNull": true, |
||||
"autoincrement": false |
||||
}, |
||||
"name": { |
||||
"name": "name", |
||||
"type": "text", |
||||
"primaryKey": false, |
||||
"notNull": true, |
||||
"autoincrement": false |
||||
} |
||||
}, |
||||
"indexes": {}, |
||||
"foreignKeys": {}, |
||||
"compositePrimaryKeys": {}, |
||||
"uniqueConstraints": {}, |
||||
"checkConstraints": {} |
||||
}, |
||||
"user": { |
||||
"name": "user", |
||||
"columns": { |
||||
"id": { |
||||
"name": "id", |
||||
"type": "text", |
||||
"primaryKey": true, |
||||
"notNull": true, |
||||
"autoincrement": false |
||||
}, |
||||
"email": { |
||||
"name": "email", |
||||
"type": "text", |
||||
"primaryKey": false, |
||||
"notNull": true, |
||||
"autoincrement": false |
||||
}, |
||||
"steam_id": { |
||||
"name": "steam_id", |
||||
"type": "text", |
||||
"primaryKey": false, |
||||
"notNull": false, |
||||
"autoincrement": false |
||||
}, |
||||
"first_name": { |
||||
"name": "first_name", |
||||
"type": "text", |
||||
"primaryKey": false, |
||||
"notNull": true, |
||||
"autoincrement": false |
||||
}, |
||||
"last_name": { |
||||
"name": "last_name", |
||||
"type": "text", |
||||
"primaryKey": false, |
||||
"notNull": true, |
||||
"autoincrement": false |
||||
}, |
||||
"ticket_id": { |
||||
"name": "ticket_id", |
||||
"type": "text", |
||||
"primaryKey": false, |
||||
"notNull": false, |
||||
"autoincrement": false |
||||
}, |
||||
"ticket_type": { |
||||
"name": "ticket_type", |
||||
"type": "text", |
||||
"primaryKey": false, |
||||
"notNull": false, |
||||
"autoincrement": false |
||||
} |
||||
}, |
||||
"indexes": { |
||||
"user_email_unique": { |
||||
"name": "user_email_unique", |
||||
"columns": [ |
||||
"email" |
||||
], |
||||
"isUnique": true |
||||
}, |
||||
"user_steam_id_unique": { |
||||
"name": "user_steam_id_unique", |
||||
"columns": [ |
||||
"steam_id" |
||||
], |
||||
"isUnique": true |
||||
}, |
||||
"user_ticket_id_unique": { |
||||
"name": "user_ticket_id_unique", |
||||
"columns": [ |
||||
"ticket_id" |
||||
], |
||||
"isUnique": true |
||||
} |
||||
}, |
||||
"foreignKeys": {}, |
||||
"compositePrimaryKeys": {}, |
||||
"uniqueConstraints": {}, |
||||
"checkConstraints": {} |
||||
} |
||||
}, |
||||
"views": {}, |
||||
"enums": {}, |
||||
"_meta": { |
||||
"schemas": {}, |
||||
"tables": {}, |
||||
"columns": {} |
||||
}, |
||||
"internal": { |
||||
"indexes": {} |
||||
} |
||||
} |
||||
@ -0,0 +1,288 @@ |
||||
{ |
||||
"version": "6", |
||||
"dialect": "sqlite", |
||||
"id": "eae7a237-8469-4a6a-acac-1910adfc98ff", |
||||
"prevId": "efe4e865-a061-4ab0-8023-5211e6d86e14", |
||||
"tables": { |
||||
"member": { |
||||
"name": "member", |
||||
"columns": { |
||||
"id": { |
||||
"name": "id", |
||||
"type": "text", |
||||
"primaryKey": true, |
||||
"notNull": true, |
||||
"autoincrement": false |
||||
}, |
||||
"user_id": { |
||||
"name": "user_id", |
||||
"type": "text", |
||||
"primaryKey": false, |
||||
"notNull": true, |
||||
"autoincrement": false |
||||
}, |
||||
"team_id": { |
||||
"name": "team_id", |
||||
"type": "text", |
||||
"primaryKey": false, |
||||
"notNull": false, |
||||
"autoincrement": false |
||||
}, |
||||
"role": { |
||||
"name": "role", |
||||
"type": "text", |
||||
"primaryKey": false, |
||||
"notNull": true, |
||||
"autoincrement": false |
||||
} |
||||
}, |
||||
"indexes": { |
||||
"user_team_unique": { |
||||
"name": "user_team_unique", |
||||
"columns": [ |
||||
"user_id", |
||||
"team_id" |
||||
], |
||||
"isUnique": true |
||||
} |
||||
}, |
||||
"foreignKeys": { |
||||
"member_user_id_user_id_fk": { |
||||
"name": "member_user_id_user_id_fk", |
||||
"tableFrom": "member", |
||||
"tableTo": "user", |
||||
"columnsFrom": [ |
||||
"user_id" |
||||
], |
||||
"columnsTo": [ |
||||
"id" |
||||
], |
||||
"onDelete": "cascade", |
||||
"onUpdate": "no action" |
||||
}, |
||||
"member_team_id_team_id_fk": { |
||||
"name": "member_team_id_team_id_fk", |
||||
"tableFrom": "member", |
||||
"tableTo": "team", |
||||
"columnsFrom": [ |
||||
"team_id" |
||||
], |
||||
"columnsTo": [ |
||||
"id" |
||||
], |
||||
"onDelete": "cascade", |
||||
"onUpdate": "no action" |
||||
} |
||||
}, |
||||
"compositePrimaryKeys": {}, |
||||
"uniqueConstraints": {}, |
||||
"checkConstraints": {} |
||||
}, |
||||
"team": { |
||||
"name": "team", |
||||
"columns": { |
||||
"id": { |
||||
"name": "id", |
||||
"type": "text", |
||||
"primaryKey": true, |
||||
"notNull": true, |
||||
"autoincrement": false |
||||
}, |
||||
"name": { |
||||
"name": "name", |
||||
"type": "text", |
||||
"primaryKey": false, |
||||
"notNull": true, |
||||
"autoincrement": false |
||||
} |
||||
}, |
||||
"indexes": {}, |
||||
"foreignKeys": {}, |
||||
"compositePrimaryKeys": {}, |
||||
"uniqueConstraints": {}, |
||||
"checkConstraints": {} |
||||
}, |
||||
"tournament_team": { |
||||
"name": "tournament_team", |
||||
"columns": { |
||||
"id": { |
||||
"name": "id", |
||||
"type": "text", |
||||
"primaryKey": true, |
||||
"notNull": true, |
||||
"autoincrement": false |
||||
}, |
||||
"tournament_id": { |
||||
"name": "tournament_id", |
||||
"type": "text", |
||||
"primaryKey": false, |
||||
"notNull": true, |
||||
"autoincrement": false |
||||
}, |
||||
"team_id": { |
||||
"name": "team_id", |
||||
"type": "text", |
||||
"primaryKey": false, |
||||
"notNull": true, |
||||
"autoincrement": false |
||||
}, |
||||
"registration_date": { |
||||
"name": "registration_date", |
||||
"type": "integer", |
||||
"primaryKey": false, |
||||
"notNull": true, |
||||
"autoincrement": false |
||||
} |
||||
}, |
||||
"indexes": { |
||||
"tournament_team_unique": { |
||||
"name": "tournament_team_unique", |
||||
"columns": [ |
||||
"tournament_id", |
||||
"team_id" |
||||
], |
||||
"isUnique": true |
||||
} |
||||
}, |
||||
"foreignKeys": { |
||||
"tournament_team_tournament_id_tournament_id_fk": { |
||||
"name": "tournament_team_tournament_id_tournament_id_fk", |
||||
"tableFrom": "tournament_team", |
||||
"tableTo": "tournament", |
||||
"columnsFrom": [ |
||||
"tournament_id" |
||||
], |
||||
"columnsTo": [ |
||||
"id" |
||||
], |
||||
"onDelete": "cascade", |
||||
"onUpdate": "no action" |
||||
}, |
||||
"tournament_team_team_id_team_id_fk": { |
||||
"name": "tournament_team_team_id_team_id_fk", |
||||
"tableFrom": "tournament_team", |
||||
"tableTo": "team", |
||||
"columnsFrom": [ |
||||
"team_id" |
||||
], |
||||
"columnsTo": [ |
||||
"id" |
||||
], |
||||
"onDelete": "cascade", |
||||
"onUpdate": "no action" |
||||
} |
||||
}, |
||||
"compositePrimaryKeys": {}, |
||||
"uniqueConstraints": {}, |
||||
"checkConstraints": {} |
||||
}, |
||||
"tournament": { |
||||
"name": "tournament", |
||||
"columns": { |
||||
"id": { |
||||
"name": "id", |
||||
"type": "text", |
||||
"primaryKey": true, |
||||
"notNull": true, |
||||
"autoincrement": false |
||||
}, |
||||
"name": { |
||||
"name": "name", |
||||
"type": "text", |
||||
"primaryKey": false, |
||||
"notNull": true, |
||||
"autoincrement": false |
||||
} |
||||
}, |
||||
"indexes": {}, |
||||
"foreignKeys": {}, |
||||
"compositePrimaryKeys": {}, |
||||
"uniqueConstraints": {}, |
||||
"checkConstraints": {} |
||||
}, |
||||
"user": { |
||||
"name": "user", |
||||
"columns": { |
||||
"id": { |
||||
"name": "id", |
||||
"type": "text", |
||||
"primaryKey": true, |
||||
"notNull": true, |
||||
"autoincrement": false |
||||
}, |
||||
"email": { |
||||
"name": "email", |
||||
"type": "text", |
||||
"primaryKey": false, |
||||
"notNull": true, |
||||
"autoincrement": false |
||||
}, |
||||
"steam_id": { |
||||
"name": "steam_id", |
||||
"type": "text", |
||||
"primaryKey": false, |
||||
"notNull": false, |
||||
"autoincrement": false |
||||
}, |
||||
"first_name": { |
||||
"name": "first_name", |
||||
"type": "text", |
||||
"primaryKey": false, |
||||
"notNull": true, |
||||
"autoincrement": false |
||||
}, |
||||
"last_name": { |
||||
"name": "last_name", |
||||
"type": "text", |
||||
"primaryKey": false, |
||||
"notNull": true, |
||||
"autoincrement": false |
||||
}, |
||||
"ticket_id": { |
||||
"name": "ticket_id", |
||||
"type": "text", |
||||
"primaryKey": false, |
||||
"notNull": false, |
||||
"autoincrement": false |
||||
}, |
||||
"ticket_type": { |
||||
"name": "ticket_type", |
||||
"type": "text", |
||||
"primaryKey": false, |
||||
"notNull": false, |
||||
"autoincrement": false |
||||
} |
||||
}, |
||||
"indexes": { |
||||
"user_steam_id_unique": { |
||||
"name": "user_steam_id_unique", |
||||
"columns": [ |
||||
"steam_id" |
||||
], |
||||
"isUnique": true |
||||
}, |
||||
"user_ticket_id_unique": { |
||||
"name": "user_ticket_id_unique", |
||||
"columns": [ |
||||
"ticket_id" |
||||
], |
||||
"isUnique": true |
||||
} |
||||
}, |
||||
"foreignKeys": {}, |
||||
"compositePrimaryKeys": {}, |
||||
"uniqueConstraints": {}, |
||||
"checkConstraints": {} |
||||
} |
||||
}, |
||||
"views": {}, |
||||
"enums": {}, |
||||
"_meta": { |
||||
"schemas": {}, |
||||
"tables": {}, |
||||
"columns": {} |
||||
}, |
||||
"internal": { |
||||
"indexes": {} |
||||
} |
||||
} |
||||
@ -0,0 +1,20 @@ |
||||
{ |
||||
"version": "7", |
||||
"dialect": "sqlite", |
||||
"entries": [ |
||||
{ |
||||
"idx": 0, |
||||
"version": "6", |
||||
"when": 1751642215588, |
||||
"tag": "0000_awesome_wendigo", |
||||
"breakpoints": true |
||||
}, |
||||
{ |
||||
"idx": 1, |
||||
"version": "6", |
||||
"when": 1752632968857, |
||||
"tag": "0001_cool_ma_gnuci", |
||||
"breakpoints": true |
||||
} |
||||
] |
||||
} |
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,102 @@ |
||||
// Fonts
|
||||
import { vipnagorgialla } from "@/components/Vipnagorgialla"; |
||||
|
||||
// Database
|
||||
import { db } from "@/db/drizzle"; |
||||
|
||||
import Link from "next/link"; |
||||
|
||||
// User interface
|
||||
import { |
||||
Table, |
||||
TableBody, |
||||
TableCaption, |
||||
TableCell, |
||||
TableHead, |
||||
TableHeader, |
||||
TableRow, |
||||
} from "@/components/ui/table"; |
||||
|
||||
// Later on we can use a i8 solution?
|
||||
function translateRole(role: string): string { |
||||
switch (role) { |
||||
case "CAPTAIN": |
||||
return "Kapten"; |
||||
case "TEAMMATE": |
||||
return "Meeskonnaliige"; |
||||
default: |
||||
return role; |
||||
} |
||||
} |
||||
|
||||
export default async function AdminTeams() { |
||||
// Fetch teams with their members and member users
|
||||
const teams = await db.query.teams.findMany({ |
||||
with: { |
||||
members: { |
||||
with: { |
||||
user: true, |
||||
}, |
||||
}, |
||||
}, |
||||
}); |
||||
|
||||
return ( |
||||
<div className="flex flex-col min-h-[90vh] p-12 pt-18"> |
||||
<div className="flex items-center gap-4"> |
||||
<Link href={"/haldus"}> |
||||
<span className="material-symbols-outlined !text-[clamp(2rem,1.5rem+1.5vw,3.5rem)] !font-bold text-[#007CAB] dark:text-[#00A3E0] translate-y-2.5 hover:-translate-x-2 dark:hover:text-[#EEE5E5] hover:text-[#2A2C3F] transition"> |
||||
arrow_left_alt |
||||
</span> |
||||
</Link> |
||||
<h1 |
||||
className={`text-5xl sm:text-6xl ${vipnagorgialla.className} font-bold italic uppercase text-[#2A2C3F] dark:text-[#EEE5E5] mt-8 mb-4`} |
||||
> |
||||
Haldus - Meeskonnad |
||||
</h1> |
||||
</div> |
||||
<div className="text-2xl text-[#2A2C3F] dark:text-[#EEE5E5]"> |
||||
<div> |
||||
<Table> |
||||
<TableHeader> |
||||
<TableRow> |
||||
<TableHead className="w-[100px]">ID</TableHead> |
||||
<TableHead>Nimi</TableHead> |
||||
<TableHead>Liikmed</TableHead> |
||||
</TableRow> |
||||
</TableHeader> |
||||
<TableBody> |
||||
{teams.map((team: any) => ( |
||||
<TableRow key={team.id}> |
||||
<TableCell className="font-medium">{team.id}</TableCell> |
||||
<TableCell>{team.name}</TableCell> |
||||
<TableCell> |
||||
<div className="flex flex-col gap-1"> |
||||
{team.members && team.members.length > 0 ? ( |
||||
team.members.map((member: any) => ( |
||||
<div |
||||
key={member.id} |
||||
className="flex items-center gap-2 text-sm" |
||||
> |
||||
<span className="font-semibold"> |
||||
{member.user.firstName} {member.user.lastName} |
||||
</span> |
||||
<span className="text-gray-500"> |
||||
({translateRole(member.role)}) |
||||
</span> |
||||
</div> |
||||
)) |
||||
) : ( |
||||
<span className="text-gray-500">Liikmeid puuduvad</span> |
||||
)} |
||||
</div> |
||||
</TableCell> |
||||
</TableRow> |
||||
))} |
||||
</TableBody> |
||||
</Table> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
@ -0,0 +1,168 @@ |
||||
// Fonts
|
||||
import { vipnagorgialla } from "@/components/Vipnagorgialla"; |
||||
|
||||
// Database
|
||||
import { db } from "@/db/drizzle"; |
||||
import { syncFientaEvent } from "@/lib/fienta"; |
||||
|
||||
// Enviornment variables
|
||||
import("dotenv"); |
||||
|
||||
// User interface
|
||||
import { |
||||
Users, |
||||
IdCardLanyard, |
||||
DatabaseBackup, |
||||
CheckCircle2Icon, |
||||
X, |
||||
} from "lucide-react"; |
||||
|
||||
import Link from "next/link"; |
||||
import { revalidatePath } from "next/cache"; |
||||
import { redirect, RedirectType } from "next/navigation"; |
||||
|
||||
import { Button } from "@/components/ui/button"; |
||||
|
||||
import { |
||||
AlertDialog, |
||||
AlertDialogAction, |
||||
AlertDialogCancel, |
||||
AlertDialogContent, |
||||
AlertDialogDescription, |
||||
AlertDialogFooter, |
||||
AlertDialogTitle, |
||||
AlertDialogTrigger, |
||||
} from "@/components/ui/alert-dialog"; |
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; |
||||
|
||||
import { DataTable } from "@/components/haldus/data-table"; |
||||
import { columns } from "@/components/haldus/columns"; |
||||
|
||||
async function syncAction() { |
||||
"use server"; |
||||
await syncFientaEvent(process.env.EVENT_ID!, process.env.FIENTA_API_KEY!); |
||||
|
||||
// Revalidate due to data change
|
||||
revalidatePath("/haldus"); |
||||
redirect("/haldus?success=true"); |
||||
} |
||||
|
||||
async function dismissAlert() { |
||||
"use server"; |
||||
redirect("/haldus", RedirectType.replace); |
||||
} |
||||
|
||||
const SuccessAlertDB = () => { |
||||
return ( |
||||
<Alert className="flex items-start mt-8"> |
||||
<CheckCircle2Icon className="mt-0.5" /> |
||||
<div className="flex-1"> |
||||
<AlertTitle>Toiming oli edukas!</AlertTitle> |
||||
<AlertDescription>Andmebaasi andmed on uuendatud.</AlertDescription> |
||||
</div> |
||||
<form action={dismissAlert} className="ml-2"> |
||||
<Button |
||||
type="submit" |
||||
variant="ghost" |
||||
size="icon" |
||||
className="cursor-pointer" |
||||
> |
||||
<X className="" /> |
||||
</Button> |
||||
</form> |
||||
</Alert> |
||||
); |
||||
}; |
||||
|
||||
export default async function Admin({ |
||||
searchParams, |
||||
}: { |
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>; |
||||
}) { |
||||
const alarmStatus = await searchParams; |
||||
const showSuccess = alarmStatus.success === "true"; |
||||
|
||||
// Fetch users
|
||||
const usersData = await db.query.users.findMany({ |
||||
with: { |
||||
members: { |
||||
with: { |
||||
team: true, |
||||
}, |
||||
}, |
||||
}, |
||||
}); |
||||
const teamsData = await db.query.teams.findMany(); |
||||
|
||||
return ( |
||||
<div className="flex flex-col min-h-[90vh] p-12 pt-18"> |
||||
{showSuccess && <SuccessAlertDB />} |
||||
<div className="flex items-center gap-4"> |
||||
<Link href={"/"}> |
||||
<span className="material-symbols-outlined !text-[clamp(2rem,1.5rem+1.5vw,3.5rem)] !font-bold text-[#007CAB] dark:text-[#00A3E0] translate-y-2.5 hover:-translate-x-2 dark:hover:text-[#EEE5E5] hover:text-[#2A2C3F] transition"> |
||||
arrow_left_alt |
||||
</span> |
||||
</Link> |
||||
<h1 |
||||
className={`text-5xl sm:text-6xl ${vipnagorgialla.className} font-bold italic uppercase text-[#2A2C3F] dark:text-[#EEE5E5] mt-8 mb-4`} |
||||
> |
||||
Haldus |
||||
</h1> |
||||
</div> |
||||
<div className="text-2xl text-[#2A2C3F] dark:text-[#EEE5E5]"> |
||||
<div className="pl-2 flex gap-8 pb-4"> |
||||
<div className="flex text-lg md:text-2xl flex-row items-center"> |
||||
<Users className="mr-2" /> |
||||
Kasutajaid: {usersData.length} |
||||
</div> |
||||
<Link href="/haldus/meeskonnad" className="flex items-center"> |
||||
<div className="flex text-lg md:text-2xl flex-row items-center"> |
||||
<IdCardLanyard className="mr-2" /> |
||||
Meeskondasid: {teamsData.length} |
||||
</div> |
||||
</Link> |
||||
<AlertDialog> |
||||
<AlertDialogTrigger asChild> |
||||
<div className="ml-auto"> |
||||
<Button |
||||
variant="ghost" |
||||
size="icon" |
||||
className="size-12 cursor-pointer" |
||||
> |
||||
<DatabaseBackup className="scale-150" /> |
||||
</Button> |
||||
</div> |
||||
</AlertDialogTrigger> |
||||
<AlertDialogContent> |
||||
<AlertDialogTitle> |
||||
Kas soovite värskendada andmebaasi? |
||||
</AlertDialogTitle> |
||||
<AlertDialogDescription> |
||||
See tõmbab Fientast praegused andmed ning asendab{" "} |
||||
<span className="text-red-600 font-semibold">KÕIK</span>{" "} |
||||
olemasolevad andmed andmebaasis! |
||||
<br /> |
||||
<br /> |
||||
Kui sa ei ole kindel, vajuta "Tühista". |
||||
</AlertDialogDescription> |
||||
<AlertDialogFooter> |
||||
<AlertDialogCancel className="cursor-pointer"> |
||||
Tühista |
||||
</AlertDialogCancel> |
||||
<form action={syncAction}> |
||||
<AlertDialogAction type="submit" className="cursor-pointer"> |
||||
Värskenda |
||||
</AlertDialogAction> |
||||
</form> |
||||
</AlertDialogFooter> |
||||
</AlertDialogContent> |
||||
</AlertDialog> |
||||
</div> |
||||
<div> |
||||
<DataTable columns={columns} data={usersData} /> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
@ -0,0 +1,148 @@ |
||||
"use client"; |
||||
|
||||
import { ColumnDef } from "@tanstack/react-table"; |
||||
import { |
||||
KeyIcon, |
||||
Mails, |
||||
Ticket, |
||||
IdCardLanyard, |
||||
ArrowUpDown, |
||||
} from "lucide-react"; |
||||
import { Button } from "@/components/ui/button"; |
||||
|
||||
// Define the user type based on the database schema
|
||||
export type User = { |
||||
id: string; |
||||
firstName: string; |
||||
lastName: string; |
||||
email: string; |
||||
steamId: string | null; |
||||
ticketId: string | null; |
||||
ticketType: string | null; |
||||
members: { |
||||
team: { |
||||
name: string; |
||||
} | null; |
||||
}[]; |
||||
}; |
||||
|
||||
export const columns: ColumnDef<User>[] = [ |
||||
{ |
||||
accessorKey: "id", |
||||
header: ({ column }) => ( |
||||
<Button |
||||
variant="ghost" |
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} |
||||
className="flex items-center gap-2 h-auto p-0" |
||||
> |
||||
<KeyIcon className="h-4 w-4" /> |
||||
ID |
||||
<ArrowUpDown className="ml-2 h-4 w-4" /> |
||||
</Button> |
||||
), |
||||
cell: ({ row }) => <div className="font-medium">{row.getValue("id")}</div>, |
||||
}, |
||||
{ |
||||
accessorKey: "firstName", |
||||
header: ({ column }) => ( |
||||
<Button |
||||
variant="ghost" |
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} |
||||
className="h-auto p-0" |
||||
> |
||||
Eesnimi |
||||
<ArrowUpDown className="ml-2 h-4 w-4" /> |
||||
</Button> |
||||
), |
||||
cell: ({ row }) => <div>{row.getValue("firstName")}</div>, |
||||
}, |
||||
{ |
||||
accessorKey: "lastName", |
||||
header: ({ column }) => ( |
||||
<Button |
||||
variant="ghost" |
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} |
||||
className="h-auto p-0" |
||||
> |
||||
Perenimi |
||||
<ArrowUpDown className="ml-2 h-4 w-4" /> |
||||
</Button> |
||||
), |
||||
cell: ({ row }) => <div>{row.getValue("lastName")}</div>, |
||||
}, |
||||
{ |
||||
accessorKey: "email", |
||||
header: ({ column }) => ( |
||||
<Button |
||||
variant="ghost" |
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} |
||||
className="flex items-center gap-2 h-auto p-0" |
||||
> |
||||
<Mails className="h-4 w-4" /> |
||||
Email |
||||
<ArrowUpDown className="ml-2 h-4 w-4" /> |
||||
</Button> |
||||
), |
||||
cell: ({ row }) => <div>{row.getValue("email")}</div>, |
||||
}, |
||||
{ |
||||
accessorKey: "steamId", |
||||
header: "Steam ID", |
||||
cell: ({ row }) => { |
||||
const steamId = row.getValue("steamId") as string | null; |
||||
return <div>{steamId || "-"}</div>; |
||||
}, |
||||
}, |
||||
{ |
||||
accessorKey: "ticketId", |
||||
header: () => ( |
||||
<div className="flex items-center gap-2"> |
||||
<Ticket className="h-4 w-4" /> |
||||
Pileti ID |
||||
</div> |
||||
), |
||||
cell: ({ row }) => { |
||||
const ticketId = row.getValue("ticketId") as string | null; |
||||
return <div>{ticketId || "-"}</div>; |
||||
}, |
||||
}, |
||||
{ |
||||
accessorKey: "ticketType", |
||||
header: ({ column }) => ( |
||||
<Button |
||||
variant="ghost" |
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} |
||||
className="h-auto p-0" |
||||
> |
||||
Pileti tüüp |
||||
<ArrowUpDown className="ml-2 h-4 w-4" /> |
||||
</Button> |
||||
), |
||||
cell: ({ row }) => { |
||||
const ticketType = row.getValue("ticketType") as string | null; |
||||
return <div>{ticketType || "-"}</div>; |
||||
}, |
||||
}, |
||||
{ |
||||
id: "team", |
||||
accessorFn: (user) => |
||||
user.members && user.members.length > 0 && user.members[0].team |
||||
? user.members[0].team.name |
||||
: "", |
||||
header: ({ column }) => ( |
||||
<Button |
||||
variant="ghost" |
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} |
||||
className="flex items-center gap-2 h-auto p-0" |
||||
> |
||||
<IdCardLanyard className="h-4 w-4" /> |
||||
Meeskond |
||||
<ArrowUpDown className="ml-2 h-4 w-4" /> |
||||
</Button> |
||||
), |
||||
cell: ({ row }) => { |
||||
const teamName = row.getValue("team") as string; |
||||
return <div>{teamName || "-"}</div>; |
||||
}, |
||||
}, |
||||
]; |
||||
@ -0,0 +1,220 @@ |
||||
"use client"; |
||||
|
||||
import * as React from "react"; |
||||
import { |
||||
ColumnDef, |
||||
ColumnFiltersState, |
||||
SortingState, |
||||
VisibilityState, |
||||
flexRender, |
||||
getCoreRowModel, |
||||
getFilteredRowModel, |
||||
getPaginationRowModel, |
||||
getSortedRowModel, |
||||
useReactTable, |
||||
} from "@tanstack/react-table"; |
||||
|
||||
import { Eraser, Funnel, Search } from "lucide-react"; |
||||
|
||||
import { |
||||
Table, |
||||
TableBody, |
||||
TableCell, |
||||
TableHead, |
||||
TableHeader, |
||||
TableRow, |
||||
} from "@/components/ui/table"; |
||||
|
||||
import { |
||||
Select, |
||||
SelectContent, |
||||
SelectItem, |
||||
SelectTrigger, |
||||
SelectValue, |
||||
} from "@/components/ui/select"; |
||||
|
||||
import { Button } from "@/components/ui/button"; |
||||
import { Input } from "@/components/ui/input"; |
||||
|
||||
interface DataTableProps<TData, TValue> { |
||||
columns: ColumnDef<TData, TValue>[]; |
||||
data: TData[]; |
||||
} |
||||
|
||||
// Search input placeholders
|
||||
function getPlaceholderText(column: string): string { |
||||
const placeholders = { |
||||
email: "Filtreeri emaile...", |
||||
firstName: "Filtreeri eesnimesid...", |
||||
ticketType: "Filtreeri pileti tüüpi...", |
||||
team: "Filtreeri tiimi nimesid...", |
||||
}; |
||||
return placeholders[column as keyof typeof placeholders] || "Otsi..."; |
||||
} |
||||
|
||||
export function DataTable<TData, TValue>({ |
||||
columns, |
||||
data, |
||||
}: DataTableProps<TData, TValue>) { |
||||
const [sorting, setSorting] = React.useState<SortingState>([]); |
||||
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>( |
||||
[], |
||||
); |
||||
const [columnVisibility, setColumnVisibility] = |
||||
React.useState<VisibilityState>({}); |
||||
const [rowSelection, setRowSelection] = React.useState({}); |
||||
|
||||
const [searchColumn, setSearchColumn] = React.useState<string>("email"); |
||||
const [searchValue, setSearchValue] = React.useState<string>(""); |
||||
|
||||
const table = useReactTable({ |
||||
data, |
||||
columns, |
||||
onSortingChange: setSorting, |
||||
onColumnFiltersChange: setColumnFilters, |
||||
getCoreRowModel: getCoreRowModel(), |
||||
getPaginationRowModel: getPaginationRowModel(), |
||||
getSortedRowModel: getSortedRowModel(), |
||||
getFilteredRowModel: getFilteredRowModel(), |
||||
onColumnVisibilityChange: setColumnVisibility, |
||||
onRowSelectionChange: setRowSelection, |
||||
state: { |
||||
sorting, |
||||
columnFilters, |
||||
columnVisibility, |
||||
rowSelection, |
||||
}, |
||||
}); |
||||
|
||||
return ( |
||||
<div className="w-full"> |
||||
<div className="flex items-center py-4"> |
||||
<Search className="h-6 w-6 mr-2" /> |
||||
<Input |
||||
placeholder={getPlaceholderText(searchColumn)} |
||||
value={searchValue} |
||||
onChange={(event) => { |
||||
const value = event.target.value; |
||||
setSearchValue(value); |
||||
|
||||
// Clear all column filters first
|
||||
table.getColumn("email")?.setFilterValue(""); |
||||
table.getColumn("firstName")?.setFilterValue(""); |
||||
table.getColumn("ticketType")?.setFilterValue(""); |
||||
table.getColumn("team")?.setFilterValue(""); |
||||
|
||||
// Set filter for selected column
|
||||
table.getColumn(searchColumn)?.setFilterValue(value); |
||||
}} |
||||
className="max-w-sm rounded-r-none" |
||||
/> |
||||
<Select value={searchColumn} onValueChange={setSearchColumn}> |
||||
<SelectTrigger className="w-48 border-l-0 rounded-l-none"> |
||||
<Funnel /> |
||||
<SelectValue placeholder="Vali otsingu väli" /> |
||||
</SelectTrigger> |
||||
<SelectContent> |
||||
<SelectItem value="email">Email</SelectItem> |
||||
<SelectItem value="firstName">Eesnimi</SelectItem> |
||||
<SelectItem value="ticketType">Pileti tüüp</SelectItem> |
||||
<SelectItem value="team">Tiim</SelectItem> |
||||
</SelectContent> |
||||
</Select> |
||||
{searchValue && ( |
||||
<Button |
||||
variant="ghost" |
||||
size="lg" |
||||
onClick={() => { |
||||
setSearchValue(""); |
||||
table.getColumn(searchColumn)?.setFilterValue(""); |
||||
}} |
||||
className="ml-2" |
||||
> |
||||
<Eraser /> |
||||
Tühjenda |
||||
</Button> |
||||
)} |
||||
</div> |
||||
<div className="rounded-md border"> |
||||
<Table> |
||||
<TableHeader> |
||||
{table.getHeaderGroups().map((headerGroup) => ( |
||||
<TableRow key={headerGroup.id}> |
||||
{headerGroup.headers.map((header) => { |
||||
return ( |
||||
<TableHead key={header.id}> |
||||
{header.isPlaceholder |
||||
? null |
||||
: flexRender( |
||||
header.column.columnDef.header, |
||||
header.getContext(), |
||||
)} |
||||
</TableHead> |
||||
); |
||||
})} |
||||
</TableRow> |
||||
))} |
||||
</TableHeader> |
||||
<TableBody> |
||||
{table.getRowModel().rows?.length ? ( |
||||
table.getRowModel().rows.map((row) => ( |
||||
<TableRow |
||||
key={row.id} |
||||
data-state={row.getIsSelected() && "selected"} |
||||
> |
||||
{row.getVisibleCells().map((cell) => ( |
||||
<TableCell key={cell.id}> |
||||
{flexRender( |
||||
cell.column.columnDef.cell, |
||||
cell.getContext(), |
||||
)} |
||||
</TableCell> |
||||
))} |
||||
</TableRow> |
||||
)) |
||||
) : ( |
||||
<TableRow> |
||||
<TableCell |
||||
colSpan={columns.length} |
||||
className="h-24 text-center" |
||||
> |
||||
Tulemusi ei leitud. |
||||
</TableCell> |
||||
</TableRow> |
||||
)} |
||||
</TableBody> |
||||
</Table> |
||||
</div> |
||||
<div className="flex items-center justify-between space-x-2 py-4"> |
||||
<div className="flex-1 text-sm text-muted-foreground"> |
||||
Näidatakse {table.getFilteredRowModel().rows.length} rida(de) kokku{" "} |
||||
{data.length} reast. |
||||
</div> |
||||
<div className="flex items-center space-x-2 text-sm text-muted-foreground"> |
||||
<span> |
||||
Lehekülg {table.getState().pagination.pageIndex + 1} /{" "} |
||||
{table.getPageCount()} |
||||
</span> |
||||
</div> |
||||
<div className="space-x-2"> |
||||
<Button |
||||
variant="outline" |
||||
size="sm" |
||||
onClick={() => table.previousPage()} |
||||
disabled={!table.getCanPreviousPage()} |
||||
> |
||||
Eelmine |
||||
</Button> |
||||
<Button |
||||
variant="outline" |
||||
size="sm" |
||||
onClick={() => table.nextPage()} |
||||
disabled={!table.getCanNextPage()} |
||||
> |
||||
Järgmine |
||||
</Button> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
@ -0,0 +1,157 @@ |
||||
"use client" |
||||
|
||||
import * as React from "react" |
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" |
||||
|
||||
import { cn } from "@/lib/utils" |
||||
import { buttonVariants } from "@/components/ui/button" |
||||
|
||||
function AlertDialog({ |
||||
...props |
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) { |
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} /> |
||||
} |
||||
|
||||
function AlertDialogTrigger({ |
||||
...props |
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) { |
||||
return ( |
||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} /> |
||||
) |
||||
} |
||||
|
||||
function AlertDialogPortal({ |
||||
...props |
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) { |
||||
return ( |
||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} /> |
||||
) |
||||
} |
||||
|
||||
function AlertDialogOverlay({ |
||||
className, |
||||
...props |
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) { |
||||
return ( |
||||
<AlertDialogPrimitive.Overlay |
||||
data-slot="alert-dialog-overlay" |
||||
className={cn( |
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50", |
||||
className |
||||
)} |
||||
{...props} |
||||
/> |
||||
) |
||||
} |
||||
|
||||
function AlertDialogContent({ |
||||
className, |
||||
...props |
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) { |
||||
return ( |
||||
<AlertDialogPortal> |
||||
<AlertDialogOverlay /> |
||||
<AlertDialogPrimitive.Content |
||||
data-slot="alert-dialog-content" |
||||
className={cn( |
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg", |
||||
className |
||||
)} |
||||
{...props} |
||||
/> |
||||
</AlertDialogPortal> |
||||
) |
||||
} |
||||
|
||||
function AlertDialogHeader({ |
||||
className, |
||||
...props |
||||
}: React.ComponentProps<"div">) { |
||||
return ( |
||||
<div |
||||
data-slot="alert-dialog-header" |
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)} |
||||
{...props} |
||||
/> |
||||
) |
||||
} |
||||
|
||||
function AlertDialogFooter({ |
||||
className, |
||||
...props |
||||
}: React.ComponentProps<"div">) { |
||||
return ( |
||||
<div |
||||
data-slot="alert-dialog-footer" |
||||
className={cn( |
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", |
||||
className |
||||
)} |
||||
{...props} |
||||
/> |
||||
) |
||||
} |
||||
|
||||
function AlertDialogTitle({ |
||||
className, |
||||
...props |
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) { |
||||
return ( |
||||
<AlertDialogPrimitive.Title |
||||
data-slot="alert-dialog-title" |
||||
className={cn("text-lg font-semibold", className)} |
||||
{...props} |
||||
/> |
||||
) |
||||
} |
||||
|
||||
function AlertDialogDescription({ |
||||
className, |
||||
...props |
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) { |
||||
return ( |
||||
<AlertDialogPrimitive.Description |
||||
data-slot="alert-dialog-description" |
||||
className={cn("text-muted-foreground text-sm", className)} |
||||
{...props} |
||||
/> |
||||
) |
||||
} |
||||
|
||||
function AlertDialogAction({ |
||||
className, |
||||
...props |
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) { |
||||
return ( |
||||
<AlertDialogPrimitive.Action |
||||
className={cn(buttonVariants(), className)} |
||||
{...props} |
||||
/> |
||||
) |
||||
} |
||||
|
||||
function AlertDialogCancel({ |
||||
className, |
||||
...props |
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) { |
||||
return ( |
||||
<AlertDialogPrimitive.Cancel |
||||
className={cn(buttonVariants({ variant: "outline" }), className)} |
||||
{...props} |
||||
/> |
||||
) |
||||
} |
||||
|
||||
export { |
||||
AlertDialog, |
||||
AlertDialogPortal, |
||||
AlertDialogOverlay, |
||||
AlertDialogTrigger, |
||||
AlertDialogContent, |
||||
AlertDialogHeader, |
||||
AlertDialogFooter, |
||||
AlertDialogTitle, |
||||
AlertDialogDescription, |
||||
AlertDialogAction, |
||||
AlertDialogCancel, |
||||
} |
||||
@ -0,0 +1,66 @@ |
||||
import * as React from "react" |
||||
import { cva, type VariantProps } from "class-variance-authority" |
||||
|
||||
import { cn } from "@/lib/utils" |
||||
|
||||
const alertVariants = cva( |
||||
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", |
||||
{ |
||||
variants: { |
||||
variant: { |
||||
default: "bg-card text-card-foreground", |
||||
destructive: |
||||
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90", |
||||
}, |
||||
}, |
||||
defaultVariants: { |
||||
variant: "default", |
||||
}, |
||||
} |
||||
) |
||||
|
||||
function Alert({ |
||||
className, |
||||
variant, |
||||
...props |
||||
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) { |
||||
return ( |
||||
<div |
||||
data-slot="alert" |
||||
role="alert" |
||||
className={cn(alertVariants({ variant }), className)} |
||||
{...props} |
||||
/> |
||||
) |
||||
} |
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { |
||||
return ( |
||||
<div |
||||
data-slot="alert-title" |
||||
className={cn( |
||||
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight", |
||||
className |
||||
)} |
||||
{...props} |
||||
/> |
||||
) |
||||
} |
||||
|
||||
function AlertDescription({ |
||||
className, |
||||
...props |
||||
}: React.ComponentProps<"div">) { |
||||
return ( |
||||
<div |
||||
data-slot="alert-description" |
||||
className={cn( |
||||
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed", |
||||
className |
||||
)} |
||||
{...props} |
||||
/> |
||||
) |
||||
} |
||||
|
||||
export { Alert, AlertTitle, AlertDescription } |
||||
@ -0,0 +1,92 @@ |
||||
import * as React from "react"; |
||||
|
||||
import { cn } from "@/lib/utils"; |
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) { |
||||
return ( |
||||
<div |
||||
data-slot="card" |
||||
className={cn( |
||||
"bg-transparent text-card-foreground flex flex-col gap-6 rounded-xl border-3 border-[#1F5673] py-6 shadow-sm", |
||||
className, |
||||
)} |
||||
{...props} |
||||
/> |
||||
); |
||||
} |
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) { |
||||
return ( |
||||
<div |
||||
data-slot="card-header" |
||||
className={cn( |
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6", |
||||
className, |
||||
)} |
||||
{...props} |
||||
/> |
||||
); |
||||
} |
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) { |
||||
return ( |
||||
<div |
||||
data-slot="card-title" |
||||
className={cn("leading-none font-semibold", className)} |
||||
{...props} |
||||
/> |
||||
); |
||||
} |
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) { |
||||
return ( |
||||
<div |
||||
data-slot="card-description" |
||||
className={cn("text-muted-foreground text-sm", className)} |
||||
{...props} |
||||
/> |
||||
); |
||||
} |
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) { |
||||
return ( |
||||
<div |
||||
data-slot="card-action" |
||||
className={cn( |
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end", |
||||
className, |
||||
)} |
||||
{...props} |
||||
/> |
||||
); |
||||
} |
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) { |
||||
return ( |
||||
<div |
||||
data-slot="card-content" |
||||
className={cn("px-6", className)} |
||||
{...props} |
||||
/> |
||||
); |
||||
} |
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) { |
||||
return ( |
||||
<div |
||||
data-slot="card-footer" |
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)} |
||||
{...props} |
||||
/> |
||||
); |
||||
} |
||||
|
||||
export { |
||||
Card, |
||||
CardHeader, |
||||
CardFooter, |
||||
CardTitle, |
||||
CardAction, |
||||
CardDescription, |
||||
CardContent, |
||||
}; |
||||
@ -0,0 +1,21 @@ |
||||
import * as React from "react"; |
||||
|
||||
import { cn } from "@/lib/utils"; |
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) { |
||||
return ( |
||||
<input |
||||
type={type} |
||||
data-slot="input" |
||||
className={cn( |
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", |
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", |
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", |
||||
className, |
||||
)} |
||||
{...props} |
||||
/> |
||||
); |
||||
} |
||||
|
||||
export { Input }; |
||||
@ -0,0 +1,185 @@ |
||||
"use client"; |
||||
|
||||
import * as React from "react"; |
||||
import * as SelectPrimitive from "@radix-ui/react-select"; |
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"; |
||||
|
||||
import { cn } from "@/lib/utils"; |
||||
|
||||
function Select({ |
||||
...props |
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) { |
||||
return <SelectPrimitive.Root data-slot="select" {...props} />; |
||||
} |
||||
|
||||
function SelectGroup({ |
||||
...props |
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) { |
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />; |
||||
} |
||||
|
||||
function SelectValue({ |
||||
...props |
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) { |
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />; |
||||
} |
||||
|
||||
function SelectTrigger({ |
||||
className, |
||||
size = "default", |
||||
children, |
||||
...props |
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & { |
||||
size?: "sm" | "default"; |
||||
}) { |
||||
return ( |
||||
<SelectPrimitive.Trigger |
||||
data-slot="select-trigger" |
||||
data-size={size} |
||||
className={cn( |
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", |
||||
className, |
||||
)} |
||||
{...props} |
||||
> |
||||
{children} |
||||
<SelectPrimitive.Icon asChild> |
||||
<ChevronDownIcon className="size-4 opacity-50" /> |
||||
</SelectPrimitive.Icon> |
||||
</SelectPrimitive.Trigger> |
||||
); |
||||
} |
||||
|
||||
function SelectContent({ |
||||
className, |
||||
children, |
||||
position = "popper", |
||||
...props |
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) { |
||||
return ( |
||||
<SelectPrimitive.Portal> |
||||
<SelectPrimitive.Content |
||||
data-slot="select-content" |
||||
className={cn( |
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md", |
||||
position === "popper" && |
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", |
||||
className, |
||||
)} |
||||
position={position} |
||||
{...props} |
||||
> |
||||
<SelectScrollUpButton /> |
||||
<SelectPrimitive.Viewport |
||||
className={cn( |
||||
"p-1", |
||||
position === "popper" && |
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1", |
||||
)} |
||||
> |
||||
{children} |
||||
</SelectPrimitive.Viewport> |
||||
<SelectScrollDownButton /> |
||||
</SelectPrimitive.Content> |
||||
</SelectPrimitive.Portal> |
||||
); |
||||
} |
||||
|
||||
function SelectLabel({ |
||||
className, |
||||
...props |
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) { |
||||
return ( |
||||
<SelectPrimitive.Label |
||||
data-slot="select-label" |
||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)} |
||||
{...props} |
||||
/> |
||||
); |
||||
} |
||||
|
||||
function SelectItem({ |
||||
className, |
||||
children, |
||||
...props |
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) { |
||||
return ( |
||||
<SelectPrimitive.Item |
||||
data-slot="select-item" |
||||
className={cn( |
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2", |
||||
className, |
||||
)} |
||||
{...props} |
||||
> |
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center"> |
||||
<SelectPrimitive.ItemIndicator> |
||||
<CheckIcon className="size-4" /> |
||||
</SelectPrimitive.ItemIndicator> |
||||
</span> |
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> |
||||
</SelectPrimitive.Item> |
||||
); |
||||
} |
||||
|
||||
function SelectSeparator({ |
||||
className, |
||||
...props |
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) { |
||||
return ( |
||||
<SelectPrimitive.Separator |
||||
data-slot="select-separator" |
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)} |
||||
{...props} |
||||
/> |
||||
); |
||||
} |
||||
|
||||
function SelectScrollUpButton({ |
||||
className, |
||||
...props |
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) { |
||||
return ( |
||||
<SelectPrimitive.ScrollUpButton |
||||
data-slot="select-scroll-up-button" |
||||
className={cn( |
||||
"flex cursor-default items-center justify-center py-1", |
||||
className, |
||||
)} |
||||
{...props} |
||||
> |
||||
<ChevronUpIcon className="size-4" /> |
||||
</SelectPrimitive.ScrollUpButton> |
||||
); |
||||
} |
||||
|
||||
function SelectScrollDownButton({ |
||||
className, |
||||
...props |
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) { |
||||
return ( |
||||
<SelectPrimitive.ScrollDownButton |
||||
data-slot="select-scroll-down-button" |
||||
className={cn( |
||||
"flex cursor-default items-center justify-center py-1", |
||||
className, |
||||
)} |
||||
{...props} |
||||
> |
||||
<ChevronDownIcon className="size-4" /> |
||||
</SelectPrimitive.ScrollDownButton> |
||||
); |
||||
} |
||||
|
||||
export { |
||||
Select, |
||||
SelectContent, |
||||
SelectGroup, |
||||
SelectItem, |
||||
SelectLabel, |
||||
SelectScrollDownButton, |
||||
SelectScrollUpButton, |
||||
SelectSeparator, |
||||
SelectTrigger, |
||||
SelectValue, |
||||
}; |
||||
@ -0,0 +1,116 @@ |
||||
"use client" |
||||
|
||||
import * as React from "react" |
||||
|
||||
import { cn } from "@/lib/utils" |
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) { |
||||
return ( |
||||
<div |
||||
data-slot="table-container" |
||||
className="relative w-full overflow-x-auto" |
||||
> |
||||
<table |
||||
data-slot="table" |
||||
className={cn("w-full caption-bottom text-sm", className)} |
||||
{...props} |
||||
/> |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { |
||||
return ( |
||||
<thead |
||||
data-slot="table-header" |
||||
className={cn("[&_tr]:border-b", className)} |
||||
{...props} |
||||
/> |
||||
) |
||||
} |
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { |
||||
return ( |
||||
<tbody |
||||
data-slot="table-body" |
||||
className={cn("[&_tr:last-child]:border-0", className)} |
||||
{...props} |
||||
/> |
||||
) |
||||
} |
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { |
||||
return ( |
||||
<tfoot |
||||
data-slot="table-footer" |
||||
className={cn( |
||||
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0", |
||||
className |
||||
)} |
||||
{...props} |
||||
/> |
||||
) |
||||
} |
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) { |
||||
return ( |
||||
<tr |
||||
data-slot="table-row" |
||||
className={cn( |
||||
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors", |
||||
className |
||||
)} |
||||
{...props} |
||||
/> |
||||
) |
||||
} |
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) { |
||||
return ( |
||||
<th |
||||
data-slot="table-head" |
||||
className={cn( |
||||
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", |
||||
className |
||||
)} |
||||
{...props} |
||||
/> |
||||
) |
||||
} |
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) { |
||||
return ( |
||||
<td |
||||
data-slot="table-cell" |
||||
className={cn( |
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", |
||||
className |
||||
)} |
||||
{...props} |
||||
/> |
||||
) |
||||
} |
||||
|
||||
function TableCaption({ |
||||
className, |
||||
...props |
||||
}: React.ComponentProps<"caption">) { |
||||
return ( |
||||
<caption |
||||
data-slot="table-caption" |
||||
className={cn("text-muted-foreground mt-4 text-sm", className)} |
||||
{...props} |
||||
/> |
||||
) |
||||
} |
||||
|
||||
export { |
||||
Table, |
||||
TableHeader, |
||||
TableBody, |
||||
TableFooter, |
||||
TableHead, |
||||
TableRow, |
||||
TableCell, |
||||
TableCaption, |
||||
} |
||||
@ -0,0 +1,61 @@ |
||||
"use client" |
||||
|
||||
import * as React from "react" |
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip" |
||||
|
||||
import { cn } from "@/lib/utils" |
||||
|
||||
function TooltipProvider({ |
||||
delayDuration = 0, |
||||
...props |
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) { |
||||
return ( |
||||
<TooltipPrimitive.Provider |
||||
data-slot="tooltip-provider" |
||||
delayDuration={delayDuration} |
||||
{...props} |
||||
/> |
||||
) |
||||
} |
||||
|
||||
function Tooltip({ |
||||
...props |
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) { |
||||
return ( |
||||
<TooltipProvider> |
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} /> |
||||
</TooltipProvider> |
||||
) |
||||
} |
||||
|
||||
function TooltipTrigger({ |
||||
...props |
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) { |
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} /> |
||||
} |
||||
|
||||
function TooltipContent({ |
||||
className, |
||||
sideOffset = 0, |
||||
children, |
||||
...props |
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) { |
||||
return ( |
||||
<TooltipPrimitive.Portal> |
||||
<TooltipPrimitive.Content |
||||
data-slot="tooltip-content" |
||||
sideOffset={sideOffset} |
||||
className={cn( |
||||
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance", |
||||
className |
||||
)} |
||||
{...props} |
||||
> |
||||
{children} |
||||
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" /> |
||||
</TooltipPrimitive.Content> |
||||
</TooltipPrimitive.Portal> |
||||
) |
||||
} |
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } |
||||
@ -0,0 +1,6 @@ |
||||
import { drizzle } from "drizzle-orm/bun-sqlite"; |
||||
import { Database } from "bun:sqlite"; |
||||
import * as schema from "./schema"; |
||||
|
||||
const sqlite = new Database("data/tipilan.db"); |
||||
export const db = drizzle(sqlite, { schema }); |
||||
@ -0,0 +1,139 @@ |
||||
import { |
||||
sqliteTable, |
||||
text, |
||||
integer, |
||||
primaryKey, |
||||
uniqueIndex, |
||||
} from "drizzle-orm/sqlite-core"; |
||||
import { relations } from "drizzle-orm"; |
||||
import { createId } from "@paralleldrive/cuid2"; |
||||
|
||||
// Roles enum equivalent
|
||||
export const RoleEnum = { |
||||
VISITOR: "VISITOR", |
||||
PARTICIPANT: "PARTICIPANT", |
||||
TEAMMATE: "TEAMMATE", |
||||
CAPTAIN: "CAPTAIN", |
||||
ADMIN: "ADMIN", |
||||
} as const; |
||||
|
||||
export type Role = (typeof RoleEnum)[keyof typeof RoleEnum]; |
||||
|
||||
// User table
|
||||
export const users = sqliteTable("user", { |
||||
id: text("id") |
||||
.primaryKey() |
||||
.notNull() |
||||
.$defaultFn(() => createId()), |
||||
email: text("email").notNull(), |
||||
steamId: text("steam_id").unique(), |
||||
firstName: text("first_name").notNull(), |
||||
lastName: text("last_name").notNull(), |
||||
ticketId: text("ticket_id").unique(), |
||||
ticketType: text("ticket_type"), |
||||
}); |
||||
|
||||
// Team table
|
||||
export const teams = sqliteTable("team", { |
||||
id: text("id") |
||||
.primaryKey() |
||||
.notNull() |
||||
.$defaultFn(() => createId()), |
||||
name: text("name").notNull(), |
||||
}); |
||||
|
||||
// Member table (join table for User and Team with role)
|
||||
export const members = sqliteTable( |
||||
"member", |
||||
{ |
||||
id: text("id") |
||||
.primaryKey() |
||||
.notNull() |
||||
.$defaultFn(() => createId()), |
||||
userId: text("user_id") |
||||
.notNull() |
||||
.references(() => users.id, { onDelete: "cascade" }), |
||||
teamId: text("team_id").references(() => teams.id, { onDelete: "cascade" }), |
||||
role: text("role", { |
||||
enum: Object.values(RoleEnum) as [string, ...string[]], |
||||
}).notNull(), |
||||
}, |
||||
(table) => { |
||||
return { |
||||
userTeamUnique: uniqueIndex("user_team_unique").on( |
||||
table.userId, |
||||
table.teamId, |
||||
), |
||||
}; |
||||
}, |
||||
); |
||||
|
||||
// Tournament table
|
||||
export const tournaments = sqliteTable("tournament", { |
||||
id: text("id") |
||||
.primaryKey() |
||||
.notNull() |
||||
.$defaultFn(() => createId()), |
||||
name: text("name").notNull(), |
||||
}); |
||||
|
||||
// TournamentTeam join table
|
||||
export const tournamentTeams = sqliteTable( |
||||
"tournament_team", |
||||
{ |
||||
id: text("id") |
||||
.primaryKey() |
||||
.notNull() |
||||
.$defaultFn(() => createId()), |
||||
tournamentId: text("tournament_id") |
||||
.notNull() |
||||
.references(() => tournaments.id, { onDelete: "cascade" }), |
||||
teamId: text("team_id") |
||||
.notNull() |
||||
.references(() => teams.id, { onDelete: "cascade" }), |
||||
registrationDate: integer("registration_date", { mode: "timestamp" }) |
||||
.notNull() |
||||
.$defaultFn(() => new Date()), |
||||
}, |
||||
(table) => { |
||||
return { |
||||
tournamentTeamUnique: uniqueIndex("tournament_team_unique").on( |
||||
table.tournamentId, |
||||
table.teamId, |
||||
), |
||||
}; |
||||
}, |
||||
); |
||||
|
||||
// Relations
|
||||
export const usersRelations = relations(users, ({ many }) => ({ |
||||
members: many(members), |
||||
})); |
||||
|
||||
export const teamsRelations = relations(teams, ({ many }) => ({ |
||||
members: many(members), |
||||
tournamentTeams: many(tournamentTeams), |
||||
})); |
||||
|
||||
export const membersRelations = relations(members, ({ one }) => ({ |
||||
user: one(users, { fields: [members.userId], references: [users.id] }), |
||||
team: one(teams, { fields: [members.teamId], references: [teams.id] }), |
||||
})); |
||||
|
||||
export const tournamentsRelations = relations(tournaments, ({ many }) => ({ |
||||
tournamentTeams: many(tournamentTeams), |
||||
})); |
||||
|
||||
export const tournamentTeamsRelations = relations( |
||||
tournamentTeams, |
||||
({ one }) => ({ |
||||
tournament: one(tournaments, { |
||||
fields: [tournamentTeams.tournamentId], |
||||
references: [tournaments.id], |
||||
}), |
||||
team: one(teams, { |
||||
fields: [tournamentTeams.teamId], |
||||
references: [teams.id], |
||||
}), |
||||
}), |
||||
); |
||||
@ -0,0 +1,350 @@ |
||||
import { db } from "@/db/drizzle"; |
||||
import { users, teams, members, tournamentTeams } from "@/db/schema"; |
||||
import { eq, and, isNull } from "drizzle-orm"; |
||||
import { RoleEnum, type Role } from "@/db/schema"; |
||||
|
||||
// Types based on the Fienta API response
|
||||
export interface FientaApiResponse { |
||||
success: { |
||||
code: number; |
||||
user_message: string; |
||||
internal_message: string; |
||||
}; |
||||
time: { |
||||
timestamp: number; |
||||
date: string; |
||||
time: string; |
||||
full_datetime: string; |
||||
timezone: string; |
||||
timezone_short: string; |
||||
gmt: string; |
||||
}; |
||||
count: number; |
||||
tickets: FientaTicket[]; |
||||
} |
||||
|
||||
export interface FientaTicket { |
||||
id: number; |
||||
organizer_id: number; |
||||
event_id: number; |
||||
order_id: number; |
||||
code: string; |
||||
status: string; |
||||
used_at: string | null; |
||||
created_at: string; |
||||
updated_at: string; |
||||
validated_by_id: number | null; |
||||
ip: string; |
||||
is_parent: boolean; |
||||
parent_id: number | null; |
||||
order_email: string; |
||||
order_phone: string; |
||||
contents_html: string; |
||||
nametag_html: string; |
||||
qty: number; |
||||
permissions: { |
||||
update: boolean; |
||||
}; |
||||
rows: FientaTicketRow[]; |
||||
} |
||||
|
||||
export interface FientaTicketRow { |
||||
ticket_type: { |
||||
id: number; |
||||
title: string; |
||||
attendance_mode: string; |
||||
}; |
||||
attendee: { |
||||
first_name: string; |
||||
last_name: string; |
||||
full_name: string; |
||||
email: string; |
||||
[key: string]: string; // To handle dynamic field names
|
||||
}; |
||||
} |
||||
|
||||
/** |
||||
* Fetches tickets from the Fienta API for a specific event |
||||
*/ |
||||
export async function fetchFientaTickets( |
||||
eventId: string, |
||||
apiKey: string, |
||||
): Promise<FientaApiResponse> { |
||||
const response = await fetch( |
||||
`https://fienta.com/api/v1/events/${eventId}/tickets/?attendees`, |
||||
{ |
||||
headers: { |
||||
Authorization: `Bearer ${apiKey}`, |
||||
Accept: "application/json", |
||||
}, |
||||
}, |
||||
); |
||||
|
||||
if (!response.ok) { |
||||
throw new Error(`Failed to fetch tickets: ${response.statusText}`); |
||||
} |
||||
|
||||
return response.json(); |
||||
} |
||||
|
||||
/** |
||||
* Extracts Steam ID from a Steam profile URL |
||||
*/ |
||||
export function extractSteamId(steamUrl: string): string | null { |
||||
if (!steamUrl) return null; |
||||
|
||||
// Use regex to handle both escaped and unescaped URLs, with or without trailing slash
|
||||
const regex = /(?:\/|\\\/)(id|profiles)\/([^\/\\]+)(?:\/|\\"\/)?$/; |
||||
const match = steamUrl.match(regex); |
||||
|
||||
// Return the matched ID or null if no match
|
||||
return match && match[2] ? match[2] : null; |
||||
} |
||||
|
||||
/** |
||||
* Finds an attendee field by prefix in the attendee data |
||||
* Fienta's API can return custom form fields with dynamic names |
||||
*/ |
||||
function findAttendeeFieldByPrefix( |
||||
attendee: { [key: string]: string }, |
||||
prefix: string, |
||||
): string | null { |
||||
// Find the first field that starts with the given prefix
|
||||
const fieldKey = Object.keys(attendee).find((key) => key.startsWith(prefix)); |
||||
|
||||
// Return the value if found, or null otherwise
|
||||
return fieldKey ? attendee[fieldKey] : null; |
||||
} |
||||
|
||||
/** |
||||
* Determines the appropriate user role based on ticket type and captain status |
||||
*/ |
||||
function determineUserRole( |
||||
ticketType: string, |
||||
isCaptain: boolean, |
||||
isTeamMember: boolean, |
||||
): Role { |
||||
// Case-insensitive check for tournament participants
|
||||
const isTournamentParticipant = ticketType |
||||
.toLowerCase() |
||||
.includes("põhiturniiri osaleja"); |
||||
|
||||
const isParticipant = ticketType.toLowerCase().includes("arvutiga osaleja"); |
||||
|
||||
if (!isTeamMember && isParticipant) { |
||||
return RoleEnum.PARTICIPANT; |
||||
} |
||||
|
||||
if (isTournamentParticipant) { |
||||
if (isCaptain) { |
||||
return RoleEnum.CAPTAIN; |
||||
} else if (isTeamMember) { |
||||
return RoleEnum.TEAMMATE; |
||||
} |
||||
return RoleEnum.PARTICIPANT; |
||||
} |
||||
|
||||
return RoleEnum.VISITOR; |
||||
} |
||||
|
||||
/** |
||||
* Upserts a user in the database (create or update) |
||||
*/ |
||||
async function upsertUser(userData: { |
||||
email: string; |
||||
firstName: string; |
||||
lastName: string; |
||||
steamId: string | null; |
||||
ticketId: string; |
||||
ticketType: string; |
||||
}) { |
||||
// Try to find existing user by ticket ID (primary unique identifier)
|
||||
const existingUser = await db.query.users.findFirst({ |
||||
where: eq(users.ticketId, userData.ticketId), |
||||
}); |
||||
|
||||
if (existingUser) { |
||||
// Update existing user
|
||||
await db |
||||
.update(users) |
||||
.set({ |
||||
email: userData.email, |
||||
firstName: userData.firstName, |
||||
lastName: userData.lastName, |
||||
steamId: userData.steamId, |
||||
ticketType: userData.ticketType, |
||||
}) |
||||
.where(eq(users.id, existingUser.id)); |
||||
|
||||
return existingUser; |
||||
} else { |
||||
// Create new user
|
||||
const [newUser] = await db.insert(users).values(userData).returning(); |
||||
return newUser; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Upserts a team member relationship |
||||
*/ |
||||
async function upsertMember(userId: string, teamId: string | null, role: Role) { |
||||
// Try to find existing member
|
||||
const whereCondition = teamId |
||||
? and(eq(members.userId, userId), eq(members.teamId, teamId)) |
||||
: and(eq(members.userId, userId), isNull(members.teamId)); |
||||
|
||||
const existingMember = await db.query.members.findFirst({ |
||||
where: whereCondition, |
||||
}); |
||||
|
||||
if (existingMember) { |
||||
// Update existing member
|
||||
await db |
||||
.update(members) |
||||
.set({ role }) |
||||
.where(eq(members.id, existingMember.id)); |
||||
} else { |
||||
// Create new member
|
||||
await db.insert(members).values({ |
||||
userId, |
||||
teamId, |
||||
role, |
||||
}); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Processes tickets from Fienta and updates the database |
||||
*/ |
||||
export async function syncFientaTickets( |
||||
tickets: FientaTicket[], |
||||
): Promise<void> { |
||||
// Process each ticket to extract user and team information
|
||||
for (const ticket of tickets) { |
||||
// Skip tickets with CANCELLED status or empty rows
|
||||
if (ticket.rows.length === 0 || ticket.status === "CANCELLED") continue; |
||||
|
||||
const ticketRow = ticket.rows[0]; |
||||
const attendee = ticketRow.attendee; |
||||
const ticketType = ticketRow.ticket_type.title; |
||||
|
||||
// Extract data
|
||||
const email = attendee.email || ticket.order_email; |
||||
const firstName = attendee.first_name; |
||||
const lastName = attendee.last_name; |
||||
const steamUrl = findAttendeeFieldByPrefix(attendee, "steam_konto_link"); |
||||
const teamName = findAttendeeFieldByPrefix(attendee, "tiimi_nimi"); |
||||
const captainName = findAttendeeFieldByPrefix( |
||||
attendee, |
||||
"tiimi_kapteni_nimi", |
||||
); |
||||
|
||||
const steamId = steamUrl ? extractSteamId(steamUrl) : null; |
||||
// Check if user is captain - captain name must exist and match user's name
|
||||
const isCaptain = captainName !== null && steamId === captainName; |
||||
|
||||
// Create or update user
|
||||
const user = await upsertUser({ |
||||
email, |
||||
firstName, |
||||
lastName, |
||||
steamId, |
||||
ticketId: ticket.code, |
||||
ticketType, |
||||
}); |
||||
|
||||
// Handle team association if there is a team name
|
||||
if (teamName) { |
||||
// Find the team by name first
|
||||
let team = await db.query.teams.findFirst({ |
||||
where: eq(teams.name, teamName), |
||||
}); |
||||
|
||||
// Create the team if it doesn't exist
|
||||
if (!team) { |
||||
const [newTeam] = await db |
||||
.insert(teams) |
||||
.values({ name: teamName }) |
||||
.returning(); |
||||
team = newTeam; |
||||
} |
||||
|
||||
// Determine appropriate role
|
||||
const role = determineUserRole(ticketType, isCaptain, true); |
||||
|
||||
// Create or update membership with appropriate role
|
||||
await upsertMember(user.id, team.id, role); |
||||
} else { |
||||
// For users without a team, handle membership without team association
|
||||
const role = determineUserRole(ticketType, false, false); |
||||
await upsertMember(user.id, null, role); |
||||
} |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Main function to fetch and sync tickets from Fienta to the database |
||||
*/ |
||||
export async function syncFientaEvent( |
||||
eventId: string, |
||||
apiKey: string, |
||||
): Promise<void> { |
||||
try { |
||||
const response = await fetchFientaTickets(eventId, apiKey); |
||||
await syncFientaTickets(response.tickets); |
||||
} catch (error) { |
||||
console.error("Error syncing Fienta tickets:", error); |
||||
throw error; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Gets teams with their members from the database |
||||
*/ |
||||
export async function getTeamsWithMembers() { |
||||
return await db.query.teams.findMany({ |
||||
with: { |
||||
members: { |
||||
with: { |
||||
user: true, |
||||
}, |
||||
}, |
||||
}, |
||||
}); |
||||
} |
||||
|
||||
/** |
||||
* Gets a specific team with its members |
||||
*/ |
||||
export async function getTeamWithMembers(teamId: string) { |
||||
return await db.query.teams.findFirst({ |
||||
where: eq(teams.id, teamId), |
||||
with: { |
||||
members: { |
||||
with: { |
||||
user: true, |
||||
}, |
||||
}, |
||||
}, |
||||
}); |
||||
} |
||||
|
||||
/** |
||||
* Gets tournament teams with related data |
||||
*/ |
||||
export async function getTournamentTeams(tournamentId: string) { |
||||
return await db.query.tournamentTeams.findMany({ |
||||
where: eq(tournamentTeams.tournamentId, tournamentId), |
||||
with: { |
||||
team: { |
||||
with: { |
||||
members: { |
||||
with: { |
||||
user: true, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}); |
||||
} |
||||
@ -1,6 +1,6 @@ |
||||
import { clsx, type ClassValue } from "clsx" |
||||
import { twMerge } from "tailwind-merge" |
||||
import { clsx, type ClassValue } from "clsx"; |
||||
import { twMerge } from "tailwind-merge"; |
||||
|
||||
export function cn(...inputs: ClassValue[]) { |
||||
return twMerge(clsx(inputs)) |
||||
return twMerge(clsx(inputs)); |
||||
} |
||||
|
||||
Loading…
Reference in new issue